diff options
Diffstat (limited to 'app')
1585 files changed, 17657 insertions, 10360 deletions
diff --git a/app/assets/images/logos/jira-gray.svg b/app/assets/images/logos/jira-gray.svg deleted file mode 100644 index 0e7069f2bd2..00000000000 --- a/app/assets/images/logos/jira-gray.svg +++ /dev/null @@ -1 +0,0 @@ -<svg id="Logos" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><style>.cls-1{fill:#7a869a;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" x1="38.11" y1="18.54" x2="23.17" y2="33.48" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#344563"/><stop offset="1" stop-color="#7a869a"/></linearGradient><linearGradient id="linear-gradient-2" x1="42.07" y1="61.47" x2="56.98" y2="46.55" xlink:href="#linear-gradient"/></defs><title>jira software-icon-gradient-neutral</title><path class="cls-1" d="M74.18,38,43,6.9l-3-3h0L16.58,27.32h0L5.86,38a2.86,2.86,0,0,0,0,4.05L27.28,63.51,40,76.25,63.47,52.81l.36-.36L74.18,42.09A2.86,2.86,0,0,0,74.18,38ZM40,50.77l-10.7-10.7L40,29.37l10.7,10.7Z"/><path class="cls-2" d="M40,29.37A18,18,0,0,1,40,4L16.54,27.37,29.28,40.11,40,29.37Z"/><path class="cls-3" d="M50.75,40,40,50.77a18,18,0,0,1,0,25.48h0L63.5,52.78Z"/></svg> diff --git a/app/assets/images/logos/shimo.svg b/app/assets/images/logos/shimo.svg new file mode 100644 index 00000000000..65bd1cc7167 --- /dev/null +++ b/app/assets/images/logos/shimo.svg @@ -0,0 +1 @@ +<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path d="m7.99985 15.9997c4.41815 0 7.99985-3.5817 7.99985-7.99985 0-4.4182-3.5817-7.99985-7.99985-7.99985-4.4182 0-7.99985 3.58165-7.99985 7.99985 0 4.41815 3.58165 7.99985 7.99985 7.99985z" fill="#3f464a"/><g fill="#fff"><path d="m10.1501 3.13098c.0077.00805.017.01641.025.02601l.2988.34857c.0099.01099.0189.0228.0269.03529.0048.00966.0075.0202.0081.03096.0001.00853-.0027.01685-.0079.02359-.0053.00675-.0126.01153-.0209.01355-.0147.00206-.0295.0032-.0443.00341l-.70117.00991c-.24229.0035-.48457.0067-.72686.00959-.22104.00248-.44207.00372-.6631.00681-.20401.00248-.40801.0065-.61171.00991-.01269 0-.02538 0-.03807.00248-.00527 0-.01115.00959-.00867.01393.00455.00951.00972.01871.01548.02755.0552.07835.12273.14725.19998.204.0356.02662.07368.04984.11051.07523.01242.00804.02403.01727.03468.02755.00948.01029.01475.02377.01475.03777 0 .01399-.00527.02747-.01475.03776-.00419.00471-.00886.00896-.01394.0127-.01157.00943-.02097.02126-.02755.03467-.06748.11309-.13507.2266-.20276.34052-.10524.17487-.22146.34289-.34796.50305-.01089.01016-.01917.0228-.02415.03684-.00017.00237-.00017.00475 0 .00712.00497.00357.01026.00668.01579.00928.06192.0257.126.05077.18884.07678.01379.0055.02849.00834.04334.00836h.05758 1.5045.05108c.00857-.00025.01691-.00276.0242-.00727.00728-.00451.01325-.01087.01728-.01843.00743-.01284.01395-.02618.0195-.03993.02167-.0483.04303-.09721.0647-.1455.00487-.01181.01089-.02311.01796-.03374.00581-.00902.01482-.01551.02521-.01815.0104-.00264.02141-.00125.03082.00391l.02786.01548.33527.19688c.01106.00633.02144.0138.03095.02229.00321.00283.00551.00654.00661.01067s.00096.0085-.00042.01255c-.0034.0099-.00804.0195-.01238.0291-.0291.06191-.05696.12382-.08823.18388-.02023.03975-.03033.08388-.02941.12847v1.02003c0 .09999.00001.19998-.00247.29966-.00185.09338-.01054.1865-.02601.27861-.00686.04206-.01721.08348-.03095.12382-.02828.08507-.07696.16191-.14178.22382-.05123.04802-.10725.09065-.16717.12723-.01271.00783-.0275.01161-.04241.01084-.00639-.00011-.01258-.0022-.01773-.00596-.00516-.00377-.00902-.00904-.01106-.01509-.00248-.00805-.00403-.01641-.00588-.02477-.00918-.0392-.02055-.07785-.03406-.11578-.01986-.05201-.05688-.0957-.10494-.12382-.00743-.00402-.0157-.00625-.02415-.0065-.01485-.00078-.02972-.00078-.04457 0-.15912.00774-.31855.00743-.47798.00866l-.75875.00589c-.0743 0-.14859.00216-.22289.00402-.01637-.00159-.03289.00009-.0486.00495-.00376.00242-.00682.00578-.00888.00974-.00207.00397-.00306.0084-.00289.01286-.00557.03096-.0099.06192-.0164.09287-.01604.07268-.04132.14301-.07523.20927-.05921.11305-.13198.21846-.2167.3139-.0047.00582-.01055.01061-.01719.01407-.00663.00346-.01391.00551-.02137.00603-.00747.00053-.01496-.00049-.02201-.00299s-.01352-.00643-.01899-.01154c-.00892-.00618-.01504-.01564-.01702-.02631-.00186-.01023-.0031-.02057-.00372-.03096 0-.02321 0-.04674 0-.06996v-2.37841c0-.01702 0-.03436 0-.05107 0-.01672.00589-.03096-.0099-.04087-.01393.00279-.01951.01517-.02724.02477-.18029.22601-.37688.43853-.58818.63585-.29527.27883-.61431.53136-.95348.75473-.01764.01176-.0356.02321-.05386.03405-.00952.00504-.02063.00615-.03096.00309-.00515-.00121-.0099-.00373-.01381-.00731-.0039-.00357-.00682-.00809-.00848-.01312-.00253-.00507-.00335-.01082-.00234-.0164.001-.00558.00378-.01068.00792-.01455.01393-.01301.02848-.02539.04334-.03746.11722-.09494.23124-.19369.34207-.29626.3874-.36168.72258-.77551.99588-1.2296.16487-.27522.31301-.56013.44361-.85317.08668-.19162.16717-.38572.2458-.58075.02446-.06191.01207-.06191-.04055-.06191l-.34424.00898c-.20401.00557-.40771.01052-.61171.01671-.13157.00434-.26313.01084-.39501.01672-.01482.00051-.02963-.00115-.04396-.00495-.01457-.00344-.02781-.01109-.03808-.02198-.00588-.00619-.01145-.01239-.01702-.01889-.04768-.05407-.09525-.10845-.14271-.16314-.0068-.00812-.01271-.01694-.01765-.02631 0-.00217.00186-.00991.00372-.01022.0126-.00195.02532-.00299.03807-.00309h.31236.88629l.79033-.00248c.25715 0 .5143-.00093.77145-.00279l1.01383-.00247c.01268-.00078.0254-.00078.03808 0 .01271.00099.02548-.00098.03729-.00576.01182-.00478.02237-.01224.03081-.02179.08947-.08421.18017-.16779.27087-.25168.0105-.00929.0189-.0226.0378-.02446zm-2.54778 3.05791v.8476c0 .02105 0 .04241.00217.06191.00051.00421.00239.00813.00536.01115s.00685.00498.01105.00557c.01888 0 .03808.00185.05727.00185l1.44011-.00433h.03096c.01172.00058.02344-.00133.03438-.0056.01093-.00427.02085-.0108.02908-.01917.0125-.01146.02409-.02388.03467-.03714.03005-.03864.05291-.08237.06749-.12909.01811-.05894.02678-.12038.02569-.18203 0-.46745-.00062-.93489-.00186-1.40234 0-.01703 0-.03405 0-.05077-.0006-.00849-.00247-.01684-.00557-.02476-.00288-.0103-.0089-.01945-.01722-.02616-.00833-.00672-.01854-.01067-.02921-.0113-.01053 0-.02136 0-.03096 0-.10835 0-.2167-.00341-.32505-.00341-.32731 0-.65442 0-.98133 0-.08699 0-.17429.00403-.26127.00557-.01487.00085-.02941.00469-.04276.01129-.01335.00659-.02524.01581-.03495.0271-.01269.013-.0065.03096-.00712.04891-.00062.01796 0 .03096 0 .04458z"/><path d="m6.35047 8.7363c.01814-.00207.03651.00027.05356.00681.09287.02662.18574.05293.28046.08017.0365.01068.07435.016.11238.01579l1.99857.00186h.71695c.08978 0 .06904.0096.11269-.06377.02352-.04025.04674-.08049.07027-.12042.00261-.0047.00697-.00817.01212-.00968.00516-.00151.0107-.00092.01543.00163l.02786.01455c.0904.05077.18079.10154.27364.14766.0369.01827.0585.03096.0273.08389-.0217.03622-.0412.07399-.06195.11021-.01119.01807-.01659.03914-.01548.06037v.03095.686.03808c-.00102.00948-.00015.01906.00256.0282s.00721.01765.01323.02504c.00273.00326.00511.00679.00712.01053.00362.00653.00542.01385.00552.02129 0 .00745-.0018.01479-.00528.02136-.0035.00657-.00858.01217-.01479.01629-.0062.00412-.01333.00663-.02074.00731-.01681.00092-.03366.00092-.05046 0-.22413-.00155-.44857-.00403-.67269-.00496-.26221 0-.52441 0-.78661 0-.01889 0-.03808 0-.05696.00279-.00413.00087-.0079.00296-.01082.00599-.00293.00304-.00488.00688-.00559.01104v.01888.27864c-.00041.0103.00042.0207.00247.0309.00102.0029.00266.0055.00479.0077.00214.0022.00473.0039.0076.005.00802.0025.01639.0035.02476.0031h.91385.07058c.01269-.0118.02538-.0226.03715-.0343l.14704-.1496c.00743-.0074.01517-.0145.02291-.0216.00324-.0027.00729-.0041.01145-.0041.00417 0 .00821.0014.01146.0041.00495.0039.00971.0082.01424.0127l.26003.2513c.00873.0091.0168.0189.02415.0291.00302.0056.00381.0121.0022.0183-.00161.0061-.00549.0114-.01087.0148-.00971.005-.02017.0082-.03096.0096-.01888.0016-.03807 0-.05696 0l-1.36395.0062c-.10401 0-.09287-.0096-.09287.091v.2477c.00004.0104.00097.0207.00279.031.00141.0039.0043.0071.00804.0089.00382.0021.00805.0033.01239.0034h.04426l.69158.0034c.18822 0 .37643.0028.56465.0031.08699 0 .06625.0081.12878-.0489.05325-.0483.10587-.0972.15881-.1458.00864-.0105.02037-.018.03347-.0216.0063.0043.0124.0089.0182.0139.091.0913.1824.1826.2731.2743.0072.0075.0134.0159.0185.025.0016.0034.0025.007.0025.0107.0001.0037-.0007.0074-.0022.0108-.0015.0033-.0037.0063-.0065.0088-.0027.0024-.006.0043-.0095.0053-.0124.0025-.0249.0038-.0375.0041-.0551 0-.1099 0-.165 0-.36591-.0016-.73182-.0041-1.09742-.005-.82552-.0019-1.65041-.0032-2.47469-.004h-.35507c-.01084 0-.02136 0-.03096 0-.01145.0007-.02292-.0011-.03361-.0053s-.02034-.0106-.0283-.0189l-.10773-.1077c-.00121-.0016-.00194-.0035-.00212-.0055s.0002-.004.0011-.0058.00228-.0034.00399-.0044c.00171-.0011.00369-.0016.0057-.0016h.03807l.99619.0059.88815.0065h.05696c.00411-.0009.00789-.0029.01091-.0058s.00514-.0066.00612-.0107c0-.0065.00186-.0126.00186-.0188 0-.1016 0-.2031 0-.3047.00035-.0084-.00091-.0168-.00372-.0247-.00117-.0028-.00298-.0053-.00529-.0073s-.00506-.0034-.00802-.0042c-.02064-.0018-.04128-.0028-.06191-.0028-.20308 0-.40616 0-.60892 0-.22227 0-.44434-.0006-.66619-.0018-.00867 0-.01703 0-.02539 0-.01271.0007-.02541-.0016-.03715-.0065s-.02222-.0124-.03064-.022c-.03251-.0331-.06563-.0659-.09875-.0987-.00253-.0018-.00437-.0045-.00521-.0074-.00085-.003-.00065-.0062.00056-.0091.0031-.0068.00991-.0065.0161-.0065.13745 0 .2749 0 .41234.0016l1.04046.0093h.06346c.00406-.0008.00782-.0027.01088-.0055.00305-.0028.00529-.0063.00646-.0103.00128-.0083.00211-.0167.00247-.0251v-.27859c-.00036-.0084-.00119-.01677-.00247-.02508-.00083-.00414-.00291-.00793-.00595-.01087-.00304-.00293-.00691-.00486-.01108-.00554-.01269 0-.02538-.00185-.03808-.00185-.09503 0-.19038 0-.28542.00185-.29409.0031-.58817 0-.88195 0-.01672 0-.03375 0-.05077 0-.00839.00074-.01641.00373-.02322.00867-.01149.00973-.02244.02006-.03282.03096-.06408.05885-.13032.11455-.19936.16715-.01486.0118-.03095.0226-.04612.0334-.00516.0037-.01065.0068-.01641.0093-.00493.002-.01022.0027-.01549.0023s-.01038-.002-.01496-.0046c-.00457-.0026-.0085-.0063-.01148-.0106-.00298-.0044-.00494-.0094-.00574-.0146-.00155-.0254-.00186-.0508-.00186-.0762 0-.24515 0-.49053 0-.73612 0-.18388 0-.36787 0-.55196-.00287-.02176-.00329-.04377-.00124-.06562zm1.01724 1.09277h.53865.05696c.01888 0 .02693-.00805.02786-.02663.00093-.01857 0-.02941 0-.04426 0-.24085 0-.48169 0-.72253 0-.01703 0-.03375 0-.05077.00002-.00528-.00194-.01037-.0055-.01427-.00355-.0039-.00844-.00632-.01369-.00678-.0192 0-.03808 0-.05696 0h-1.10454-.04427c-.00315-.00036-.00635-.00003-.00937.00095-.00301.00098-.00579.00259-.00813.00473-.00235.00214-.00421.00476-.00546.00767-.00125.00292-.00187.00607-.0018.00925v.05696.70364.04427c.00019.00632.00071.01262.00154.01888.00054.00416.00234.00806.00516.01116.00283.0031.00654.00526.01063.00618.01888 0 .03808.00217.05696.00247zm1.6144-.86679h-.53401c-.07615 0-.07089-.00402-.07089.07337v.71571.04427c0 .02384.00774.03096.02941.03096h.25353c.00527.00006.01048-.00111.01521-.00342.00473-.00232.00885-.0057.01204-.00989l.00774-.00991c.08256-.11785.15112-.2449.20431-.3786.03491-.09285.06447-.18763.08854-.28387.00588-.0226.01083-.0452.01764-.06749.00366-.00982.00866-.0191.01486-.02755.00162-.00133.00353-.00225.00558-.00268s.00417-.00036.00618.00021c.00774.00295.01496.00713.02137.01238.05386.04736.10773.09534.16128.14302.03096.02662.06037.05293.08977.08018.01063.01026.02008.02168.02818.03405.00342.00436.00543.00966.00576.01519s-.00103.01103-.00391.01576c-.03387.06352-.07661.12189-.12692.17336-.03634.03538-.07502.06826-.11578.09844-.09345.06928-.19451.12765-.30121.17398-.01733.00774-.03436.0161-.05139.02446-.00136.00132-.00223.00307-.00247.00495 0 .00495.00186.00743.00743.00774s.01671 0 .02507 0h.75473c.01052 0 .02105 0 .03095-.00186.00413-.00058.00797-.00245.01097-.00534s.00501-.00666.00575-.01076c0-.02105.00217-.0421.00217-.06191 0-.23465 0-.4693 0-.70395 0-.01486 0-.02941 0-.04427 0-.0065 0-.01269-.00155-.01889-.00041-.00417-.00218-.00809-.00503-.01117-.00285-.00307-.00663-.00513-.01076-.00585-.01888 0-.03807-.00186-.05696-.00186z"/><path d="m9.7848 12.8222c.01332-.0133.02849-.0278.04304-.043.07181-.0749.14333-.1498.21666-.2241.0115-.0118.0208-.0273.0378-.031.0102.003.0192.0093.0254.018.1187.1238.237.2476.3551.3715.0056.0062.0108.0128.0154.0198.0021.0036.0034.0076.0036.0118.0003.0042-.0004.0083-.0019.0122-.0016.0038-.0041.0073-.0072.01-.0031.0028-.0068.0048-.0109.0059-.0062.0014-.0125.0023-.0189.0028h-.0507-.53403-2.46788c-.32133 0-.64277.0005-.9643.0015-.12909 0-.25787.0019-.38696.0028-.00836 0-.01703 0-.02539 0-.01167.0002-.02325-.0021-.03393-.0068-.01069-.0047-.02023-.0117-.02798-.0204-.03529-.0393-.0712-.078-.10649-.1173-.0009-.0018-.00137-.0037-.00137-.0056s.00047-.0039.00137-.0056c.00095-.0018.00231-.0033.00398-.0045s.00359-.002.00561-.0023c.01025-.0015.0206-.0022.03096-.0022h.29192c.12383 0 .24518.0016.36808.0019h.73584.64081c.01888 0 .03777 0 .05665-.0022.00412-.0007.0079-.0028.01084-.0057.00293-.003.00488-.0069.00557-.011 0-.0087.00155-.017.00186-.0254 0-.1238 0-.2486 0-.3742-.00017-.0105-.00142-.0208-.00372-.031 0-.0034-.00526-.0065-.00867-.0084-.00357-.0021-.0076-.0034-.01176-.0037-.01486 0-.03096 0-.04458 0-.25157.0023-.50315.0047-.75473.0072-.24332.0024-.48633.0058-.72965.0089h-.09504c-.00851.0005-.01703-.0008-.02503-.0038-.00799-.003-.01527-.0076-.0214-.0135-.04056-.0427-.07987-.0873-.11919-.1313-.00035-.0018-.00025-.0038.00029-.0056s.0015-.0035.00281-.0049c.00357-.0023.00782-.0034.01207-.0031h.05696 1.66208c.08544 0 .07987.0065.07987-.0777v-.5736c-.00217-.0216-.00017-.0433.00588-.0641.00849 0 .01698.0006.02538.0019l.36313.0882c.00433 0 .00804.0028.01207.0037.03436.0102.03808.0207.01641.048l-.03529.0446c-.00161.0026-.00306.0052-.00434.008v.0483.4059.0445c0 .0205.01238.0251.0291.026.01672.001.02539 0 .03808 0l1.10361.0019c.06625 0 .05263.0049.09968-.0406.05944-.0572.11826-.1151.17738-.1727.00898-.0084.01889-.0161.03096-.0251.01084.0093.02074.017.02941.026.08565.0877.17117.1757.25667.2638.0045.0044.0089.009.013.0139.0056.0048.0097.0113.0114.0185.0018.0073.0013.0149-.0015.0218-.003.0055-.0075.0102-.0129.0134-.0055.0031-.0118.0047-.0181.0045-.017 0-.0337 0-.0508 0l-.34884.0025-1.28781.0117c-.01671 0-.03374.0019-.05046.0028-.00407.0011-.00776.0032-.01062.0063s-.00476.0069-.00547.0111c-.00108.0083-.0017.0167-.00186.025v.3808.0189c.00025.0053.00238.0104.00601.0143.00362.0039.00851.0064.0138.0071.01239 0 .02508 0 .03777.0018h.13342l1.1355.0028c.02548.0022.05113.0016.07646-.0019z"/><path d="m10.1624 11.4382c0 .0068 0 .0173-.0034.0275-.0169.096-.0571.1864-.117.2632-.0199.0247-.04399.0456-.0712.0619-.01287.0073-.02646.0132-.04055.0176-.0171.0058-.03544.0069-.05309.0031-.01765-.0037-.03396-.0122-.04721-.0244-.02256-.0192-.04081-.0429-.05356-.0697-.00959-.0189-.01764-.0384-.02631-.0576-.03797-.087-.08086-.1719-.12847-.2541-.03249-.0568-.06912-.1111-.10958-.1625-.03991-.052-.08703-.0979-.13993-.1366-.00681-.0049-.01331-.0105-.01981-.0158-.0065-.0052-.00836-.0185-.0034-.0232.0041-.0044.00954-.0073.01547-.0083.01256-.0007.02514.0003.03746.0028.20435.0435.40349.1087.59405.1944.02293.0108.04583.0216.06813.0337.0148.008.0289.0173.0421.0279.0178.0144.0322.0328.0419.0536.0098.0208.0147.0435.0144.0665z"/><path d="m6.52884 11.8564c-.03474 0-.0692-.0061-.10185-.0179-.05603-.0186-.11082-.0406-.16593-.0619-.02512-.0109-.0466-.0287-.06191-.0514-.01313-.0178-.02058-.0392-.02135-.0613s.00516-.0439.01701-.0625c.01012-.0162.02313-.0304.03839-.0418.01362-.0102.02755-.0195.04148-.0294.06718-.0474.13374-.0957.1972-.1483.04368-.0362.08504-.075.12382-.1164.06313-.0654.11482-.141.15293-.2235.00464-.0096.00898-.0192.01393-.0285.00199-.0037.005-.0068.00867-.0089s.00786-.0031.01207-.0029c.00475.0022.0088.0056.0117.0099s.00453.0094.00471.0146v.0189c-.01207.1154-.02291.2309-.03715.3461-.01021.0817-.02414.1628-.03777.2439-.00493.027-.01207.0535-.02136.0793-.00757.0215-.01721.0422-.02879.0619-.01414.0258-.03538.0471-.06121.0613s-.05518.0208-.08459.0188z"/><path d="m8.77719 11.8183c-.01359 0-.02704-.0027-.03954-.008-.0125-.0054-.0238-.0131-.03321-.0229-.02061-.0215-.03597-.0475-.04489-.0759-.00619-.0176-.00991-.0365-.01486-.0548-.02659-.1042-.06042-.2065-.10123-.3061-.0195-.0469-.04204-.0924-.06749-.1362-.02726-.0477-.06067-.0917-.09937-.1307-.00743-.0074-.01398-.0158-.01951-.0247-.00151-.0038-.00151-.008 0-.0118.00118-.0029.00314-.0054.00565-.0073.00251-.0018.00548-.0029.00859-.0032.01082-.0005.02155.0021.03096.0074.13998.0538.27557.1184.40554.1932.05665.0331.11176.0684.16501.1065.01937.0129.03687.0284.052.0461.01496.0165.0256.0364.031.058.00541.0216.00539.0442-.00004.0658-.00271.0124-.00622.0246-.01052.0365-.0316.0787-.07999.1495-.14179.2074-.02664.0243-.05838.0423-.09287.0527-.01098.0033-.02214.006-.03343.008z"/><path d="m7.56893 11.8338c-.01297-.0002-.02575-.0032-.03742-.0088-.01166-.0057-.02193-.0139-.03006-.024-.01559-.0174-.02731-.0378-.03436-.0601-.00634-.0203-.01161-.041-.01579-.0619-.02196-.105-.05092-.2084-.08668-.3095-.01697-.0478-.03692-.0944-.05975-.1397-.02626-.053-.05981-.102-.09968-.1458-.0061-.0058-.01132-.0124-.01548-.0198-.00121-.0028-.00184-.0058-.00184-.0088s.00063-.006.00184-.0088c.00185-.0025.00417-.0045.00683-.0061.00266-.0015.0056-.0025.00865-.0029.0063-.0001.01251.0015.01796.0046.19823.0843.38537.1925.55722.3223.01674.0129.03266.0269.04767.0418.01878.0188.0319.0425.03787.0684.00596.0259.00453.0529-.00413.078-.00208.0082-.00488.0162-.00835.0239-.03509.0797-.08805.1504-.15479.2064-.02157.0169-.04544.0307-.07089.0409-.0188.007-.03876.0103-.05882.0099z"/><path d="m7.43301 9.75413c-.01598.00163-.0321-.0009-.04681-.00736-.0147-.00645-.02748-.01661-.03708-.02948-.01864-.02588-.03123-.0556-.03684-.08699-.01649-.08282-.03715-.16476-.06192-.24549-.01723-.0545-.03791-.10786-.06191-.15974-.02747-.06198-.06456-.11924-.1099-.16964-.00517-.00655-.00983-.01348-.01393-.02074-.00074-.00302-.00048-.00619.00075-.00904s.00336-.00522.00606-.00675c.0038-.00173.0079-.00268.01207-.00279.0083.00128.01641.00357.02415.00681.17685.07642.34421.17314.49872.28821.02291.01553.04371.03395.06191.0548.01255.0144.02159.0315.02642.04998.00483.01847.00533.03781.00145.05651-.00242.01464-.00647.02897-.01208.04272-.03176.07417-.08042.1399-.14209.19193-.01827.01444-.03836.02641-.05975.0356-.01563.0066-.03228.01048-.04922.01146z"/></g></svg> diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue new file mode 100644 index 00000000000..3954e541fe0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/token.vue @@ -0,0 +1,55 @@ +<script> +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +export default { + components: { InputCopyToggleVisibility }, + props: { + token: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + inputLabel: { + type: String, + required: true, + }, + copyButtonTitle: { + type: String, + required: true, + }, + }, + computed: { + formInputGroupProps() { + return { id: this.inputId }; + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-12"> + <hr /> + </div> + <div class="col-lg-4"> + <h4 class="gl-mt-0"><slot name="title"></slot></h4> + <slot name="description"></slot> + </div> + <div class="col-lg-8"> + <input-copy-toggle-visibility + :label="inputLabel" + :label-for="inputId" + :form-input-group-props="formInputGroupProps" + :value="token" + :copy-button-title="copyButtonTitle" + > + <template #description> + <slot name="input-description"></slot> + </template> + </input-copy-toggle-visibility> + </div> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue new file mode 100644 index 00000000000..755991f64e0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -0,0 +1,111 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { pickBy } from 'lodash'; + +import { s__ } from '~/locale'; + +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '../constants'; +import Token from './token.vue'; + +export default { + i18n: { + canNotAccessOtherData: s__('AccessTokens|It cannot be used to access any other data.'), + [FEED_TOKEN]: { + label: s__('AccessTokens|Feed token'), + copyButtonTitle: s__('AccessTokens|Copy feed token'), + description: s__( + 'AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.', + ), + }, + [INCOMING_EMAIL_TOKEN]: { + label: s__('AccessTokens|Incoming email token'), + copyButtonTitle: s__('AccessTokens|Copy incoming email token'), + description: s__( + 'AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.', + ), + }, + [STATIC_OBJECT_TOKEN]: { + label: s__('AccessTokens|Static object token'), + copyButtonTitle: s__('AccessTokens|Copy static object token'), + description: s__( + 'AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__('AccessTokens|Are you sure?'), + }, + }, + htmlAttributes: { + [FEED_TOKEN]: { + inputId: 'feed_token', + containerTestId: 'feed-token-container', + }, + [INCOMING_EMAIL_TOKEN]: { + inputId: 'incoming_email_token', + containerTestId: 'incoming-email-token-container', + }, + [STATIC_OBJECT_TOKEN]: { + inputId: 'static_object_token', + containerTestId: 'static-object-token-container', + }, + }, + components: { Token, GlSprintf, GlLink }, + inject: ['tokenTypes'], + computed: { + enabledTokenTypes() { + return pickBy(this.tokenTypes, (tokenData, tokenType) => { + return ( + tokenData?.enabled && + this.$options.i18n[tokenType] && + this.$options.htmlAttributes[tokenType] + ); + }); + }, + }, +}; +</script> + +<template> + <div> + <token + v-for="(tokenData, tokenType) in enabledTokenTypes" + :key="tokenType" + :token="tokenData.token" + :input-id="$options.htmlAttributes[tokenType].inputId" + :input-label="$options.i18n[tokenType].label" + :copy-button-title="$options.i18n[tokenType].copyButtonTitle" + :data-testid="$options.htmlAttributes[tokenType].containerTestId" + > + <template #title>{{ $options.i18n[tokenType].label }}</template> + <template #description> + <p>{{ $options.i18n[tokenType].description }}</p> + <p>{{ $options.i18n.canNotAccessOtherData }}</p> + </template> + <template #input-description> + <gl-sprintf :message="$options.i18n[tokenType].inputDescription"> + <template #link="{ content }"> + <gl-link + :href="tokenData.resetPath" + :data-confirm="$options.i18n[tokenType].resetConfirmMessage" + data-method="put" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </template> + </token> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/constants.js b/app/assets/javascripts/access_tokens/constants.js new file mode 100644 index 00000000000..6188c6d1bb5 --- /dev/null +++ b/app/assets/javascripts/access_tokens/constants.js @@ -0,0 +1,4 @@ +// Token types +export const FEED_TOKEN = 'feedToken'; +export const INCOMING_EMAIL_TOKEN = 'incomingEmailToken'; +export const STATIC_OBJECT_TOKEN = 'staticObjectToken'; diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 7f5f0403de6..9a1e7d877f8 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,9 +1,13 @@ import Vue from 'vue'; + import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { __ } from '~/locale'; import ExpiresAtField from './components/expires_at_field.vue'; +import TokensApp from './components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants'; export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); @@ -81,3 +85,29 @@ export const initProjectsField = () => { return null; }; + +export const initTokensApp = () => { + const el = document.getElementById('js-tokens-app'); + + if (!el) return false; + + const tokensData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.tokensData), { + deep: true, + }); + + const tokenTypes = { + [FEED_TOKEN]: tokensData[FEED_TOKEN], + [INCOMING_EMAIL_TOKEN]: tokensData[INCOMING_EMAIL_TOKEN], + [STATIC_OBJECT_TOKEN]: tokensData[STATIC_OBJECT_TOKEN], + }; + + return new Vue({ + el, + provide: { + tokenTypes, + }, + render(createElement) { + return createElement(TokensApp); + }, + }); +}; diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 97a5a2f2f32..29e8b9a724e 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -1,13 +1,33 @@ <script> -import { GlTable, GlButton } from '@gitlab/ui'; +import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState, GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import csrf from '~/lib/utils/csrf'; export default { name: 'DeployKeysTable', i18n: { pageTitle: __('Public deploy keys'), newDeployKeyButtonText: __('New deploy key'), + emptyStateTitle: __('No public deploy keys'), + emptyStateDescription: __( + 'Deploy keys grant read/write access to all repositories in your instance', + ), + delete: __('Delete deploy key'), + edit: __('Edit deploy key'), + pagination: { + next: __('Next'), + prev: __('Prev'), + }, + modal: { + title: __('Are you sure?'), + body: __('Are you sure you want to delete this deploy key?'), + }, + apiErrorMessage: __('An error occurred fetching the public deploy keys. Please try again.'), }, fields: [ { @@ -29,13 +49,118 @@ export default { { key: 'actions', label: __('Actions'), + tdClass: 'gl-lg-w-1px gl-white-space-nowrap', + thClass: 'gl-lg-w-1px gl-white-space-nowrap', }, ], + modal: { + id: 'delete-deploy-key-modal', + actionPrimary: { + text: __('Delete'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + csrf, + DEFAULT_PER_PAGE, components: { GlTable, GlButton, + GlPagination, + TimeAgoTooltip, + GlLoadingIcon, + GlEmptyState, + GlModal, }, inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'], + data() { + return { + page: 1, + totalItems: 0, + loading: false, + items: [], + deployKeyToDelete: null, + }; + }, + computed: { + shouldShowTable() { + return this.totalItems !== 0 || this.loading; + }, + isModalVisible() { + return this.deployKeyToDelete !== null; + }, + deleteAction() { + return this.deployKeyToDelete === null + ? null + : this.deletePath.replace(':id', this.deployKeyToDelete); + }, + }, + watch: { + page(newPage) { + this.fetchDeployKeys(newPage); + }, + }, + mounted() { + this.fetchDeployKeys(); + }, + methods: { + editHref(id) { + return this.editPath.replace(':id', id); + }, + projectHref(project) { + return `/${cleanLeadingSeparator(project.path_with_namespace)}`; + }, + async fetchDeployKeys(page) { + this.loading = true; + try { + const { headers, data: items } = await Api.deployKeys({ + page, + public: true, + }); + + if (this.totalItems === 0) { + this.totalItems = parseInt(headers?.['x-total'], 10) || 0; + } + + this.items = items.map( + ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({ + id, + title, + fingerprint, + projects: projects_with_write_access, + created: created_at, + }), + ); + } catch (error) { + createFlash({ + message: this.$options.i18n.apiErrorMessage, + captureError: true, + error, + }); + + this.totalItems = 0; + + this.items = []; + } + this.loading = false; + }, + handleDeleteClick(id) { + this.deployKeyToDelete = id; + }, + handleModalHide() { + this.deployKeyToDelete = null; + }, + handleModalPrimary() { + this.$refs.modalForm.submit(); + }, + }, }; </script> @@ -45,10 +170,92 @@ export default { <h4 class="gl-m-0"> {{ $options.i18n.pageTitle }} </h4> - <gl-button variant="confirm" :href="createPath">{{ + <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{ $options.i18n.newDeployKeyButtonText }}</gl-button> </div> - <gl-table :fields="$options.fields" data-testid="deploy-keys-list" /> + <template v-if="shouldShowTable"> + <gl-table + :busy="loading" + :items="items" + :fields="$options.fields" + stacked="lg" + data-testid="deploy-keys-list" + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> + + <template #cell(projects)="{ item: { projects } }"> + <a + v-for="project in projects" + :key="project.id" + :href="projectHref(project)" + class="gl-display-block" + >{{ project.name_with_namespace }}</a + > + </template> + + <template #cell(fingerprint)="{ item: { fingerprint } }"> + <code>{{ fingerprint }}</code> + </template> + + <template #cell(created)="{ item: { created } }"> + <time-ago-tooltip :time="created" /> + </template> + + <template #head(actions)="{ label }"> + <span class="gl-sr-only">{{ label }}</span> + </template> + + <template #cell(actions)="{ item: { id } }"> + <gl-button + icon="pencil" + :aria-label="$options.i18n.edit" + :href="editHref(id)" + class="gl-mr-2" + /> + <gl-button + variant="danger" + icon="remove" + :aria-label="$options.i18n.delete" + @click="handleDeleteClick(id)" + /> + </template> + </gl-table> + <gl-pagination + v-if="!loading" + v-model="page" + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :next-text="$options.i18n.pagination.next" + :prev-text="$options.i18n.pagination.prev" + align="center" + /> + </template> + <gl-empty-state + v-else + :svg-path="emptyStateSvgPath" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + :primary-button-text="$options.i18n.newDeployKeyButtonText" + :primary-button-link="createPath" + /> + <gl-modal + :modal-id="$options.modal.id" + :visible="isModalVisible" + :title="$options.i18n.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" + size="sm" + @hide="handleModalHide" + @primary="handleModalPrimary" + > + <form ref="modalForm" :action="deleteAction" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + {{ $options.i18n.modal.body }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index 74e9c60a57b..3a54035c587 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -26,16 +27,15 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Activate user %{username}?'), { username: this.username, }), - messageHtml, actionCancel: { text: __('Cancel'), }, @@ -43,15 +43,16 @@ export default { text: I18N_USER_ACTIONS.activate, attributes: [{ variant: 'confirm' }], }, - }), - }; + messageHtml, + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 77a9be8eec2..5a8c675822d 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -28,12 +29,12 @@ export default { required: true, }, }, - computed: { - attributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Approve user %{username}?'), { username: this.username, }), @@ -45,16 +46,15 @@ export default { attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }], }, messageHtml, - }), - 'data-qa-selector': 'approve_user_button', - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }"> + <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index e5ab0f9123f..55938832dce 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -2,6 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -39,12 +40,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Ban user %{username}?'), { username: this.username, }), @@ -56,15 +57,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index 03557008a89..d25dd400f9b 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -29,12 +30,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }), actionCancel: { text: __('Cancel'), @@ -44,15 +45,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index 640c8fefc20..c85f3f01675 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -36,12 +37,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), { username: this.username, }), @@ -53,15 +54,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index 901306455fa..bac08de1d5e 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -2,6 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -39,12 +40,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'delete', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'delete', + modalAttributes: { title: sprintf(s__('AdminUsers|Reject user %{username}?'), { username: this.username, }), @@ -56,15 +57,15 @@ export default { attributes: [{ variant: 'danger' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue index 8083e26177e..beede2d37d7 100644 --- a/app/assets/javascripts/admin/users/components/actions/unban.vue +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -22,12 +23,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unban user %{username}?'), { username: this.username, }), @@ -39,15 +40,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index 7de6653e0cd..720f2efd932 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { @@ -17,12 +18,13 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }), message: s__('AdminUsers|You can always block their account again if needed.'), actionCancel: { @@ -32,15 +34,15 @@ export default { text: I18N_USER_ACTIONS.unblock, attributes: [{ variant: 'confirm' }], }, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index 10d4fb06d61..55ea3e0aba7 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { @@ -17,12 +18,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }), message: __('Are you sure?'), actionCancel: { @@ -32,15 +33,15 @@ export default { text: I18N_USER_ACTIONS.unlock, attributes: [{ variant: 'confirm' }], }, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index e949498c55b..d7c08096376 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -57,14 +57,17 @@ export default { }; }, computed: { + trimmedUsername() { + return this.username.trim(); + }, modalTitle() { - return sprintf(this.title, { username: this.username }, false); + return sprintf(this.title, { username: this.trimmedUsername }, false); }, secondaryButtonLabel() { return s__('AdminUsers|Block user'); }, canSubmit() { - return this.enteredUsername === this.username; + return this.enteredUsername === this.trimmedUsername; }, obstacles() { try { @@ -104,7 +107,7 @@ export default { <p> <gl-sprintf :message="content"> <template #username> - <strong>{{ username }}</strong> + <strong>{{ trimmedUsername }}</strong> </template> <template #strong="props"> <strong>{{ props.content }}</strong> @@ -115,13 +118,13 @@ export default { <user-deletion-obstacles-list v-if="obstacles.length" :obstacles="obstacles" - :user-name="username" + :user-name="trimmedUsername" /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <template #username> - <code class="gl-white-space-pre-wrap">{{ username }}</code> + <code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index 4f4e2947341..567d7151847 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -112,7 +112,7 @@ export default { right :text="$options.i18n.userAdministration" :text-sr-only="!showButtonLabels" - icon="settings" + icon="ellipsis_h" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" > diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql index 40ec4c56171..0f9075c58bf 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -1,5 +1,6 @@ query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") { project(fullPath: $projectPath) { + id alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) { all open diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index babcdea935d..d4f4f244759 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql index a3a50651fd0..caa258e0848 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation destroyHttpIntegration($id: ID!) { httpIntegrationDestroy(input: { id: $id }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql index c0754d8e32b..2f30f9abb5c 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql @@ -3,6 +3,8 @@ mutation resetHttpIntegrationToken($id: ID!) { httpIntegrationResetToken(input: { id: $id }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql index 37df9ec25eb..2cf56613673 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql index d20a8b8334b..7299e6836d4 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql @@ -2,6 +2,7 @@ query getHttpIntegration($projectPath: ID!, $id: ID) { project(fullPath: $projectPath) { + id alertManagementHttpIntegrations(id: $id) { nodes { ...HttpIntegrationPayloadData diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql index 228dd5fb176..3cd3f2d92f8 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -2,6 +2,7 @@ query getIntegrations($projectPath: ID!) { project(fullPath: $projectPath) { + id alertManagementIntegrations { nodes { ...IntegrationItem diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql index 159b2661f0b..15df4a08cc2 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql @@ -1,5 +1,6 @@ query parsePayloadFields($projectPath: ID!, $payload: String!) { project(fullPath: $projectPath) { + id alertManagementPayloadFields(payloadExample: $payload) { path label 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 238081cc3c0..5a394059931 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui'; +import { GlBadge, GlTableLite, GlLink, GlEmptyState } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__ } from '~/locale'; @@ -13,7 +13,7 @@ const defaultHeaderAttrs = { export default { components: { GlBadge, - GlTable, + GlTableLite, GlSingleStat, GlLink, GlEmptyState, @@ -94,7 +94,7 @@ export default { :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label" :variant="devopsScoreMetrics.averageScore.scoreLevel.variant" /> - <gl-table + <gl-table-lite :fields="$options.tableHeaderFields" :items="devopsScoreMetrics.cards" thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" @@ -108,7 +108,7 @@ export default { }}</gl-badge> </div> </template> - </gl-table> + </gl-table-lite> </div> </div> </template> diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql index b870ed4dcbf..ea2f911fb54 100644 --- a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql +++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql @@ -5,6 +5,7 @@ query analyticsGetGroupProjects( $includeSubgroups: Boolean = false ) { group(fullPath: $groupFullPath) { + id projects( search: $search first: $first diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index adf3e122a64..8c996b448aa 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -91,6 +91,7 @@ const Api = { projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings', groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', notificationSettingsPath: '/api/:version/notification_settings', + deployKeysPath: '/api/:version/deploy_keys', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -950,6 +951,12 @@ const Api = { return axios.delete(url); }, + deployKeys(params = {}) { + const url = Api.buildUrl(this.deployKeysPath); + + return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } }); + }, + async updateNotificationSettings(projectId, groupId, data = {}) { let url = Api.buildUrl(this.notificationSettingsPath); diff --git a/app/assets/javascripts/api/packages_api.js b/app/assets/javascripts/api/packages_api.js new file mode 100644 index 00000000000..47f51c7e80e --- /dev/null +++ b/app/assets/javascripts/api/packages_api.js @@ -0,0 +1,32 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const PUBLISH_PACKAGE_PATH = + '/api/:version/projects/:id/packages/generic/:package_name/:package_version/:file_name'; + +export function publishPackage( + { projectPath, name, version, fileName, files }, + options, + axiosOptions = {}, +) { + const url = buildApiUrl(PUBLISH_PACKAGE_PATH) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':package_name', name) + .replace(':package_version', version) + .replace(':file_name', fileName); + + const defaults = { + status: 'default', + }; + + const formData = new FormData(); + formData.append('file', files[0]); + + return axios.put(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + params: Object.assign(defaults, options), + ...axiosOptions, + }); +} diff --git a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql index 7486512c57c..91fa468fc8c 100644 --- a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql +++ b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql @@ -1,5 +1,6 @@ query getKeepLatestArtifactProjectSetting($fullPath: ID!) { project(fullPath: $fullPath) { + id ciCdSettings { keepLatestArtifact } diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 918519f386b..a218624f2d4 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -76,7 +76,7 @@ export default { }, }, safeHtmlConfig: { - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js new file mode 100644 index 00000000000..a6e203ea5a2 --- /dev/null +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -0,0 +1,66 @@ +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import { spriteIcon } from '~/lib/utils/common_utils'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +class CopyCodeButton extends HTMLElement { + connectedCallback() { + this.for = uniqueId('code-'); + + this.parentNode.querySelector('pre').setAttribute('id', this.for); + + this.appendChild(this.createButton()); + } + + createButton() { + const button = document.createElement('button'); + + setAttributes(button, { + type: 'button', + class: 'btn btn-default btn-md gl-button btn-icon has-tooltip', + 'data-title': __('Copy to clipboard'), + 'data-clipboard-target': `pre#${this.for}`, + }); + + button.innerHTML = spriteIcon('copy-to-clipboard'); + + return button; + } +} + +function addCodeButton() { + [...document.querySelectorAll('pre.code.js-syntax-highlight')] + .filter((el) => !el.closest('.js-markdown-code')) + .forEach((el) => { + const copyCodeEl = document.createElement('copy-code'); + copyCodeEl.setAttribute('for', uniqueId('code-')); + + const wrapper = document.createElement('div'); + wrapper.className = 'gl-relative markdown-code-block js-markdown-code'; + wrapper.appendChild(el.cloneNode(true)); + wrapper.appendChild(copyCodeEl); + + el.parentNode.insertBefore(wrapper, el); + + el.remove(); + }); +} + +export const initCopyCodeButton = (selector = '#content-body') => { + if (!customElements.get('copy-code')) { + customElements.define('copy-code', CopyCodeButton); + } + + const el = document.querySelector(selector); + + if (!el) return () => {}; + + const observer = new MutationObserver(() => addCodeButton()); + + observer.observe(document.querySelector(selector), { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); +}; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index ef445548e6e..8fe90b6bb15 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -33,7 +33,7 @@ class GlEmoji extends HTMLElement { this.dataset.unicodeVersion = unicodeVersion; emojiUnicode = emojiInfo.e; - this.innerHTML = emojiInfo.e; + this.textContent = emojiInfo.e; this.title = emojiInfo.d; } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index bfd025e8dab..30160248a77 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import './autosize'; -import './bind_in_out'; import './markdown/render_gfm'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initCopyToClipboard from './copy_to_clipboard'; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index c2908133fd0..e58c51104c5 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -3,6 +3,7 @@ import Mousetrap from 'mousetrap'; import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; +import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import Sidebar from '../../right_sidebar'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { @@ -114,6 +115,14 @@ export default class ShortcutsIssuable extends Shortcuts { static openSidebarDropdown(name) { Sidebar.instance.openDropdown(name); + // Wait for the sidebar to trigger('click') open + // so it doesn't cause our dropdown to close preemptively + setTimeout(() => { + const editBtn = + document.querySelector(`.block.${name} .shortcut-sidebar-dropdown-toggle`) || + document.querySelector(`.block.${name} .edit-link`); + editBtn.click(); + }, DEBOUNCE_DROPDOWN_DELAY); return false; } diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index e3e43ea3a0e..9832ebbea5c 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -86,7 +86,7 @@ export default { :file-name="blob.name" :type="activeViewer.fileType" :hide-line-numbers="hideLineNumbers" - data-qa-selector="file_content" + data-qa-selector="blob_viewer_file_content" /> </template> </div> diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index 96d6f500960..a1a62abeb6f 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -38,7 +38,13 @@ export default { <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> - <pdf-lab v-if="!loadError" :pdf="pdf" @pdflabload="onLoad" @pdflaberror="onError" /> + <pdf-lab + v-if="!loadError" + :pdf="pdf" + @pdflabload="onLoad" + @pdflaberror="onError" + v-on="$listeners" + /> <p v-if="error" class="text-center"> <span v-if="loadError" ref="loadError"> {{ __('An error occurred while loading the file. Please try again later.') }} diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index e75aa523ed0..47a0c4ba2d1 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -71,7 +71,7 @@ export default { i18n: { modalTitle: __("That's it, well done!"), pipelinesButton: s__('MR widget|See your pipeline in action'), - mergeRequestButton: s__('MR widget|Back to the Merge request'), + mergeRequestButton: s__('MR widget|Back to the merge request'), bodyMessage: s__( `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`, ), diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 118cef59d5a..ee2f6cfb46c 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import SourceEditor from '~/editor/source_editor'; import { getBlobLanguage } from '~/editor/utils'; @@ -26,23 +27,29 @@ export default class EditBlob { this.editor.focus(); } - fetchMarkdownExtension() { - import('~/editor/extensions/source_editor_markdown_ext') - .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use( - new MarkdownExtension({ - instance: this.editor, - previewMarkdownPath: this.options.previewMarkdownPath, - }), - ); - this.hasMarkdownExtension = true; - addEditorMarkdownListeners(this.editor); - }) - .catch((e) => - createFlash({ - message: `${BLOB_EDITOR_ERROR}: ${e}`, - }), - ); + async fetchMarkdownExtension() { + try { + const [ + { EditorMarkdownExtension: MarkdownExtension }, + { EditorMarkdownPreviewExtension: MarkdownLivePreview }, + ] = await Promise.all([ + import('~/editor/extensions/source_editor_markdown_ext'), + import('~/editor/extensions/source_editor_markdown_livepreview_ext'), + ]); + this.editor.use([ + { definition: MarkdownExtension }, + { + definition: MarkdownLivePreview, + setupOptions: { previewMarkdownPath: this.options.previewMarkdownPath }, + }, + ]); + } catch (e) { + createFlash({ + message: `${BLOB_EDITOR_ERROR}: ${e}`, + }); + } + this.hasMarkdownExtension = true; + addEditorMarkdownListeners(this.editor); } configureMonacoEditor() { @@ -60,7 +67,7 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use(new FileTemplateExtension({ instance: this.editor })); + this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index e6c91c7ac1f..7e4d3ebb686 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; import { isGid } from '~/graphql_shared/utils'; -import { ListType, MilestoneIDs } from './constants'; +import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants'; export function getMilestone() { return null; @@ -186,6 +186,7 @@ export function isListDraggable(list) { export const FiltersInfo = { assigneeUsername: { negatedSupport: true, + remap: (k, v) => (v === AssigneeFilterType.any ? 'assigneeWildcardId' : k), }, assigneeId: { // assigneeId should be renamed to assigneeWildcardId. @@ -204,6 +205,11 @@ export const FiltersInfo = { }, milestoneTitle: { negatedSupport: true, + remap: (k, v) => (Object.values(MilestoneFilterType).includes(v) ? 'milestoneWildcardId' : k), + }, + milestoneWildcardId: { + negatedSupport: true, + transform: (val) => val.toUpperCase(), }, myReactionEmoji: { negatedSupport: true, @@ -214,6 +220,10 @@ export const FiltersInfo = { types: { negatedSupport: true, }, + confidential: { + negatedSupport: false, + transform: (val) => val === 'yes', + }, search: { negatedSupport: false, }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index b6ccc6a00fe..ea80496c3f5 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -13,7 +13,7 @@ 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 TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +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 { ListType } from '../constants'; import eventHub from '../eventhub'; diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 54668c9e88e..f89f8e5feb8 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -4,7 +4,6 @@ import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { __, sprintf } from '~/locale'; -import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; @@ -26,7 +25,6 @@ export default { SidebarDateWidget, SidebarConfidentialityWidget, BoardSidebarTimeTracker, - BoardSidebarLabelsSelect, SidebarLabelsWidget, SidebarSubscriptionsWidget, SidebarDropdownWidget, @@ -210,7 +208,6 @@ export default { data-testid="sidebar-due-date" /> <sidebar-labels-widget - v-if="glFeatures.labelsWidget" class="block labels" data-testid="sidebar-labels" :iid="activeBoardItem.iid" @@ -230,7 +227,6 @@ export default { > {{ __('None') }} </sidebar-labels-widget> - <board-sidebar-labels-select v-else class="block labels" /> <sidebar-weight-widget v-if="weightFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 6e6ada2d109..09ec385bbba 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,7 +1,7 @@ <script> import { pickBy, isEmpty } from 'lodash'; import { mapActions } from 'vuex'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -39,30 +39,33 @@ export default { assigneeUsername, search, milestoneTitle, + iterationId, types, weight, epicId, myReactionEmoji, + releaseTag, + confidential, } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { filteredSearchValue.push({ - type: 'author_username', + type: 'author', value: { data: authorUsername, operator: '=' }, }); } if (assigneeUsername) { filteredSearchValue.push({ - type: 'assignee_username', + type: 'assignee', value: { data: assigneeUsername, operator: '=' }, }); } if (types) { filteredSearchValue.push({ - type: 'types', + type: 'type', value: { data: types, operator: '=' }, }); } @@ -70,7 +73,7 @@ export default { if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ - type: 'label_name', + type: 'label', value: { data: label, operator: '=' }, })), ); @@ -78,11 +81,18 @@ export default { if (milestoneTitle) { filteredSearchValue.push({ - type: 'milestone_title', + type: 'milestone', value: { data: milestoneTitle, operator: '=' }, }); } + if (iterationId) { + filteredSearchValue.push({ + type: 'iteration', + value: { data: iterationId, operator: '=' }, + }); + } + if (weight) { filteredSearchValue.push({ type: 'weight', @@ -92,32 +102,53 @@ export default { if (myReactionEmoji) { filteredSearchValue.push({ - type: 'my_reaction_emoji', + type: 'my-reaction', value: { data: myReactionEmoji, operator: '=' }, }); } + if (releaseTag) { + filteredSearchValue.push({ + type: 'release', + value: { data: releaseTag, operator: '=' }, + }); + } + + if (confidential !== undefined) { + filteredSearchValue.push({ + type: 'confidential', + value: { data: confidential }, + }); + } + if (epicId) { filteredSearchValue.push({ - type: 'epic_id', + type: 'epic', value: { data: epicId, operator: '=' }, }); } if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ - type: 'author_username', + type: 'author', value: { data: this.filterParams['not[authorUsername]'], operator: '!=' }, }); } if (this.filterParams['not[milestoneTitle]']) { filteredSearchValue.push({ - type: 'milestone_title', + type: 'milestone', value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' }, }); } + if (this.filterParams['not[iteration_id]']) { + filteredSearchValue.push({ + type: 'iteration_id', + value: { data: this.filterParams['not[iteration_id]'], operator: '!=' }, + }); + } + if (this.filterParams['not[weight]']) { filteredSearchValue.push({ type: 'weight', @@ -127,7 +158,7 @@ export default { if (this.filterParams['not[assigneeUsername]']) { filteredSearchValue.push({ - type: 'assignee_username', + type: 'assignee', value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' }, }); } @@ -135,7 +166,7 @@ export default { if (this.filterParams['not[labelName]']) { filteredSearchValue.push( ...this.filterParams['not[labelName]'].map((label) => ({ - type: 'label_name', + type: 'label', value: { data: label, operator: '!=' }, })), ); @@ -143,25 +174,32 @@ export default { if (this.filterParams['not[types]']) { filteredSearchValue.push({ - type: 'types', + type: 'type', value: { data: this.filterParams['not[types]'], operator: '!=' }, }); } if (this.filterParams['not[epicId]']) { filteredSearchValue.push({ - type: 'epic_id', + type: 'epic', value: { data: this.filterParams['not[epicId]'], operator: '!=' }, }); } if (this.filterParams['not[myReactionEmoji]']) { filteredSearchValue.push({ - type: 'my_reaction_emoji', + type: 'my-reaction', value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' }, }); } + if (this.filterParams['not[releaseTag]']) { + filteredSearchValue.push({ + type: 'release', + value: { data: this.filterParams['not[releaseTag]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } @@ -179,8 +217,10 @@ export default { weight, epicId, myReactionEmoji, + iterationId, + releaseTag, + confidential, } = this.filterParams; - let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -194,6 +234,8 @@ export default { 'not[weight]': this.filterParams.not.weight, 'not[epic_id]': this.filterParams.not.epicId, 'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji, + 'not[iteration_id]': this.filterParams.not.iterationId, + 'not[release_tag]': this.filterParams.not.releaseTag, }, undefined, ); @@ -205,11 +247,14 @@ export default { 'label_name[]': labelName, assignee_username: assigneeUsername, milestone_title: milestoneTitle, + iteration_id: iterationId, search, types, weight, - epic_id: getIdFromGraphQLId(epicId), + epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId, my_reaction_emoji: myReactionEmoji, + release_tag: releaseTag, + confidential, }; }, }, @@ -246,30 +291,39 @@ export default { filters.forEach((filter) => { switch (filter.type) { - case 'author_username': + case 'author': filterParams.authorUsername = filter.value.data; break; - case 'assignee_username': + case 'assignee': filterParams.assigneeUsername = filter.value.data; break; - case 'types': + case 'type': filterParams.types = filter.value.data; break; - case 'label_name': + case 'label': labels.push(filter.value.data); break; - case 'milestone_title': + case 'milestone': filterParams.milestoneTitle = filter.value.data; break; + case 'iteration': + filterParams.iterationId = filter.value.data; + break; case 'weight': filterParams.weight = filter.value.data; break; - case 'epic_id': + case 'epic': filterParams.epicId = filter.value.data; break; - case 'my_reaction_emoji': + case 'my-reaction': filterParams.myReactionEmoji = filter.value.data; break; + case 'release': + filterParams.releaseTag = filter.value.data; + break; + case 'confidential': + filterParams.confidential = filter.value.data; + break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; @@ -285,6 +339,7 @@ export default { if (plainText.length) { filterParams.search = plainText.join(' '); } + return filterParams; }, }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 47dffc985aa..e4c3c3206a8 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; import Tracking from '~/tracking'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; @@ -50,11 +51,22 @@ export default { showEpicForm: false, }; }, + apollo: { + boardList: { + query: listQuery, + variables() { + return { + id: this.list.id, + filters: this.filterParams, + }; + }, + }, + }, computed: { - ...mapState(['pageInfoByListId', 'listsFlags']), + ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']), ...mapGetters(['isEpicBoard']), listItemsCount() { - return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount; + return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; }, paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index e985a368e64..19004518edf 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -17,6 +17,7 @@ import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import { formatDate } from '~/lib/utils/datetime_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; @@ -74,7 +75,7 @@ export default { }, }, computed: { - ...mapState(['activeId']), + ...mapState(['activeId', 'filterParams']), ...mapGetters(['isEpicBoard', 'isSwimlanesOn']), isLoggedIn() { return Boolean(this.currentUserId); @@ -119,14 +120,11 @@ export default { } return false; }, - itemsCount() { - return this.list.issuesCount; - }, countIcon() { return 'issues'; }, itemsTooltipLabel() { - return n__(`%d issue`, `%d issues`, this.itemsCount); + return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount); }, chevronTooltip() { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; @@ -158,6 +156,23 @@ export default { userCanDrag() { return !this.disabled && isListDraggable(this.list); }, + isLoading() { + return this.$apollo.queries.boardList.loading; + }, + }, + apollo: { + boardList: { + query: listQuery, + variables() { + return { + id: this.list.id, + filters: this.filterParams, + }; + }, + skip() { + return this.isEpicBoard; + }, + }, }, created() { const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`)); @@ -375,10 +390,10 @@ export default { </gl-sprintf> </div> <div v-else>• {{ itemsTooltipLabel }}</div> - <div v-if="weightFeatureAvailable"> + <div v-if="weightFeatureAvailable && !isLoading"> • <gl-sprintf :message="__('%{totalWeight} total weight')"> - <template #totalWeight>{{ list.totalWeight }}</template> + <template #totalWeight>{{ boardList.totalWeight }}</template> </gl-sprintf> </div> </gl-tooltip> @@ -396,14 +411,18 @@ export default { <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> <gl-icon class="gl-mr-2" :name="countIcon" /> - <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" /> + <item-count + v-if="!isLoading" + :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" + :max-issue-count="list.maxIssueCount" + /> </span> <!-- EE start --> - <template v-if="weightFeatureAvailable && !isEpicBoard"> + <template v-if="weightFeatureAvailable && !isEpicBoard && !isLoading"> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <gl-icon class="gl-mr-2" name="weight" /> - {{ list.totalWeight }} + {{ boardList.totalWeight }} </span> </template> <!-- EE end --> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 71facba1378..69343cd78d8 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -349,6 +349,9 @@ export default { 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') }} 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 bdb9c2be836..7fc87f9f672 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -2,22 +2,25 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { mapActions } from 'vuex'; +import { orderBy } from 'lodash'; import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; import { BoardType } from '~/boards/constants'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import issueBoardFilters from '~/boards/issue_board_filters'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { - DEFAULT_MILESTONES_GRAPHQL, TOKEN_TITLE_MY_REACTION, + OPERATOR_IS_AND_IS_NOT, + OPERATOR_IS_ONLY, } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; -import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; +import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; export default { types: { @@ -34,12 +37,11 @@ export default { incident: __('Incident'), issue: __('Issue'), milestone: __('Milestone'), - weight: __('Weight'), - is: __('is'), - isNot: __('is not'), + release: __('Release'), + confidential: __('Confidential'), }, components: { BoardFilteredSearch }, - inject: ['isSignedIn'], + inject: ['isSignedIn', 'releasesFetchPath'], props: { fullPath: { type: String, @@ -62,15 +64,14 @@ export default { tokensCE() { const { label, - is, - isNot, author, assignee, issue, incident, type, milestone, - weight, + release, + confidential, } = this.$options.i18n; const { types } = this.$options; const { fetchAuthors, fetchLabels } = issueBoardFilters( @@ -79,15 +80,12 @@ export default { this.boardType, ); - return [ + const tokens = [ { icon: 'user', title: assignee, - type: 'assignee_username', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'assignee', + operators: OPERATOR_IS_AND_IS_NOT, token: AuthorToken, unique: true, fetchAuthors, @@ -96,11 +94,8 @@ export default { { icon: 'pencil', title: author, - type: 'author_username', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'author', + operators: OPERATOR_IS_AND_IS_NOT, symbol: '@', token: AuthorToken, unique: true, @@ -110,11 +105,8 @@ export default { { icon: 'labels', title: label, - type: 'label_name', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'label', + operators: OPERATOR_IS_AND_IS_NOT, token: LabelToken, unique: false, symbol: '~', @@ -123,7 +115,7 @@ export default { ...(this.isSignedIn ? [ { - type: 'my_reaction_emoji', + type: 'my-reaction', title: TOKEN_TITLE_MY_REACTION, icon: 'thumb-up', token: EmojiToken, @@ -144,22 +136,33 @@ export default { }); }, }, + { + type: 'confidential', + icon: 'eye-slash', + title: confidential, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + options: [ + { icon: 'eye-slash', value: 'yes', title: __('Yes') }, + { icon: 'eye', value: 'no', title: __('No') }, + ], + }, ] : []), { - type: 'milestone_title', + type: 'milestone', title: milestone, icon: 'clock', symbol: '%', token: MilestoneToken, unique: true, - defaultMilestones: DEFAULT_MILESTONES_GRAPHQL, fetchMilestones: this.fetchMilestones, }, { icon: 'issues', title: type, - type: 'types', + type: 'type', token: GlFilteredSearchToken, unique: true, options: [ @@ -168,13 +171,27 @@ export default { ], }, { - type: 'weight', - title: weight, - icon: 'weight', - token: WeightToken, - unique: true, + type: 'release', + title: release, + icon: 'rocket', + token: ReleaseToken, + fetchReleases: (search) => { + // TODO: Switch to GraphQL query when backend is ready: https://gitlab.com/gitlab-org/gitlab/-/issues/337686 + return axios + .get(joinPaths(gon.relative_url_root, this.releasesFetchPath)) + .then(({ data }) => { + if (search) { + return fuzzaldrinPlus.filter(data, search, { + key: ['tag'], + }); + } + return data; + }); + }, }, ]; + + return orderBy(tokens, ['title']); }, tokens() { return this.tokensCE; diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue deleted file mode 100644 index ec53947fd5f..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ /dev/null @@ -1,173 +0,0 @@ -<script> -import { GlLabel } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import Api from '~/api'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; - -export default { - components: { - BoardEditableItem, - LabelsSelect, - GlLabel, - }, - inject: { - labelsFetchPath: { - default: null, - }, - labelsManagePath: {}, - labelsFilterBasePath: {}, - }, - data() { - return { - loading: false, - oldIid: null, - isEditing: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), - selectedLabels() { - const { labels = [] } = this.activeBoardItem; - - return labels.map((label) => ({ - ...label, - id: getIdFromGraphQLId(label.id), - })); - }, - issueLabels() { - const { labels = [] } = this.activeBoardItem; - - return labels.map((label) => ({ - ...label, - scoped: isScopedLabel(label), - })); - }, - fetchPath() { - /* - Labels fetched in epic boards are always group-level labels - and the correct path are passed from the backend (injected through labelsFetchPath) - - For issue boards, we should always include project-level labels and use a different endpoint. - (it requires knowing the project path of a selected issue.) - - Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget. - And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653. - - Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates. - 'labels-select' has its own vuex store and initializes the passed props as states - and these states aren't reactively bound to the passed props. - */ - - const projectLabelsFetchPath = mergeUrlParams( - { include_ancestor_groups: true }, - Api.buildUrl(Api.projectLabelsPath).replace( - ':namespace_path/:project_path', - this.projectPathForActiveIssue, - ), - ); - - return this.labelsFetchPath || projectLabelsFetchPath; - }, - }, - watch: { - activeBoardItem(_, oldVal) { - if (this.isEditing) { - this.oldIid = oldVal.iid; - } else { - this.oldIid = null; - } - }, - }, - methods: { - ...mapActions(['setActiveBoardItemLabels', 'setError']), - async setLabels(payload) { - this.loading = true; - this.$refs.sidebarItem.collapse(); - - try { - const addLabelIds = payload.filter((label) => label.set).map((label) => label.id); - const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id); - - const input = { - addLabelIds, - removeLabelIds, - projectPath: this.projectPathForActiveIssue, - iid: this.oldIid, - }; - await this.setActiveBoardItemLabels(input); - this.oldIid = null; - } catch (e) { - this.setError({ error: e, message: __('An error occurred while updating labels.') }); - } finally { - this.loading = false; - } - }, - async removeLabel(id) { - this.loading = true; - - try { - const removeLabelIds = [getIdFromGraphQLId(id)]; - const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveBoardItemLabels(input); - } catch (e) { - this.setError({ error: e, message: __('An error occurred when removing the label.') }); - } finally { - this.loading = false; - } - }, - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - :title="__('Labels')" - :loading="loading" - data-testid="sidebar-labels" - @open="isEditing = true" - @close="isEditing = false" - > - <template #collapsed> - <gl-label - v-for="label in issueLabels" - :key="label.id" - :background-color="label.color" - :title="label.title" - :description="label.description" - :scoped="label.scoped" - :show-close-button="true" - :disabled="loading" - class="gl-mr-2 gl-mb-2" - @close="removeLabel(label.id)" - /> - </template> - <template #default="{ edit }"> - <labels-select - ref="labelsSelect" - :key="fetchPath" - :allow-label-edit="false" - :allow-label-create="false" - :allow-multiselect="true" - :allow-scoped-labels="true" - :selected-labels="selectedLabels" - :labels-fetch-path="fetchPath" - :labels-manage-path="labelsManagePath" - :labels-filter-base-path="labelsFilterBasePath" - :labels-list-title="__('Select label')" - :dropdown-button-text="__('Choose labels')" - :is-editing="edit" - variant="sidebar" - class="gl-display-block labels gl-w-full" - @updateSelectedLabels="setLabels" - > - {{ __('None') }} - </labels-select> - </template> - </board-editable-item> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue deleted file mode 100644 index 4f5c55d0c5d..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ /dev/null @@ -1,75 +0,0 @@ -<script> -import { GlToggle } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import { __, s__ } from '~/locale'; - -export default { - i18n: { - header: { - title: __('Notifications'), - /* Any change to subscribeDisabledDescription - must be reflected in app/helpers/notifications_helper.rb */ - subscribeDisabledDescription: __( - 'Notifications have been disabled by the project or group owner', - ), - }, - updateSubscribedErrorMessage: s__( - 'IssueBoards|An error occurred while setting notifications status. Please try again.', - ), - }, - components: { - GlToggle, - }, - inject: ['emailsDisabled'], - data() { - return { - loading: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']), - isEmailsDisabled() { - return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled; - }, - notificationText() { - return this.isEmailsDisabled - ? this.$options.i18n.header.subscribeDisabledDescription - : this.$options.i18n.header.title; - }, - }, - methods: { - ...mapActions(['setActiveItemSubscribed', 'setError']), - async handleToggleSubscription() { - this.loading = true; - try { - await this.setActiveItemSubscribed({ - subscribed: !this.activeBoardItem.subscribed, - projectPath: this.projectPathForActiveIssue, - }); - } catch (error) { - this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage }); - } finally { - this.loading = false; - } - }, - }, -}; -</script> - -<template> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between" - data-testid="sidebar-notifications" - > - <span data-testid="notification-header-text"> {{ notificationText }} </span> - <gl-toggle - v-if="!isEmailsDisabled" - :value="activeBoardItem.subscribed" - :is-loading="loading" - :label="$options.i18n.header.title" - label-position="hidden" - data-testid="notification-subscribe-toggle" - @change="handleToggleSubscription" - /> - </div> -</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 391e0d1fb0a..851b5eca40d 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -104,8 +104,10 @@ export const FilterFields = { 'assigneeUsername', 'assigneeWildcardId', 'authorUsername', + 'confidential', 'labelName', 'milestoneTitle', + 'milestoneWildcardId', 'myReactionEmoji', 'releaseTag', 'search', @@ -114,6 +116,18 @@ export const FilterFields = { ], }; +/* eslint-disable @gitlab/require-i18n-strings */ +export const AssigneeFilterType = { + any: 'Any', +}; + +export const MilestoneFilterType = { + any: 'Any', + none: 'None', + started: 'Started', + upcoming: 'Upcoming', +}; + export const DraggableItemTypes = { card: 'card', list: 'list', diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql index b19a24e8808..525a4863379 100644 --- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql @@ -7,6 +7,7 @@ query BoardLabels( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { nodes { ...Label @@ -14,6 +15,7 @@ query BoardLabels( } } project(fullPath: $fullPath) @include(if: $isProject) { + id labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { ...Label diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index 0e1d11727cf..81cc7b4d246 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -2,6 +2,8 @@ mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql index d85b736720b..5b532906f6a 100644 --- a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql @@ -4,7 +4,6 @@ fragment BoardListShared on BoardList { position listType collapsed - issuesCount label { id title diff --git a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql index b474c9acb93..7ea0e2f915a 100644 --- a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql @@ -2,6 +2,8 @@ mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) { updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index 47e87907d76..e6e98864aad 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -8,9 +8,13 @@ query BoardLists( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id board(id: $boardId) { + id hideBacklogList lists(issueFilters: $filters) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } @@ -18,9 +22,13 @@ query BoardLists( } } project(fullPath: $fullPath) @include(if: $isProject) { + id board(id: $boardId) { + id hideBacklogList lists(issueFilters: $filters) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql new file mode 100644 index 00000000000..bae3220dfad --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql @@ -0,0 +1,6 @@ +query BoardList($id: ID!, $filters: BoardIssueInput) { + boardList(id: $id, issueFilters: $filters) { + id + issuesCount + } +} diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql index 77c8e0378f0..8d87b83da96 100644 --- a/app/assets/javascripts/boards/graphql/group_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql @@ -2,6 +2,7 @@ query GroupBoard($fullPath: ID!, $boardId: ID!) { workspace: group(fullPath: $fullPath) { + id board(id: $boardId) { ...BoardScopeFragment } diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql index d3251c2aa12..aec674eb006 100644 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -3,6 +3,7 @@ query GroupBoardMembers($fullPath: ID!, $search: String) { workspace: group(fullPath: $fullPath) { __typename + id assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { __typename nodes { diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql index 73aa9137dec..0963b3fbfaa 100644 --- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -1,5 +1,6 @@ query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) { group(fullPath: $fullPath) { + id milestones(includeAncestors: true, searchTitle: $searchTerm) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql index feafd6ae10d..0823c4f5a83 100644 --- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql @@ -2,6 +2,7 @@ query group_boards($fullPath: ID!) { group(fullPath: $fullPath) { + id boards { edges { node { diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index c5732bbaff3..0da14d0b872 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -2,6 +2,7 @@ query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) { group(fullPath: $fullPath) { + id projects(search: $search, after: $after, first: 100, includeSubgroups: true) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql index 70eb1dfbf7e..c9c5d744371 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql @@ -1,13 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + mutation issueSetLabels($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { + updateIssuableLabels: updateIssue(input: $input) { + issuable: issue { id labels { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql index bfb87758e17..c130a64cac4 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { updateIssuableSubscription: issueSetSubscription(input: $input) { issue { + id subscribed } errors diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql index 6ad12d982e0..147cf040a85 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetTitle($input: UpdateIssueInput!) { updateIssuableTitle: updateIssue(input: $input) { issue { + id title } errors diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 9f93bc6d5bf..105f2931caa 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/issue.fragment.graphql" -query BoardListEE( +query BoardListsEE( $fullPath: ID! $boardId: ID! $id: ID @@ -11,7 +11,9 @@ query BoardListEE( $first: Int ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id board(id: $boardId) { + id lists(id: $id, issueFilters: $filters) { nodes { id @@ -33,7 +35,9 @@ query BoardListEE( } } project(fullPath: $fullPath) @include(if: $isProject) { + id board(id: $boardId) { + id lists(id: $id, issueFilters: $filters) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql index 6e4cd6bed57..8246d615a6a 100644 --- a/app/assets/javascripts/boards/graphql/project_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql @@ -2,6 +2,7 @@ query ProjectBoard($fullPath: ID!, $boardId: ID!) { workspace: project(fullPath: $fullPath) { + id board(id: $boardId) { ...BoardScopeFragment } diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql index fc6cc6b832c..45bec5e574b 100644 --- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql @@ -3,6 +3,7 @@ query ProjectBoardMembers($fullPath: ID!, $search: String) { workspace: project(fullPath: $fullPath) { __typename + id assignees: projectMembers(search: $search) { __typename nodes { diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql index 8dd4d256caa..e456823d78a 100644 --- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -1,5 +1,6 @@ query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) { project(fullPath: $fullPath) { + id milestones(searchTitle: $searchTerm, includeAncestors: true) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql index f98d25ba671..b8879bc260c 100644 --- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql @@ -2,6 +2,7 @@ query project_boards($fullPath: ID!) { project(fullPath: $fullPath) { + id boards { edges { node { diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql index 61c9ddded9b..4c952096d76 100644 --- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql @@ -5,6 +5,7 @@ query boardProjectMilestones( $searchTitle: String ) { project(fullPath: $fullPath) { + id milestones(state: $state, includeAncestors: $includeAncestors, searchTitle: $searchTitle) { edges { node { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 6fa8dd63245..ded3bfded86 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -110,7 +110,8 @@ export default () => { }); if (gon?.features?.issueBoardsFilteredSearch) { - initBoardsFilteredSearch(apolloProvider, isLoggedIn()); + const { releasesFetchPath } = $boardApp.dataset; + initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath); } mountBoardApp($boardApp); diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js index 1ea74d5685c..a8ade58e316 100644 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -4,7 +4,7 @@ import store from '~/boards/stores'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; -export default (apolloProvider, isSignedIn) => { +export default (apolloProvider, isSignedIn, releasesFetchPath) => { const el = document.getElementById('js-issue-board-filtered-search'); const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -21,6 +21,7 @@ export default (apolloProvider, isSignedIn) => { provide: { initialFilterParams, isSignedIn, + releasesFetchPath, }, store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 apolloProvider, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 3a96e535cf7..1ebfcfc331b 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -16,30 +16,30 @@ import { ListTypeTitles, DraggableItemTypes, } from 'ee_else_ce/boards/constants'; -import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; -import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import { + formatIssueInput, formatBoardLists, formatListIssues, formatListsPageInfo, formatIssue, - formatIssueInput, updateListPosition, moveItemListHelper, getMoveData, FiltersInfo, filterVariables, -} from '../boards_util'; +} from 'ee_else_ce/boards/boards_util'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; +import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import { gqlClient } from '../graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; @@ -373,7 +373,6 @@ export default { commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, fullBoardId, boardType, filterParams } = state; - const variables = { fullPath, boardId: fullBoardId, @@ -503,9 +502,10 @@ export default { updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { try { - const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData; + const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData; const { fullBoardId, + filterParams, boardItems: { [itemId]: { iid, referencePath }, }, @@ -524,6 +524,67 @@ export default { // 'mutationVariables' allows EE code to pass in extra parameters. ...mutationVariables, }, + update( + cache, + { + data: { + issueMoveList: { + issue: { weight }, + }, + }, + }, + ) { + if (fromListId === toListId) return; + + const updateFromList = () => { + const fromList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: fromListId, filters: filterParams }, + }); + + const updatedFromList = { + boardList: { + __typename: 'BoardList', + id: fromList.boardList.id, + issuesCount: fromList.boardList.issuesCount - 1, + totalWeight: fromList.boardList.totalWeight - Number(weight), + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: fromListId, filters: filterParams }, + data: updatedFromList, + }); + }; + + const updateToList = () => { + if (!itemNotInToList) return; + + const toList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: toListId, filters: filterParams }, + }); + + const updatedToList = { + boardList: { + __typename: 'BoardList', + id: toList.boardList.id, + issuesCount: toList.boardList.issuesCount + 1, + totalWeight: toList.boardList.totalWeight + Number(weight), + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: toListId, filters: filterParams }, + data: updatedToList, + }); + }; + + updateFromList(); + updateToList(); + }, }); if (data?.issueMoveList?.errors.length || !data.issueMoveList) { @@ -567,7 +628,7 @@ export default { }, addListNewIssue: ( - { state: { boardConfig, boardType, fullPath }, dispatch, commit }, + { state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit }, { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` }, ) => { const input = formatIssueInput(issueInput, boardConfig); @@ -583,6 +644,27 @@ export default { .mutate({ mutation: issueCreateMutation, variables: { input }, + update(cache) { + const fromList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: list.id, filters: filterParams }, + }); + + const updatedList = { + boardList: { + __typename: 'BoardList', + id: fromList.boardList.id, + issuesCount: fromList.boardList.issuesCount + 1, + totalWeight: fromList.boardList.totalWeight, + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: list.id, filters: filterParams }, + data: updatedList, + }); + }, }) .then(({ data }) => { if (data.createIssue.errors.length) { @@ -610,33 +692,6 @@ export default { setActiveIssueLabels: async ({ commit, getters }, input) => { const { activeBoardItem } = getters; - if (!gon.features?.labelsWidget) { - const { data } = await gqlClient.mutate({ - mutation: issueSetLabelsMutation, - variables: { - input: { - iid: input.iid || String(activeBoardItem.iid), - labelIds: input.labelsId ?? undefined, - addLabelIds: input.addLabelIds ?? [], - removeLabelIds: input.removeLabelIds ?? [], - projectPath: input.projectPath, - }, - }, - }); - - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: data.updateIssue?.issue?.id || activeBoardItem.id, - prop: 'labels', - value: data.updateIssue?.issue?.labels.nodes, - }); - - return; - } - let labels = input?.labels || []; if (input.removeLabelIds) { labels = activeBoardItem.labels.filter( diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index bc8a1f05ef5..d541e89756a 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; +import lintCiMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 77ec1f1af47..4ab9b36058d 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -3,7 +3,7 @@ import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +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'; export default { diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 8e527e2bff6..e630ce71bd3 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -17,6 +17,7 @@ import { import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { mapComputed } from '~/vuex_shared/bindings'; import { @@ -25,10 +26,14 @@ import { AWS_TIP_DISMISSED_COOKIE_NAME, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, + EVENT_LABEL, + EVENT_ACTION, } from '../constants'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); + export default { modalId: ADD_CI_VARIABLE_MODAL_ID, tokens: awsTokens, @@ -51,10 +56,11 @@ export default { GlModal, GlSprintf, }, - mixins: [glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin(), trackingMixin], data() { return { isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + validationErrorEventProperty: '', }; }, computed: { @@ -147,6 +153,14 @@ export default { return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); }, }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, methods: { ...mapActions([ 'addVariable', @@ -179,6 +193,7 @@ export default { this.clearModal(); this.resetSelectedEnvironment(); + this.resetValidationErrorEvents(); }, updateOrAddVariable() { if (this.variableBeingEdited) { @@ -193,6 +208,31 @@ export default { this.setVariableProtected(); } }, + trackVariableValidationErrors() { + const property = this.getTrackingErrorProperty(); + if (!this.validationErrorEventProperty && property) { + this.track(EVENT_ACTION, { property }); + this.validationErrorEventProperty = property; + } + }, + getTrackingErrorProperty() { + let property; + if (this.variable.secret_value?.length && !property) { + if (this.displayMaskedError && this.maskableRegex?.length) { + const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); + const regex = new RegExp(supportedChars, 'g'); + property = this.variable.secret_value.replace(regex, ''); + } + if (this.containsVariableReference) { + property = '$'; + } + } + + return property; + }, + resetValidationErrorEvents() { + this.validationErrorEventProperty = ''; + }, }, }; </script> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index b959d97daea..9c0ffab7f6b 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,5 +1,5 @@ <script> -import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui'; +import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { s__, __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -59,6 +59,7 @@ export default { }, directives: { GlModalDirective, + GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -102,27 +103,38 @@ export default { <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> </template> <template #cell(key)="{ item }"> - <div class="d-flex truncated-container"> - <span :id="`ci-variable-key-${item.id}`" class="d-inline-block mw-100 text-truncate">{{ - item.key - }}</span> - <ci-variable-popover - :target="`ci-variable-key-${item.id}`" - :value="item.key" - :tooltip-text="__('Copy key')" + <div class="gl-display-flex truncated-container gl-align-items-center"> + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.key }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy key')" + :data-clipboard-text="item.key" + :aria-label="__('Copy to clipboard')" /> </div> </template> <template #cell(value)="{ item }"> - <span v-if="valuesHidden">*********************</span> - <div v-else class="d-flex truncated-container"> - <span :id="`ci-variable-value-${item.id}`" class="d-inline-block mw-100 text-truncate">{{ - item.value - }}</span> - <ci-variable-popover - :target="`ci-variable-value-${item.id}`" - :value="item.value" - :tooltip-text="__('Copy value')" + <div class="gl-display-flex gl-align-items-center truncated-container"> + <span v-if="valuesHidden">*********************</span> + <span + v-else + :id="`ci-variable-value-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.value }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy value')" + :data-clipboard-text="item.value" + :aria-label="__('Copy to clipboard')" /> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 4ebbf05814b..663a912883b 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -19,6 +19,9 @@ export const AWS_TIP_MESSAGE = __( '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', ); +export const EVENT_LABEL = 'ci_variable_modal'; +export const EVENT_ACTION = 'validation_error'; + // AWS TOKEN CONSTANTS export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue new file mode 100644 index 00000000000..6567ce203bc --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -0,0 +1,176 @@ +<script> +import { + GlLoadingIcon, + GlEmptyState, + GlLink, + GlIcon, + GlAlert, + GlTooltipDirective, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { n__, s__, __ } from '~/locale'; +import { formatDate, getDayDifference, isToday } from '~/lib/utils/datetime_utility'; +import { EVENTS_STORED_DAYS } from '../constants'; +import getAgentActivityEventsQuery from '../graphql/queries/get_agent_activity_events.query.graphql'; +import ActivityHistoryItem from './activity_history_item.vue'; + +export default { + components: { + GlLoadingIcon, + GlEmptyState, + GlAlert, + GlLink, + GlIcon, + ActivityHistoryItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + emptyText: s__( + 'ClusterAgents|See Agent activity updates such as tokens created or revoked and clusters connected or not connected.', + ), + emptyTooltip: s__('ClusterAgents|What is GitLab Agent activity?'), + error: s__( + 'ClusterAgents|An error occurred while retrieving GitLab Agent activity. Reload the page to try again.', + ), + today: __('Today'), + yesterday: __('Yesterday'), + }, + emptyHelpLink: helpPagePath('user/clusters/agent/install/index', { + anchor: 'view-agent-activity', + }), + borderClasses: 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100', + apollo: { + agentEvents: { + query: getAgentActivityEventsQuery, + variables() { + return { + agentName: this.agentName, + projectPath: this.projectPath, + }; + }, + update: (data) => data?.project?.clusterAgent?.activityEvents?.nodes, + error() { + this.isError = true; + }, + }, + }, + inject: ['agentName', 'projectPath', 'activityEmptyStateImage'], + data() { + return { + isError: false, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.agentEvents?.loading; + }, + emptyStateTitle() { + return n__( + "ClusterAgents|There's no activity from the past day", + "ClusterAgents|There's no activity from the past %d days", + EVENTS_STORED_DAYS, + ); + }, + eventsList() { + const list = this.agentEvents; + const listByDates = {}; + + if (!list?.length) { + return listByDates; + } + + list.forEach((event) => { + const dateName = this.getFormattedDate(event.recordedAt); + if (!listByDates[dateName]) { + listByDates[dateName] = []; + } + listByDates[dateName].push(event); + }); + + return listByDates; + }, + hasEvents() { + return Object.keys(this.eventsList).length; + }, + }, + methods: { + isYesterday(date) { + const today = new Date(); + return getDayDifference(today, date) === -1; + }, + getFormattedDate(dateString) { + const date = new Date(dateString); + let dateName; + if (isToday(date)) { + dateName = this.$options.i18n.today; + } else if (this.isYesterday(date)) { + dateName = this.$options.i18n.yesterday; + } else { + dateName = formatDate(date, 'yyyy-mm-dd'); + } + return dateName; + }, + isLast(dateEvents, idx) { + return idx === dateEvents.length - 1; + }, + getBodyClasses(dateEvents, idx) { + return !this.isLast(dateEvents, idx) ? this.$options.borderClasses : ''; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" size="md" /> + + <div v-else-if="hasEvents"> + <div + v-for="(dateEvents, key) in eventsList" + :key="key" + class="agent-activity-list issuable-discussion" + > + <h4 + class="gl-pb-4 gl-ml-5" + :class="$options.borderClasses" + data-testid="activity-section-title" + > + {{ key }} + </h4> + + <ul class="notes main-notes-list timeline"> + <activity-history-item + v-for="(event, idx) in dateEvents" + :key="idx" + :event="event" + :body-class="getBodyClasses(dateEvents, idx)" + /> + </ul> + </div> + </div> + + <gl-alert v-else-if="isError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.error }} + </gl-alert> + + <gl-empty-state + v-else + :title="emptyStateTitle" + :svg-path="activityEmptyStateImage" + :svg-height="150" + > + <template #description + >{{ $options.i18n.emptyText }} + <gl-link + v-gl-tooltip + :href="$options.emptyHelpLink" + :title="$options.i18n.emptyTooltip" + :aria-label="$options.i18n.emptyTooltip" + ><gl-icon name="question" :size="14" + /></gl-link> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/activity_history_item.vue b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue new file mode 100644 index 00000000000..7792d89a575 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue @@ -0,0 +1,79 @@ +<script> +import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { EVENT_DETAILS, DEFAULT_ICON } from '../constants'; + +export default { + i18n: { + defaultBodyText: s__('ClusterAgents|Event occurred'), + }, + components: { + GlLink, + GlIcon, + GlSprintf, + TimeAgoTooltip, + HistoryItem, + }, + props: { + event: { + required: true, + type: Object, + }, + bodyClass: { + required: false, + default: '', + type: String, + }, + }, + computed: { + eventDetails() { + const defaultEvent = { + eventTypeIcon: DEFAULT_ICON, + title: this.event.kind, + body: this.$options.i18n.defaultBodyText, + }; + + const eventDetails = EVENT_DETAILS[this.event.kind] || defaultEvent; + const { eventTypeIcon, title, body, titleIcon } = eventDetails; + const resultEvent = { ...this.event, eventTypeIcon, title, body, titleIcon }; + + return resultEvent; + }, + }, +}; +</script> +<template> + <history-item :icon="eventDetails.eventTypeIcon" class="gl-my-0! gl-pr-0!"> + <strong> + <gl-sprintf :message="eventDetails.title" + ><template v-if="eventDetails.titleIcon" #titleIcon + ><gl-icon + class="gl-mr-2" + :name="eventDetails.titleIcon.name" + :size="12" + :class="eventDetails.titleIcon.class" + /> + </template> + <template #tokenName>{{ eventDetails.agentToken.name }}</template></gl-sprintf + > + </strong> + + <template #body> + <p class="gl-mt-2 gl-mb-0 gl-pb-2" :class="bodyClass"> + <gl-sprintf :message="eventDetails.body"> + <template #userName> + <span class="gl-font-weight-bold">{{ eventDetails.user.name }}</span> + <gl-link :href="eventDetails.user.webUrl">@{{ eventDetails.user.username }}</gl-link> + </template> + + <template #strong="{ content }"> + <span class="gl-font-weight-bold"> {{ content }} </span> + </template> + </gl-sprintf> + <time-ago-tooltip :time="eventDetails.recordedAt" /> + </p> + </template> + </history-item> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index afbba9d1f7c..9109c010500 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -8,11 +8,12 @@ import { GlTab, GlTabs, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { MAX_LIST_COUNT } from '../constants'; import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; import TokenTable from './token_table.vue'; +import ActivityEvents from './activity_events_list.vue'; export default { i18n: { @@ -20,6 +21,7 @@ export default { loadingError: s__('ClusterAgents|An error occurred while loading your agent'), tokens: s__('ClusterAgents|Access tokens'), unknownUser: s__('ClusterAgents|Unknown user'), + activity: __('Activity'), }, apollo: { clusterAgent: { @@ -47,6 +49,7 @@ export default { GlTabs, TimeAgoTooltip, TokenTable, + ActivityEvents, }, props: { agentName: { @@ -127,9 +130,14 @@ export default { </gl-sprintf> </p> - <gl-tabs> + <gl-tabs sync-active-tab-with-query-params lazy> + <gl-tab :title="$options.i18n.activity" query-param-value="activity"> + <activity-events :agent-name="agentName" :project-path="projectPath" /> + </gl-tab> + <slot name="ee-security-tab"></slot> - <gl-tab> + + <gl-tab query-param-value="tokens"> <template #title> <span data-testid="cluster-agent-token-count"> {{ $options.i18n.tokens }} @@ -143,7 +151,7 @@ export default { <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" /> <div v-else> - <TokenTable :tokens="tokens" /> + <token-table :tokens="tokens" /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" /> diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue index 70ed2566134..019fac531d1 100644 --- a/app/assets/javascripts/clusters/agents/components/token_table.vue +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -62,8 +62,8 @@ export default { ]; }, learnMoreUrl() { - return helpPagePath('user/clusters/agent/index.md', { - anchor: 'create-an-agent-record-in-gitlab', + return helpPagePath('user/clusters/agent/install/index', { + anchor: 'register-an-agent-with-gitlab', }); }, }, @@ -83,7 +83,14 @@ export default { </gl-link> </div> - <gl-table :items="tokens" :fields="fields" fixed stacked="md"> + <gl-table + :items="tokens" + :fields="fields" + fixed + stacked="md" + head-variant="white" + thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" + > <template #cell(lastUsed)="{ item }"> <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" /> <span v-else>{{ $options.i18n.neverUsed }}</span> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index bbc4630f83b..315c7662755 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -1 +1,38 @@ +import { s__ } from '~/locale'; + export const MAX_LIST_COUNT = 25; + +export const EVENTS_STORED_DAYS = 7; + +export const EVENT_DETAILS = { + token_created: { + eventTypeIcon: 'token', + title: s__('ClusterAgents|%{tokenName} created'), + body: s__('ClusterAgents|Token created by %{userName}'), + }, + token_revoked: { + eventTypeIcon: 'token', + title: s__('ClusterAgents|%{tokenName} revoked'), + body: s__('ClusterAgents|Token revoked by %{userName}'), + }, + agent_connected: { + eventTypeIcon: 'connected', + title: s__('ClusterAgents|%{titleIcon}Connected'), + body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'), + titleIcon: { + name: 'status-success', + class: 'text-success-500', + }, + }, + agent_disconnected: { + eventTypeIcon: 'connected', + title: s__('ClusterAgents|%{titleIcon}Not connected'), + body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'), + titleIcon: { + name: 'severity-critical', + class: 'text-danger-800', + }, + }, +}; + +export const DEFAULT_ICON = 'token'; diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql index 1e9187e8ad1..7deb057ede9 100644 --- a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql @@ -4,8 +4,8 @@ fragment Token on ClusterAgentToken { description lastUsedAt name - createdByUser { + id name } } diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql new file mode 100644 index 00000000000..0d7ff029387 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql @@ -0,0 +1,25 @@ +query getAgentActivityEvents($projectPath: ID!, $agentName: String!) { + project(fullPath: $projectPath) { + id + clusterAgent(name: $agentName) { + id + activityEvents { + nodes { + kind + level + recordedAt + agentToken { + id + name + } + user { + id + name + username + webUrl + } + } + } + } + } +} diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql index d01db8f0a6a..3662e925261 100644 --- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql @@ -10,11 +10,13 @@ query getClusterAgent( $beforeToken: String ) { project(fullPath: $projectPath) { + id clusterAgent(name: $agentName) { id createdAt createdByUser { + id name } diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index 426d8d83847..5796c9e308d 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -13,11 +13,12 @@ export default () => { } const defaultClient = createDefaultClient(); - const { agentName, projectPath } = el.dataset; + const { agentName, projectPath, activityEmptyStateImage } = el.dataset; return new Vue({ el, apolloProvider: new VueApollo({ defaultClient }), + provide: { agentName, projectPath, activityEmptyStateImage }, render(createElement) { return createElement(AgentShowPage, { props: { 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 af44a23b4b3..f54f7b11414 100644 --- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -1,107 +1,54 @@ <script> -import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants'; export default { i18n: I18N_AGENTS_EMPTY_STATE, modalId: INSTALL_AGENT_MODAL_ID, - multipleClustersDocsUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), - installDocsUrl: helpPagePath('administration/clusters/kas'), - getStartedDocsUrl: helpPagePath('user/clusters/agent/index', { - anchor: 'define-a-configuration-repository', - }), + agentDocsUrl: helpPagePath('user/clusters/agent/index'), components: { GlButton, GlEmptyState, GlLink, GlSprintf, - GlAlert, }, directives: { GlModalDirective, }, - inject: ['emptyStateImage', 'projectPath'], + inject: ['emptyStateImage'], props: { - hasConfigurations: { - type: Boolean, - required: true, - }, isChildComponent: { default: false, required: false, type: Boolean, }, }, - computed: { - repositoryPath() { - return `/${this.projectPath}`; - }, - }, }; </script> <template> <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state"> <template #description> - <p class="mw-460 gl-mx-auto gl-text-left"> - {{ $options.i18n.introText }} - </p> - <p class="mw-460 gl-mx-auto gl-text-left"> - <gl-sprintf :message="$options.i18n.multipleClustersText"> + <p class="gl-text-left"> + <gl-sprintf :message="$options.i18n.introText"> <template #link="{ content }"> - <gl-link - :href="$options.multipleClustersDocsUrl" - target="_blank" - data-testid="multiple-clusters-docs-link" - > + <gl-link :href="$options.agentDocsUrl"> {{ content }} </gl-link> </template> </gl-sprintf> </p> - - <p class="mw-460 gl-mx-auto"> - <gl-link :href="$options.installDocsUrl" target="_blank" data-testid="install-docs-link"> - {{ $options.i18n.learnMoreText }} - </gl-link> - </p> - - <gl-alert - v-if="!hasConfigurations" - variant="warning" - class="gl-mb-5 text-left" - :dismissible="false" - > - {{ $options.i18n.warningText }} - - <template #actions> - <gl-button - category="primary" - variant="info" - :href="$options.getStartedDocsUrl" - target="_blank" - class="gl-ml-0!" - > - {{ $options.i18n.readMoreText }} - </gl-button> - <gl-button category="secondary" variant="info" :href="repositoryPath"> - {{ $options.i18n.repositoryButtonText }} - </gl-button> - </template> - </gl-alert> </template> <template #actions> <gl-button v-if="!isChildComponent" v-gl-modal-directive="$options.modalId" - :disabled="!hasConfigurations" - data-testid="integration-primary-button" category="primary" variant="confirm" > - {{ $options.i18n.primaryButtonText }} + {{ $options.i18n.buttonText }} </gl-button> </template> </gl-empty-state> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index fb5cf7d1206..45108a28e37 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -86,9 +86,6 @@ export default { treePageInfo() { return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; }, - hasConfigurations() { - return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length); - }, }, methods: { reloadAgents() { @@ -161,11 +158,7 @@ export default { </div> </div> - <agent-empty-state - v-else - :has-configurations="hasConfigurations" - :is-child-component="isChildComponent" - /> + <agent-empty-state v-else :is-child-component="isChildComponent" /> </section> <gl-alert v-else variant="danger" :dismissible="false"> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index 9fb020d2f4f..1630d0d5c92 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -1,7 +1,6 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; -import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; export default { name: 'AvailableAgentsDropdown', @@ -10,36 +9,22 @@ export default { GlDropdown, GlDropdownItem, }, - inject: ['projectPath'], props: { isRegistering: { required: true, type: Boolean, }, - }, - apollo: { - agents: { - query: agentConfigurations, - variables() { - return { - projectPath: this.projectPath, - }; - }, - update(data) { - this.populateAvailableAgents(data); - }, + availableAgents: { + required: true, + type: Array, }, }, data() { return { - availableAgents: [], selectedAgent: null, }; }, computed: { - isLoading() { - return this.$apollo.queries.agents.loading; - }, dropdownText() { if (this.isRegistering) { return this.$options.i18n.registeringAgent; @@ -58,18 +43,11 @@ export default { isSelected(agent) { return this.selectedAgent === agent; }, - populateAvailableAgents(data) { - const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? []; - const configuredAgents = - data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; - - this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); - }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering"> + <gl-dropdown :text="dropdownText" :loading="isRegistering"> <gl-dropdown-item v-for="agent in availableAgents" :key="agent" 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 3879af6e9cb..ce601de57bd 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlButton, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; import { mapState } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; import { I18N_CLUSTERS_EMPTY_STATE } from '../constants'; @@ -11,6 +11,7 @@ export default { GlButton, GlLink, GlSprintf, + GlAlert, }, inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], props: { @@ -20,8 +21,11 @@ export default { type: Boolean, }, }, - learnMoreHelpUrl: helpPagePath('user/project/clusters/index'), - multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), + clustersHelpUrl: helpPagePath('user/infrastructure/clusters/index', { + anchor: 'certificate-based-kubernetes-integration-deprecated', + }), + blogPostUrl: + 'https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/', computed: { ...mapState(['canAddCluster']), }, @@ -29,48 +33,45 @@ export default { </script> <template> - <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> - <template #description> - <p class="gl-text-left"> - {{ $options.i18n.description }} - </p> - <p class="gl-text-left"> - <gl-sprintf :message="$options.i18n.multipleClustersText"> - <template #link="{ content }"> - <gl-link - :href="$options.multipleClustersHelpUrl" - target="_blank" - data-testid="multiple-clusters-docs-link" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> + <div> + <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> + <template #description> + <p class="gl-text-left"> + <gl-sprintf :message="$options.i18n.introText"> + <template #link="{ content }"> + <gl-link :href="$options.clustersHelpUrl">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> - <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text"> - {{ emptyStateHelpText }} - </p> + <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text"> + {{ emptyStateHelpText }} + </p> + </template> - <p> - <gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link"> - {{ $options.i18n.learnMoreLinkText }} - </gl-link> - </p> - </template> + <template #actions> + <gl-button + v-if="!isChildComponent" + data-testid="integration-primary-button" + data-qa-selector="add_kubernetes_cluster_link" + category="primary" + variant="confirm" + :disabled="!canAddCluster" + :href="newClusterPath" + > + {{ $options.i18n.buttonText }} + </gl-button> + </template> + </gl-empty-state> - <template #actions> - <gl-button - v-if="!isChildComponent" - data-testid="integration-primary-button" - data-qa-selector="add_kubernetes_cluster_link" - category="primary" - variant="confirm" - :disabled="!canAddCluster" - :href="newClusterPath" - > - {{ $options.i18n.buttonText }} - </gl-button> - </template> - </gl-empty-state> + <gl-alert variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.alertText"> + <template #link="{ content }"> + <gl-link :href="$options.blogPostUrl" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue index 9e03093aa67..7dd5ece9b8e 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue @@ -1,12 +1,22 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; -import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants'; +import Tracking from '~/tracking'; +import { + CLUSTERS_TABS, + MAX_CLUSTERS_LIST, + MAX_LIST_COUNT, + AGENT, + EVENT_LABEL_TABS, + EVENT_ACTIONS_CHANGE, +} from '../constants'; import Agents from './agents.vue'; import InstallAgentModal from './install_agent_modal.vue'; import ClustersActions from './clusters_actions.vue'; import Clusters from './clusters.vue'; import ClustersViewAll from './clusters_view_all.vue'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_TABS }); + export default { components: { GlTabs, @@ -18,6 +28,7 @@ export default { InstallAgentModal, }, CLUSTERS_TABS, + mixins: [trackingMixin], props: { defaultBranchName: { default: '.noBranch', @@ -34,9 +45,12 @@ export default { methods: { onTabChange(tabName) { this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName); - this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST; }, + trackTabChange(tab) { + const tabName = CLUSTERS_TABS[tab].queryParamValue; + this.track(EVENT_ACTIONS_CHANGE, { property: tabName }); + }, }, }; </script> @@ -47,6 +61,7 @@ export default { sync-active-tab-with-query-params nav-class="gl-flex-grow-1 gl-align-items-center" lazy + @input="trackTabChange" > <gl-tab v-for="(tab, idx) in $options.CLUSTERS_TABS" diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index 285876e57d8..0e312d21e4e 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -34,10 +34,12 @@ export default { directives: { GlModalDirective, }, - AGENT_CARD_INFO, - CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST, INSTALL_AGENT_MODAL_ID, + i18n: { + agent: AGENT_CARD_INFO, + certificate: CERTIFICATE_BASED_CARD_INFO, + }, inject: ['addClusterPath'], props: { defaultBranchName: { @@ -122,21 +124,21 @@ export default { </gl-sprintf> </h3> - <gl-badge id="clusters-recommended-badge" size="md" variant="info">{{ - $options.AGENT_CARD_INFO.tooltip.label + <gl-badge id="clusters-recommended-badge" variant="info">{{ + $options.i18n.agent.tooltip.label }}</gl-badge> <gl-popover target="clusters-recommended-badge" container="viewport" placement="bottom" - :title="$options.AGENT_CARD_INFO.tooltip.title" + :title="$options.i18n.agent.tooltip.title" > <p class="gl-mb-0"> - <gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text"> + <gl-sprintf :message="$options.i18n.agent.tooltip.text"> <template #link="{ content }"> <gl-link - :href="$options.AGENT_CARD_INFO.tooltip.link" + :href="$options.i18n.agent.tooltip.link" target="_blank" class="gl-font-sm" > @@ -159,9 +161,9 @@ export default { <gl-link v-if="totalAgents" data-testid="agents-tab-footer-link" - :href="`?tab=${$options.AGENT_CARD_INFO.tabName}`" - @click="changeTab($event, $options.AGENT_CARD_INFO.tabName)" - ><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText" + :href="`?tab=${$options.i18n.agent.tabName}`" + @click="changeTab($event, $options.i18n.agent.tabName)" + ><gl-sprintf :message="$options.i18n.agent.footerText" ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf ></gl-link ><gl-button @@ -169,7 +171,7 @@ export default { class="gl-ml-4" category="secondary" variant="confirm" - >{{ $options.AGENT_CARD_INFO.actionText }}</gl-button + >{{ $options.i18n.agent.actionText }}</gl-button > </template> </gl-card> @@ -190,6 +192,7 @@ export default { <template #total>{{ clustersCardTitle.total }}</template> </gl-sprintf> </h3> + <gl-badge variant="warning">{{ $options.i18n.certificate.badgeText }}</gl-badge> </template> <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" /> @@ -198,9 +201,9 @@ export default { <gl-link v-if="totalClusters" data-testid="clusters-tab-footer-link" - :href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`" - @click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)" - ><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText" + :href="`?tab=${$options.i18n.certificate.tabName}`" + @click="changeTab($event, $options.i18n.certificate.tabName)" + ><gl-sprintf :message="$options.i18n.certificate.footerText" ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf ></gl-link ><gl-button @@ -209,7 +212,7 @@ export default { variant="confirm" class="gl-ml-4" :href="addClusterPath" - >{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button + >{{ $options.i18n.certificate.actionText }}</gl-button > </template> </gl-card> diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 6eb2e85ecea..5eef76252bd 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -9,22 +9,48 @@ import { GlSprintf, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; +import Tracking from '~/tracking'; import { generateAgentRegistrationCommand } from '../clusters_util'; -import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants'; -import { addAgentToStore } from '../graphql/cache_update'; +import { + INSTALL_AGENT_MODAL_ID, + I18N_AGENT_MODAL, + KAS_DISABLED_ERROR, + EVENT_LABEL_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_SELECT, + EVENT_ACTIONS_CLICK, + MODAL_TYPE_EMPTY, + MODAL_TYPE_REGISTER, +} from '../constants'; +import { addAgentToStore, addAgentConfigToStore } from '../graphql/cache_update'; import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; +import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; import AvailableAgentsDropdown from './available_agents_dropdown.vue'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL }); + export default { modalId: INSTALL_AGENT_MODAL_ID, - i18n: I18N_INSTALL_AGENT_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_CLICK, + EVENT_LABEL_MODAL, + basicInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'install-the-agent-into-the-cluster', + }), + advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'advanced-installation', + }), + enableKasPath: helpPagePath('administration/clusters/kas'), + installAgentPath: helpPagePath('user/clusters/agent/install/index'), + registerAgentPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'register-an-agent-with-gitlab', + }), components: { AvailableAgentsDropdown, - ClipboardButton, CodeBlock, GlAlert, GlButton, @@ -33,8 +59,10 @@ export default { GlLink, GlModal, GlSprintf, + ModalCopyButton, }, - inject: ['projectPath', 'kasAddress'], + mixins: [trackingMixin], + inject: ['projectPath', 'kasAddress', 'emptyStateImage'], props: { defaultBranchName: { default: '.noBranch', @@ -46,6 +74,22 @@ export default { type: Number, }, }, + apollo: { + agents: { + query: agentConfigurations, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + this.populateAvailableAgents(data); + }, + error(error) { + this.kasDisabled = error?.message?.indexOf(KAS_DISABLED_ERROR) >= 0; + }, + }, + }, data() { return { registering: false, @@ -53,6 +97,8 @@ export default { agentToken: null, error: null, clusterAgent: null, + availableAgents: [], + kasDisabled: false, }; }, computed: { @@ -63,19 +109,11 @@ export default { return !this.registering && this.agentName !== null; }, canCancel() { - return !this.registered && !this.registering; + return !this.registered && !this.registering && this.isAgentRegistrationModal; }, agentRegistrationCommand() { return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); }, - basicInstallPath() { - return helpPagePath('user/clusters/agent/install/index', { - anchor: 'install-the-agent-into-the-cluster', - }); - }, - advancedInstallPath() { - return helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation' }); - }, getAgentsQueryVariables() { return { defaultBranchName: this.defaultBranchName, @@ -84,10 +122,31 @@ export default { projectPath: this.projectPath, }; }, + i18n() { + return I18N_AGENT_MODAL[this.modalType]; + }, + repositoryPath() { + return `/${this.projectPath}`; + }, + modalType() { + return !this.availableAgents?.length && !this.registered + ? MODAL_TYPE_EMPTY + : MODAL_TYPE_REGISTER; + }, + modalSize() { + return this.isEmptyStateModal ? 'sm' : 'md'; + }, + isEmptyStateModal() { + return this.modalType === MODAL_TYPE_EMPTY; + }, + isAgentRegistrationModal() { + return this.modalType === MODAL_TYPE_REGISTER; + }, }, methods: { setAgentName(name) { this.agentName = name; + this.track(EVENT_ACTIONS_SELECT); }, closeModal() { this.$refs.modal.hide(); @@ -96,8 +155,16 @@ export default { this.registering = false; this.agentName = null; this.agentToken = null; + this.clusterAgent = null; this.error = null; }, + populateAvailableAgents(data) { + const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? []; + const configuredAgents = + data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; + + this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); + }, createAgentMutation() { return this.$apollo .mutate({ @@ -117,7 +184,9 @@ export default { ); }, }) - .then(({ data: { createClusterAgent } }) => createClusterAgent); + .then(({ data: { createClusterAgent } }) => { + return createClusterAgent; + }); }, createAgentTokenMutation(agendId) { return this.$apollo @@ -129,6 +198,17 @@ export default { name: this.agentName, }, }, + update: (store, { data: { clusterAgentTokenCreate } }) => { + addAgentConfigToStore( + store, + clusterAgentTokenCreate, + this.clusterAgent, + agentConfigurations, + { + projectPath: this.projectPath, + }, + ); + }, }) .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate); }, @@ -158,7 +238,7 @@ export default { if (error) { this.error = error.message; } else { - this.error = this.$options.i18n.unknownError; + this.error = this.i18n.unknownError; } } finally { this.registering = false; @@ -172,115 +252,172 @@ export default { <gl-modal ref="modal" :modal-id="$options.modalId" - :title="$options.i18n.modalTitle" + :title="i18n.modalTitle" + :size="modalSize" static lazy @hidden="resetModal" + @show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })" > - <template v-if="!registered"> - <p> - <strong>{{ $options.i18n.selectAgentTitle }}</strong> - </p> + <template v-if="isAgentRegistrationModal"> + <template v-if="!registered"> + <p> + <strong>{{ i18n.selectAgentTitle }}</strong> + </p> - <p> - <gl-sprintf :message="$options.i18n.selectAgentBody"> - <template #link="{ content }"> - <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> + <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p> + <p> + <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link> + </p> - <form> - <gl-form-group label-for="agent-name"> - <available-agents-dropdown - class="gl-w-70p" - :is-registering="registering" - @agentSelected="setAgentName" - /> - </gl-form-group> - </form> + <form> + <gl-form-group label-for="agent-name"> + <available-agents-dropdown + class="gl-w-70p" + :is-registering="registering" + :available-agents="availableAgents" + @agentSelected="setAgentName" + /> + </gl-form-group> + </form> - <p v-if="error"> - <gl-alert - :title="$options.i18n.registrationErrorTitle" - variant="danger" - :dismissible="false" - > - {{ error }} - </gl-alert> - </p> - </template> + <p v-if="error"> + <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false"> + {{ error }} + </gl-alert> + </p> + </template> - <template v-else> - <p> - <strong>{{ $options.i18n.tokenTitle }}</strong> - </p> + <template v-else> + <p> + <strong>{{ i18n.tokenTitle }}</strong> + </p> - <p> - <gl-sprintf :message="$options.i18n.tokenBody"> - <template #link="{ content }"> - <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> + <p> + <gl-sprintf :message="i18n.tokenBody"> + <template #link="{ content }"> + <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> - <p> - <gl-alert - :title="$options.i18n.tokenSingleUseWarningTitle" - variant="warning" - :dismissible="false" - > - {{ $options.i18n.tokenSingleUseWarningBody }} - </gl-alert> - </p> + <p> + <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false"> + {{ i18n.tokenSingleUseWarningBody }} + </gl-alert> + </p> - <p> - <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> - <template #append> - <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" /> - </template> - </gl-form-input-group> - </p> + <p> + <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> + <template #append> + <modal-copy-button + :text="agentToken" + :title="i18n.copyToken" + :modal-id="$options.modalId" + /> + </template> + </gl-form-input-group> + </p> - <p> - <strong>{{ $options.i18n.basicInstallTitle }}</strong> - </p> + <p> + <strong>{{ i18n.basicInstallTitle }}</strong> + </p> - <p> - {{ $options.i18n.basicInstallBody }} - </p> + <p> + {{ i18n.basicInstallBody }} + </p> - <p> - <code-block :code="agentRegistrationCommand" /> - </p> + <p> + <code-block :code="agentRegistrationCommand" /> + </p> + + <p> + <strong>{{ i18n.advancedInstallTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="i18n.advancedInstallBody"> + <template #link="{ content }"> + <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </template> + + <template v-else> + <div class="gl-text-center gl-mb-5"> + <img :alt="i18n.altText" :src="emptyStateImage" height="100" /> + </div> <p> - <strong>{{ $options.i18n.advancedInstallTitle }}</strong> + <gl-sprintf :message="i18n.modalBody"> + <template #link="{ content }"> + <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link> + </template> + </gl-sprintf> </p> - <p> - <gl-sprintf :message="$options.i18n.advancedInstallBody"> + <p v-if="kasDisabled"> + <gl-sprintf :message="i18n.enableKasText"> <template #link="{ content }"> - <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link> + <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link> </template> </gl-sprintf> </p> </template> <template #modal-footer> - <gl-button v-if="canCancel" @click="closeModal">{{ $options.i18n.cancel }} </gl-button> - - <gl-button v-if="registered" variant="confirm" category="primary" @click="closeModal" - >{{ $options.i18n.close }} + <gl-button + v-if="registered" + variant="confirm" + category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="close" + @click="closeModal" + >{{ i18n.close }} </gl-button> <gl-button - v-else + v-else-if="isAgentRegistrationModal" :disabled="!nextButtonDisabled" variant="confirm" category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="register" @click="registerAgent" - >{{ $options.i18n.registerAgentButton }} + >{{ i18n.registerAgentButton }} + </gl-button> + + <gl-button + v-if="canCancel" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="cancel" + @click="closeModal" + >{{ i18n.cancel }} + </gl-button> + + <gl-button + v-if="isEmptyStateModal" + :href="repositoryPath" + variant="confirm" + category="secondary" + data-testid="agent-secondary-button" + >{{ i18n.secondaryButton }} + </gl-button> + + <gl-button + v-if="isEmptyStateModal" + variant="confirm" + category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="done" + @click="closeModal" + >{{ i18n.done }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 9fefdf450c4..9b52df74fc5 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -64,47 +64,63 @@ export const STATUSES = { creating: { title: __('Creating') }, }; -export const I18N_INSTALL_AGENT_MODAL = { - registerAgentButton: s__('ClusterAgents|Register Agent'), - close: __('Close'), - cancel: __('Cancel'), - - modalTitle: s__('ClusterAgents|Install new Agent'), - - selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'), - selectAgentBody: s__( - `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`, - ), +export const I18N_AGENT_MODAL = { + agent_registration: { + registerAgentButton: s__('ClusterAgents|Register'), + close: __('Close'), + cancel: __('Cancel'), + + modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'), + selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'), + selectAgentBody: s__( + 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.', + ), + learnMoreLink: s__('ClusterAgents|How to register an agent?'), - copyToken: s__('ClusterAgents|Copy token'), - tokenTitle: s__('ClusterAgents|Registration token'), - tokenBody: s__( - `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`, - ), + copyToken: s__('ClusterAgents|Copy token'), + tokenTitle: s__('ClusterAgents|Registration token'), + tokenBody: s__( + `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, + ), - tokenSingleUseWarningTitle: s__( - 'ClusterAgents|The token value will not be shown again after you close this window.', - ), - tokenSingleUseWarningBody: s__( - `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`, - ), + tokenSingleUseWarningTitle: s__( + 'ClusterAgents|You cannot see this token again after you close this window.', + ), + tokenSingleUseWarningBody: s__( + `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`, + ), - basicInstallTitle: s__('ClusterAgents|Recommended installation method'), - basicInstallBody: __( - `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, - ), + basicInstallTitle: s__('ClusterAgents|Recommended installation method'), + basicInstallBody: __( + `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, + ), - advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'), - advancedInstallBody: s__( - 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.', - ), + advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), + advancedInstallBody: s__( + 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.', + ), - registrationErrorTitle: __('Failed to register Agent'), - unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), + unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + }, + empty_state: { + modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'), + modalBody: s__( + "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}What's the agent's configuration file?%{linkEnd}", + ), + enableKasText: s__( + "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", + ), + altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), + secondaryButton: s__('ClusterAgents|Go to the repository files'), + done: __('Cancel'), + }, }; +export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError'; + export const I18N_AVAILABLE_AGENTS_DROPDOWN = { - selectAgent: s__('ClusterAgents|Select an Agent'), + selectAgent: s__('ClusterAgents|Select an agent'), registeringAgent: s__('ClusterAgents|Registering Agent'), }; @@ -125,7 +141,7 @@ export const AGENT_STATUSES = { title: s__('ClusterAgents|Agent might not be connected to GitLab'), body: sprintf( s__( - 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.', + 'ClusterAgents|The agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.', ), ), }, @@ -143,55 +159,48 @@ export const AGENT_STATUSES = { export const I18N_AGENTS_EMPTY_STATE = { introText: s__( - 'ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more.', - ), - multipleClustersText: s__( - 'ClusterAgents|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', - ), - learnMoreText: s__('ClusterAgents|Learn more about the GitLab Kubernetes Agent.'), - warningText: s__( - 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.', + 'ClusterIntegration|Use the %{linkStart}GitLab Agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.', ), - readMoreText: s__('ClusterAgents|Read more about getting started'), - repositoryButtonText: s__('ClusterAgents|Go to the repository'), - primaryButtonText: s__('ClusterAgents|Connect with a GitLab Agent'), + buttonText: s__('ClusterAgents|Connect with the GitLab Agent'), }; export const I18N_CLUSTERS_EMPTY_STATE = { - description: s__( - 'ClusterIntegration|Use certificates to integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more in an easy way.', - ), - multipleClustersText: s__( - 'ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', + introText: s__( + 'ClusterIntegration|Connect your cluster to GitLab through %{linkStart}cluster certificates%{linkEnd}.', ), - learnMoreLinkText: s__('ClusterIntegration|Learn more about the GitLab managed clusters'), buttonText: s__('ClusterIntegration|Connect with a certificate'), + alertText: s__( + 'ClusterIntegration|The certificate-based method to connect clusters to GitLab was %{linkStart}deprecated%{linkEnd} in GitLab 14.5.', + ), }; export const AGENT_CARD_INFO = { tabName: 'agent', - title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')), - emptyTitle: s__('ClusterAgents|No Agent based integrations'), + title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')), + emptyTitle: s__('ClusterAgents|No agents'), tooltip: { label: s__('ClusterAgents|Recommended'), - title: s__('ClusterAgents|GitLab Agents'), + title: s__('ClusterAgents|GitLab Agent'), text: sprintf( s__( - 'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}', + 'ClusterAgents|The GitLab Agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab Agent.%{linkEnd}', ), ), link: helpPagePath('user/clusters/agent/index'), }, - actionText: s__('ClusterAgents|Install new Agent'), - footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')), + actionText: s__('ClusterAgents|Install a new agent'), + footerText: sprintf(s__('ClusterAgents|View all %{number} agents')), }; export const CERTIFICATE_BASED_CARD_INFO = { tabName: 'certificate_based', - title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')), - emptyTitle: s__('ClusterAgents|No Certificate based integrations'), + title: sprintf( + s__('ClusterAgents|%{number} of %{total} clusters connected through cluster certificates'), + ), + emptyTitle: s__('ClusterAgents|No clusters connected through cluster certificates'), actionText: s__('ClusterAgents|Connect existing cluster'), - footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')), + footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')), + badgeText: s__('ClusterAgents|Deprecated'), }; export const MAX_CLUSTERS_LIST = 6; @@ -208,7 +217,7 @@ export const CLUSTERS_TABS = [ queryParamValue: 'agent', }, { - title: s__('ClusterAgents|Certificate based'), + title: s__('ClusterAgents|Certificate'), component: 'clusters', queryParamValue: 'certificate_based', }, @@ -216,10 +225,20 @@ export const CLUSTERS_TABS = [ export const CLUSTERS_ACTIONS = { actionsButton: s__('ClusterAgents|Actions'), - createNewCluster: s__('ClusterAgents|Create new cluster'), - connectWithAgent: s__('ClusterAgents|Connect with Agent'), - connectExistingCluster: s__('ClusterAgents|Connect with certificate'), + createNewCluster: s__('ClusterAgents|Create a new cluster'), + connectWithAgent: s__('ClusterAgents|Connect with the Agent'), + connectExistingCluster: s__('ClusterAgents|Connect with a certificate'), }; export const AGENT = 'agent'; export const CERTIFICATE_BASED = 'certificate_based'; + +export const EVENT_LABEL_MODAL = 'agent_registration_modal'; +export const EVENT_LABEL_TABS = 'kubernetes_section_tabs'; +export const EVENT_ACTIONS_OPEN = 'open_modal'; +export const EVENT_ACTIONS_SELECT = 'select_agent'; +export const EVENT_ACTIONS_CLICK = 'click_button'; +export const EVENT_ACTIONS_CHANGE = 'change_tab'; + +export const MODAL_TYPE_EMPTY = 'empty_state'; +export const MODAL_TYPE_REGISTER = 'agent_registration'; diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js index dd633820952..4d12bc8151c 100644 --- a/app/assets/javascripts/clusters_list/graphql/cache_update.js +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -1,29 +1,65 @@ import produce from 'immer'; import { getAgentConfigPath } from '../clusters_util'; +export const hasErrors = ({ errors = [] }) => errors?.length; + export function addAgentToStore(store, createClusterAgent, query, variables) { - const { clusterAgent } = createClusterAgent; - const sourceData = store.readQuery({ - query, - variables, - }); - - const data = produce(sourceData, (draftData) => { - const configuration = { - name: clusterAgent.name, - path: getAgentConfigPath(clusterAgent.name), - webPath: clusterAgent.webPath, - __typename: 'TreeEntry', - }; - - draftData.project.clusterAgents.nodes.push(clusterAgent); - draftData.project.clusterAgents.count += 1; - draftData.project.repository.tree.trees.nodes.push(configuration); - }); - - store.writeQuery({ - query, - variables, - data, - }); + if (!hasErrors(createClusterAgent)) { + const { clusterAgent } = createClusterAgent; + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + const configuration = { + id: clusterAgent.id, + name: clusterAgent.name, + path: getAgentConfigPath(clusterAgent.name), + webPath: clusterAgent.webPath, + __typename: 'TreeEntry', + }; + + draftData.project.clusterAgents.nodes.push(clusterAgent); + draftData.project.clusterAgents.count += 1; + draftData.project.repository.tree.trees.nodes.push(configuration); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} + +export function addAgentConfigToStore( + store, + clusterAgentTokenCreate, + clusterAgent, + query, + variables, +) { + if (!hasErrors(clusterAgentTokenCreate)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + const configuration = { + agentName: clusterAgent.name, + __typename: 'AgentConfiguration', + }; + + draftData.project.clusterAgents.nodes.push(clusterAgent); + draftData.project.agentConfigurations.nodes.push(configuration); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } } diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql index 9b40260471c..cd46dfee170 100644 --- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -4,6 +4,7 @@ fragment ClusterAgentFragment on ClusterAgent { webPath tokens { nodes { + id lastUsedAt } } diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql index 40b61337024..9a24cec5a9c 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql @@ -1,5 +1,6 @@ query agentConfigurations($projectPath: ID!) { project(fullPath: $projectPath) { + id agentConfigurations { nodes { agentName @@ -8,6 +9,7 @@ query agentConfigurations($projectPath: ID!) { clusterAgents { nodes { + id name } } diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql index 47b25988877..f8efb6683f6 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -12,6 +12,7 @@ query getAgents( $beforeTree: String ) { project(fullPath: $projectPath) { + id clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) { nodes { ...ClusterAgentFragment @@ -28,6 +29,7 @@ query getAgents( tree(path: ".gitlab/agents", ref: $defaultBranchName) { trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) { nodes { + id name path webPath diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue index 69d398893d9..4d44c984833 100644 --- a/app/assets/javascripts/code_navigation/components/doc_line.vue +++ b/app/assets/javascripts/code_navigation/components/doc_line.vue @@ -18,5 +18,6 @@ export default { <span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{ token.value }}</span> + <br /> </span> </template> diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js index 29ee282f2d2..72df1d071d1 100644 --- a/app/assets/javascripts/content_editor/extensions/attachment.js +++ b/app/assets/javascripts/content_editor/extensions/attachment.js @@ -5,9 +5,11 @@ import { handleFileEvent } from '../services/upload_helpers'; export default Extension.create({ name: 'attachment', - defaultOptions: { - uploadsPath: null, - renderMarkdown: null, + addOptions() { + return { + uploadsPath: null, + renderMarkdown: null, + }; }, addCommands() { diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js index 25d4068c93f..ea48ee0cee0 100644 --- a/app/assets/javascripts/content_editor/extensions/audio.js +++ b/app/assets/javascripts/content_editor/extensions/audio.js @@ -2,8 +2,10 @@ import Playable from './playable'; export default Playable.extend({ name: 'audio', - defaultOptions: { - ...Playable.options, - mediaType: 'audio', + addOptions() { + return { + ...this.parent?.(), + mediaType: 'audio', + }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 1ed1ab0315f..ea51bee3ba9 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -4,6 +4,8 @@ import * as lowlight from 'lowlight'; const extractLanguage = (element) => element.getAttribute('lang'); export default CodeBlockLowlight.extend({ + isolating: true, + addAttributes() { return { language: { @@ -17,7 +19,7 @@ export default CodeBlockLowlight.extend({ }; }, renderHTML({ HTMLAttributes }) { - return ['pre', HTMLAttributes, ['code', {}, 0]]; + return ['div', ['pre', HTMLAttributes, ['code', {}, 0]]]; }, }).configure({ lowlight, diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js index c70d1700941..566ed85acf3 100644 --- a/app/assets/javascripts/content_editor/extensions/division.js +++ b/app/assets/javascripts/content_editor/extensions/division.js @@ -1,12 +1,26 @@ import { Node } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; +const getDiv = (element) => { + if (element.nodeName === 'DIV') return element; + return element.querySelector('div'); +}; + export default Node.create({ name: 'division', content: 'block*', group: 'block', defining: true, + addAttributes() { + return { + className: { + default: null, + parseHTML: (element) => getDiv(element).className || null, + }, + }; + }, + parseHTML() { return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }]; }, diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js new file mode 100644 index 00000000000..dbab0de3421 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js @@ -0,0 +1,21 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'footnoteDefinition', + + content: 'paragraph', + + group: 'block', + + parseHTML() { + return [ + { tag: 'section.footnotes li' }, + { tag: '.footnote-backref', priority: PARSE_HTML_PRIORITY_HIGHEST, ignore: true }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['li', mergeAttributes(HTMLAttributes), 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js new file mode 100644 index 00000000000..1ac8016f774 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'footnoteReference', + + inline: true, + + group: 'inline', + + atom: true, + + draggable: true, + + selectable: true, + + addAttributes() { + return { + footnoteId: { + default: null, + parseHTML: (element) => element.querySelector('a').getAttribute('id'), + }, + footnoteNumber: { + default: null, + parseHTML: (element) => element.textContent, + }, + }; + }, + + parseHTML() { + return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }]; + }, + + renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) { + return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js new file mode 100644 index 00000000000..914a8934734 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js @@ -0,0 +1,19 @@ +import { mergeAttributes, Node } from '@tiptap/core'; + +export default Node.create({ + name: 'footnotesSection', + + content: 'footnoteDefinition+', + + group: 'block', + + isolating: true, + + parseHTML() { + return [{ tag: 'section.footnotes > ol' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js index 3abf0e3eee2..9579f3b06f6 100644 --- a/app/assets/javascripts/content_editor/extensions/html_marks.js +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -31,13 +31,12 @@ const attrs = { export default marks.map((name) => Mark.create({ name, - inclusive: false, - - defaultOptions: { - HTMLAttributes: {}, + addOptions() { + return { + HTMLAttributes: {}, + }; }, - addAttributes() { return (attrs[name] || []).reduce( (acc, attr) => ({ diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 837fab0585f..d7fb617f7ee 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -7,9 +7,11 @@ const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); export default Image.extend({ - defaultOptions: { - ...Image.options, - inline: true, + addOptions() { + return { + ...this.parent?.(), + inline: true, + }; }, addAttributes() { return { diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index 22bb1ac072e..f76943a0669 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -3,8 +3,10 @@ import { Mark, markInputRule, mergeAttributes } from '@tiptap/core'; export default Mark.create({ name: 'inlineDiff', - defaultOptions: { - HTMLAttributes: {}, + addOptions() { + return { + HTMLAttributes: {}, + }; }, addAttributes() { diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 27bc05dce6f..f9b12f631fe 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -18,10 +18,13 @@ export const extractHrefFromMarkdownLink = (match) => { }; export default Link.extend({ - defaultOptions: { - ...Link.options, - openOnClick: false, + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + }; }, + addInputRules() { const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 9b050edcb28..6efef3f8198 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -2,9 +2,11 @@ import { TaskItem } from '@tiptap/extension-task-item'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; export default TaskItem.extend({ - defaultOptions: { - nested: true, - HTMLAttributes: {}, + addOptions() { + return { + nested: true, + HTMLAttributes: {}, + }; }, addAttributes() { diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js index 9923b7c04cd..312e8cd5ff6 100644 --- a/app/assets/javascripts/content_editor/extensions/video.js +++ b/app/assets/javascripts/content_editor/extensions/video.js @@ -2,9 +2,11 @@ import Playable from './playable'; export default Playable.extend({ name: 'video', - defaultOptions: { - ...Playable.options, - mediaType: 'video', - extraElementAttrs: { width: '400' }, + addOptions() { + return { + ...this.parent?.(), + mediaType: 'video', + extraElementAttrs: { width: '400' }, + }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js index fa7e02f8cc8..457b7c36564 100644 --- a/app/assets/javascripts/content_editor/extensions/word_break.js +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -7,10 +7,12 @@ export default Node.create({ selectable: false, atom: true, - defaultOptions: { - HTMLAttributes: { - class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', - }, + addOptions() { + return { + HTMLAttributes: { + class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', + }, + }; }, parseHTML() { diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 385f1c63801..f451357e211 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -19,6 +19,9 @@ import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import FootnoteDefinition from '../extensions/footnote_definition'; +import FootnoteReference from '../extensions/footnote_reference'; +import FootnotesSection from '../extensions/footnotes_section'; import Frontmatter from '../extensions/frontmatter'; import Gapcursor from '../extensions/gapcursor'; import HardBreak from '../extensions/hard_break'; @@ -94,6 +97,9 @@ export const createContentEditor = ({ Emoji, Figure, FigureCaption, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, Frontmatter, Gapcursor, HardBreak, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 0dd3cb5b73f..278ef326c7a 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -17,6 +17,9 @@ import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import FootnotesSection from '../extensions/footnotes_section'; +import FootnoteDefinition from '../extensions/footnote_definition'; +import FootnoteReference from '../extensions/footnote_reference'; import Frontmatter from '../extensions/frontmatter'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; @@ -135,7 +138,16 @@ const defaultSerializerConfig = { state.write('```'); state.closeBlock(node); }, - [Division.name]: renderHTMLNode('div'), + [Division.name]: (state, node) => { + if (node.attrs.className?.includes('js-markdown-code')) { + state.renderInline(node); + } else { + const newNode = node; + delete newNode.attrs.className; + + renderHTMLNode('div')(state, newNode); + } + }, [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); @@ -156,6 +168,15 @@ const defaultSerializerConfig = { state.write(`:${name}:`); }, + [FootnoteDefinition.name]: (state, node) => { + state.renderInline(node); + }, + [FootnoteReference.name]: (state, node) => { + state.write(`[^${node.attrs.footnoteNumber}]`); + }, + [FootnotesSection.name]: (state, node) => { + state.renderList(node, '', (index) => `[^${index + 1}]: `); + }, [Frontmatter.name]: (state, node) => { const { language } = node.attrs; const syntax = { diff --git a/app/assets/javascripts/crm/components/contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue new file mode 100644 index 00000000000..81ae5c246be --- /dev/null +++ b/app/assets/javascripts/crm/components/contact_form.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { produce } from 'immer'; +import { __, s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_GROUP } from '~/graphql_shared/constants'; +import createContactMutation from './queries/create_contact.mutation.graphql'; +import updateContactMutation from './queries/update_contact.mutation.graphql'; +import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlDrawer, + GlFormGroup, + GlFormInput, + }, + inject: ['groupFullPath', 'groupId'], + props: { + drawerOpen: { + type: Boolean, + required: true, + }, + contact: { + type: Object, + required: false, + default: () => {}, + }, + }, + data() { + return { + firstName: '', + lastName: '', + phone: '', + email: '', + description: '', + submitting: false, + errorMessages: [], + }; + }, + computed: { + invalid() { + const { firstName, lastName, email } = this; + + return firstName.trim() === '' || lastName.trim() === '' || email.trim() === ''; + }, + editMode() { + return Boolean(this.contact); + }, + title() { + return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle; + }, + buttonLabel() { + return this.editMode + ? this.$options.i18n.editButtonLabel + : this.$options.i18n.createButtonLabel; + }, + mutation() { + return this.editMode ? updateContactMutation : createContactMutation; + }, + variables() { + const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this; + + const variables = { + input: { + firstName, + lastName, + phone, + email, + description, + }, + }; + + if (editMode) { + variables.input.id = contact.id; + } else { + variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId); + } + + return variables; + }, + }, + mounted() { + if (this.editMode) { + const { contact } = this; + + this.firstName = contact.firstName || ''; + this.lastName = contact.lastName || ''; + this.phone = contact.phone || ''; + this.email = contact.email || ''; + this.description = contact.description || ''; + } + }, + methods: { + save() { + const { mutation, variables, updateCache, close } = this; + + this.submitting = true; + + return this.$apollo + .mutate({ + mutation, + variables, + update: updateCache, + }) + .then(({ data }) => { + if ( + data.customerRelationsContactCreate?.errors.length === 0 || + data.customerRelationsContactUpdate?.errors.length === 0 + ) { + close(true); + } + + this.submitting = false; + }) + .catch(() => { + this.errorMessages = [this.$options.i18n.somethingWentWrong]; + this.submitting = false; + }); + }, + close(success) { + this.$emit('close', success); + }, + updateCache(store, { data }) { + const mutationData = + data.customerRelationsContactCreate || data.customerRelationsContactUpdate; + + if (mutationData?.errors.length > 0) { + this.errorMessages = mutationData.errors; + return; + } + + const queryArgs = { + query: getGroupContactsQuery, + variables: { groupFullPath: this.groupFullPath }, + }; + + const sourceData = store.readQuery(queryArgs); + + queryArgs.data = produce(sourceData, (draftState) => { + draftState.group.contacts.nodes = [ + ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id), + mutationData.contact, + ]; + }); + + store.writeQuery(queryArgs); + }, + getDrawerHeaderHeight() { + const wrapperEl = document.querySelector('.content-wrapper'); + + if (wrapperEl) { + return `${wrapperEl.offsetTop}px`; + } + + return ''; + }, + }, + i18n: { + createButtonLabel: s__('Crm|Create new contact'), + editButtonLabel: __('Save changes'), + cancel: __('Cancel'), + firstName: s__('Crm|First name'), + lastName: s__('Crm|Last name'), + email: s__('Crm|Email'), + phone: s__('Crm|Phone number (optional)'), + description: s__('Crm|Description (optional)'), + newTitle: s__('Crm|New contact'), + editTitle: s__('Crm|Edit contact'), + somethingWentWrong: __('Something went wrong. Please try again.'), + }, +}; +</script> + +<template> + <gl-drawer + class="gl-drawer-responsive" + :open="drawerOpen" + :header-height="getDrawerHeaderHeight()" + @close="close(false)" + > + <template #title> + <h3>{{ title }}</h3> + </template> + <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> + <ul class="gl-mb-0! gl-ml-5"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <form @submit.prevent="save"> + <gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name"> + <gl-form-input id="contact-first-name" v-model="firstName" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name"> + <gl-form-input id="contact-last-name" v-model="lastName" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.email" label-for="contact-email"> + <gl-form-input id="contact-email" v-model="email" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.phone" label-for="contact-phone"> + <gl-form-input id="contact-phone" v-model="phone" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.description" label-for="contact-description"> + <gl-form-input id="contact-description" v-model="description" /> + </gl-form-group> + <span class="gl-float-right"> + <gl-button data-testid="cancel-button" @click="close(false)"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + variant="confirm" + :disabled="invalid" + :loading="submitting" + data-testid="save-contact-button" + type="submit" + >{{ buttonLabel }}</gl-button + > + </span> + </form> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue index 83c02f7d5fe..178ce84c64d 100644 --- a/app/assets/javascripts/crm/components/contacts_root.vue +++ b/app/assets/javascripts/crm/components/contacts_root.vue @@ -1,17 +1,30 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; +import ContactForm from './contact_form.vue'; export default { components: { + GlAlert, + GlButton, GlLoadingIcon, GlTable, + ContactForm, }, - inject: ['groupFullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'], data() { - return { contacts: [] }; + return { + contacts: [], + error: false, + }; }, apollo: { contacts: { @@ -26,12 +39,8 @@ export default { update(data) { return this.extractContacts(data); }, - error(error) { - createFlash({ - message: __('Something went wrong. Please try again.'), - error, - captureError: true, - }); + error() { + this.error = true; }, }, }, @@ -39,12 +48,51 @@ export default { isLoading() { return this.$apollo.queries.contacts.loading; }, + showNewForm() { + return this.$route.name === NEW_ROUTE_NAME; + }, + showEditForm() { + return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME; + }, + canAdmin() { + return parseBoolean(this.canAdminCrmContact); + }, + editingContact() { + return this.contacts.find( + (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id), + ); + }, }, methods: { extractContacts(data) { const contacts = data?.group?.contacts?.nodes || []; return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); }, + displayNewForm() { + if (this.showNewForm) return; + + this.$router.push({ name: NEW_ROUTE_NAME }); + }, + hideNewForm(success) { + if (success) this.$toast.show(s__('Crm|Contact has been added')); + + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, + hideEditForm(success) { + if (success) this.$toast.show(s__('Crm|Contact has been updated')); + + this.editingContactId = 0; + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, + getIssuesPath(path, value) { + return `${path}?scope=all&state=opened&crm_contact_id=${value}`; + }, + edit(value) { + if (this.showEditForm) return; + + this.editingContactId = value; + this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } }); + }, }, fields: [ { key: 'firstName', sortable: true }, @@ -59,22 +107,81 @@ export default { }, sortable: true, }, + { + key: 'id', + label: '', + formatter: (id) => { + return getIdFromGraphQLId(id); + }, + }, ], i18n: { emptyText: s__('Crm|No contacts found'), + issuesButtonLabel: __('View issues'), + editButtonLabel: __('Edit'), + title: s__('Crm|Customer Relations Contacts'), + newContact: s__('Crm|New contact'), + errorText: __('Something went wrong. Please try again.'), }, }; </script> <template> <div> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> + {{ $options.i18n.errorText }} + </gl-alert> + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <h2 class="gl-font-size-h2 gl-my-0"> + {{ $options.i18n.title }} + </h2> + <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> + <gl-button + v-if="canAdmin" + variant="confirm" + data-testid="new-contact-button" + @click="displayNewForm" + > + {{ $options.i18n.newContact }} + </gl-button> + </div> + </div> + <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> + <contact-form + v-if="showEditForm" + :contact="editingContact" + :drawer-open="showEditForm" + @close="hideEditForm" + /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else + class="gl-mt-5" :items="contacts" :fields="$options.fields" :empty-text="$options.i18n.emptyText" show-empty - /> + > + <template #cell(id)="data"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, data.value)" + /> + <gl-button + v-if="canAdmin" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-contact-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + @click="edit(data.value)" + /> + </template> + </gl-table> </div> </template> diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue new file mode 100644 index 00000000000..3b11edc6935 --- /dev/null +++ b/app/assets/javascripts/crm/components/new_organization_form.vue @@ -0,0 +1,164 @@ +<script> +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { produce } from 'immer'; +import { __, s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_GROUP } from '~/graphql_shared/constants'; +import createOrganization from './queries/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlDrawer, + GlFormGroup, + GlFormInput, + }, + inject: ['groupFullPath', 'groupId'], + props: { + drawerOpen: { + type: Boolean, + required: true, + }, + }, + data() { + return { + name: '', + defaultRate: null, + description: '', + submitting: false, + errorMessages: [], + }; + }, + computed: { + invalid() { + return this.name.trim() === ''; + }, + }, + methods: { + save() { + this.submitting = true; + return this.$apollo + .mutate({ + mutation: createOrganization, + variables: { + input: { + groupId: convertToGraphQLId(TYPE_GROUP, this.groupId), + name: this.name, + defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null, + description: this.description, + }, + }, + update: this.updateCache, + }) + .then(({ data }) => { + if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true); + + this.submitting = false; + }) + .catch(() => { + this.errorMessages = [this.$options.i18n.somethingWentWrong]; + this.submitting = false; + }); + }, + close(success) { + this.$emit('close', success); + }, + updateCache(store, { data: { customerRelationsOrganizationCreate } }) { + if (customerRelationsOrganizationCreate.errors.length > 0) { + this.errorMessages = customerRelationsOrganizationCreate.errors; + return; + } + + const variables = { + groupFullPath: this.groupFullPath, + }; + const sourceData = store.readQuery({ + query: getGroupOrganizationsQuery, + variables, + }); + + const data = produce(sourceData, (draftState) => { + draftState.group.organizations.nodes = [ + ...sourceData.group.organizations.nodes, + customerRelationsOrganizationCreate.organization, + ]; + }); + + store.writeQuery({ + query: getGroupOrganizationsQuery, + variables, + data, + }); + }, + getDrawerHeaderHeight() { + const wrapperEl = document.querySelector('.content-wrapper'); + + if (wrapperEl) { + return `${wrapperEl.offsetTop}px`; + } + + return ''; + }, + }, + i18n: { + buttonLabel: s__('Crm|Create organization'), + cancel: __('Cancel'), + name: __('Name'), + defaultRate: s__('Crm|Default rate (optional)'), + description: __('Description (optional)'), + title: s__('Crm|New Organization'), + somethingWentWrong: __('Something went wrong. Please try again.'), + }, +}; +</script> + +<template> + <gl-drawer + class="gl-drawer-responsive" + :open="drawerOpen" + :header-height="getDrawerHeaderHeight()" + @close="close(false)" + > + <template #title> + <h4>{{ $options.i18n.title }}</h4> + </template> + <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> + <ul class="gl-mb-0! gl-ml-5"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <form @submit.prevent="save"> + <gl-form-group :label="$options.i18n.name" label-for="organization-name"> + <gl-form-input id="organization-name" v-model="name" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate"> + <gl-form-input + id="organization-default-rate" + v-model="defaultRate" + type="number" + step="0.01" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.description" label-for="organization-description"> + <gl-form-input id="organization-description" v-model="description" /> + </gl-form-group> + <span class="gl-float-right"> + <gl-button data-testid="cancel-button" @click="close(false)"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + variant="confirm" + :disabled="invalid" + :loading="submitting" + data-testid="create-new-organization-button" + type="submit" + >{{ $options.i18n.buttonLabel }}</gl-button + > + </span> + </form> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue index 98b45d0a042..9370c6377e9 100644 --- a/app/assets/javascripts/crm/components/organizations_root.vue +++ b/app/assets/javascripts/crm/components/organizations_root.vue @@ -1,17 +1,29 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants'; import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; +import NewOrganizationForm from './new_organization_form.vue'; export default { components: { + GlAlert, + GlButton, GlLoadingIcon, GlTable, + NewOrganizationForm, }, - inject: ['groupFullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'], data() { - return { organizations: [] }; + return { + error: false, + organizations: [], + }; }, apollo: { organizations: { @@ -26,12 +38,8 @@ export default { update(data) { return this.extractOrganizations(data); }, - error(error) { - createFlash({ - message: __('Something went wrong. Please try again.'), - error, - captureError: true, - }); + error() { + this.error = true; }, }, }, @@ -39,33 +47,94 @@ export default { isLoading() { return this.$apollo.queries.organizations.loading; }, + showNewForm() { + return this.$route.name === NEW_ROUTE_NAME; + }, + canCreateNew() { + return parseBoolean(this.canAdminCrmOrganization); + }, }, methods: { extractOrganizations(data) { const organizations = data?.group?.organizations?.nodes || []; return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); }, + getIssuesPath(path, value) { + return `${path}?scope=all&state=opened&crm_organization_id=${value}`; + }, + displayNewForm() { + if (this.showNewForm) return; + + this.$router.push({ name: NEW_ROUTE_NAME }); + }, + hideNewForm(success) { + if (success) this.$toast.show(this.$options.i18n.organizationAdded); + + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, }, fields: [ { key: 'name', sortable: true }, { key: 'defaultRate', sortable: true }, { key: 'description', sortable: true }, + { + key: 'id', + label: __('Issues'), + formatter: (id) => { + return getIdFromGraphQLId(id); + }, + }, ], i18n: { emptyText: s__('Crm|No organizations found'), + issuesButtonLabel: __('View issues'), + title: s__('Crm|Customer Relations Organizations'), + newOrganization: s__('Crm|New organization'), + errorText: __('Something went wrong. Please try again.'), + organizationAdded: s__('Crm|Organization has been added'), }, }; </script> <template> <div> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> + {{ $options.i18n.errorText }} + </gl-alert> + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <h2 class="gl-font-size-h2 gl-my-0"> + {{ $options.i18n.title }} + </h2> + <div + v-if="canCreateNew" + class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" + > + <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm"> + {{ $options.i18n.newOrganization }} + </gl-button> + </div> + </div> + <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else + class="gl-mt-5" :items="organizations" :fields="$options.fields" :empty-text="$options.i18n.emptyText" show-empty - /> + > + <template #cell(id)="data"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, data.value)" + /> + </template> + </gl-table> </div> </template> diff --git a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql new file mode 100644 index 00000000000..e0192459609 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_contact_fields.fragment.graphql" + +mutation createContact($input: CustomerRelationsContactCreateInput!) { + customerRelationsContactCreate(input: $input) { + contact { + ...ContactFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql new file mode 100644 index 00000000000..2cc7e53ee9b --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_organization_fields.fragment.graphql" + +mutation createOrganization($input: CustomerRelationsOrganizationCreateInput!) { + customerRelationsOrganizationCreate(input: $input) { + organization { + ...OrganizationFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql new file mode 100644 index 00000000000..cef4083b446 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql @@ -0,0 +1,14 @@ +fragment ContactFragment on CustomerRelationsContact { + __typename + id + firstName + lastName + email + phone + description + organization { + __typename + id + name + } +} diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql new file mode 100644 index 00000000000..4adc5742d3a --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql @@ -0,0 +1,7 @@ +fragment OrganizationFragment on CustomerRelationsOrganization { + __typename + id + name + defaultRate + description +} diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql index f6acd258585..2a8150e42e3 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql +++ b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql @@ -1,21 +1,12 @@ +#import "./crm_contact_fields.fragment.graphql" + query contacts($groupFullPath: ID!) { group(fullPath: $groupFullPath) { __typename id contacts { nodes { - __typename - id - firstName - lastName - email - phone - description - organization { - __typename - id - name - } + ...ContactFragment } } } diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql index 7c4ec6ec585..e8d8109431e 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql +++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql @@ -1,14 +1,12 @@ +#import "./crm_organization_fields.fragment.graphql" + query organizations($groupFullPath: ID!) { group(fullPath: $groupFullPath) { __typename id organizations { nodes { - __typename - id - name - defaultRate - description + ...OrganizationFragment } } } diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql new file mode 100644 index 00000000000..f55f6a10e0a --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_contact_fields.fragment.graphql" + +mutation updateContact($input: CustomerRelationsContactUpdateInput!) { + customerRelationsContactUpdate(input: $input) { + contact { + ...ContactFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js new file mode 100644 index 00000000000..3b085837aea --- /dev/null +++ b/app/assets/javascripts/crm/constants.js @@ -0,0 +1,3 @@ +export const INDEX_ROUTE_NAME = 'index'; +export const NEW_ROUTE_NAME = 'new'; +export const EDIT_ROUTE_NAME = 'edit'; diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js index 6438953596e..f49ec64210f 100644 --- a/app/assets/javascripts/crm/contacts_bundle.js +++ b/app/assets/javascripts/crm/contacts_bundle.js @@ -1,9 +1,14 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import CrmContactsRoot from './components/contacts_root.vue'; +import routes from './routes'; Vue.use(VueApollo); +Vue.use(VueRouter); +Vue.use(GlToast); export default () => { const el = document.getElementById('js-crm-contacts-app'); @@ -16,10 +21,19 @@ export default () => { return false; } + const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset; + + const router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + return new Vue({ el, + router, apolloProvider, - provide: { groupFullPath: el.dataset.groupFullPath }, + provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId }, render(createElement) { return createElement(CrmContactsRoot); }, diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js index ac9990b9fb4..828d7cd426c 100644 --- a/app/assets/javascripts/crm/organizations_bundle.js +++ b/app/assets/javascripts/crm/organizations_bundle.js @@ -1,9 +1,14 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import CrmOrganizationsRoot from './components/organizations_root.vue'; +import routes from './routes'; Vue.use(VueApollo); +Vue.use(VueRouter); +Vue.use(GlToast); export default () => { const el = document.getElementById('js-crm-organizations-app'); @@ -16,10 +21,19 @@ export default () => { return false; } + const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset; + + const router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + return new Vue({ el, + router, apolloProvider, - provide: { groupFullPath: el.dataset.groupFullPath }, + provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath }, render(createElement) { return createElement(CrmOrganizationsRoot); }, diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/routes.js new file mode 100644 index 00000000000..12aa17d73b6 --- /dev/null +++ b/app/assets/javascripts/crm/routes.js @@ -0,0 +1,16 @@ +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants'; + +export default [ + { + name: INDEX_ROUTE_NAME, + path: '/', + }, + { + name: NEW_ROUTE_NAME, + path: '/new', + }, + { + name: EDIT_ROUTE_NAME, + path: '/:id/edit', + }, +]; diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js deleted file mode 100644 index cf7c9e7734f..00000000000 --- a/app/assets/javascripts/delete_label_modal.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; - -const mountDeleteLabelModal = (optionalProps) => - new Vue({ - render(h) { - return h(DeleteLabelModal, { - props: { - selector: '.js-delete-label-modal-button', - ...optionalProps, - }, - }); - }, - }).$mount(); - -export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 813f87452d8..10976202d06 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,11 +1,12 @@ <script> -import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink, GlBadge } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; @@ -30,7 +31,7 @@ export default { GlLink, ToggleRepliesWidget, TimeAgoTooltip, - GlBadge, + DesignNotePin, }, directives: { GlTooltip: GlTooltipDirective, @@ -213,12 +214,7 @@ export default { <template> <div class="design-discussion-wrapper"> - <gl-badge - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-cursor-pointer" - :class="{ resolved: discussion.resolved }" - > - {{ discussion.index }} - </gl-badge> + <design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" /> <ul class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" data-qa-selector="design_discussion_content" diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 7815a57ce18..b058709b316 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -1,9 +1,9 @@ <script> import { __ } from '~/locale'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; -import DesignNotePin from './design_note_pin.vue'; export default { name: 'DesignOverlay', @@ -251,9 +251,6 @@ export default { !discussionNotes.some(({ id }) => id === this.activeDiscussion.id) ); }, - designPinClass(note) { - return { inactive: this.isNoteInactive(note), resolved: note.resolved }; - }, }, i18n: { newCommentButtonLabel: __('Add comment to design'), @@ -287,7 +284,8 @@ export default { ? getNotePositionStyle(movingNoteNewPosition) : getNotePositionStyle(note.position) " - :class="designPinClass(note)" + :is-inactive="isNoteInactive(note)" + :is-resolved="note.resolved" @mousedown.stop="onNoteMousedown($event, note)" @mouseup.stop="onNoteMouseup(note)" /> diff --git a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql index 7483b508721..9ad85017921 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql @@ -1,8 +1,10 @@ fragment ResolvedStatus on Discussion { + id resolvable resolved resolvedAt resolvedBy { + id name webUrl } diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 111f5ac18a7..34d683ac1ee 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -3,6 +3,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { + # eslint-disable-next-line @graphql-eslint/require-id-when-available designs { ...DesignItem versions { @@ -14,6 +15,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { } } skippedDesigns { + id filename } errors diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 99a61191c6e..a5394457f73 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -10,8 +10,10 @@ query getDesign( project(fullPath: $fullPath) { id issue(iid: $iid) { + id designCollection { designs(atVersion: $atVersion, filenames: $filenames) { + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...DesignItem issue { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index f405b82b05b..66d06a3a1b6 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -44,7 +44,6 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; -import { discussionIntersectionObserverHandlerFactory } from '../utils/discussions'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -87,9 +86,6 @@ export default { ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, }, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, props: { endpoint: { type: String, diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index d09cc064b2c..4e77bf81c1e 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -10,6 +10,7 @@ import { import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; +import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; import createFlash from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; @@ -28,7 +29,6 @@ import { import eventHub from '../event_hub'; import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n'; import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; -import DiffContent from './diff_content.vue'; import DiffFileHeader from './diff_file_header.vue'; export default { diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 4e33a02ca0e..4893803a3b6 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -42,6 +42,11 @@ export default { required: false, default: false, }, + coverageLoaded: { + type: Boolean, + required: false, + default: false, + }, inline: { type: Boolean, required: false, @@ -83,14 +88,15 @@ export default { if (!props.inline || !props.line.left) return {}; return props.fileLineCoverage(props.filePath, props.line.left.new_line); }, - (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'), + (props) => + [props.inline, props.filePath, props.line.left?.new_line, props.coverageLoaded].join(':'), ), coverageStateRight: memoize( (props) => { if (!props.line.right) return {}; return props.fileLineCoverage(props.filePath, props.line.right.new_line); }, - (props) => [props.line.right?.new_line, props.filePath].join(':'), + (props) => [props.line.right?.new_line, props.filePath, props.coverageLoaded].join(':'), ), showCodequalityLeft: memoize( (props) => { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 55c796182ee..8562a1d44e7 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -52,7 +52,7 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), - ...mapState('diffs', ['codequalityDiff', 'highlightedRow']), + ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -180,6 +180,7 @@ export default { :index="index" :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" + :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" @setHighlightedRow="setHighlightedRow" @toggleLineDiscussions=" diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index a5b1a577a78..5f66360a040 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -21,6 +21,7 @@ export default () => ({ startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff diffFiles: [], coverageFiles: {}, + coverageLoaded: false, mergeRequestDiffs: [], mergeRequestDiff: null, diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 4a9df0eafcc..fb35114c0a9 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -86,7 +86,7 @@ export default { }, [types.SET_COVERAGE_DATA](state, coverageFiles) { - Object.assign(state, { coverageFiles }); + Object.assign(state, { coverageFiles, coverageLoaded: true }); }, [types.RENDER_FILE](state, file) { diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js deleted file mode 100644 index c404705d209..00000000000 --- a/app/assets/javascripts/diffs/utils/discussions.js +++ /dev/null @@ -1,76 +0,0 @@ -function normalize(processable) { - const { entry } = processable; - const offset = entry.rootBounds.bottom - entry.boundingClientRect.top; - const direction = - offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */ - - return { - ...processable, - entry: { - time: entry.time, - type: entry.isIntersecting ? 'intersection' : `scroll${direction}`, - }, - }; -} - -function sort({ entry: alpha }, { entry: beta }) { - const diff = alpha.time - beta.time; - let order = 0; - - if (diff < 0) { - order = -1; - } else if (diff > 0) { - order = 1; - } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') { - order = 2; - } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') { - order = -2; - } - - return order; -} - -function filter(entry) { - return entry.type !== 'scrollDown'; -} - -export function discussionIntersectionObserverHandlerFactory() { - let unprocessed = []; - let timer = null; - - return (processable) => { - unprocessed.push(processable); - - if (timer) { - clearTimeout(timer); - } - - timer = setTimeout(() => { - unprocessed - .map(normalize) - .filter(filter) - .sort(sort) - .forEach((discussionObservationContainer) => { - const { - entry: { type }, - currentDiscussion, - isFirstUnresolved, - isDiffsPage, - functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId }, - } = discussionObservationContainer; - - if (type === 'intersection') { - setCurrentDiscussionId(currentDiscussion.id); - } else if (type === 'scrollUp') { - setCurrentDiscussionId( - isFirstUnresolved - ? null - : getPreviousUnresolvedDiscussionId(currentDiscussion.id, isDiffsPage), - ); - } - }); - - unprocessed = []; - }, 0); - }; -} diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f404fa4e0e8..7c7127dfa44 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -44,6 +44,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { let addFileToForm; let updateAttachingMessage; let uploadFile; + let hasPlainText; formTextarea.wrap('<div class="div-dropzone"></div>'); formTextarea.on('paste', (event) => handlePaste(event)); @@ -184,7 +185,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { event.preventDefault(); const text = converter.convertToTableMarkdown(); pasteText(text); - } else { + } else if (!hasPlainText(pasteEvent)) { const fileList = [...clipboardData.files]; fileList.forEach((file) => { if (file.type.indexOf('image') !== -1) { @@ -203,6 +204,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { } }; + hasPlainText = (data) => { + const clipboardDataList = [...data.clipboardData.items]; + return clipboardDataList.some((item) => item.type === 'text/plain'); + }; + pasteText = (text, shouldPad) => { let formattedText = text; if (shouldPad) { diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index e855e304d27..2ae9c377683 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( // EXTENSIONS' CONSTANTS // +// Source Editor Base Extension +export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor'; +export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers'; + // For CI config schemas the filename must match // '*.gitlab-ci.yml' regardless of project configuration. // https://gitlab.com/gitlab-org/gitlab/-/issues/293641 diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js index 119a2aea9eb..52e2bb0b5ff 100644 --- a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js +++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js @@ -7,6 +7,16 @@ export class MyFancyExtension { /** + * A required getter returning the extension's name + * We have to provide it for every extension instead of relying on the built-in + * `name` prop because the prop does not survive the webpack's minification + * and the name mangling. + * @returns {string} + */ + static get extensionName() { + return 'MyFancyExtension'; + } + /** * THE LIFE-CYCLE CALLBACKS */ @@ -16,11 +26,11 @@ export class MyFancyExtension { * actions, keystrokes, update options, etc. * Is called only once before the extension gets registered * - * @param { Object } [setupOptions] The setupOptions object * @param { Object } [instance] The Source Editor instance + * @param { Object } [setupOptions] The setupOptions object */ // eslint-disable-next-line class-methods-use-this,no-unused-vars - onSetup(setupOptions, instance) {} + onSetup(instance, setupOptions) {} /** * The first thing called after the extension is diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js index 7069568275d..0290bb84b5f 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js @@ -1,32 +1,27 @@ import ciSchemaPath from '~/editor/schema/ci.json'; import { registerSchema } from '~/ide/utils'; -import { SourceEditorExtension } from './source_editor_extension_base'; -export class CiSchemaExtension extends SourceEditorExtension { - /** - * Registers a syntax schema to the editor based on project - * identifier and commit. - * - * The schema is added to the file that is currently edited - * in the editor. - * - * @param {Object} opts - * @param {String} opts.projectNamespace - * @param {String} opts.projectPath - * @param {String?} opts.ref - Current ref. Defaults to main - */ - registerCiSchema() { - // In order for workers loaded from `data://` as the - // ones loaded by monaco editor, we use absolute URLs - // to fetch schema files, hence the `gon.gitlab_url` - // reference. This prevents error: - // "Failed to execute 'fetch' on 'WorkerGlobalScope'" - const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath; - const modelFileName = this.getModel().uri.path.split('/').pop(); +export class CiSchemaExtension { + static get extensionName() { + return 'CiSchema'; + } + // eslint-disable-next-line class-methods-use-this + provides() { + return { + registerCiSchema: (instance) => { + // In order for workers loaded from `data://` as the + // ones loaded by monaco editor, we use absolute URLs + // to fetch schema files, hence the `gon.gitlab_url` + // reference. This prevents error: + // "Failed to execute 'fetch' on 'WorkerGlobalScope'" + const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath; + const modelFileName = instance.getModel().uri.path.split('/').pop(); - registerSchema({ - uri: absoluteSchemaUrl, - fileMatch: [modelFileName], - }); + registerSchema({ + uri: absoluteSchemaUrl, + fileMatch: [modelFileName], + }); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 03c68fed3b1..3aa19df964c 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -1,13 +1,16 @@ import { Range } from 'monaco-editor'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; -import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants'; +import { + EDITOR_TYPE_CODE, + EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS, + EXTENSION_BASE_LINE_NUMBERS_CLASS, +} from '../constants'; const hashRegexp = new RegExp('#?L', 'g'); const createAnchor = (href) => { const fragment = new DocumentFragment(); const el = document.createElement('a'); - el.classList.add('link-anchor'); + el.classList.add(EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS); el.href = href; fragment.appendChild(el); el.addEventListener('contextmenu', (e) => { @@ -17,38 +20,46 @@ const createAnchor = (href) => { }; export class SourceEditorExtension { - constructor({ instance, ...options } = {}) { - if (instance) { - Object.assign(instance, options); - SourceEditorExtension.highlightLines(instance); - if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { - SourceEditorExtension.setupLineLinking(instance); - } - SourceEditorExtension.deferRerender(instance); - } else if (Object.entries(options).length) { - throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); + static get extensionName() { + return 'BaseExtension'; + } + + // eslint-disable-next-line class-methods-use-this + onUse(instance) { + SourceEditorExtension.highlightLines(instance); + if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { + SourceEditorExtension.setupLineLinking(instance); } } - static deferRerender(instance) { - waitForCSSLoaded(() => { - instance.layout(); - }); + static onMouseMoveHandler(e) { + const target = e.target.element; + if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) { + const lineNum = e.target.position.lineNumber; + const hrefAttr = `#L${lineNum}`; + let lineLink = target.querySelector('a'); + if (!lineLink) { + lineLink = createAnchor(hrefAttr); + target.appendChild(lineLink); + } + } } - static removeHighlights(instance) { - Object.assign(instance, { - lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + static setupLineLinking(instance) { + instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); + instance.onMouseDown((e) => { + const isCorrectAnchor = e.target.element.classList.contains( + EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS, + ); + if (!isCorrectAnchor) { + return; + } + if (instance.lineDecorations) { + instance.deltaDecorations(instance.lineDecorations, []); + } }); } - /** - * Returns a function that can only be invoked once between - * each browser screen repaint. - * @param {Object} instance - The Source Editor instance - * @param {Array} bounds - The [start, end] array with start - * and end coordinates for highlighting - */ static highlightLines(instance, bounds = null) { const [start, end] = bounds && Array.isArray(bounds) @@ -74,29 +85,29 @@ export class SourceEditorExtension { } } - static onMouseMoveHandler(e) { - const target = e.target.element; - if (target.classList.contains('line-numbers')) { - const lineNum = e.target.position.lineNumber; - const hrefAttr = `#L${lineNum}`; - let el = target.querySelector('a'); - if (!el) { - el = createAnchor(hrefAttr); - target.appendChild(el); - } - } - } + // eslint-disable-next-line class-methods-use-this + provides() { + return { + /** + * Removes existing line decorations and updates the reference on the instance + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + */ + removeHighlights: (instance) => { + Object.assign(instance, { + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + }); + }, - static setupLineLinking(instance) { - instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); - instance.onMouseDown((e) => { - const isCorrectAnchor = e.target.element.classList.contains('link-anchor'); - if (!isCorrectAnchor) { - return; - } - if (instance.lineDecorations) { - instance.deltaDecorations(instance.lineDecorations, []); - } - }); + /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Array} bounds - The [start, end] array with start + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * and end coordinates for highlighting + */ + highlightLines(instance, bounds = null) { + SourceEditorExtension.highlightLines(instance, bounds); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js index 397e090ed30..ba4980896e5 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js @@ -1,8 +1,16 @@ import { Position } from 'monaco-editor'; -import { SourceEditorExtension } from './source_editor_extension_base'; -export class FileTemplateExtension extends SourceEditorExtension { - navigateFileStart() { - this.setPosition(new Position(1, 1)); +export class FileTemplateExtension { + static get extensionName() { + return 'FileTemplate'; + } + + // eslint-disable-next-line class-methods-use-this + provides() { + return { + navigateFileStart: (instance) => { + instance.setPosition(new Position(1, 1)); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index 57de21c933e..a16fe93026e 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,248 +1,102 @@ -import { debounce } from 'lodash'; -import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; -import createFlash from '~/flash'; -import { sanitize } from '~/lib/dompurify'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import syntaxHighlight from '~/syntax_highlight'; -import { - EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, - EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, - EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, - EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, -} from '../constants'; -import { SourceEditorExtension } from './source_editor_extension_base'; - -const getPreview = (text, previewMarkdownPath) => { - return axios - .post(previewMarkdownPath, { - text, - }) - .then(({ data }) => { - return data.body; - }); -}; - -const setupDomElement = ({ injectToEl = null } = {}) => { - const previewEl = document.createElement('div'); - previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); - previewEl.style.display = 'none'; - if (injectToEl) { - injectToEl.appendChild(previewEl); +export class EditorMarkdownExtension { + static get extensionName() { + return 'EditorMarkdown'; } - return previewEl; -}; -export class EditorMarkdownExtension extends SourceEditorExtension { - constructor({ instance, previewMarkdownPath, ...args } = {}) { - super({ instance, ...args }); - Object.assign(instance, { - previewMarkdownPath, - preview: { - el: undefined, - action: undefined, - shown: false, - modelChangeListener: undefined, + // eslint-disable-next-line class-methods-use-this + provides() { + return { + getSelectedText: (instance, selection = instance.getSelection()) => { + const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; + const valArray = instance.getValue().split('\n'); + let text = ''; + if (startLineNumber === endLineNumber) { + text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); + } else { + const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); + const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); + + for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { + text += `${valArray[i]}`; + if (i !== k - 1) text += `\n`; + } + text = text + ? [startLineText, text, endLineText].join('\n') + : [startLineText, endLineText].join('\n'); + } + return text; }, - }); - this.setupPreviewAction.call(instance); - - instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { - if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { - instance.setupPreviewAction(); - } else { - instance.cleanup(); - } - }); - - instance.onDidChangeModel(() => { - const model = instance.getModel(); - if (model) { - const { language } = model.getLanguageIdentifier(); - instance.cleanup(); - if (language === 'markdown') { - instance.setupPreviewAction(); + replaceSelectedText: (instance, text, select) => { + const forceMoveMarkers = !select; + instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers }]); + }, + moveCursor: (instance, dx = 0, dy = 0) => { + const pos = instance.getPosition(); + pos.column += dx; + pos.lineNumber += dy; + instance.setPosition(pos); + }, + /** + * Adjust existing selection to select text within the original selection. + * - If `selectedText` is not supplied, we fetch selected text with + * + * ALGORITHM: + * + * MULTI-LINE SELECTION + * 1. Find line that contains `toSelect` text. + * 2. Using the index of this line and the position of `toSelect` text in it, + * construct: + * * newStartLineNumber + * * newStartColumn + * + * SINGLE-LINE SELECTION + * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` + * 2. Find the position of `toSelect` text in it to get `newStartColumn` + * + * 3. `newEndLineNumber` — Since this method is supposed to be used with + * markdown decorators that are pretty short, the `newEndLineNumber` is + * suggested to be assumed the same as the startLine. + * 4. `newEndColumn` — pretty obvious + * 5. Adjust the start and end positions of the current selection + * 6. Re-set selection on the instance + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance. Is passed automatically. + * @param {string} toSelect - New text to select within current selection. + * @param {string} selectedText - Currently selected text. It's just a + * shortcut: If it's not supplied, we fetch selected text from the instance + */ + selectWithinSelection: (instance, toSelect, selectedText) => { + const currentSelection = instance.getSelection(); + if (currentSelection.isEmpty() || !toSelect) { + return; + } + const text = selectedText || instance.getSelectedText(currentSelection); + let lineShift; + let newStartLineNumber; + let newStartColumn; + + const textLines = text.split('\n'); + + if (textLines.length > 1) { + // Multi-line selection + lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1); + newStartLineNumber = currentSelection.startLineNumber + lineShift; + newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; + } else { + // Single-line selection + newStartLineNumber = currentSelection.startLineNumber; + newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); } - } - }); - } - - static togglePreviewLayout() { - const { width, height } = this.getLayoutInfo(); - const newWidth = this.preview.shown - ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH - : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; - this.layout({ width: newWidth, height }); - } - - static togglePreviewPanel() { - const parentEl = this.getDomNode().parentElement; - const { el: previewEl } = this.preview; - parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); - - if (previewEl.style.display === 'none') { - // Show the preview panel - this.fetchPreview(); - } else { - // Hide the preview panel - previewEl.style.display = 'none'; - } - } - - cleanup() { - if (this.preview.modelChangeListener) { - this.preview.modelChangeListener.dispose(); - } - this.preview.action.dispose(); - if (this.preview.shown) { - EditorMarkdownExtension.togglePreviewPanel.call(this); - EditorMarkdownExtension.togglePreviewLayout.call(this); - } - this.preview.shown = false; - } - - fetchPreview() { - const { el: previewEl } = this.preview; - getPreview(this.getValue(), this.previewMarkdownPath) - .then((data) => { - previewEl.innerHTML = sanitize(data); - syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); - previewEl.style.display = 'block'; - }) - .catch(() => createFlash(BLOB_PREVIEW_ERROR)); - } - setupPreviewAction() { - if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + const newEndLineNumber = newStartLineNumber; + const newEndColumn = newStartColumn + toSelect.length; - this.preview.action = this.addAction({ - id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - label: __('Preview Markdown'), - keybindings: [ - // eslint-disable-next-line no-bitwise,no-undef - monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), - ], - contextMenuGroupId: 'navigation', - contextMenuOrder: 1.5, + const newSelection = currentSelection + .setStartPosition(newStartLineNumber, newStartColumn) + .setEndPosition(newEndLineNumber, newEndColumn); - // Method that will be executed when the action is triggered. - // @param ed The editor instance is passed in as a convenience - run(instance) { - instance.togglePreview(); + instance.setSelection(newSelection); }, - }); - } - - togglePreview() { - if (!this.preview?.el) { - this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); - } - EditorMarkdownExtension.togglePreviewLayout.call(this); - EditorMarkdownExtension.togglePreviewPanel.call(this); - - if (!this.preview?.shown) { - this.preview.modelChangeListener = this.onDidChangeModelContent( - debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), - ); - } else { - this.preview.modelChangeListener.dispose(); - } - - this.preview.shown = !this.preview?.shown; - } - - getSelectedText(selection = this.getSelection()) { - const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; - const valArray = this.getValue().split('\n'); - let text = ''; - if (startLineNumber === endLineNumber) { - text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); - } else { - const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); - const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); - - for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { - text += `${valArray[i]}`; - if (i !== k - 1) text += `\n`; - } - text = text - ? [startLineText, text, endLineText].join('\n') - : [startLineText, endLineText].join('\n'); - } - return text; - } - - replaceSelectedText(text, select = undefined) { - const forceMoveMarkers = !select; - this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); - } - - moveCursor(dx = 0, dy = 0) { - const pos = this.getPosition(); - pos.column += dx; - pos.lineNumber += dy; - this.setPosition(pos); - } - - /** - * Adjust existing selection to select text within the original selection. - * - If `selectedText` is not supplied, we fetch selected text with - * - * ALGORITHM: - * - * MULTI-LINE SELECTION - * 1. Find line that contains `toSelect` text. - * 2. Using the index of this line and the position of `toSelect` text in it, - * construct: - * * newStartLineNumber - * * newStartColumn - * - * SINGLE-LINE SELECTION - * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` - * 2. Find the position of `toSelect` text in it to get `newStartColumn` - * - * 3. `newEndLineNumber` — Since this method is supposed to be used with - * markdown decorators that are pretty short, the `newEndLineNumber` is - * suggested to be assumed the same as the startLine. - * 4. `newEndColumn` — pretty obvious - * 5. Adjust the start and end positions of the current selection - * 6. Re-set selection on the instance - * - * @param {string} toSelect - New text to select within current selection. - * @param {string} selectedText - Currently selected text. It's just a - * shortcut: If it's not supplied, we fetch selected text from the instance - */ - selectWithinSelection(toSelect, selectedText) { - const currentSelection = this.getSelection(); - if (currentSelection.isEmpty() || !toSelect) { - return; - } - const text = selectedText || this.getSelectedText(currentSelection); - let lineShift; - let newStartLineNumber; - let newStartColumn; - - const textLines = text.split('\n'); - - if (textLines.length > 1) { - // Multi-line selection - lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1); - newStartLineNumber = currentSelection.startLineNumber + lineShift; - newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; - } else { - // Single-line selection - newStartLineNumber = currentSelection.startLineNumber; - newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); - } - - const newEndLineNumber = newStartLineNumber; - const newEndColumn = newStartColumn + toSelect.length; - - const newSelection = currentSelection - .setStartPosition(newStartLineNumber, newStartColumn) - .setEndPosition(newEndLineNumber, newEndColumn); - - this.setSelection(newSelection); + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js new file mode 100644 index 00000000000..9d53268c340 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -0,0 +1,167 @@ +import { debounce } from 'lodash'; +import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import syntaxHighlight from '~/syntax_highlight'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '../constants'; + +const fetchPreview = (text, previewMarkdownPath) => { + return axios + .post(previewMarkdownPath, { + text, + }) + .then(({ data }) => { + return data.body; + }); +}; + +const setupDomElement = ({ injectToEl = null } = {}) => { + const previewEl = document.createElement('div'); + previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); + previewEl.style.display = 'none'; + if (injectToEl) { + injectToEl.appendChild(previewEl); + } + return previewEl; +}; + +export class EditorMarkdownPreviewExtension { + static get extensionName() { + return 'EditorMarkdownPreview'; + } + + onSetup(instance, setupOptions) { + this.preview = { + el: undefined, + action: undefined, + shown: false, + modelChangeListener: undefined, + path: setupOptions.previewMarkdownPath, + }; + this.setupPreviewAction(instance); + + instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { + if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { + instance.setupPreviewAction(); + } else { + instance.cleanup(); + } + }); + + instance.onDidChangeModel(() => { + const model = instance.getModel(); + if (model) { + const { language } = model.getLanguageIdentifier(); + instance.cleanup(); + if (language === 'markdown') { + instance.setupPreviewAction(); + } + } + }); + } + + togglePreviewLayout(instance) { + const { width, height } = instance.getLayoutInfo(); + const newWidth = this.preview.shown + ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH + : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + instance.layout({ width: newWidth, height }); + } + + togglePreviewPanel(instance) { + const parentEl = instance.getDomNode().parentElement; + const { el: previewEl } = this.preview; + parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); + + if (previewEl.style.display === 'none') { + // Show the preview panel + this.fetchPreview(instance); + } else { + // Hide the preview panel + previewEl.style.display = 'none'; + } + } + + fetchPreview(instance) { + const { el: previewEl } = this.preview; + fetchPreview(instance.getValue(), this.preview.path) + .then((data) => { + previewEl.innerHTML = sanitize(data); + syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); + previewEl.style.display = 'block'; + }) + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + } + + setupPreviewAction(instance) { + if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + + this.preview.action = instance.addAction({ + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: __('Preview Markdown'), + keybindings: [ + // eslint-disable-next-line no-bitwise,no-undef + monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), + ], + contextMenuGroupId: 'navigation', + contextMenuOrder: 1.5, + + // Method that will be executed when the action is triggered. + // @param ed The editor instance is passed in as a convenience + run(inst) { + inst.togglePreview(); + }, + }); + } + + provides() { + return { + markdownPreview: this.preview, + + cleanup: (instance) => { + if (this.preview.modelChangeListener) { + this.preview.modelChangeListener.dispose(); + } + this.preview.action.dispose(); + if (this.preview.shown) { + this.togglePreviewPanel(instance); + this.togglePreviewLayout(instance); + } + this.preview.shown = false; + }, + + fetchPreview: (instance) => this.fetchPreview(instance), + + setupPreviewAction: (instance) => this.setupPreviewAction(instance), + + togglePreview: (instance) => { + if (!this.preview?.el) { + this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement }); + } + this.togglePreviewLayout(instance); + this.togglePreviewPanel(instance); + + if (!this.preview?.shown) { + this.preview.modelChangeListener = instance.onDidChangeModelContent( + debounce( + this.fetchPreview.bind(this, instance), + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, + ), + ); + } else { + this.preview.modelChangeListener.dispose(); + } + + this.preview.shown = !this.preview?.shown; + }, + }; + } +} diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js index 98e05489c1c..4e8c11bac54 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js @@ -1,7 +1,15 @@ +/** + * A WebIDE Extension options for Source Editor + * @typedef {Object} WebIDEExtensionOptions + * @property {Object} modelManager The root manager for WebIDE models + * @property {Object} store The state store for communication + * @property {Object} file + * @property {Object} options The Monaco editor options + */ + import { debounce } from 'lodash'; import { KeyCode, KeyMod, Range } from 'monaco-editor'; import { EDITOR_TYPE_DIFF } from '~/editor/constants'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import Disposable from '~/ide/lib/common/disposable'; import { editorOptions } from '~/ide/lib/editor_options'; import keymap from '~/ide/lib/keymap.json'; @@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => { }; export const UPDATE_DIMENSIONS_DELAY = 200; +const defaultOptions = { + modelManager: undefined, + store: undefined, + file: undefined, + options: {}, +}; -export class EditorWebIdeExtension extends SourceEditorExtension { - constructor({ instance, modelManager, ...options } = {}) { - super({ - instance, - ...options, - modelManager, - disposable: new Disposable(), - debouncedUpdate: debounce(() => { - instance.updateDimensions(); - }, UPDATE_DIMENSIONS_DELAY), - }); - - window.addEventListener('resize', instance.debouncedUpdate, false); - - instance.onDidDispose(() => { - window.removeEventListener('resize', instance.debouncedUpdate); - - // catch any potential errors with disposing the error - // this is mainly for tests caused by elements not existing - try { - instance.disposable.dispose(); - } catch (e) { - if (process.env.NODE_ENV !== 'test') { - // eslint-disable-next-line no-console - console.error(e); - } - } - }); +const addActions = (instance, store) => { + const getKeyCode = (key) => { + const monacoKeyMod = key.indexOf('KEY_') === 0; - EditorWebIdeExtension.addActions(instance); - } + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; + }; - static addActions(instance) { - const { store } = instance; - const getKeyCode = (key) => { - const monacoKeyMod = key.indexOf('KEY_') === 0; + keymap.forEach((command) => { + const { bindings, id, label, action } = command; - return monacoKeyMod ? KeyCode[key] : KeyMod[key]; - }; + const keybindings = bindings.map((binding) => { + const keys = binding.split('+'); - keymap.forEach((command) => { - const { bindings, id, label, action } = command; - - const keybindings = bindings.map((binding) => { - const keys = binding.split('+'); - - // eslint-disable-next-line no-bitwise - return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); - }); - - instance.addAction({ - id, - label, - keybindings, - run() { - store.dispatch(action.name, action.params); - return null; - }, - }); + // eslint-disable-next-line no-bitwise + return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); }); - } - - createModel(file, head = null) { - return this.modelManager.addModel(file, head); - } - - attachModel(model) { - if (isDiffEditorType(this)) { - this.setModel({ - original: model.getOriginalModel(), - modified: model.getModel(), - }); - return; - } - - this.setModel(model.getModel()); + instance.addAction({ + id, + label, + keybindings, + run() { + store.dispatch(action.name, action.params); + return null; + }, + }); + }); +}; - this.updateOptions( - editorOptions.reduce((acc, obj) => { - Object.keys(obj).forEach((key) => { - Object.assign(acc, { - [key]: obj[key](model), - }); - }); - return acc; - }, {}), - ); - } +const renderSideBySide = (domElement) => { + return domElement.offsetWidth >= 700; +}; - attachMergeRequestModel(model) { - this.setModel({ - original: model.getBaseModel(), - modified: model.getModel(), +const updateInstanceDimensions = (instance) => { + instance.layout(); + if (isDiffEditorType(instance)) { + instance.updateOptions({ + renderSideBySide: renderSideBySide(instance.getDomNode()), }); } +}; - updateDimensions() { - this.layout(); - this.updateDiffView(); +export class EditorWebIdeExtension { + static get extensionName() { + return 'EditorWebIde'; } - setPos({ lineNumber, column }) { - this.revealPositionInCenter({ - lineNumber, - column, - }); - this.setPosition({ - lineNumber, - column, - }); + /** + * Set up the WebIDE extension for Source Editor + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {WebIDEExtensionOptions} setupOptions + */ + onSetup(instance, setupOptions = defaultOptions) { + this.modelManager = setupOptions.modelManager; + this.store = setupOptions.store; + this.file = setupOptions.file; + this.options = setupOptions.options; + + this.disposable = new Disposable(); + this.debouncedUpdate = debounce(() => { + updateInstanceDimensions(instance); + }, UPDATE_DIMENSIONS_DELAY); + + addActions(instance, setupOptions.store); } - onPositionChange(cb) { - if (!this.onDidChangeCursorPosition) { - return; - } + onUse(instance) { + window.addEventListener('resize', this.debouncedUpdate, false); - this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); + instance.onDidDispose(() => { + this.onUnuse(); + }); } - updateDiffView() { - if (!isDiffEditorType(this)) { - return; + onUnuse() { + window.removeEventListener('resize', this.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + this.disposable.dispose(); + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } } - - this.updateOptions({ - renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()), - }); } - replaceSelectedText(text) { - let selection = this.getSelection(); - const range = new Range( - selection.startLineNumber, - selection.startColumn, - selection.endLineNumber, - selection.endColumn, - ); + provides() { + return { + createModel: (instance, file, head = null) => { + return this.modelManager.addModel(file, head); + }, + attachModel: (instance, model) => { + if (isDiffEditorType(instance)) { + instance.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); - this.executeEdits('', [{ range, text }]); + return; + } - selection = this.getSelection(); - this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); - } + instance.setModel(model.getModel()); + + instance.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + }, + attachMergeRequestModel: (instance, model) => { + instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + }, + updateDimensions: (instance) => updateInstanceDimensions(instance), + setPos: (instance, { lineNumber, column }) => { + instance.revealPositionInCenter({ + lineNumber, + column, + }); + instance.setPosition({ + lineNumber, + column, + }); + }, + onPositionChange: (instance, cb) => { + if (typeof instance.onDidChangeCursorPosition !== 'function') { + return; + } - static renderSideBySide(domElement) { - return domElement.offsetWidth >= 700; + this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e))); + }, + replaceSelectedText: (instance, text) => { + let selection = instance.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + instance.executeEdits('', [{ range, text }]); + + selection = instance.getSelection(); + instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js index 212e09c8724..05ce617ca7c 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -1,50 +1,46 @@ +/** + * A Yaml Editor Extension options for Source Editor + * @typedef {Object} YamlEditorExtensionOptions + * @property { boolean } enableComments Convert model nodes with the comment + * pattern to comments? + * @property { string } highlightPath Add a line highlight to the + * node specified by this e.g. `"foo.bar[0]"` + * @property { * } model Any JS Object that will be stringified and used as the + * editor's value. Equivalent to using `setDataModel()` + * @property options SourceEditorExtension Options + */ + import { toPath } from 'lodash'; import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml'; import { findPair } from 'yaml/util'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; -export class YamlEditorExtension extends SourceEditorExtension { +export class YamlEditorExtension { + static get extensionName() { + return 'YamlEditor'; + } + /** * Extends the source editor with capabilities for yaml files. * - * @param { Instance } instance Source Editor Instance - * @param { boolean } enableComments Convert model nodes with the comment - * pattern to comments? - * @param { string } highlightPath Add a line highlight to the - * node specified by this e.g. `"foo.bar[0]"` - * @param { * } model Any JS Object that will be stringified and used as the - * editor's value. Equivalent to using `setDataModel()` - * @param options SourceEditorExtension Options + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {YamlEditorExtensionOptions} setupOptions */ - constructor({ - instance, - enableComments = false, - highlightPath = null, - model = null, - ...options - } = {}) { - super({ - instance, - options: { - ...options, - enableComments, - highlightPath, - }, - }); + onSetup(instance, setupOptions = {}) { + const { enableComments = false, highlightPath = null, model = null } = setupOptions; + this.enableComments = enableComments; + this.highlightPath = highlightPath; + this.model = model; if (model) { - YamlEditorExtension.initFromModel(instance, model); + this.initFromModel(instance, model); } instance.onDidChangeModelContent(() => instance.onUpdate()); } - /** - * @private - */ - static initFromModel(instance, model) { + initFromModel(instance, model) { const doc = new Document(model); - if (instance.options.enableComments) { + if (this.enableComments) { YamlEditorExtension.transformComments(doc); } instance.setValue(doc.toString()); @@ -160,110 +156,13 @@ export class YamlEditorExtension extends SourceEditorExtension { return doc; } - /** - * Get the editor's value parsed as a `Document` as defined by the `yaml` - * package - * @returns {Document} - */ - getDoc() { - return parseDocument(this.getValue()); - } - - /** - * Accepts a `Document` as defined by the `yaml` package and - * sets the Editor's value to a stringified version of it. - * @param { Document } doc - */ - setDoc(doc) { - if (this.options.enableComments) { - YamlEditorExtension.transformComments(doc); - } - - if (!this.getValue()) { - this.setValue(doc.toString()); - } else { - this.updateValue(doc.toString()); - } - } - - /** - * Returns the parsed value of the Editor's content as JS. - * @returns {*} - */ - getDataModel() { - return this.getDoc().toJS(); - } - - /** - * Accepts any JS Object and sets the Editor's value to a stringified version - * of that value. - * - * @param value - */ - setDataModel(value) { - this.setDoc(new Document(value)); - } - - /** - * Method to be executed when the Editor's <TextModel> was updated - */ - onUpdate() { - if (this.options.highlightPath) { - this.highlight(this.options.highlightPath); - } - } - - /** - * Set the editors content to the input without recreating the content model. - * - * @param blob - */ - updateValue(blob) { - // Using applyEdits() instead of setValue() ensures that tokens such as - // highlighted lines aren't deleted/recreated which causes a flicker. - const model = this.getModel(); - model.applyEdits([ - { - // A nice improvement would be to replace getFullModelRange() with - // a range of the actual diff, avoiding re-formatting the document, - // but that's something for a later iteration. - range: model.getFullModelRange(), - text: blob, - }, - ]); - } - - /** - * Add a line highlight style to the node specified by the path. - * - * @param {string|null|false} path A path to a node of the Editor's value, - * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all - * highlights. - */ - highlight(path) { - if (this.options.highlightPath === path) return; - if (!path) { - SourceEditorExtension.removeHighlights(this); - } else { - const res = this.locate(path); - SourceEditorExtension.highlightLines(this, res); - } - this.options.highlightPath = path || null; + static getDoc(instance) { + return parseDocument(instance.getValue()); } - /** - * Return the line numbers of a certain node identified by `path` within - * the yaml. - * - * @param {string} path A path to a node, eg. `foo.bar[0]` - * @returns {number[]} Array following the schema `[firstLine, lastLine]` - * (both inclusive) - * - * @throws {Error} Will throw if the path is not found inside the document - */ - locate(path) { + static locate(instance, path) { if (!path) throw Error(`No path provided.`); - const blob = this.getValue(); + const blob = instance.getValue(); const doc = parseDocument(blob); const pathArray = toPath(path); @@ -290,4 +189,120 @@ export class YamlEditorExtension extends SourceEditorExtension { const endLine = (endSlice.match(/\n/g) || []).length; return [startLine, endLine]; } + + setDoc(instance, doc) { + if (this.enableComments) { + YamlEditorExtension.transformComments(doc); + } + + if (!instance.getValue()) { + instance.setValue(doc.toString()); + } else { + instance.updateValue(doc.toString()); + } + } + + highlight(instance, path) { + // IMPORTANT + // removeHighlight and highlightLines both come from + // SourceEditorExtension. So it has to be installed prior to this extension + if (this.highlightPath === path) return; + if (!path) { + instance.removeHighlights(); + } else { + const res = YamlEditorExtension.locate(instance, path); + instance.highlightLines(res); + } + this.highlightPath = path || null; + } + + provides() { + return { + /** + * Get the editor's value parsed as a `Document` as defined by the `yaml` + * package + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @returns {Document} + */ + getDoc: (instance) => YamlEditorExtension.getDoc(instance), + + /** + * Accepts a `Document` as defined by the `yaml` package and + * sets the Editor's value to a stringified version of it. + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param { Document } doc + */ + setDoc: (instance, doc) => this.setDoc(instance, doc), + + /** + * Returns the parsed value of the Editor's content as JS. + * @returns {*} + */ + getDataModel: (instance) => YamlEditorExtension.getDoc(instance).toJS(), + + /** + * Accepts any JS Object and sets the Editor's value to a stringified version + * of that value. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param value + */ + setDataModel: (instance, value) => this.setDoc(instance, new Document(value)), + + /** + * Method to be executed when the Editor's <TextModel> was updated + */ + onUpdate: (instance) => { + if (this.highlightPath) { + this.highlight(instance, this.highlightPath); + } + }, + + /** + * Set the editors content to the input without recreating the content model. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param blob + */ + updateValue: (instance, blob) => { + // Using applyEdits() instead of setValue() ensures that tokens such as + // highlighted lines aren't deleted/recreated which causes a flicker. + const model = instance.getModel(); + model.applyEdits([ + { + // A nice improvement would be to replace getFullModelRange() with + // a range of the actual diff, avoiding re-formatting the document, + // but that's something for a later iteration. + range: model.getFullModelRange(), + text: blob, + }, + ]); + }, + + /** + * Add a line highlight style to the node specified by the path. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {string|null|false} path A path to a node of the Editor's value, + * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all + * highlights. + */ + highlight: (instance, path) => this.highlight(instance, path), + + /** + * Return the line numbers of a certain node identified by `path` within + * the yaml. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {string} path A path to a node, eg. `foo.bar[0]` + * @returns {number[]} Array following the schema `[firstLine, lastLine]` + * (both inclusive) + * + * @throws {Error} Will throw if the path is not found inside the document + */ + locate: (instance, path) => YamlEditorExtension.locate(instance, path), + + initFromModel: (instance, model) => this.initFromModel(instance, model), + }; + } } diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 81ddf8d77fa..57e2b0da565 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -1,4 +1,5 @@ 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'; @@ -11,10 +12,39 @@ import { EDITOR_TYPE_DIFF, } from './constants'; import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils'; +import EditorInstance from './source_editor_instance'; + +const instanceRemoveFromRegistry = (editor, instance) => { + const index = editor.instances.findIndex((inst) => inst === instance); + editor.instances.splice(index, 1); +}; + +const instanceDisposeModels = (editor, instance, model) => { + const instanceModel = instance.getModel() || model; + if (!instanceModel) { + return; + } + if (instance.getEditorType() === EDITOR_TYPE_DIFF) { + const { original, modified } = instanceModel; + if (original) { + original.dispose(); + } + if (modified) { + modified.dispose(); + } + } else { + instanceModel.dispose(); + } +}; export default class SourceEditor { + /** + * Constructs a global editor. + * @param {Object} options - Monaco config options used to create the editor + */ constructor(options = {}) { this.instances = []; + this.extensionsStore = new Map(); this.options = { extraEditorClassName: 'gl-source-editor', ...defaultEditorOptions, @@ -26,39 +56,6 @@ export default class SourceEditor { registerLanguages(...languages); } - static pushToImportsArray(arr, toImport) { - arr.push(import(toImport)); - } - - static loadExtensions(extensions) { - if (!extensions) { - return Promise.resolve(); - } - const promises = []; - const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions; - - extensionsArray.forEach((ext) => { - const prefix = ext.includes('/') ? '' : 'editor/'; - const trimmedExt = ext.replace(/^\//, '').trim(); - SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); - }); - - return Promise.all(promises); - } - - static mixIntoInstance(source, inst) { - if (!inst) { - return; - } - const isClassInstance = source.constructor.prototype !== Object.prototype; - const sanitizedSource = isClassInstance ? source.constructor.prototype : source; - Object.getOwnPropertyNames(sanitizedSource).forEach((prop) => { - if (prop !== 'constructor') { - Object.assign(inst, { [prop]: source[prop] }); - } - }); - } - static prepareInstance(el) { if (!el) { throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); @@ -71,23 +68,6 @@ export default class SourceEditor { }); } - static manageDefaultExtensions(instance, el, extensions) { - SourceEditor.loadExtensions(extensions, instance) - .then((modules) => { - if (modules) { - modules.forEach((module) => { - instance.use(module.default); - }); - } - }) - .then(() => { - el.dispatchEvent(new Event(EDITOR_READY_EVENT)); - }) - .catch((e) => { - throw e; - }); - } - static createEditorModel({ blobPath, blobContent, @@ -115,71 +95,17 @@ export default class SourceEditor { return diffModel; } - static convertMonacoToELInstance = (inst) => { - const sourceEditorInstanceAPI = { - updateModelLanguage: (path) => { - return SourceEditor.instanceUpdateLanguage(inst, path); - }, - use: (exts = []) => { - return SourceEditor.instanceApplyExtension(inst, exts); - }, - }; - const handler = { - get(target, prop, receiver) { - if (Reflect.has(sourceEditorInstanceAPI, prop)) { - return sourceEditorInstanceAPI[prop]; - } - return Reflect.get(target, prop, receiver); - }, - }; - return new Proxy(inst, handler); - }; - - static instanceUpdateLanguage(inst, path) { - const lang = getBlobLanguage(path); - const model = inst.getModel(); - return monacoEditor.setModelLanguage(model, lang); - } - - static instanceApplyExtension(inst, exts = []) { - const extensions = [].concat(exts); - extensions.forEach((extension) => { - SourceEditor.mixIntoInstance(extension, inst); - }); - return inst; - } - - static instanceRemoveFromRegistry(editor, instance) { - const index = editor.instances.findIndex((inst) => inst === instance); - editor.instances.splice(index, 1); - } - - static instanceDisposeModels(editor, instance, model) { - const instanceModel = instance.getModel() || model; - if (!instanceModel) { - return; - } - if (instance.getEditorType() === EDITOR_TYPE_DIFF) { - const { original, modified } = instanceModel; - if (original) { - original.dispose(); - } - if (modified) { - modified.dispose(); - } - } else { - instanceModel.dispose(); - } - } - /** - * Creates a monaco instance with the given options. - * - * @param {Object} options Options used to initialize monaco. - * @param {Element} options.el The element which will be used to create the monacoEditor. + * Creates a Source Editor Instance with the given options. + * @param {Object} options Options used to initialize the instance. + * @param {Element} options.el The element to attach the instance for. * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. * @param {string} options.blobContent The content to initialize the monacoEditor. + * @param {string} options.blobOriginalContent The original blob's content. Is used when creating a Diff Instance. * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. + * @param {Boolean} options.isDiff Flag to enable creation of a Diff Instance? + * @param {...*} options.instanceOptions Configuration options used to instantiate an instance. + * @returns {EditorInstance} */ createInstance({ el = undefined, @@ -187,20 +113,24 @@ export default class SourceEditor { blobContent = '', blobOriginalContent = '', blobGlobalId = uuids()[0], - extensions = [], isDiff = false, ...instanceOptions } = {}) { SourceEditor.prepareInstance(el); const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; - const instance = SourceEditor.convertMonacoToELInstance( + const instance = new EditorInstance( monacoEditor[createEditorFn].call(this, el, { ...this.options, ...instanceOptions, }), + this.extensionsStore, ); + waitForCSSLoaded(() => { + instance.layout(); + }); + let model; if (instanceOptions.model !== null) { model = SourceEditor.createEditorModel({ @@ -214,16 +144,20 @@ export default class SourceEditor { } instance.onDidDispose(() => { - SourceEditor.instanceRemoveFromRegistry(this, instance); - SourceEditor.instanceDisposeModels(this, instance, model); + instanceRemoveFromRegistry(this, instance); + instanceDisposeModels(this, instance, model); }); - SourceEditor.manageDefaultExtensions(instance, el, extensions); - this.instances.push(instance); + el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance })); return instance; } + /** + * Create a Diff Instance + * @param {Object} args Options to be passed further down to createInstance() with the same signature + * @returns {EditorInstance} + */ createDiffInstance(args) { return this.createInstance({ ...args, @@ -231,14 +165,11 @@ export default class SourceEditor { }); } + /** + * Dispose global editor + * Automatically disposes all the instances registered for this editor + */ dispose() { this.instances.forEach((instance) => instance.dispose()); } - - use(exts) { - this.instances.forEach((inst) => { - inst.use(exts); - }); - return this; - } } diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js index f6bc62a1c09..6d47e1e2248 100644 --- a/app/assets/javascripts/editor/source_editor_extension.js +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -5,10 +5,10 @@ export default class EditorExtension { if (typeof definition !== 'function') { throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR); } - this.name = definition.name; // both class- and fn-based extensions have a name this.setupOptions = setupOptions; // eslint-disable-next-line new-cap this.obj = new definition(); + this.extensionName = definition.extensionName || this.obj.extensionName; // both class- and fn-based extensions have a name } get api() { diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js index e0ca4ea518b..8372a59964b 100644 --- a/app/assets/javascripts/editor/source_editor_instance.js +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -13,7 +13,7 @@ * A Source Editor Extension * @typedef {Object} SourceEditorExtension * @property {Object} obj - * @property {string} name + * @property {string} extensionName * @property {Object} api */ @@ -43,12 +43,12 @@ const utils = { } }, - getStoredExtension: (extensionsStore, name) => { + getStoredExtension: (extensionsStore, extensionName) => { if (!extensionsStore) { logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); return undefined; } - return extensionsStore.get(name); + return extensionsStore.get(extensionName); }, }; @@ -73,32 +73,18 @@ export default class EditorInstance { if (methodExtension) { const extension = extensionsStore.get(methodExtension); - return (...args) => { - return extension.api[prop].call(seInstance, ...args, receiver); - }; + if (typeof extension.api[prop] === 'function') { + return extension.api[prop].bind(extension.obj, receiver); + } + + return extension.api[prop]; } return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); }, - set(target, prop, value) { - Object.assign(seInstance, { - [prop]: value, - }); - return true; - }, }; const instProxy = new Proxy(rootInstance, getHandler); - /** - * Main entry point to apply an extension to the instance - * @param {SourceEditorExtensionDefinition} - */ - this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension); - - /** - * Main entry point to un-use an extension and remove it from the instance - * @param {SourceEditorExtension} - */ - this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); + this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore); return instProxy; } @@ -143,7 +129,7 @@ export default class EditorInstance { } // Existing Extension Path - const existingExt = utils.getStoredExtension(extensionsStore, definition.name); + const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName); if (existingExt) { if (isEqual(extension.setupOptions, existingExt.setupOptions)) { return existingExt; @@ -155,7 +141,7 @@ export default class EditorInstance { const extensionInstance = new EditorExtension(extension); const { setupOptions, obj: extensionObj } = extensionInstance; if (extensionObj.onSetup) { - extensionObj.onSetup(setupOptions, this); + extensionObj.onSetup(this, setupOptions); } if (extensionsStore) { this.registerExtension(extensionInstance, extensionsStore); @@ -170,14 +156,14 @@ export default class EditorInstance { * @param {Map} extensionsStore - The global registry for the extension instances */ registerExtension(extension, extensionsStore) { - const { name } = extension; + const { extensionName } = extension; const hasExtensionRegistered = - extensionsStore.has(name) && - isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions); + extensionsStore.has(extensionName) && + isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions); if (hasExtensionRegistered) { return; } - extensionsStore.set(name, extension); + extensionsStore.set(extensionName, extension); const { obj: extensionObj } = extension; if (extensionObj.onUse) { extensionObj.onUse(this); @@ -189,7 +175,7 @@ export default class EditorInstance { * @param {SourceEditorExtension} extension - Instance of Source Editor extension */ registerExtensionMethods(extension) { - const { api, name } = extension; + const { api, extensionName } = extension; if (!api) { return; @@ -199,7 +185,7 @@ export default class EditorInstance { if (this[prop]) { logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); } else { - this.methods[prop] = name; + this.methods[prop] = extensionName; } }, this); } @@ -217,10 +203,10 @@ export default class EditorInstance { if (!extension) { throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); } - const { name } = extension; - const existingExt = utils.getStoredExtension(extensionsStore, name); + const { extensionName } = extension; + const existingExt = utils.getStoredExtension(extensionsStore, extensionName); if (!existingExt) { - throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name })); + throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName })); } const { obj: extensionObj } = existingExt; if (extensionObj.onBeforeUnuse) { @@ -237,12 +223,12 @@ export default class EditorInstance { * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use */ unregisterExtensionMethods(extension) { - const { api, name } = extension; + const { api, extensionName } = extension; if (!api) { return; } Object.keys(api).forEach((method) => { - utils.removeExtFromMethod(method, name, this.methods); + utils.removeExtFromMethod(method, extensionName, this.methods); }); } @@ -262,6 +248,24 @@ export default class EditorInstance { } /** + * Main entry point to apply an extension to the instance + * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use + * @returns {EditorExtension|*} + */ + use(extDefs) { + return this.dispatchExtAction(this.useExtension, extDefs); + } + + /** + * Main entry point to remove an extension to the instance + * @param {SourceEditorExtension[]|SourceEditorExtension} exts - + * @returns {*} + */ + unuse(exts) { + return this.dispatchExtAction(this.unuseExtension, exts); + } + + /** * Get the methods returned by extensions. * @returns {Array} */ diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index e9f2272e759..a6eb4256561 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -16,3 +16,6 @@ export const CATEGORY_ICON_MAP = { export const EMOJIS_PER_ROW = 9; export const EMOJI_ROW_HEIGHT = 34; export const CATEGORY_ROW_HEIGHT = 37; + +export const CACHE_VERSION_KEY = 'gl-emoji-map-version'; +export const CACHE_KEY = 'gl-emoji-map'; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 478e3f6aed9..b507792cc91 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,26 +1,31 @@ import { escape, minBy } from 'lodash'; +import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; -import { sanitize } from '~/lib/dompurify'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; -import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; +import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; export const FALLBACK_EMOJI_KEY = 'grey_question'; // Keep the version in sync with `lib/gitlab/emoji.rb` -export const EMOJI_VERSION = '1'; +export const EMOJI_VERSION = '2'; const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); async function loadEmoji() { if ( isLocalStorageAvailable && - window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && - window.localStorage.getItem('gl-emoji-map') + window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION && + window.localStorage.getItem(CACHE_KEY) ) { - return JSON.parse(window.localStorage.getItem('gl-emoji-map')); + const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY)); + // Workaround because the pride flag is broken in EMOJI_VERSION = '1' + if (emojis.gay_pride_flag) { + emojis.gay_pride_flag.e = '🏳️🌈'; + } + return emojis; } // We load the JSON file direct from the server @@ -29,15 +34,19 @@ async function loadEmoji() { const { data } = await axios.get( `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, ); - window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); - window.localStorage.setItem('gl-emoji-map', JSON.stringify(data)); + window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); + window.localStorage.setItem(CACHE_KEY, JSON.stringify(data)); return data; } async function loadEmojiWithNames() { - return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { - acc[key] = { ...value, name: key, e: sanitize(value.e) }; + const emojiRegex = emojiRegexFactory(); + return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { + // Filter out entries which aren't emojis + if (value.e.match(emojiRegex)?.[0] === value.e) { + acc[key] = { ...value, name: key }; + } return acc; }, {}); } diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 4783b92942c..0e556f093e2 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -7,6 +7,7 @@ import { escape } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; +import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql'; import eventHub from '../event_hub'; export default { @@ -40,10 +41,15 @@ export default { required: false, default: null, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { modalTitle() { - const title = this.environment.isLastDeployment + const title = this.isLastDeployment ? s__('Environments|Re-deploy environment %{name}?') : s__('Environments|Rollback environment %{name}?'); @@ -53,6 +59,11 @@ export default { }, commitShortSha() { if (this.hasMultipleCommits) { + if (this.graphql) { + const { lastDeployment } = this.environment; + return this.commitData(lastDeployment, 'shortId'); + } + const { last_deployment } = this.environment; return this.commitData(last_deployment, 'short_id'); } @@ -61,6 +72,11 @@ export default { }, commitUrl() { if (this.hasMultipleCommits) { + if (this.graphql) { + const { lastDeployment } = this.environment; + return this.commitData(lastDeployment, 'commitPath'); + } + const { last_deployment } = this.environment; return this.commitData(last_deployment, 'commit_path'); } @@ -68,9 +84,7 @@ export default { return this.environment.commitUrl; }, modalActionText() { - return this.environment.isLastDeployment - ? s__('Environments|Re-deploy') - : s__('Environments|Rollback'); + return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback'); }, primaryProps() { let attributes = [{ variant: 'danger' }]; @@ -84,20 +98,27 @@ export default { attributes, }; }, + isLastDeployment() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.environment?.isLastDeployment || this.environment?.lastDeployment?.['last?']; + }, }, methods: { handleChange(event) { this.$emit('change', event); }, onOk() { - eventHub.$emit('rollbackEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: rollbackEnvironment, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('rollbackEnvironment', this.environment); + } }, commitData(lastDeployment, key) { - if (lastDeployment && lastDeployment.commit) { - return lastDeployment.commit[key]; - } - - return ''; + return lastDeployment?.commit?.[key] ?? ''; }, }, csrf, diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index 26ec882472b..d3d4c7d23d8 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -1,7 +1,9 @@ <script> import { GlTooltipDirective, GlModal } from '@gitlab/ui'; +import createFlash from '~/flash'; import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; +import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql'; export default { id: 'delete-environment-modal', @@ -17,6 +19,11 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { primaryProps() { @@ -49,7 +56,29 @@ export default { }, methods: { onSubmit() { - eventHub.$emit('deleteEnvironment', this.environment); + if (this.graphql) { + this.$apollo + .mutate({ + mutation: deleteEnvironmentMutation, + variables: { environment: this.environment }, + }) + .then(([message]) => { + if (message) { + createFlash({ message }); + } + }) + .catch((error) => + createFlash({ + message: s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + error, + captureError: true, + }), + ); + } else { + eventHub.$emit('deleteEnvironment', this.environment); + } }, }, }; diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index d770a2302e8..b757c55bfdb 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -12,11 +12,20 @@ export default { ModalCopyButton, }, inject: ['defaultBranchName'], + model: { + prop: 'visible', + event: 'change', + }, props: { modalId: { type: String, required: true, }, + visible: { + type: Boolean, + required: false, + default: false, + }, }, instructionText: { step1: s__( @@ -57,12 +66,15 @@ export default { </script> <template> <gl-modal + :visible="visible" :modal-id="modalId" :title="$options.modalInfo.title" + static size="lg" ok-only ok-variant="light" :ok-title="$options.modalInfo.closeText" + @change="$emit('change', $event)" > <p> <gl-sprintf :message="$options.instructionText.step1"> diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 8609503e486..63169b790c7 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -7,6 +7,7 @@ import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql'; export default { components: { @@ -20,6 +21,11 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -30,14 +36,25 @@ export default { title: s__('Environments|Delete environment'), }, mounted() { - eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); + if (!this.graphql) { + eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); + } }, beforeDestroy() { - eventHub.$off('deleteEnvironment', this.onDeleteEnvironment); + if (!this.graphql) { + eventHub.$off('deleteEnvironment', this.onDeleteEnvironment); + } }, methods: { onClick() { - eventHub.$emit('requestDeleteEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: setEnvironmentToDelete, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('requestDeleteEnvironment', this.environment); + } }, onDeleteEnvironment(environment) { if (this.environment.id === environment.id) { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index db01d455b2b..be9bfb50de5 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -5,7 +5,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +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 timeagoMixin from '~/vue_shared/mixins/timeago'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 00497b3c683..f7f0cf4cb8d 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -8,6 +8,7 @@ import { GlModalDirective, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql'; export default { components: { @@ -32,11 +33,12 @@ export default { type: String, required: true, }, - }, - data() { - return { - isLoading: false, - }; + + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { @@ -49,16 +51,18 @@ export default { methods: { onClick() { - eventHub.$emit('requestRollbackEnvironment', { - ...this.environment, - retryUrl: this.retryUrl, - isLastDeployment: this.isLastDeployment, - }); - eventHub.$on('rollbackEnvironment', (environment) => { - if (environment.id === this.environment.id) { - this.isLoading = true; - } - }); + if (this.graphql) { + this.$apollo.mutate({ + mutation: setEnvironmentToRollback, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('requestRollbackEnvironment', { + ...this.environment, + retryUrl: this.retryUrl, + isLastDeployment: this.isLastDeployment, + }); + } }, }, }; diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue index 0615bdef537..fe3d6f1e8ca 100644 --- a/app/assets/javascripts/environments/components/new_environment_folder.vue +++ b/app/assets/javascripts/environments/components/new_environment_folder.vue @@ -1,9 +1,11 @@ <script> -import { GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; +import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import folderQuery from '../graphql/queries/folder.query.graphql'; export default { components: { + GlButton, GlCollapse, GlIcon, GlBadge, @@ -26,12 +28,20 @@ export default { }, }, }, + i18n: { + collapse: __('Collapse'), + expand: __('Expand'), + link: s__('Environments|Show all'), + }, computed: { icons() { return this.visible ? { caret: 'angle-down', folder: 'folder-open' } : { caret: 'angle-right', folder: 'folder-o' }; }, + label() { + return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand; + }, count() { return this.folder?.availableCount ?? 0; }, @@ -51,18 +61,21 @@ export default { </script> <template> <div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5"> - <div class="gl-w-full gl-display-flex gl-align-items-center" @click="toggleCollapse"> - <gl-icon - class="gl-mr-2 gl-fill-current-color gl-text-gray-500" - :name="icons.caret" - :size="12" + <div class="gl-w-full gl-display-flex gl-align-items-center"> + <gl-button + class="gl-mr-4 gl-fill-current-color gl-text-gray-500" + :aria-label="label" + :icon="icons.caret" + size="small" + category="tertiary" + @click="toggleCollapse" /> <gl-icon class="gl-mr-2 gl-fill-current-color gl-text-gray-500" :name="icons.folder" /> <div class="gl-mr-2 gl-text-gray-500" :class="folderClass"> {{ nestedEnvironment.name }} </div> <gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge> - <gl-link v-if="visible" :href="folderPath">{{ s__('Environments|Show all') }}</gl-link> + <gl-link v-if="visible" :href="folderPath">{{ $options.i18n.link }}</gl-link> </div> <gl-collapse :visible="visible" /> </div> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue index a5526f9cd71..8d94e7021ca 100644 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -1,47 +1,205 @@ <script> -import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; -import environmentAppQuery from '../graphql/queries/environmentApp.query.graphql'; +import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; +import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; +import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; import EnvironmentFolder from './new_environment_folder.vue'; +import EnableReviewAppModal from './enable_review_app_modal.vue'; export default { components: { EnvironmentFolder, + EnableReviewAppModal, GlBadge, + GlPagination, GlTab, GlTabs, }, apollo: { environmentApp: { query: environmentAppQuery, + variables() { + return { + scope: this.scope, + page: this.page ?? 1, + }; + }, + pollInterval() { + return this.interval; + }, }, + interval: { + query: pollIntervalQuery, + }, + pageInfo: { + query: pageInfoQuery, + }, + }, + inject: ['newEnvironmentPath', 'canCreateEnvironment'], + i18n: { + newEnvironmentButtonLabel: s__('Environments|New environment'), + reviewAppButtonLabel: s__('Environments|Enable review app'), + available: __('Available'), + stopped: __('Stopped'), + prevPage: __('Go to previous page'), + nextPage: __('Go to next page'), + next: __('Next'), + prev: __('Prev'), + goto: (page) => sprintf(__('Go to page %{page}'), { page }), + }, + modalId: 'enable-review-app-info', + data() { + const { page = '1', scope = 'available' } = queryToObject(window.location.search); + return { + interval: undefined, + isReviewAppModalVisible: false, + page: parseInt(page, 10), + scope, + }; }, computed: { + canSetupReviewApp() { + return this.environmentApp?.reviewApp?.canSetupReviewApp; + }, folders() { return this.environmentApp?.environments.filter((e) => e.size > 1) ?? []; }, availableCount() { return this.environmentApp?.availableCount; }, + addEnvironment() { + if (!this.canCreateEnvironment) { + return null; + } + + return { + text: this.$options.i18n.newEnvironmentButtonLabel, + attributes: { + href: this.newEnvironmentPath, + category: 'primary', + variant: 'confirm', + }, + }; + }, + openReviewAppModal() { + if (!this.canSetupReviewApp) { + return null; + } + + return { + text: this.$options.i18n.reviewAppButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, + stoppedCount() { + return this.environmentApp?.stoppedCount; + }, + totalItems() { + return this.pageInfo?.total; + }, + itemsPerPage() { + return this.pageInfo?.perPage; + }, + }, + mounted() { + window.addEventListener('popstate', this.syncPageFromQueryParams); + }, + destroyed() { + window.removeEventListener('popstate', this.syncPageFromQueryParams); + this.$apollo.queries.environmentApp.stopPolling(); + }, + methods: { + showReviewAppModal() { + this.isReviewAppModalVisible = true; + }, + setScope(scope) { + this.scope = scope; + this.resetPolling(); + }, + movePage(direction) { + this.moveToPage(this.pageInfo[`${direction}Page`]); + }, + moveToPage(page) { + this.page = page; + updateHistory({ + url: setUrlParams({ page: this.page }), + title: document.title, + }); + this.resetPolling(); + }, + syncPageFromQueryParams() { + const { page = '1' } = queryToObject(window.location.search); + this.page = parseInt(page, 10); + }, + resetPolling() { + this.$apollo.queries.environmentApp.stopPolling(); + this.$nextTick(() => { + if (this.interval) { + this.$apollo.queries.environmentApp.startPolling(this.interval); + } else { + this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page }); + } + }); + }, }, }; </script> <template> <div> - <gl-tabs> - <gl-tab> + <enable-review-app-modal + v-if="canSetupReviewApp" + v-model="isReviewAppModalVisible" + :modal-id="$options.modalId" + data-testid="enable-review-app-modal" + /> + <gl-tabs + :action-secondary="addEnvironment" + :action-primary="openReviewAppModal" + sync-active-tab-with-query-params + query-param-name="scope" + @primary="showReviewAppModal" + > + <gl-tab query-param-value="available" @click="setScope('available')"> <template #title> - <span>{{ __('Available') }}</span> + <span>{{ $options.i18n.available }}</span> <gl-badge size="sm" class="gl-tab-counter-badge"> {{ availableCount }} </gl-badge> </template> - <environment-folder - v-for="folder in folders" - :key="folder.name" - class="gl-mb-3" - :nested-environment="folder" - /> + </gl-tab> + <gl-tab query-param-value="stopped" @click="setScope('stopped')"> + <template #title> + <span>{{ $options.i18n.stopped }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ stoppedCount }} + </gl-badge> + </template> </gl-tab> </gl-tabs> + <environment-folder + v-for="folder in folders" + :key="folder.name" + class="gl-mb-3" + :nested-environment="folder" + /> + <gl-pagination + align="center" + :total-items="totalItems" + :per-page="itemsPerPage" + :value="page" + :next="$options.i18n.next" + :prev="$options.i18n.prev" + :label-previous-page="$options.prevPage" + :label-next-page="$options.nextPage" + :label-page="$options.goto" + @next="movePage('next')" + @previous="movePage('previous')" + @input="moveToPage" + /> </div> </template> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index c734c2fba0c..64b18c2003b 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -1,6 +1,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import environmentApp from './queries/environmentApp.query.graphql'; +import environmentApp from './queries/environment_app.query.graphql'; +import pageInfoQuery from './queries/page_info.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => { stoppedCount: 0, }, }); + + cache.writeQuery({ + query: pageInfoQuery, + data: { + pageInfo: { + total: 0, + perPage: 20, + nextPage: 0, + previousPage: 0, + __typename: 'LocalPageInfo', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql new file mode 100644 index 00000000000..ea72067bd37 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToDelete($environment: Environment) { + setEnvironmentToDelete(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql new file mode 100644 index 00000000000..aba978ed79e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToRollback($environment: Environment) { + setEnvironmentToRollback(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql deleted file mode 100644 index faa76c0a42c..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql +++ /dev/null @@ -1,8 +0,0 @@ -query getEnvironmentApp { - environmentApp @client { - availableCount - environments - reviewApp - stoppedCount - } -} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql new file mode 100644 index 00000000000..2c17c42dd6d --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -0,0 +1,9 @@ +query getEnvironmentApp($page: Int, $scope: String) { + environmentApp(page: $page, scope: $scope) @client { + availableCount + stoppedCount + environments + reviewApp + stoppedCount + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql new file mode 100644 index 00000000000..5d39de8a0f1 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql @@ -0,0 +1,7 @@ +query environmentToDelete { + environmentToDelete @client { + id + name + deletePath + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql new file mode 100644 index 00000000000..f7586e27665 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql @@ -0,0 +1,7 @@ +query environmentToRollback { + environmentToRollback @client { + id + name + lastDeployment + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql new file mode 100644 index 00000000000..d77ca05d46f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql @@ -0,0 +1,8 @@ +query getPageInfo { + pageInfo @client { + total + perPage + nextPage + previousPage + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql new file mode 100644 index 00000000000..28afc30a0dd --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql @@ -0,0 +1,3 @@ +query pollInterval { + interval @client +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 8322b806370..9ebbc0ad1f8 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,5 +1,20 @@ import axios from '~/lib/utils/axios_utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; + +import pollIntervalQuery from './queries/poll_interval.query.graphql'; +import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; +import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; +import pageInfoQuery from './queries/page_info.query.graphql'; + +const buildErrors = (errors = []) => ({ + errors, + __typename: 'LocalEnvironmentErrors', +}); const mapNestedEnvironment = (env) => ({ ...convertObjectPropsToCamelCase(env, { deep: true }), @@ -12,17 +27,34 @@ const mapEnvironment = (env) => ({ export const resolvers = (endpoint) => ({ Query: { - environmentApp() { - return axios.get(endpoint, { params: { nested: true } }).then((res) => ({ - availableCount: res.data.available_count, - environments: res.data.environments.map(mapNestedEnvironment), - reviewApp: { - ...convertObjectPropsToCamelCase(res.data.review_app), - __typename: 'ReviewApp', - }, - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentApp', - })); + environmentApp(_context, { page, scope }, { cache }) { + return axios.get(endpoint, { params: { nested: true, page, scope } }).then((res) => { + const headers = normalizeHeaders(res.headers); + const interval = headers['POLL-INTERVAL']; + const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; + + if (interval) { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); + } else { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); + } + + cache.writeQuery({ + query: pageInfoQuery, + data: { pageInfo }, + }); + + return { + availableCount: res.data.available_count, + environments: res.data.environments.map(mapNestedEnvironment), + reviewApp: { + ...convertObjectPropsToCamelCase(res.data.review_app), + __typename: 'ReviewApp', + }, + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentApp', + }; + }); }, folder(_, { environment: { folderPath } }) { return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({ @@ -32,19 +64,72 @@ export const resolvers = (endpoint) => ({ __typename: 'LocalEnvironmentFolder', })); }, + isLastDeployment(_, { environment }) { + // eslint-disable-next-line @gitlab/require-i18n-strings + return environment?.lastDeployment?.['last?']; + }, }, - Mutations: { - stopEnvironment(_, { environment: { stopPath } }) { - return axios.post(stopPath); + Mutation: { + stopEnvironment(_, { environment }) { + return axios + .post(environment.stopPath) + .then(() => buildErrors()) + .catch(() => { + return buildErrors([ + s__('Environments|An error occurred while stopping the environment, please try again'), + ]); + }); }, deleteEnvironment(_, { environment: { deletePath } }) { - return axios.delete(deletePath); + return axios + .delete(deletePath) + .then(() => buildErrors()) + .catch(() => + buildErrors([ + s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + ]), + ); + }, + rollbackEnvironment(_, { environment, isLastDeployment }) { + return axios + .post(environment?.retryUrl) + .then(() => buildErrors()) + .catch(() => { + buildErrors([ + isLastDeployment + ? s__( + 'Environments|An error occurred while re-deploying the environment, please try again', + ) + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ), + ]); + }); + }, + setEnvironmentToDelete(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToDeleteQuery, + data: { environmentToDelete: environment }, + }); }, - rollbackEnvironment(_, { environment: { retryUrl } }) { - return axios.post(retryUrl); + setEnvironmentToRollback(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToRollbackQuery, + data: { environmentToRollback: environment }, + }); }, cancelAutoStop(_, { environment: { autoStopPath } }) { - return axios.post(autoStopPath); + return axios + .post(autoStopPath) + .then(() => buildErrors()) + .catch((err) => + buildErrors([ + err?.response?.data?.message || + s__('Environments|An error occurred while canceling the auto stop, please try again'), + ]), + ); }, }, }); diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 49ea719449e..4a3abb0e89f 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -9,12 +9,29 @@ type LocalEnvironment { autoStopPath: String } +input LocalEnvironmentInput { + id: Int! + globalId: ID! + name: String! + folderPath: String + stopPath: String + deletePath: String + retryUrl: String + autoStopPath: String +} + type NestedLocalEnvironment { name: String! size: Int! latest: LocalEnvironment! } +input NestedLocalEnvironmentInput { + name: String! + size: Int! + latest: LocalEnvironmentInput! +} + type LocalEnvironmentFolder { environments: [LocalEnvironment!]! availableCount: Int! @@ -33,3 +50,32 @@ type LocalEnvironmentApp { environments: [NestedLocalEnvironment!]! reviewApp: ReviewApp! } + +type LocalErrors { + errors: [String!]! +} + +type LocalPageInfo { + total: Int! + perPage: Int! + nextPage: Int! + previousPage: Int! +} + +extend type Query { + environmentApp(page: Int, scope: String): LocalEnvironmentApp + folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder + environmentToDelete: LocalEnvironment + pageInfo: LocalPageInfo + environmentToRollback: LocalEnvironment + isLastDeployment: Boolean +} + +extend type Mutation { + stopEnvironment(environment: LocalEnvironmentInput): LocalErrors + deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors + rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors + cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors +} diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 4adbf5362b7..e00fec6fddf 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -17,7 +17,7 @@ import createFlash from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import query from '../queries/details.query.graphql'; import { diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index af386528f00..f70e09d76f7 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,5 +1,6 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { project(fullPath: $fullPath) { + id sentryErrors { detailedError(id: $errorId) { id diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index dcb6a8e20a3..69fa7adc653 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -1,5 +1,5 @@ -// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment -import { get } from 'lodash'; +// This file only applies to use of experiments through https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment +import { get, mapValues, pick } from 'lodash'; import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants'; function getExperimentsData() { @@ -8,19 +8,18 @@ function getExperimentsData() { // Pull from preferred window.gl.experiments const experimentsFromGl = get(window, ['gl', 'experiments'], {}); - return { ...experimentsFromGon, ...experimentsFromGl }; -} - -function convertExperimentDataToExperimentContext(experimentData) { - // Bandaid to allow-list only the properties which the current gitlab_experiment context schema suppports. + // Bandaid to allow-list only the properties which the current gitlab_experiment + // context schema suppports, since we most often use this data to create that + // Snowplow context. // See TRACKING_CONTEXT_SCHEMA for current version (1-0-0) // https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0 - const { experiment: experimentName, key, variant, migration_keys } = experimentData; + return mapValues({ ...experimentsFromGon, ...experimentsFromGl }, (xp) => { + return pick(xp, ['experiment', 'key', 'variant', 'migration_keys']); + }); +} - return { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: experimentName, key, variant, migration_keys }, - }; +function createGitlabExperimentContext(experimentData) { + return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData }; } export function getExperimentData(experimentName) { @@ -28,10 +27,10 @@ export function getExperimentData(experimentName) { } export function getAllExperimentContexts() { - return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext); + return Object.values(getExperimentsData()).map(createGitlabExperimentContext); } -export function isExperimentVariant(experimentName, variantName) { +export function isExperimentVariant(experimentName, variantName = CANDIDATE_VARIANT) { return getExperimentData(experimentName)?.variant === variantName; } diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 29e82289107..26da0d56f9a 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -142,7 +142,14 @@ export default { return !this.$options.rolloutPercentageRegex.test(percentage); }), onFormStrategyChange(strategy, index) { + const currentUserListId = this.filteredStrategies[index]?.userList?.id; + const newUserListId = strategy?.userList?.id; + Object.assign(this.filteredStrategies[index], strategy); + + if (currentUserListId !== newUserListId) { + this.formStrategies = [...this.formStrategies]; + } }, }, }; diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index e0281b8f443..3cd4d48a4a3 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,4 +1,4 @@ -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownEmoji from './dropdown_emoji'; diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 08736b09407..e2d6936acbd 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -11,3 +11,10 @@ export const FILTER_TYPE = { }; export const MAX_HISTORY_SIZE = 5; + +export const FILTERED_SEARCH = { + MERGE_REQUESTS: 'merge_requests', + ISSUES: 'issues', + ADMIN_RUNNERS: 'admin/runners', + GROUP_RUNNERS_ANCHOR: 'runners-settings', +}; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 1287a7ed746..f0ef55f73eb 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -62,7 +62,7 @@ const createFlashEl = (message, type) => ` </div> `; -const removeFlashClickListener = (flashEl, fadeTransition) => { +const addDismissFlashClickListener = (flashEl, fadeTransition) => { // There are some flash elements which do not have a closeEl. // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); @@ -113,7 +113,7 @@ const createFlash = function createFlash({ } } - removeFlashClickListener(flashEl, fadeTransition); + addDismissFlashClickListener(flashEl, fadeTransition); flashContainer.classList.add('gl-display-block'); @@ -130,10 +130,8 @@ const createFlash = function createFlash({ export { createFlash as default, - createFlashEl, - createAction, hideFlash, - removeFlashClickListener, + addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, }; diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue index 1e5be9df019..64784755b66 100644 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ b/app/assets/javascripts/google_cloud/components/app.vue @@ -1,22 +1,42 @@ <script> -import { GlTab, GlTabs } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import Home from './home.vue'; import IncubationBanner from './incubation_banner.vue'; -import ServiceAccounts from './service_accounts.vue'; +import ServiceAccountsForm from './service_accounts_form.vue'; +import NoGcpProjects from './errors/no_gcp_projects.vue'; +import GcpError from './errors/gcp_error.vue'; + +const SCREEN_GCP_ERROR = 'gcp_error'; +const SCREEN_HOME = 'home'; +const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects'; +const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form'; export default { - components: { GlTab, GlTabs, IncubationBanner, ServiceAccounts }, + components: { + IncubationBanner, + }, + inheritAttrs: false, props: { - serviceAccounts: { - type: Array, + screen: { required: true, - }, - createServiceAccountUrl: { type: String, - required: true, }, - emptyIllustrationUrl: { - type: String, - required: true, + }, + computed: { + mainComponent() { + switch (this.screen) { + case SCREEN_HOME: + return Home; + case SCREEN_GCP_ERROR: + return GcpError; + case SCREEN_NO_GCP_PROJECTS: + return NoGcpProjects; + case SCREEN_SERVICE_ACCOUNTS_FORM: + return ServiceAccountsForm; + default: + throw new Error(__('Unknown screen')); + } }, }, methods: { @@ -34,17 +54,6 @@ export default { :report-bug-url="feedbackUrl('report_bug')" :feature-request-url="feedbackUrl('feature_request')" /> - <gl-tabs> - <gl-tab :title="__('Configuration')"> - <service-accounts - class="gl-mx-3" - :list="serviceAccounts" - :create-url="createServiceAccountUrl" - :empty-illustration-url="emptyIllustrationUrl" - /> - </gl-tab> - <gl-tab :title="__('Deployments')" disabled /> - <gl-tab :title="__('Services')" disabled /> - </gl-tabs> + <component :is="mainComponent" v-bind="$attrs" /> </div> </template> diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue new file mode 100644 index 00000000000..90aa0e1ae68 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue @@ -0,0 +1,29 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlAlert }, + props: { + error: { + type: String, + required: true, + }, + }, + i18n: { + title: __('Google Cloud project misconfigured'), + description: __( + 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:', + ), + }, +}; +</script> + +<template> + <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> + {{ $options.i18n.description }} + <blockquote> + <code>{{ error }}</code> + </blockquote> + </gl-alert> +</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue new file mode 100644 index 00000000000..da229ac3f0e --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue @@ -0,0 +1,26 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlAlert, GlButton }, + i18n: { + title: __('Google Cloud project required'), + description: __( + 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.', + ), + createLabel: __('Create Google Cloud project'), + }, +}; +</script> + +<template> + <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> + {{ $options.i18n.description }} + <template #actions> + <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank"> + {{ $options.i18n.createLabel }} + </gl-button> + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue new file mode 100644 index 00000000000..05f39de66ee --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -0,0 +1,41 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import ServiceAccountsList from './service_accounts_list.vue'; + +export default { + components: { + GlTabs, + GlTab, + ServiceAccountsList, + }, + props: { + serviceAccounts: { + type: Array, + required: true, + }, + createServiceAccountUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab :title="__('Configuration')"> + <service-accounts-list + class="gl-mx-4" + :list="serviceAccounts" + :create-url="createServiceAccountUrl" + :empty-illustration-url="emptyIllustrationUrl" + /> + </gl-tab> + <gl-tab :title="__('Deployments')" disabled /> + <gl-tab :title="__('Services')" disabled /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue new file mode 100644 index 00000000000..e7a09668473 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue @@ -0,0 +1,70 @@ +<script> +import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlButton, GlFormGroup, GlFormSelect }, + props: { + gcpProjects: { required: true, type: Array }, + environments: { required: true, type: Array }, + cancelPath: { required: true, type: String }, + }, + i18n: { + title: __('Create service account'), + gcpProjectLabel: __('Google Cloud project'), + gcpProjectDescription: __( + 'New service account is generated for the selected Google Cloud project', + ), + environmentLabel: __('Environment'), + environmentDescription: __('Generated service account is linked to the selected environment'), + submitLabel: __('Create service account'), + cancelLabel: __('Cancel'), + }, +}; +</script> + +<template> + <div> + <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> + <h2 class="gl-font-size-h1">{{ $options.i18n.title }}</h2> + </header> + <gl-form-group + label-for="gcp_project" + :label="$options.i18n.gcpProjectLabel" + :description="$options.i18n.gcpProjectDescription" + > + <gl-form-select id="gcp_project" name="gcp_project" required> + <option + v-for="gcpProject in gcpProjects" + :key="gcpProject.project_id" + :value="gcpProject.project_id" + > + {{ gcpProject.name }} + </option> + </gl-form-select> + </gl-form-group> + <gl-form-group + label-for="environment" + :label="$options.i18n.environmentLabel" + :description="$options.i18n.environmentDescription" + > + <gl-form-select id="environment" name="environment" required> + <option value="*">{{ __('All') }}</option> + <option + v-for="environment in environments" + :key="environment.name" + :value="environment.name" + > + {{ environment.name }} + </option> + </gl-form-select> + </gl-form-group> + + <div class="form-actions row"> + <gl-button type="submit" category="primary" variant="confirm"> + {{ $options.i18n.submitLabel }} + </gl-button> + <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue index b70b25a5dc3..b70b25a5dc3 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js index a156a632e9a..ab9e8227812 100644 --- a/app/assets/javascripts/google_cloud/index.js +++ b/app/assets/javascripts/google_cloud/index.js @@ -1,11 +1,12 @@ import Vue from 'vue'; import App from './components/app.vue'; -const elementRenderer = (element, props = {}) => (createElement) => - createElement(element, { props }); - export default () => { - const root = document.querySelector('#js-google-cloud'); - const props = JSON.parse(root.getAttribute('data')); - return new Vue({ el: root, render: elementRenderer(App, props) }); + const root = '#js-google-cloud'; + const element = document.querySelector(root); + const { screen, ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(App, { props: { screen }, attrs }), + }); }; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 692de9dcb88..3b36c3e6ac5 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,6 +1,8 @@ export const MINIMUM_SEARCH_LENGTH = 3; export const TYPE_CI_RUNNER = 'Ci::Runner'; +export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_EPIC = 'Epic'; export const TYPE_GROUP = 'Group'; export const TYPE_ISSUE = 'Issue'; @@ -8,10 +10,10 @@ export const TYPE_ITERATION = 'Iteration'; export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; export const TYPE_MERGE_REQUEST = 'MergeRequest'; export const TYPE_MILESTONE = 'Milestone'; +export const TYPE_NOTE = 'Note'; +export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; export const TYPE_PROJECT = 'Project'; export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; export const TYPE_VULNERABILITY = 'Vulnerability'; -export const TYPE_NOTE = 'Note'; -export const TYPE_DISCUSSION = 'Discussion'; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 2c771c32e16..64f547f933a 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -6,6 +6,7 @@ fragment AlertListItem on AlertManagementAlert { startedAt eventCount issue { + id iid state title diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql index 9a9ae369519..794fe0a6151 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql @@ -12,6 +12,7 @@ fragment AlertDetailItem on AlertManagementAlert { endedAt hosts environment { + id name path } diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql index 3551394ff97..78b2cd34a5c 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -1,10 +1,12 @@ fragment TimelogFragment on Timelog { timeSpent user { + id name } spentAt note { + id body } summary diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql index 0b451262b5a..429993b37bf 100644 --- a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql @@ -1,3 +1,4 @@ +# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment UserAvailability on User { status { availability diff --git a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql index 79c56448b3f..2adaf24ed34 100644 --- a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql @@ -1,6 +1,7 @@ mutation createMergeRequest($input: MergeRequestCreateInput!) { mergeRequestCreate(input: $input) { mergeRequest { + id iid } errors diff --git a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql index 5ee2cf7ca44..8debc6113d1 100644 --- a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql @@ -2,6 +2,7 @@ query alertDetails($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { + id alertManagementAlerts(iid: $alertId) { nodes { ...AlertDetailItem diff --git a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql index 095e4fe29df..9ffa0bad9ad 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql @@ -14,6 +14,7 @@ query getAlerts( $domain: AlertManagementDomainFilter = operations ) { project(fullPath: $projectPath) { + id alertManagementAlerts( search: $searchTerm assigneeUsername: $assigneeUsername 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 c5f99a1657e..7c88e494a2e 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 @@ -6,6 +6,7 @@ query groupUsersSearch($search: String!, $fullPath: ID!) { id users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { nodes { + id user { ...User ...UserAvailability diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql index 62ce27815c7..ef3070d3437 100644 --- a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql @@ -3,6 +3,7 @@ query searchProjectMembers($fullPath: ID!, $search: String) { id projectMembers(search: $search) { nodes { + id user { id name diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index d04a49f8b3a..bb34e4032f4 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -3,8 +3,10 @@ query projectUsersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { + id users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { nodes { + id user { ...User ...UserAvailability diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index c6590fd8eb3..edc6573a489 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,8 +1,17 @@ <script> import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__, sprintf } from '~/locale'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, +} from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; import HeaderSearchScopedItems from './header_search_scoped_items.vue'; @@ -10,7 +19,21 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue'; export default { name: 'HeaderSearchApp', i18n: { - searchPlaceholder: __('Search or jump to...'), + searchPlaceholder: s__('GlobalSearch|Search or jump to...'), + searchAria: s__('GlobalSearch|Search GitLab'), + searchInputDescribeByNoDropdown: s__( + 'GlobalSearch|Type and press the enter key to submit search.', + ), + searchInputDescribeByWithDropdown: s__( + 'GlobalSearch|Type for new suggestions to appear below.', + ), + searchDescribedByDefault: s__( + 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.', + ), + searchDescribedByUpdated: s__( + 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.', + ), + searchResultsLoading: s__('GlobalSearch|Search results are loading'), }, directives: { Outside }, components: { @@ -18,15 +41,17 @@ export default { HeaderSearchDefaultItems, HeaderSearchScopedItems, HeaderSearchAutocompleteItems, + DropdownKeyboardNavigation, }, data() { return { showDropdown: false, + currentFocusIndex: SEARCH_BOX_INDEX, }; }, computed: { - ...mapState(['search']), - ...mapGetters(['searchQuery']), + ...mapState(['search', 'loading']), + ...mapGetters(['searchQuery', 'searchOptions']), searchText: { get() { return this.search; @@ -35,15 +60,55 @@ export default { this.setSearch(value); }, }, + currentFocusedOption() { + return this.searchOptions[this.currentFocusIndex]; + }, + currentFocusedId() { + return this.currentFocusedOption?.html_id; + }, + isLoggedIn() { + return gon?.current_username; + }, showSearchDropdown() { - return this.showDropdown && gon?.current_username; + return this.showDropdown && this.isLoggedIn; }, showDefaultItems() { return !this.searchText; }, + defaultIndex() { + if (this.showDefaultItems) { + return SEARCH_BOX_INDEX; + } + + return FIRST_DROPDOWN_INDEX; + }, + searchInputDescribeBy() { + if (this.isLoggedIn) { + return this.$options.i18n.searchInputDescribeByWithDropdown; + } + + return this.$options.i18n.searchInputDescribeByNoDropdown; + }, + dropdownResultsDescription() { + if (!this.showSearchDropdown) { + return ''; // This allows aria-live to see register an update when the dropdown is shown + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.searchDescribedByDefault, { + count: this.searchOptions.length, + }); + } + + return this.loading + ? this.$options.i18n.searchResultsLoading + : sprintf(this.$options.i18n.searchDescribedByUpdated, { + count: this.searchOptions.length, + }); + }, }, methods: { - ...mapActions(['setSearch', 'fetchAutocompleteOptions']), + ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; }, @@ -51,44 +116,77 @@ export default { this.showDropdown = false; }, submitSearch() { - return visitUrl(this.searchQuery); + return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, - getAutocompleteOptions(searchTerm) { + getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { if (!searchTerm) { - return; + this.clearAutocomplete(); + } else { + this.fetchAutocompleteOptions(); } - - this.fetchAutocompleteOptions(); - }, + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <section v-outside="closeDropdown" class="header-search gl-relative"> + <form + v-outside="closeDropdown" + role="search" + :aria-label="$options.i18n.searchAria" + class="header-search gl-relative" + > <gl-search-box-by-type + id="search" v-model="searchText" - :debounce="500" + role="searchbox" + class="gl-z-index-1" autocomplete="off" :placeholder="$options.i18n.searchPlaceholder" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" @click="openDropdown" @input="getAutocompleteOptions" - @keydown.enter="submitSearch" - @keydown.esc="closeDropdown" + @keydown.enter.stop.prevent="submitSearch" /> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ + searchInputDescribeBy + }}</span> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ dropdownResultsDescription }} + </span> <div v-if="showSearchDropdown" data-testid="header-search-dropdown-menu" class="header-search-dropdown-menu 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" > <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> - <header-search-default-items v-if="showDefaultItems" /> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="searchOptions.length - 1" + :min="$options.SEARCH_BOX_INDEX" + :default-index="defaultIndex" + @tab="closeDropdown" + /> + <header-search-default-items + v-if="showDefaultItems" + :current-focused-option="currentFocusedOption" + /> <template v-else> - <header-search-scoped-items /> - <header-search-autocomplete-items /> + <header-search-scoped-items :current-focused-option="currentFocusedOption" /> + <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> </template> </div> </div> - </section> + </form> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 9bea2b280f7..9f4f4768247 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -23,10 +23,26 @@ export default { directives: { SafeHtml, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search', 'loading']), ...mapGetters(['autocompleteGroupedSearchOptions']), }, + watch: { + currentFocusedOption() { + const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; + + if (focusedElement) { + focusedElement.scrollIntoView(false); + } + }, + }, methods: { highlightedName(val) { return highlight(val, this.search); @@ -38,6 +54,9 @@ export default { return SMALL_AVATAR_PX; }, + isOptionFocused(data) { + return this.currentFocusedOption?.html_id === data.html_id; + }, }, }; </script> @@ -49,13 +68,17 @@ export default { <gl-dropdown-divider /> <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(data, index) in option.data" - :id="`autocomplete-${option.category}-${index}`" - :key="index" + v-for="data in option.data" + :id="data.html_id" + :ref="data.html_id" + :key="data.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" + :aria-selected="isOptionFocused(data)" + :aria-label="data.label" tabindex="-1" :href="data.url" > - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> <gl-avatar v-if="data.avatar_url !== undefined" :src="data.avatar_url" diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue index 2871937ed3a..53e63bc6cca 100644 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -12,6 +12,13 @@ export default { GlDropdownSectionHeader, GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['searchContext']), ...mapGetters(['defaultSearchOptions']), @@ -23,6 +30,11 @@ export default { ); }, }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + }, }; </script> @@ -30,13 +42,17 @@ export default { <div> <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(option, index) in defaultSearchOptions" - :id="`default-${index}`" - :key="index" + v-for="option in defaultSearchOptions" + :id="option.html_id" + :ref="option.html_id" + :key="option.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" + :aria-selected="isOptionFocused(option)" + :aria-label="option.title" tabindex="-1" :href="option.url" > - {{ option.title }} + <span aria-hidden="true">{{ option.title }}</span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 645eba05148..3aebee71509 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,31 +1,57 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search']), ...mapGetters(['scopedSearchOptions']), }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + ariaLabel(option) { + return sprintf(__('%{search} %{description} %{scope}'), { + search: this.search, + description: option.description, + scope: option.scope || '', + }); + }, + }, }; </script> <template> <div> <gl-dropdown-item - v-for="(option, index) in scopedSearchOptions" - :id="`scoped-${index}`" - :key="index" + v-for="option in scopedSearchOptions" + :id="option.html_id" + :ref="option.html_id" + :key="option.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" + :aria-selected="isOptionFocused(option)" + :aria-label="ariaLabel(option)" tabindex="-1" :href="option.url" > - "<span class="gl-font-weight-bold">{{ search }}</span - >" {{ option.description }} - <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + <span aria-hidden="true"> + "<span class="gl-font-weight-bold">{{ search }}</span + >" {{ option.description }} + <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + </span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 2fadb1bd1ee..b2e45fcd648 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -1,20 +1,20 @@ -import { __ } from '~/locale'; +import { s__ } from '~/locale'; -export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me'); +export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); -export const MSG_ISSUES_IVE_CREATED = __("Issues I've created"); +export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created"); -export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me'); +export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me'); -export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer"); +export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer"); -export const MSG_MR_IVE_CREATED = __("Merge requests I've created"); +export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created"); -export const MSG_IN_ALL_GITLAB = __('in all GitLab'); +export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab'); -export const MSG_IN_GROUP = __('in group'); +export const MSG_IN_GROUP = s__('GlobalSearch|in group'); -export const MSG_IN_PROJECT = __('in project'); +export const MSG_IN_PROJECT = s__('GlobalSearch|in project'); export const GROUPS_CATEGORY = 'Groups'; @@ -23,3 +23,11 @@ export const PROJECTS_CATEGORY = 'Projects'; export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; + +export const FIRST_DROPDOWN_INDEX = 0; + +export const SEARCH_BOX_INDEX = -1; + +export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 2c3b1bd4c0f..0ba956f3ed1 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -14,6 +14,10 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { }); }; +export const clearAutocomplete = ({ commit }) => { + commit(types.CLEAR_AUTOCOMPLETE); +}; + export const setSearch = ({ commit }, value) => { commit(types.SET_SEARCH, value); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 3f4e231ca55..a1348a8aa3f 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -1,3 +1,4 @@ +import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; import { @@ -12,23 +13,29 @@ import { } from '../constants'; export const searchQuery = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project?.id, - group_id: state.searchContext.group?.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext.project?.id, + group_id: state.searchContext.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const autocompleteQuery = (state) => { - const query = { - term: state.search, - project_id: state.searchContext.project?.id, - project_ref: state.searchContext.ref, - }; + const query = omitBy( + { + term: state.search, + project_id: state.searchContext.project?.id, + project_ref: state.searchContext?.ref, + }, + isNil, + ); return `${state.autocompletePath}?${objectToQuery(query)}`; }; @@ -54,22 +61,27 @@ export const defaultSearchOptions = (state, getters) => { return [ { + html_id: 'default-issues-assigned', title: MSG_ISSUES_ASSIGNED_TO_ME, url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { + html_id: 'default-issues-created', title: MSG_ISSUES_IVE_CREATED, url: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, { + html_id: 'default-mrs-assigned', title: MSG_MR_ASSIGNED_TO_ME, url: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { + html_id: 'default-mrs-reviewer', title: MSG_MR_IM_REVIEWER, url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { + html_id: 'default-mrs-created', title: MSG_MR_IVE_CREATED, url: `${getters.scopedMRPath}/?author_username=${userName}`, }, @@ -77,42 +89,43 @@ export const defaultSearchOptions = (state, getters) => { }; export const projectUrl = (state) => { - if (!state.searchContext.project || !state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project.id, - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const groupUrl = (state) => { - if (!state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const allUrl = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; @@ -122,6 +135,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.project) { options.push({ + html_id: 'scoped-in-project', scope: state.searchContext.project.name, description: MSG_IN_PROJECT, url: getters.projectUrl, @@ -130,6 +144,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.group) { options.push({ + html_id: 'scoped-in-group', scope: state.searchContext.group.name, description: MSG_IN_GROUP, url: getters.groupUrl, @@ -137,6 +152,7 @@ export const scopedSearchOptions = (state, getters) => { } options.push({ + html_id: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: getters.allUrl, }); @@ -165,3 +181,18 @@ export const autocompleteGroupedSearchOptions = (state) => { return results; }; + +export const searchOptions = (state, getters) => { + if (!state.search) { + return getters.defaultSearchOptions; + } + + const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( + (options, group) => { + return [...options, ...group.data]; + }, + [], + ); + + return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); +}; diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js index a2358621ce6..6e65345757f 100644 --- a/app/assets/javascripts/header_search/store/mutation_types.js +++ b/app/assets/javascripts/header_search/store/mutation_types.js @@ -1,5 +1,6 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; +export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 175b5406540..26b4a8854fe 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -7,12 +7,17 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = data; + state.autocompleteOptions = data.map((d, i) => { + return { html_id: `autocomplete-${d.category}-${i}`, ...d }; + }); }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { state.loading = false; state.autocompleteOptions = []; }, + [types.CLEAR_AUTOCOMPLETE](state) { + state.autocompleteOptions = []; + }, [types.SET_SEARCH](state, value) { state.search = value; }, diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index c71d911adfb..846b4d92724 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -63,7 +63,7 @@ export default { class="ide-sidebar-link js-ide-review-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)" > - <gl-icon name="file-modified" /> + <gl-icon name="review-list" /> </button> </li> <li> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index b987adc8bae..0fc7337ad26 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -29,14 +29,20 @@ export default { }, }, watch: { - showLoading(newVal) { - if (!newVal) { - this.$emit('tree-ready'); - } + showLoading() { + this.notifyTreeReady(); }, }, + mounted() { + this.notifyTreeReady(); + }, methods: { ...mapActions(['toggleTreeOpen']), + notifyTreeReady() { + if (!this.showLoading) { + this.$emit('tree-ready'); + } + }, clickedFile() { performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED }); }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index bdd201aac1b..87b60eca73c 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -67,7 +67,7 @@ export default { data-qa-selector="dropdown_button" @click.stop="openDropdown()" > - <gl-icon name="ellipsis_v" /> <gl-icon name="chevron-down" /> + <gl-icon name="ellipsis_v" /> </button> <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right"> <template v-if="type === 'tree'"> diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue new file mode 100644 index 00000000000..194deb2ece0 --- /dev/null +++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue @@ -0,0 +1,35 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + components: { + GlEmptyState, + }, + computed: { + ...mapState(['pipelinesEmptyStateSvgPath']), + ciHelpPagePath() { + return helpPagePath('ci/quick_start/index.md'); + }, + }, + i18n: { + title: s__('Pipelines|Build with confidence'), + description: s__(`Pipelines|GitLab CI/CD can automatically build, + test, and deploy your code. Let GitLab take care of time + consuming tasks, so you can spend more time creating.`), + primaryButtonText: s__('Pipelines|Get started with GitLab CI/CD'), + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="pipelinesEmptyStateSvgPath" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.primaryButtonText" + :primary-button-link="ciHelpPagePath" + /> +</template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index e1caf1ba44a..7f513afe82e 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -11,10 +11,17 @@ import { import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import IDEServices from '~/ide/services'; -import { sprintf, __ } from '../../../locale'; -import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; -import CiIcon from '../../../vue_shared/components/ci_icon.vue'; +import { sprintf, __ } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobsList from '../jobs/list.vue'; +import EmptyState from './empty_state.vue'; + +const CLASSES_FLEX_VERTICAL_CENTER = [ + 'gl-h-full', + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-justify-content-center', +]; export default { components: { @@ -32,7 +39,6 @@ export default { SafeHtml, }, computed: { - ...mapState(['pipelinesEmptyStateSvgPath']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), ...mapState('pipelines', [ @@ -63,12 +69,15 @@ export default { methods: { ...mapActions('pipelines', ['fetchLatestPipeline']), }, + CLASSES_FLEX_VERTICAL_CENTER, }; </script> <template> <div class="ide-pipeline"> - <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" /> + <div v-if="showLoadingIcon" :class="$options.CLASSES_FLEX_VERTICAL_CENTER"> + <gl-loading-icon size="lg" /> + </div> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> @@ -83,12 +92,9 @@ export default { </a> </span> </header> - <empty-state - v-if="!latestPipeline" - :empty-state-svg-path="pipelinesEmptyStateSvgPath" - :can-set-ci="true" - class="gl-p-5" - /> + <div v-if="!latestPipeline" :class="$options.CLASSES_FLEX_VERTICAL_CENTER"> + <empty-state /> + </div> <gl-alert v-else-if="latestPipeline.yamlError" variant="danger" diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 2bf99550bf2..05493db1dff 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -7,6 +7,7 @@ import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, } from '~/editor/constants'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; import createFlash from '~/flash'; @@ -302,30 +303,32 @@ export default { ...instanceOptions, ...this.editorOptions, }); - - this.editor.use( - new EditorWebIdeExtension({ - instance: this.editor, - modelManager: this.modelManager, - store: this.$store, - file: this.file, - options: this.editorOptions, - }), - ); + this.editor.use([ + { + definition: SourceEditorExtension, + }, + { + definition: EditorWebIdeExtension, + setupOptions: { + modelManager: this.modelManager, + store: this.$store, + file: this.file, + options: this.editorOptions, + }, + }, + ]); if ( this.fileType === MARKDOWN_FILE_TYPE && this.editor?.getEditorType() === EDITOR_TYPE_CODE && this.previewMarkdownPath ) { - import('~/editor/extensions/source_editor_markdown_ext') - .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use( - new MarkdownExtension({ - instance: this.editor, - previewMarkdownPath: this.previewMarkdownPath, - }), - ); + import('~/editor/extensions/source_editor_markdown_livepreview_ext') + .then(({ EditorMarkdownPreviewExtension: MarkdownLivePreview }) => { + this.editor.use({ + definition: MarkdownLivePreview, + setupOptions: { previewMarkdownPath: this.previewMarkdownPath }, + }); }) .catch((e) => createFlash({ diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 706d98fdb90..775b6906498 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -76,15 +76,15 @@ export const stageKeys = { export const commitItemIconMap = { addition: { icon: 'file-addition', - class: 'ide-file-addition', + class: 'file-addition ide-file-addition', }, modified: { icon: 'file-modified', - class: 'ide-file-modified', + class: 'file-modified ide-file-modified', }, deleted: { icon: 'file-deletion', - class: 'ide-file-deletion', + class: 'file-deletion ide-file-deletion', }, }; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 27cedd80347..1fc447886bb 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,8 +1,6 @@ import Vue from 'vue'; -import createFlash from '~/flash'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import { WEBIDE_MARK_FETCH_PROJECT_DATA_START, WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, @@ -75,49 +73,34 @@ export const createRouter = (store, defaultBranch) => { router.beforeEach((to, from, next) => { if (to.params.namespace && to.params.project) { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); - store - .dispatch('getProjectData', { - namespace: to.params.namespace, - projectId: to.params.project, - }) - .then(() => { - const basePath = to.params.pathMatch || ''; - const projectId = `${to.params.namespace}/${to.params.project}`; - const branchId = to.params.branchid; - const mergeRequestId = to.params.mrid; + const basePath = to.params.pathMatch || ''; + const projectId = `${to.params.namespace}/${to.params.project}`; + const branchId = to.params.branchid; + const mergeRequestId = to.params.mrid; - if (branchId) { - performanceMarkAndMeasure({ - mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, - measures: [ - { - name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, - start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, - }, - ], - }); - store.dispatch('openBranch', { - projectId, - branchId, - basePath, - }); - } else if (mergeRequestId) { - store.dispatch('openMergeRequest', { - projectId, - mergeRequestId, - targetProjectId: to.query.target_project, - }); - } - }) - .catch((e) => { - createFlash({ - message: __('Error while loading the project data. Please try again.'), - fadeTransition: false, - addBodyClass: true, - }); - throw e; + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); + if (branchId) { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, + start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, + }, + ], + }); + store.dispatch('openBranch', { + projectId, + branchId, + basePath, + }); + } else if (mergeRequestId) { + store.dispatch('openMergeRequest', { + projectId, + mergeRequestId, + targetProjectId: to.query.target_project, }); + } } next(); diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index bdffed70882..df643675357 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -34,11 +34,18 @@ Vue.use(PerformancePlugin, { * @param {extendStoreCallback} options.extendStore - * Function that receives the default store and returns an extended one. */ -export function initIde(el, options = {}) { +export const initIde = (el, options = {}) => { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; + const store = createStore(); + const project = JSON.parse(el.dataset.project); + store.dispatch('setProject', { project }); + + // fire and forget fetching non-critical project info + store.dispatch('fetchProjectPermissions'); + const router = createRouter(store, el.dataset.defaultBranch || DEFAULT_BRANCH); return new Vue({ @@ -77,7 +84,7 @@ export function initIde(el, options = {}) { return createElement(rootComponent); }, }); -} +}; /** * Start the IDE. diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js index d7636574754..36fa5039be7 100644 --- a/app/assets/javascripts/ide/lib/themes/monokai.js +++ b/app/assets/javascripts/ide/lib/themes/monokai.js @@ -162,8 +162,8 @@ export default { 'editor.selectionBackground': '#49483E', 'editor.lineHighlightBackground': '#3E3D32', 'editorCursor.foreground': '#F8F8F0', - 'editorWhitespace.foreground': '#3B3A32', 'editorIndentGuide.activeBackground': '#9D550FB0', 'editor.selectionHighlightBorder': '#222218', + 'editorWhitespace.foreground': '#75715e', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js index 8e722c4ff88..0842bc04cff 100644 --- a/app/assets/javascripts/ide/lib/themes/none.js +++ b/app/assets/javascripts/ide/lib/themes/none.js @@ -13,5 +13,6 @@ export default { 'diffEditor.insertedTextBackground': '#a0f5b420', 'diffEditor.removedTextBackground': '#f9d7dc20', 'editorIndentGuide.activeBackground': '#cccccc', + 'editorSuggestWidget.focusHighlightForeground': '#96D8FD', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js index 3c9414b9dc9..8ae609285ac 100644 --- a/app/assets/javascripts/ide/lib/themes/solarized_dark.js +++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js @@ -1105,6 +1105,6 @@ export default { 'editor.selectionBackground': '#073642', 'editor.lineHighlightBackground': '#073642', 'editorCursor.foreground': '#819090', - 'editorWhitespace.foreground': '#073642', + 'editorWhitespace.foreground': '#586e75', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js index b7bfcf33b0f..2c9f3d904f1 100644 --- a/app/assets/javascripts/ide/lib/themes/solarized_light.js +++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js @@ -1096,6 +1096,6 @@ export default { 'editor.selectionBackground': '#EEE8D5', 'editor.lineHighlightBackground': '#EEE8D5', 'editorCursor.foreground': '#000000', - 'editorWhitespace.foreground': '#EAE3C9', + 'editorWhitespace.foreground': '#93a1a1', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/white.js b/app/assets/javascripts/ide/lib/themes/white.js index f06458d8a16..69c63c82021 100644 --- a/app/assets/javascripts/ide/lib/themes/white.js +++ b/app/assets/javascripts/ide/lib/themes/white.js @@ -142,5 +142,6 @@ export default { 'diffEditor.insertedTextBackground': '#a0f5b420', 'diffEditor.removedTextBackground': '#f9d7dc20', 'editorIndentGuide.activeBackground': '#cccccc', + 'editorSuggestWidget.focusHighlightForeground': '#96D8FD', }, }; diff --git a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql index c107f2376f9..a0b520858e6 100644 --- a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql +++ b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql @@ -1,4 +1,5 @@ fragment IdeProject on Project { + id userPermissions { createMergeRequestIn readMergeRequest diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index ef4f47f226a..805476c71bc 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,19 +1,12 @@ -import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import Api from '~/api'; +import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { query, mutate } from './gql'; -const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data); - -const fetchGqlProjectData = (projectPath) => - query({ - query: getIdeProject, - variables: { projectPath }, - }).then(({ data }) => data.project); - export default { getFileData(endpoint) { return axios.get(endpoint, { @@ -61,18 +54,6 @@ export default { ) .then(({ data }) => data); }, - getProjectData(namespace, project) { - const projectPath = `${namespace}/${project}`; - - return Promise.all([fetchApiProjectData(projectPath), fetchGqlProjectData(projectPath)]).then( - ([apiProjectData, gqlProjectData]) => ({ - data: { - ...apiProjectData, - ...gqlProjectData, - }, - }), - ); - }, getProjectMergeRequests(projectId, params = {}) { return Api.projectMergeRequests(projectId, params); }, @@ -115,4 +96,13 @@ export default { variables: { input: { featureName: name } }, }).then(({ data }) => data); }, + getProjectPermissionsData(projectPath) { + return query({ + query: getIdeProject, + variables: { projectPath }, + }).then(({ data }) => ({ + ...data.project, + id: getIdFromGraphQLId(data.project.id), + })); + }, }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 93ad19ba81e..0ec808339fb 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,35 +1,44 @@ import { escape } from 'lodash'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; +import { logError } from '~/lib/logger'; import api from '../../../api'; import service from '../../services'; import * as types from '../mutation_types'; -export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => - new Promise((resolve, reject) => { - if (!state.projects[`${namespace}/${projectId}`] || force) { - commit(types.TOGGLE_LOADING, { entry: state }); - service - .getProjectData(namespace, projectId) - .then((res) => res.data) - .then((data) => { - commit(types.TOGGLE_LOADING, { entry: state }); - commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); - commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); - resolve(data); - }) - .catch(() => { - createFlash({ - message: __('Error loading project data. Please try again.'), - fadeTransition: false, - addBodyClass: true, - }); - reject(new Error(`Project not loaded ${namespace}/${projectId}`)); - }); - } else { - resolve(state.projects[`${namespace}/${projectId}`]); - } +const ERROR_LOADING_PROJECT = __('Error loading project data. Please try again.'); + +const errorFetchingData = (e) => { + logError(ERROR_LOADING_PROJECT, e); + + createFlash({ + message: ERROR_LOADING_PROJECT, + fadeTransition: false, + addBodyClass: true, }); +}; + +export const setProject = ({ commit }, { project } = {}) => { + if (!project) { + return; + } + const projectPath = project.path_with_namespace; + commit(types.SET_PROJECT, { projectPath, project }); + commit(types.SET_CURRENT_PROJECT, projectPath); +}; + +export const fetchProjectPermissions = ({ commit, state }) => { + const projectPath = state.currentProjectId; + if (!projectPath) { + return undefined; + } + return service + .getProjectPermissionsData(projectPath) + .then((permissions) => { + commit(types.UPDATE_PROJECT, { projectPath, props: permissions }); + }) + .catch(errorFetchingData); +}; export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) => service diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 77755b179ef..13f338c4a48 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -8,6 +8,7 @@ export const SET_LINKS = 'SET_LINKS'; // Project Mutation Types export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const UPDATE_PROJECT = 'UPDATE_PROJECT'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge request mutation types diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 034fdad4305..9f65d3a543e 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import * as types from '../mutation_types'; export default { @@ -24,4 +25,15 @@ export default { empty_repo: value, }); }, + [types.UPDATE_PROJECT](state, { projectPath, props }) { + const project = state.projects[projectPath]; + + if (!project || !props) { + return; + } + + Object.keys(props).forEach((key) => { + Vue.set(project, key, props[key]); + }); + }, }; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index e004bc35087..deaf2654424 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -44,7 +44,7 @@ export default { :size="16" name="information-o" :title=" - s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.') + s__('BulkImport|Re-import creates a new group. It does not sync with the existing group.') " class="gl-ml-3" /> 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 ec6025c84bb..028197ec9b1 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 @@ -1,9 +1,8 @@ <script> import { + GlAlert, GlButton, GlEmptyState, - GlDropdown, - GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -14,8 +13,8 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import createFlash from '~/flash'; -import { s__, __, n__ } from '~/locale'; -import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import { s__, __, n__, sprintf } from '~/locale'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import { getGroupPathAvailability } from '~/rest_api'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -42,10 +41,9 @@ const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!'; export default { components: { + GlAlert, GlButton, GlEmptyState, - GlDropdown, - GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -57,7 +55,7 @@ export default { ImportTargetCell, ImportStatusCell, ImportActionsCell, - PaginationLinks, + PaginationBar, }, props: { @@ -83,6 +81,7 @@ export default { selectedGroupsIds: [], pendingGroupsIds: [], importTargets: {}, + unavailableFeaturesAlertVisible: true, }; }, @@ -170,7 +169,7 @@ export default { }, availableGroupsForImport() { - return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid); + return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && !g.flags.isInvalid); }, humanizedTotal() { @@ -204,6 +203,23 @@ export default { return { start, end, total }; }, + + unavailableFeatures() { + if (!this.hasGroups) { + return []; + } + + return Object.entries(this.bulkImportSourceGroups.versionValidation.features) + .filter(([, { available }]) => available === false) + .map(([k, v]) => ({ title: i18n.features[k] || k, version: v.minVersion })); + }, + + unavailableFeaturesAlertTitle() { + return sprintf(s__('BulkImport| %{host} is running outdated GitLab version (v%{version})'), { + host: this.sourceUrl, + version: this.bulkImportSourceGroups.versionValidation.features.sourceInstanceVersion, + }); + }, }, watch: { @@ -314,9 +330,8 @@ export default { variables: { importRequests }, }); } catch (error) { - const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT; createFlash({ - message, + message: i18n.ERROR_IMPORT, captureError: true, error, }); @@ -476,6 +491,38 @@ export default { <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> {{ s__('BulkImport|Import groups from GitLab') }} </h1> + <gl-alert + v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible" + variant="warning" + :title="unavailableFeaturesAlertTitle" + @dismiss="unavailableFeaturesAlertVisible = false" + > + <gl-sprintf + :message=" + s__( + 'BulkImport|Following data will not be migrated: %{bullets} Contact system administrator of %{host} to upgrade GitLab if you need this data in your migration', + ) + " + > + <template #host> + <gl-link :href="sourceUrl" target="_blank"> + {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" /> + </gl-link> + </template> + <template #bullets> + <ul> + <li v-for="feature in unavailableFeatures" :key="feature.title"> + <gl-sprintf :message="s__('BulkImport|%{feature} (require v%{version})')"> + <template #feature>{{ feature.title }}</template> + <template #version> + <strong>{{ feature.version }}</strong> + </template> + </gl-sprintf> + </li> + </ul> + </template> + </gl-sprintf> + </gl-alert> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > @@ -495,7 +542,7 @@ export default { </template> <template #link> <gl-link :href="sourceUrl" target="_blank"> - {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" /> + {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" /> </gl-link> </template> </gl-sprintf> @@ -521,13 +568,15 @@ export default { /> <template v-else> <div - class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center" + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar" > - <gl-sprintf :message="__('%{count} selected')"> - <template #count> - {{ selectedGroupsIds.length }} - </template> - </gl-sprintf> + <span data-test-id="selection-count"> + <gl-sprintf :message="__('%{count} selected')"> + <template #count> + {{ selectedGroupsIds.length }} + </template> + </gl-sprintf> + </span> <gl-button category="primary" variant="confirm" @@ -539,7 +588,7 @@ export default { </div> <gl-table ref="table" - class="gl-w-full" + class="gl-w-full import-table" data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" @@ -599,49 +648,13 @@ export default { /> </template> </gl-table> - <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> - <pagination-links - :change="setPage" - :page-info="bulkImportSourceGroups.pageInfo" - class="gl-m-0" - /> - <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto"> - <template #button-content> - <span class="font-weight-bold"> - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ perPage }} - </template> - </gl-sprintf> - </span> - <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> - </template> - <gl-dropdown-item - v-for="size in $options.PAGE_SIZES" - :key="size" - @click="setPageSize(size)" - > - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ size }} - </template> - </gl-sprintf> - </gl-dropdown-item> - </gl-dropdown> - <div class="gl-ml-2"> - <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> - <template #start> - {{ paginationInfo.start }} - </template> - <template #end> - {{ paginationInfo.end }} - </template> - <template #total> - {{ humanizedTotal }} - </template> - </gl-sprintf> - </div> - </div> + <pagination-bar + v-if="hasGroups" + :page-info="bulkImportSourceGroups.pageInfo" + class="gl-mt-3" + @set-page="setPage" + @set-page-size="setPageSize" + /> </template> </template> </div> 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 ca9ae9447d0..344a6e45370 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 @@ -32,72 +32,84 @@ export default { fullPath() { return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent'); }, - invalidNameValidationMessage() { - return getInvalidNameValidationMessage(this.group.importTarget); + validationMessage() { + return ( + this.group.progress?.message || getInvalidNameValidationMessage(this.group.importTarget) + ); + }, + validNameState() { + // bootstrap-vue requires null for "indifferent" state, if we return true + // this will highlight field in green like "passed validation" + return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null; }, }, }; </script> <template> - <div class="gl-display-flex gl-align-items-stretch"> - <import-group-dropdown - #default="{ namespaces }" - :text="fullPath" - :disabled="!group.flags.isAvailableForImport" - :namespaces="availableNamespaces" - toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="gl-h-7 gl-flex-grow-1" - data-qa-selector="target_namespace_selector_dropdown" - > - <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> - <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="{ - 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, - 'gl-border-gray-200': group.flags.isAvailableForImport, - }" - > - / - </div> - <div class="gl-flex-grow-1"> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + <div> + <div class="gl-display-flex gl-align-items-stretch"> + <import-group-dropdown + #default="{ namespaces }" + :text="fullPath" + :disabled="!group.flags.isAvailableForImport" + :namespaces="availableNamespaces" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="gl-h-7 gl-flex-grow-1" + data-qa-selector="target_namespace_selector_dropdown" + > + <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> + <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="{ - 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport, - 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport, - 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport, + 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, + 'gl-border-gray-200': group.flags.isAvailableForImport, }" - debounce="500" - :disabled="!group.flags.isAvailableForImport" - :value="group.importTarget.newName" - :aria-label="__('New name')" - @input="$emit('update-new-name', $event)" - /> - <p - v-if="group.flags.isAvailableForImport && group.flags.isInvalid" - class="gl-text-red-500 gl-m-0 gl-mt-2" > - {{ invalidNameValidationMessage }} - </p> + / + </div> + <div class="gl-flex-grow-1"> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :class="{ + 'gl-inset-border-1-gray-200!': + group.flags.isAvailableForImport && !group.flags.isInvalid, + 'gl-inset-border-1-gray-100!': + !group.flags.isAvailableForImport && !group.flags.isInvalid, + }" + debounce="500" + :disabled="!group.flags.isAvailableForImport" + :value="group.importTarget.newName" + :aria-label="__('New name')" + :state="validNameState" + @input="$emit('update-new-name', $event)" + /> + </div> + </div> + <div + v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)" + class="gl-text-red-500 gl-m-0 gl-mt-2" + role="alert" + > + {{ validationMessage }} </div> </div> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index aa9cf3897e6..ac1466238d0 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -11,6 +11,10 @@ export const i18n = { ), ERROR_IMPORT: s__('BulkImport|Importing the group failed.'), ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'), + + features: { + projectMigration: __('projects'), + }, }; export const NEW_NAME_FIELD = 'newName'; diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index bce6e7bcb1f..36da996ea17 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -14,6 +14,9 @@ export const clientTypenames = { BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportTarget: 'ClientBulkImportTarget', BulkImportProgress: 'ClientBulkImportProgress', + BulkImportVersionValidation: 'ClientBulkImportVersionValidation', + BulkImportVersionValidationFeature: 'ClientBulkImportVersionValidationFeature', + BulkImportVersionValidationFeatures: 'ClientBulkImportVersionValidationFeatures', }; function makeLastImportTarget(data) { @@ -92,6 +95,18 @@ export function createResolvers({ endpoints }) { __typename: clientTypenames.BulkImportPageInfo, ...pagination, }, + versionValidation: { + __typename: clientTypenames.BulkImportVersionValidation, + features: { + __typename: clientTypenames.BulkImportVersionValidationFeatures, + sourceInstanceVersion: data.version_validation.features.source_instance_version, + projectMigration: { + __typename: clientTypenames.BulkImportVersionValidationFeature, + available: data.version_validation.features.project_migration.available, + minVersion: data.version_validation.features.project_migration.min_version, + }, + }, + }, }; return response; }, @@ -142,9 +157,7 @@ export function createResolvers({ endpoints }) { }; }); - const { - data: { id: jobId }, - } = await axios.post(endpoints.createBulkImport, { + const { data: originalResponse } = await axios.post(endpoints.createBulkImport, { bulk_import: importOperations.map((op) => ({ source_type: 'group_entity', source_full_path: op.group.fullPath, @@ -153,15 +166,21 @@ export function createResolvers({ endpoints }) { })), }); - return importOperations.map((op) => { + const responses = Array.isArray(originalResponse) + ? originalResponse + : [{ success: true, id: originalResponse.id }]; + + return importOperations.map((op, idx) => { + const response = responses[idx]; const lastImportTarget = { targetNamespace: op.targetNamespace, newName: op.newName, }; const progress = { - id: jobId, - status: STATUSES.CREATED, + id: response.id || `local-${Date.now()}-${idx}`, + status: response.success ? STATUSES.CREATED : STATUSES.FAILED, + message: response.message || null, }; localStorageCache.set(op.group.webUrl, { progress, lastImportTarget }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql index 2d60bf82d65..33c564f36a8 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql @@ -1,4 +1,5 @@ fragment BulkImportSourceGroupProgress on ClientBulkImportProgress { id status + message } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql index 75215471d0f..39289887b75 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql @@ -9,6 +9,7 @@ mutation importGroups($importRequests: [ImportGroupInput!]!) { progress { id status + message } } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql index 28dfefdf8a7..ace8bffc012 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql @@ -11,5 +11,14 @@ query bulkImportSourceGroups($page: Int = 1, $perPage: Int = 20, $filter: String total totalPages } + versionValidation { + features { + sourceInstanceVersion + projectMigration { + available + minVersion + } + } + } } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js index 09bc7b33692..1aad22f0f3f 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js @@ -22,7 +22,14 @@ export class LocalStorageCache { loadCacheFromStorage() { try { - return JSON.parse(this.storage.getItem(KEY)) ?? {}; + const storage = JSON.parse(this.storage.getItem(KEY)) ?? {}; + Object.values(storage).forEach((entry) => { + if (entry.progress && !('message' in entry.progress)) { + // eslint-disable-next-line no-param-reassign + entry.progress.message = ''; + } + }); + return storage; } catch { return {}; } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql index b8dd79a5000..c48e22a7717 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -11,11 +11,13 @@ type ClientBulkImportTarget { type ClientBulkImportSourceGroupConnection { nodes: [ClientBulkImportSourceGroup!]! pageInfo: ClientBulkImportPageInfo! + versionValidation: ClientBulkImportVersionValidation! } type ClientBulkImportProgress { id: ID! status: String! + message: String } type ClientBulkImportValidationError { @@ -45,6 +47,20 @@ type ClientBulkImportNamespaceSuggestion { suggestions: [String!]! } +type ClientBulkImportVersionValidation { + features: ClientBulkImportVersionValidationFeatures! +} + +type ClientBulkImportVersionValidationFeatures { + project_migration: ClientBulkImportVersionValidationFeature! + sourceInstanceVersion: String! +} + +type ClientBulkImportVersionValidationFeature { + available: Boolean! + min_version: String! +} + extend type Query { bulkImportSourceGroups( page: Int! diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql index eb2dde14464..faa68d37088 100644 --- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql +++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql @@ -1,3 +1,4 @@ +# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment IncidentFields on Issue { severity } diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql index 4e44a506c4f..fda8a65d4a4 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -6,6 +6,7 @@ query getIncidentsCountByStatus( $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { + id issueStatusCounts( search: $searchTerm types: $issueTypes diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index f97664a3b77..1e18d89b656 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -14,6 +14,7 @@ query getIncidents( $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { + id issues( search: $searchTerm types: $issueTypes @@ -27,18 +28,21 @@ query getIncidents( before: $prevPageCursor ) { nodes { + id iid title createdAt state labels { nodes { + id title color } } assignees { nodes { + id name username avatarUrl diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js index d3d32c8be54..a8833a17467 100644 --- a/app/assets/javascripts/init_confirm_danger.js +++ b/app/assets/javascripts/init_confirm_danger.js @@ -10,6 +10,7 @@ export default () => { removeFormId = null, phrase, buttonText, + buttonClass = '', buttonTestid = null, confirmDangerMessage, disabled = false, @@ -25,6 +26,7 @@ export default () => { props: { phrase, buttonText, + buttonClass, buttonTestid, disabled: parseBoolean(disabled), }, diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js deleted file mode 100644 index 7a70d893008..00000000000 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-new */ - -import { getSidebarOptions } from '~/sidebar/mount_sidebar'; -import IssuableContext from './issuable_context'; -import Sidebar from './right_sidebar'; - -export default () => { - const sidebarOptEl = document.querySelector('.js-sidebar-options'); - - if (!sidebarOptEl) return; - - const sidebarOptions = getSidebarOptions(sidebarOptEl); - - new IssuableContext(sidebarOptions.currentUser); - Sidebar.initialize(); -}; diff --git a/app/assets/javascripts/init_labels.js b/app/assets/javascripts/init_labels.js deleted file mode 100644 index 10bfbf7960c..00000000000 --- a/app/assets/javascripts/init_labels.js +++ /dev/null @@ -1,19 +0,0 @@ -import $ from 'jquery'; -import GroupLabelSubscription from './group_label_subscription'; -import LabelManager from './label_manager'; -import ProjectLabelSubscription from './project_label_subscription'; - -export default () => { - if ($('.prioritized-labels').length) { - new LabelManager(); // eslint-disable-line no-new - } - $('.label-subscription').each((i, el) => { - const $el = $(el); - - if ($el.find('.dropdown-group-label').length) { - new GroupLabelSubscription($el); // eslint-disable-line no-new - } else { - new ProjectLabelSubscription($el); // eslint-disable-line no-new - } - }); -}; diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index d214ee4ded6..84656bd41bb 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,9 +1,5 @@ import { s__, __ } from '~/locale'; -export const TEST_INTEGRATION_EVENT = 'testIntegration'; -export const SAVE_INTEGRATION_EVENT = 'saveIntegration'; -export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes'; -export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration'; export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm'; export const integrationLevels = { diff --git a/app/assets/javascripts/integrations/edit/api.js b/app/assets/javascripts/integrations/edit/api.js new file mode 100644 index 00000000000..7bce5604f9d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/api.js @@ -0,0 +1,9 @@ +import axios from '~/lib/utils/axios_utils'; + +/** + * Test the validity of [integrationFormData]. + * @return Promise<{ issuetypes: []String }> - issuetypes contains valid Jira issue types. + */ +export const testIntegrationSettings = (testPath, integrationFormData) => { + return axios.put(testPath, integrationFormData); +}; diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index 9804a9e15f6..5ddf3aeb639 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -1,8 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants'; -import eventHub from '../event_hub'; export default { name: 'ActiveCheckbox', @@ -20,14 +18,11 @@ export default { }, mounted() { this.activated = this.propsSource.initialActivated; - // Initialize view - this.$nextTick(() => { - this.onChange(this.activated); - }); + this.onChange(this.activated); }, methods: { - onChange(e) { - eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e); + onChange(isChecked) { + this.$emit('toggle-integration-active', isChecked); }, }, }; diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue index 89f7e3b7a89..bc6aa231a93 100644 --- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -1,22 +1,17 @@ <script> import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import { __ } from '~/locale'; export default { components: { GlModal, }, + computed: { - ...mapGetters(['isDisabled']), primaryProps() { return { text: __('Save'), - attributes: [ - { variant: 'confirm' }, - { category: 'primary' }, - { disabled: this.isDisabled }, - ], + attributes: [{ variant: 'confirm' }, { category: 'primary' }], }; }, cancelProps() { diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index ba1aeb28616..e570a468944 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,14 +1,17 @@ <script> import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - TEST_INTEGRATION_EVENT, - SAVE_INTEGRATION_EVENT, + VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, integrationLevels, } from '~/integrations/constants'; import eventHub from '../event_hub'; - +import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; import ConfirmationModal from './confirmation_modal.vue'; import DynamicField from './dynamic_field.vue'; @@ -37,22 +40,26 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + formSelector: { + type: String, + required: true, + }, helpHtml: { type: String, required: false, default: '', }, }, + data() { + return { + integrationActive: false, + isTesting: false, + isSaving: false, + }; + }, computed: { - ...mapGetters(['currentKey', 'propsSource', 'isDisabled']), - ...mapState([ - 'defaultState', - 'customState', - 'override', - 'isSaving', - 'isTesting', - 'isResetting', - ]), + ...mapGetters(['currentKey', 'propsSource']), + ...mapState(['defaultState', 'customState', 'override', 'isResetting']), isEditable() { return this.propsSource.editable; }, @@ -65,29 +72,81 @@ export default { this.customState.integrationLevel === integrationLevels.GROUP ); }, - showReset() { + showResetButton() { return this.isInstanceOrGroupLevel && this.propsSource.resetPath; }, + showTestButton() { + return this.propsSource.canTest; + }, + disableButtons() { + return Boolean(this.isSaving || this.isResetting || this.isTesting); + }, + }, + mounted() { + // this form element is defined in Haml + this.form = document.querySelector(this.formSelector); }, methods: { - ...mapActions([ - 'setOverride', - 'setIsSaving', - 'setIsTesting', - 'setIsResetting', - 'fetchResetIntegration', - ]), + ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']), onSaveClick() { - this.setIsSaving(true); - eventHub.$emit(SAVE_INTEGRATION_EVENT); + this.isSaving = true; + + if (this.integrationActive && !this.form.checkValidity()) { + this.isSaving = false; + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + return; + } + + this.form.submit(); }, onTestClick() { - this.setIsTesting(true); - eventHub.$emit(TEST_INTEGRATION_EVENT); + this.isTesting = true; + + if (!this.form.checkValidity()) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + return; + } + + testIntegrationSettings(this.propsSource.testPath, this.getFormData()) + .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => { + if (error) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + this.$toast.show(message); + return; + } + + this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE); + }) + .catch((error) => { + this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE); + Sentry.captureException(error); + }) + .finally(() => { + this.isTesting = false; + }); }, onResetClick() { this.fetchResetIntegration(); }, + onRequestJiraIssueTypes() { + this.requestJiraIssueTypes(this.getFormData()); + }, + getFormData() { + return new FormData(this.form); + }, + onToggleIntegrationState(integrationActive) { + this.integrationActive = integrationActive; + if (!this.form) { + return; + } + + // If integration will be active, enable form validation. + if (integrationActive) { + this.form.removeAttribute('novalidate'); + } else { + this.form.setAttribute('novalidate', true); + } + }, }, helpHtmlConfig: { ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented @@ -114,7 +173,11 @@ export default { <!-- helpHtml is trusted input --> <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> - <active-checkbox v-if="propsSource.showActive" :key="`${currentKey}-active-checkbox`" /> + <active-checkbox + v-if="propsSource.showActive" + :key="`${currentKey}-active-checkbox`" + @toggle-integration-active="onToggleIntegrationState" + /> <jira-trigger-fields v-if="isJira" :key="`${currentKey}-jira-trigger-fields`" @@ -135,6 +198,7 @@ export default { v-if="isJira && !isInstanceOrGroupLevel" :key="`${currentKey}-jira-issues-fields`" v-bind="propsSource.jiraIssuesProps" + @request-jira-issue-types="onRequestJiraIssueTypes" /> <div v-if="isEditable" class="footer-block row-content-block"> <template v-if="isInstanceOrGroupLevel"> @@ -143,7 +207,7 @@ export default { category="primary" variant="confirm" :loading="isSaving" - :disabled="isDisabled" + :disabled="disableButtons" data-qa-selector="save_changes_button" > {{ __('Save changes') }} @@ -156,7 +220,8 @@ export default { variant="confirm" type="submit" :loading="isSaving" - :disabled="isDisabled" + :disabled="disableButtons" + data-testid="save-button" data-qa-selector="save_changes_button" @click.prevent="onSaveClick" > @@ -164,24 +229,24 @@ export default { </gl-button> <gl-button - v-if="propsSource.canTest" + v-if="showTestButton" category="secondary" variant="confirm" :loading="isTesting" - :disabled="isDisabled" - :href="propsSource.testPath" + :disabled="disableButtons" + data-testid="test-button" @click.prevent="onTestClick" > {{ __('Test settings') }} </gl-button> - <template v-if="showReset"> + <template v-if="showResetButton"> <gl-button v-gl-modal.confirmResetIntegration category="secondary" variant="confirm" :loading="isResetting" - :disabled="isDisabled" + :disabled="disableButtons" data-testid="reset-button" > {{ __('Reset') }} diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 7cbfb35aeaa..99498501f6c 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,10 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { - VALIDATE_INTEGRATION_FORM_EVENT, - GET_JIRA_ISSUE_TYPES_EVENT, -} from '~/integrations/constants'; +import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import { s__, __ } from '~/locale'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -91,9 +88,6 @@ export default { validateForm() { this.validated = true; }, - getJiraIssueTypes() { - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - }, }, i18n: { sectionTitle: s__('JiraService|View Jira issues in GitLab'), @@ -123,7 +117,11 @@ export default { </p> <template v-if="showJiraIssuesIntegration"> <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> - <gl-form-checkbox v-model="enableJiraIssues" :disabled="isInheriting"> + <gl-form-checkbox + v-model="enableJiraIssues" + :disabled="isInheriting" + data-qa-selector="service_jira_issues_enabled_checkbox" + > {{ $options.i18n.enableCheckboxLabel }} <template #help> {{ $options.i18n.enableCheckboxHelp }} @@ -136,7 +134,7 @@ export default { :initial-issue-type-id="initialVulnerabilitiesIssuetype" :show-full-feature="showJiraVulnerabilitiesIntegration" data-testid="jira-for-vulnerabilities" - @request-get-issue-types="getJiraIssueTypes" + @request-jira-issue-types="$emit('request-jira-issue-types')" /> <jira-upgrade-cta v-if="!showJiraVulnerabilitiesIntegration" @@ -168,6 +166,7 @@ export default { id="service_project_key" v-model="projectKey" name="service[project_key]" + data-qa-selector="service_jira_project_key_field" :placeholder="$options.i18n.projectKeyPlaceholder" :required="enableJiraIssues" :state="validProjectKey" diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue index 9472a3eeafe..5a445235219 100644 --- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue @@ -1,6 +1,5 @@ <script> import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import { __ } from '~/locale'; @@ -9,15 +8,10 @@ export default { GlModal, }, computed: { - ...mapGetters(['isDisabled']), primaryProps() { return { text: __('Reset'), - attributes: [ - { variant: 'warning' }, - { category: 'primary' }, - { disabled: this.isDisabled }, - ], + attributes: [{ variant: 'warning' }, { category: 'primary' }], }; }, cancelProps() { diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 792e7d8e85e..9c9e3edbeb8 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -85,35 +85,39 @@ function parseDatasetToProps(data) { }; } -export default (el, defaultEl) => { - if (!el) { +export default function initIntegrationSettingsForm(formSelector) { + const customSettingsEl = document.querySelector('.js-vue-integration-settings'); + const defaultSettingsEl = document.querySelector('.js-vue-default-integration-settings'); + + if (!customSettingsEl) { return null; } - const props = parseDatasetToProps(el.dataset); + const customSettingsProps = parseDatasetToProps(customSettingsEl.dataset); const initialState = { defaultState: null, - customState: props, + customState: customSettingsProps, }; - if (defaultEl) { - initialState.defaultState = Object.freeze(parseDatasetToProps(defaultEl.dataset)); + if (defaultSettingsEl) { + initialState.defaultState = Object.freeze(parseDatasetToProps(defaultSettingsEl.dataset)); } // Here, we capture the "helpHtml", so we can pass it to the Vue component // to position it where ever it wants. // Because this node is a _child_ of `el`, it will be removed when the Vue component is mounted, // so we don't need to manually remove it. - const helpHtml = el.querySelector('.js-integration-help-html')?.innerHTML; + const helpHtml = customSettingsEl.querySelector('.js-integration-help-html')?.innerHTML; return new Vue({ - el, + el: customSettingsEl, store: createStore(initialState), render(createElement) { return createElement(IntegrationForm, { props: { helpHtml, + formSelector, }, }); }, }); -}; +} diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 400397c050c..97565a3a69c 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -1,10 +1,15 @@ import axios from 'axios'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { + VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, +} from '~/integrations/constants'; +import { testIntegrationSettings } from '../api'; +import eventHub from '../event_hub'; import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); -export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, isSaving); -export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting); export const setIsResetting = ({ commit }, isResetting) => commit(types.SET_IS_RESETTING, isResetting); @@ -27,10 +32,28 @@ export const fetchResetIntegration = ({ dispatch, getters }) => { .catch(() => dispatch('receiveResetIntegrationError')); }; -export const requestJiraIssueTypes = ({ commit }) => { +export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) => { commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, ''); commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, true); + + return testIntegrationSettings(getters.propsSource.testPath, formData) + .then( + ({ + data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, + }) => { + if (error || !issuetypes?.length) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + throw new Error(message); + } + + dispatch('receiveJiraIssueTypesSuccess', issuetypes); + }, + ) + .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { + dispatch('receiveJiraIssueTypesError', message); + }); }; + export const receiveJiraIssueTypesSuccess = ({ commit }, issueTypes = []) => { commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, false); commit(types.SET_JIRA_ISSUE_TYPES, issueTypes); diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js index 39e14de2d0d..b79132128cc 100644 --- a/app/assets/javascripts/integrations/edit/store/getters.js +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -1,7 +1,5 @@ export const isInheriting = (state) => (state.defaultState === null ? false : !state.override); -export const isDisabled = (state) => state.isSaving || state.isTesting || state.isResetting; - export const propsSource = (state, getters) => getters.isInheriting ? state.defaultState : state.customState; diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index c681056a515..ddf6bef7554 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -1,6 +1,4 @@ export const SET_OVERRIDE = 'SET_OVERRIDE'; -export const SET_IS_SAVING = 'SET_IS_SAVING'; -export const SET_IS_TESTING = 'SET_IS_TESTING'; export const SET_IS_RESETTING = 'SET_IS_RESETTING'; export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index 279df1b9266..e7e312ce650 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -4,12 +4,6 @@ export default { [types.SET_OVERRIDE](state, override) { state.override = override; }, - [types.SET_IS_SAVING](state, isSaving) { - state.isSaving = isSaving; - }, - [types.SET_IS_TESTING](state, isTesting) { - state.isTesting = isTesting; - }, [types.SET_IS_RESETTING](state, isResetting) { state.isResetting = isResetting; }, diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index 1c0b274e4ef..3d40d1b90d5 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -6,7 +6,6 @@ export default ({ defaultState = null, customState = {} } = {}) => { defaultState, customState, isSaving: false, - isTesting: false, isResetting: false, isLoadingJiraIssueTypes: false, loadingJiraIssueTypesErrorMessage: '', diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js deleted file mode 100644 index f519fc87c46..00000000000 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ /dev/null @@ -1,151 +0,0 @@ -import { delay } from 'lodash'; -import toast from '~/vue_shared/plugins/global_toast'; -import axios from '../lib/utils/axios_utils'; -import initForm from './edit'; -import eventHub from './edit/event_hub'; -import { - TEST_INTEGRATION_EVENT, - SAVE_INTEGRATION_EVENT, - GET_JIRA_ISSUE_TYPES_EVENT, - TOGGLE_INTEGRATION_EVENT, - VALIDATE_INTEGRATION_FORM_EVENT, - I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, - I18N_DEFAULT_ERROR_MESSAGE, - I18N_SUCCESSFUL_CONNECTION_MESSAGE, -} from './constants'; - -export default class IntegrationSettingsForm { - constructor(formSelector) { - this.$form = document.querySelector(formSelector); - this.formActive = false; - - this.vue = null; - - // Form Metadata - this.testEndPoint = this.$form.dataset.testUrl; - } - - init() { - // Init Vue component - this.vue = initForm( - document.querySelector('.js-vue-integration-settings'), - document.querySelector('.js-vue-default-integration-settings'), - ); - eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => { - this.formActive = active; - this.toggleServiceState(); - }); - eventHub.$on(TEST_INTEGRATION_EVENT, () => { - this.testIntegration(); - }); - eventHub.$on(SAVE_INTEGRATION_EVENT, () => { - this.saveIntegration(); - }); - eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => { - this.getJiraIssueTypes(new FormData(this.$form)); - }); - } - - saveIntegration() { - // Save Service if not active and check the following if active; - // 1) If form contents are valid - // 2) If this service can be saved - // If both conditions are true, we override form submission - // and save the service using provided configuration. - const formValid = this.$form.checkValidity() || this.formActive === false; - - if (formValid) { - delay(() => { - this.$form.submit(); - }, 100); - } else { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - this.vue.$store.dispatch('setIsSaving', false); - } - } - - testIntegration() { - // Service was marked active so now we check; - // 1) If form contents are valid - // 2) If this service can be tested - // If both conditions are true, we override form submission - // and test the service using provided configuration. - if (this.$form.checkValidity()) { - this.testSettings(new FormData(this.$form)); - } else { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - this.vue.$store.dispatch('setIsTesting', false); - } - } - - /** - * Change Form's validation enforcement based on service status (active/inactive) - */ - toggleServiceState() { - if (this.formActive) { - this.$form.removeAttribute('novalidate'); - } else if (!this.$form.getAttribute('novalidate')) { - this.$form.setAttribute('novalidate', 'novalidate'); - } - } - - /** - * Get a list of Jira issue types for the currently configured project - * - * @param {string} formData - URL encoded string containing the form data - * - * @return {Promise} - */ - getJiraIssueTypes(formData) { - const { - $store: { dispatch }, - } = this.vue; - - dispatch('requestJiraIssueTypes'); - - return this.fetchTestSettings(formData) - .then( - ({ - data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, - }) => { - if (error || !issuetypes?.length) { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - throw new Error(message); - } - - dispatch('receiveJiraIssueTypesSuccess', issuetypes); - }, - ) - .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { - dispatch('receiveJiraIssueTypesError', message); - }); - } - - /** - * Send request to the test endpoint which checks if the current config is valid - */ - fetchTestSettings(formData) { - return axios.put(this.testEndPoint, formData); - } - - /** - * Test Integration config - */ - testSettings(formData) { - return this.fetchTestSettings(formData) - .then(({ data }) => { - if (data.error) { - toast(`${data.message} ${data.service_response}`); - } else { - this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes); - toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE); - } - }) - .catch(() => { - toast(I18N_DEFAULT_ERROR_MESSAGE); - }) - .finally(() => { - this.vue.$store.dispatch('setIsTesting', false); - }); - } -} diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index 85018f133cb..3fc554c5371 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -6,8 +6,12 @@ import { DEFAULT_PER_PAGE } from '~/api'; import { fetchOverrides } from '~/integrations/overrides/api'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { truncateNamespace } from '~/lib/utils/text_utility'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +const DEFAULT_PAGE = 1; export default { name: 'IntegrationOverrides', @@ -18,6 +22,7 @@ export default { GlTable, GlAlert, ProjectAvatar, + UrlSync, }, props: { overridesPath: { @@ -35,7 +40,7 @@ export default { return { isLoading: true, overrides: [], - page: 1, + page: DEFAULT_PAGE, totalItems: 0, errorMessage: null, }; @@ -44,12 +49,21 @@ export default { showPagination() { return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0; }, + query() { + return { + page: this.page, + }; + }, }, - mounted() { - this.loadOverrides(); + created() { + const initialPage = this.getInitialPage(); + this.loadOverrides(initialPage); }, methods: { - loadOverrides(page = this.page) { + getInitialPage() { + return getParameterByName('page') ?? DEFAULT_PAGE; + }, + loadOverrides(page) { this.isLoading = true; this.errorMessage = null; @@ -119,14 +133,16 @@ export default { </template> </gl-table> <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-pagination - v-if="showPagination" - :per-page="$options.DEFAULT_PER_PAGE" - :total-items="totalItems" - :value="page" - :disabled="isLoading" - @input="loadOverrides" - /> + <template v-if="showPagination"> + <gl-pagination + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :value="page" + :disabled="isLoading" + @input="loadOverrides" + /> + <url-sync :query="query" /> + </template> </div> </div> </template> 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 cf4f434a7a8..91a139a5105 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -20,12 +20,11 @@ import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import { - INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS, USERS_FILTER_ALL, - MEMBER_AREAS_OF_FOCUS, INVITE_MEMBERS_FOR_TASK, MODAL_LABELS, + LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; import { @@ -100,14 +99,6 @@ export default { type: String, required: true, }, - areasOfFocusOptions: { - type: Array, - required: true, - }, - noSelectionAreasOfFocus: { - type: Array, - required: true, - }, tasksToBeDoneOptions: { type: Array, required: true, @@ -125,7 +116,6 @@ export default { inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, - selectedAreasOfFocus: [], selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], groupToBeSharedWith: {}, @@ -181,16 +171,6 @@ export default { this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 ); }, - areasOfFocusEnabled() { - return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0; - }, - areasOfFocusForPost() { - if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { - return this.noSelectionAreasOfFocus; - } - - return this.selectedAreasOfFocus; - }, errorFieldDescription() { if (this.inviteeType === 'group') { return ''; @@ -200,7 +180,8 @@ export default { }, tasksToBeDoneEnabled() { return ( - getParameterValues('open_modal')[0] === 'invite_members_for_task' && + (getParameterValues('open_modal')[0] === 'invite_members_for_task' || + this.isOnLearnGitlab) && this.tasksToBeDoneOptions.length ); }, @@ -221,11 +202,16 @@ export default { ? this.selectedTaskProject.id : ''; }, + isOnLearnGitlab() { + return this.source === LEARN_GITLAB; + }, }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); - this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); + if (this.isOnLearnGitlab) { + this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source); + } }); if (this.tasksToBeDoneEnabled) { @@ -267,13 +253,6 @@ export default { this.submitInviteMembers(); } }, - trackInvite() { - if (this.source === INVITE_MEMBERS_IN_COMMENT) { - this.trackEvent(INVITE_MEMBERS_IN_COMMENT, 'comment_invite_success'); - } - - this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); - }, trackinviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); @@ -287,7 +266,6 @@ export default { this.newUsersToInvite = []; this.groupToBeSharedWith = {}; this.invalidFeedbackMessage = ''; - this.selectedAreasOfFocus = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; }, @@ -303,7 +281,7 @@ export default { : Api.groupShareWithGroup.bind(Api); apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) - .then(this.showToastMessageSuccess) + .then(this.showSuccessMessage) .catch(this.showInvalidFeedbackMessage); }, submitInviteMembers() { @@ -328,11 +306,10 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } - this.trackInvite(); this.trackinviteMembersForTask(); Promise.all(promises) - .then(this.conditionallyShowToastSuccess) + .then(this.conditionallyShowSuccessMessage) .catch(this.showInvalidFeedbackMessage); }, inviteByEmailPostData(usersToInviteByEmail) { @@ -341,7 +318,6 @@ export default { email: usersToInviteByEmail, access_level: this.selectedAccessLevel, invite_source: this.source, - areas_of_focus: this.areasOfFocusForPost, tasks_to_be_done: this.tasksToBeDoneForPost, tasks_project_id: this.tasksProjectForPost, }; @@ -352,7 +328,6 @@ export default { user_id: usersToAddById, access_level: this.selectedAccessLevel, invite_source: this.source, - areas_of_focus: this.areasOfFocusForPost, tasks_to_be_done: this.tasksToBeDoneForPost, tasks_project_id: this.tasksProjectForPost, }; @@ -364,11 +339,11 @@ export default { group_access: this.selectedAccessLevel, }; }, - conditionallyShowToastSuccess(response) { + conditionallyShowSuccessMessage(response) { const message = this.unescapeMsg(responseMessageFromSuccess(response)); if (message === '') { - this.showToastMessageSuccess(); + this.showSuccessMessage(); return; } @@ -376,8 +351,12 @@ export default { this.invalidFeedbackMessage = message; this.isLoading = false; }, - showToastMessageSuccess() { - this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + showSuccessMessage() { + if (this.isOnLearnGitlab) { + eventHub.$emit('showSuccessfulInvitationsAlert'); + } else { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + } this.closeModal(); }, showInvalidFeedbackMessage(response) { @@ -504,16 +483,6 @@ export default { </template> </gl-datepicker> </div> - <div v-if="areasOfFocusEnabled"> - <label class="gl-mt-5"> - {{ $options.labels.areasOfFocusLabel }} - </label> - <gl-form-checkbox-group - v-model="selectedAreasOfFocus" - :options="areasOfFocusOptions" - data-testid="area-of-focus-checks" - /> - </div> <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> <label class="gl-mt-5"> {{ $options.labels.members.tasksToBeDone.title }} diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index bf3250f63a5..7dd74f8803a 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants'; @@ -32,11 +31,6 @@ export default { type: String, required: true, }, - trackExperiment: { - type: String, - required: false, - default: undefined, - }, triggerElement: { type: String, required: false, @@ -72,9 +66,6 @@ export default { return baseAttributes; }, }, - mounted() { - this.trackExperimentOnShow(); - }, methods: { checkTrigger(targetTriggerElement) { return this.triggerElement === targetTriggerElement; @@ -82,12 +73,6 @@ export default { openModal() { eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); }, - trackExperimentOnShow() { - if (this.trackExperiment) { - const tracking = new ExperimentTracking(this.trackExperiment); - tracking.event('comment_invite_shown'); - } - }, }, TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 59d4c2f3077..ec59b3909fe 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -2,12 +2,6 @@ import { __, s__ } from '~/locale'; export const SEARCH_DELAY = 200; -export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; -export const MEMBER_AREAS_OF_FOCUS = { - name: 'member_areas_of_focus', - view: 'view', - submit: 'submit', -}; export const INVITE_MEMBERS_FOR_TASK = { minimum_access_level: 30, name: 'invite_members_for_task', @@ -77,9 +71,6 @@ export const READ_MORE_TEXT = s__( export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); -export const AREAS_OF_FOCUS_LABEL = s__( - 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', -); export const MODAL_LABELS = { members: { @@ -142,5 +133,6 @@ export const MODAL_LABELS = { inviteButtonText: INVITE_BUTTON_TEXT, cancelButtonText: CANCEL_BUTTON_TEXT, headerCloseLabel: HEADER_CLOSE_LABEL, - areasOfFocusLabel: AREAS_OF_FOCUS_LABEL, }; + +export const LEARN_GITLAB = 'learn_gitlab'; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index fc657a064dd..2cc056f2ddb 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -40,10 +40,8 @@ export default function initInviteMembersModal() { defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), - areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), projects: JSON.parse(el.dataset.projects || '[]'), - noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), }, diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue index 9509399e91d..9509399e91d 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js index ad15b25f9cf..ad15b25f9cf 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js index 43179a86d70..43179a86d70 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js index 463e0e5837e..14824820c0d 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js @@ -115,7 +115,7 @@ export default { }); // Add uniqueIds to add it as argument for _.intersection labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables + // Return IDs that are present but not in all selected issuables return uniqueIds.filter((x) => !intersection.apply(this, labelIds).includes(x)); }, diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js index a9d4548f8cf..1eb3ffc9808 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -3,9 +3,9 @@ import $ from 'jquery'; import { property } from 'lodash'; -import issueableEventHub from '~/issues_list/eventhub'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; +import issuableEventHub from '~/issues_list/eventhub'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; import initIssueStatusSelect from './init_issue_status_select'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import subscriptionSelect from './subscription_select'; @@ -50,8 +50,8 @@ export default class IssuableBulkUpdateSidebar { // The event hub connects this bulk update logic with `issues_list_app.vue`. // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue. // https://gitlab.com/gitlab-org/gitlab/-/issues/325874 - issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); - issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); + issuableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); + issuableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); } initDropdowns() { @@ -110,7 +110,7 @@ export default class IssuableBulkUpdateSidebar { toggleBulkEdit(e, enable) { e?.preventDefault(); - issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + issuableEventHub.$emit('issuables:toggleBulkEdit', enable); this.toggleSidebarDisplay(enable); this.toggleBulkEditButtonDisabled(enable); diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js index 179c2b83c6c..179c2b83c6c 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js index b12ac776b4f..b12ac776b4f 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 799d2bdc9e2..512fa6f8c68 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -54,8 +54,7 @@ export default { data() { return { email: this.initialEmail, - // eslint-disable-next-line @gitlab/require-i18n-strings - issuableName: this.issuableType === 'issue' ? 'issue' : 'merge request', + issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'), }; }, computed: { diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 82223ab9ef4..82223ab9ef4 100644 --- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue index 5955f31fc70..5955f31fc70 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/issuable/components/issue_assignees.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 6a0c21602bd..6a0c21602bd 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 8aeff9257a5..2bb0e3c80f9 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -9,8 +9,8 @@ import { } from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { sprintf } from '~/locale'; -import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; -import CiIcon from '../ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import relatedIssuableMixin from '../mixins/related_issuable_mixin'; import IssueAssignees from './issue_assignees.vue'; import IssueMilestone from './issue_milestone.vue'; diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js index 9344f4a7c9a..5327f251fda 100644 --- a/app/assets/javascripts/issuable/constants.js +++ b/app/assets/javascripts/issuable/constants.js @@ -4,3 +4,8 @@ export const ISSUABLE_TYPE = { issues: 'issues', mergeRequests: 'merge-requests', }; + +export const ISSUABLE_INDEX = { + ISSUE: 'issue_', + MERGE_REQUEST: 'merge_request_', +}; diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js new file mode 100644 index 00000000000..072422944f5 --- /dev/null +++ b/app/assets/javascripts/issuable/index.js @@ -0,0 +1,116 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import IssuableContext from '~/issuable/issuable_context'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import Sidebar from '~/right_sidebar'; +import { getSidebarOptions } from '~/sidebar/mount_sidebar'; +import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; +import IssuableByEmail from './components/issuable_by_email.vue'; +import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; + +export function initCsvImportExportButtons() { + const el = document.querySelector('.js-csv-import-export-buttons'); + + if (!el) return null; + + const { + showExportButton, + showImportButton, + issuableType, + issuableCount, + email, + exportCsvPath, + importCsvIssuesPath, + containerClass, + canEdit, + projectImportJiraPath, + maxAttachmentSize, + showLabel, + } = el.dataset; + + return new Vue({ + el, + provide: { + showExportButton: parseBoolean(showExportButton), + showImportButton: parseBoolean(showImportButton), + issuableType, + email, + importCsvIssuesPath, + containerClass, + canEdit: parseBoolean(canEdit), + projectImportJiraPath, + maxAttachmentSize, + showLabel, + }, + render(h) { + return h(CsvImportExportButtons, { + props: { + exportCsvPath, + issuableCount: parseInt(issuableCount, 10), + }, + }); + }, + }); +} + +export function initIssuableByEmail() { + Vue.use(GlToast); + + const el = document.querySelector('.js-issuable-by-email'); + + if (!el) return null; + + const { + initialEmail, + issuableType, + emailsHelpPagePath, + quickActionsHelpPath, + markdownHelpPath, + resetPath, + } = el.dataset; + + return new Vue({ + el, + provide: { + initialEmail, + issuableType, + emailsHelpPagePath, + quickActionsHelpPath, + markdownHelpPath, + resetPath, + }, + render(h) { + return h(IssuableByEmail); + }, + }); +} + +export function initIssuableHeaderWarnings(store) { + const el = document.getElementById('js-issuable-header-warnings'); + + if (!el) { + return false; + } + + const { hidden } = el.dataset; + + return new Vue({ + el, + store, + provide: { hidden: parseBoolean(hidden) }, + render(createElement) { + return createElement(IssuableHeaderWarnings); + }, + }); +} + +export function initIssuableSidebar() { + const sidebarOptEl = document.querySelector('.js-sidebar-options'); + + if (!sidebarOptEl) return; + + const sidebarOptions = getSidebarOptions(sidebarOptEl); + + new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new + Sidebar.initialize(); +} diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js deleted file mode 100644 index 83163e3c478..00000000000 --- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js +++ /dev/null @@ -1,48 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; - -export default () => { - const el = document.querySelector('.js-csv-import-export-buttons'); - - if (!el) return null; - - const { - showExportButton, - showImportButton, - issuableType, - issuableCount, - email, - exportCsvPath, - importCsvIssuesPath, - containerClass, - canEdit, - projectImportJiraPath, - maxAttachmentSize, - showLabel, - } = el.dataset; - - return new Vue({ - el, - provide: { - showExportButton: parseBoolean(showExportButton), - showImportButton: parseBoolean(showImportButton), - issuableType, - email, - importCsvIssuesPath, - containerClass, - canEdit: parseBoolean(canEdit), - projectImportJiraPath, - maxAttachmentSize, - showLabel, - }, - render(h) { - return h(CsvImportExportButtons, { - props: { - exportCsvPath, - issuableCount: parseInt(issuableCount, 10), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/issuable/init_issuable_by_email.js b/app/assets/javascripts/issuable/init_issuable_by_email.js deleted file mode 100644 index 984b826234c..00000000000 --- a/app/assets/javascripts/issuable/init_issuable_by_email.js +++ /dev/null @@ -1,35 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import IssuableByEmail from './components/issuable_by_email.vue'; - -Vue.use(GlToast); - -export default () => { - const el = document.querySelector('.js-issueable-by-email'); - - if (!el) return null; - - const { - initialEmail, - issuableType, - emailsHelpPagePath, - quickActionsHelpPath, - markdownHelpPath, - resetPath, - } = el.dataset; - - return new Vue({ - el, - provide: { - initialEmail, - issuableType, - emailsHelpPagePath, - quickActionsHelpPath, - markdownHelpPath, - resetPath, - }, - render(h) { - return h(IssuableByEmail); - }, - }); -}; diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js index 51b5237a339..453305dd6e0 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable/issuable_context.js @@ -1,8 +1,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Cookies from 'js-cookie'; -import { loadCSSFile } from './lib/utils/css_utils'; -import UsersSelect from './users_select'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import UsersSelect from '~/users_select'; export default class IssuableContext { constructor(currentUser) { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index bafc26befda..91f47a86cb7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -1,14 +1,14 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import Autosave from './autosave'; -import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; -import { loadCSSFile } from './lib/utils/css_utils'; -import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; -import { select2AxiosTransport } from './lib/utils/select2_utils'; -import { queryToObject, objectToQuery } from './lib/utils/url_utility'; -import UsersSelect from './users_select'; -import ZenMode from './zen_mode'; +import Autosave from '~/autosave'; +import AutoWidthDropdownSelect from '~/issuable/auto_width_dropdown_select'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; +import { select2AxiosTransport } from '~/lib/utils/select2_utils'; +import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import UsersSelect from '~/users_select'; +import ZenMode from '~/zen_mode'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js index 1bb5e214c2e..cce903d388d 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/issuable/issuable_template_selector.js @@ -1,9 +1,7 @@ -/* eslint-disable no-useless-return */ - import $ from 'jquery'; +import TemplateSelector from '~/blob/template_selector'; import { __ } from '~/locale'; import Api from '../api'; -import TemplateSelector from '../blob/template_selector'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { @@ -109,7 +107,5 @@ export default class IssuableTemplateSelector extends TemplateSelector { } else { this.setEditorContent(this.currentTemplate, { skipFocus: false }); } - - return; } } diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/issuable/issuable_template_selectors.js index 443b3084113..92f825e55d3 100644 --- a/app/assets/javascripts/templates/issuable_template_selectors.js +++ b/app/assets/javascripts/issuable/issuable_template_selectors.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new, class-methods-use-this */ - import $ from 'jquery'; import IssuableTemplateSelector from './issuable_template_selector'; @@ -10,6 +8,8 @@ export default class IssuableTemplateSelectors { this.$dropdowns.each((i, dropdown) => { const $dropdown = $(dropdown); + + // eslint-disable-next-line no-new new IssuableTemplateSelector({ pattern: /(\.md)/, data: $dropdown.data('data'), @@ -21,6 +21,7 @@ export default class IssuableTemplateSelectors { }); } + // eslint-disable-next-line class-methods-use-this initEditor() { const editor = $('.markdown-area'); // Proxy ace-editor's .setValue to jQuery's .val diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js index 4a6edae0c06..4a6edae0c06 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js deleted file mode 100644 index 5a57da292a0..00000000000 --- a/app/assets/javascripts/issuable_index.js +++ /dev/null @@ -1,7 +0,0 @@ -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; - -export default class IssuableIndex { - constructor(pagePrefix = 'issuable_') { - issuableInitBulkUpdateSidebar.init(pagePrefix); - } -} diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js deleted file mode 100644 index 433a62d1ae8..00000000000 --- a/app/assets/javascripts/issuable_type_selector/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import InfoPopover from './components/info_popover.vue'; - -export default function initIssuableTypeSelector() { - const el = document.getElementById('js-type-popover'); - - return new Vue({ - el, - components: { - InfoPopover, - }, - render(h) { - return h(InfoPopover); - }, - }); -} diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js new file mode 100644 index 00000000000..b7b123dfd5f --- /dev/null +++ b/app/assets/javascripts/issues/constants.js @@ -0,0 +1,25 @@ +import { __ } from '~/locale'; + +export const IssuableStatus = { + Closed: 'closed', + Open: 'opened', + Reopened: 'reopened', +}; + +export const IssuableStatusText = { + [IssuableStatus.Closed]: __('Closed'), + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Reopened]: __('Open'), +}; + +export const IssuableType = { + Issue: 'issue', + Epic: 'epic', + MergeRequest: 'merge_request', + Alert: 'alert', +}; + +export const WorkspaceType = { + project: 'project', + group: 'group', +}; diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/issues/filtered_search_service_desk.js index bec207aa439..bec207aa439 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js +++ b/app/assets/javascripts/issues/filtered_search_service_desk.js diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/issues/form.js index c0da0069a99..33371d065f9 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/issues/form.js @@ -1,14 +1,13 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; -import initSuggestions from '~/issuable_suggestions'; -import initIssuableTypeSelector from '~/issuable_type_selector'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new ShortcutsNavigation(); @@ -20,6 +19,6 @@ export default () => { warnTemplateOverride: true, }); - initSuggestions(); - initIssuableTypeSelector(); + initTitleSuggestions(); + initTypePopover(); }; diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js new file mode 100644 index 00000000000..1901802c11c --- /dev/null +++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js @@ -0,0 +1,11 @@ +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(); + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issues/issue.js index 1e053d7daaa..c471875654b 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import createFlash from './flash'; -import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants'; -import axios from './lib/utils/axios_utils'; -import { addDelimiter } from './lib/utils/text_utility'; -import { __ } from './locale'; +import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import createFlash from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import axios from '~/lib/utils/axios_utils'; +import { addDelimiter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; export default class Issue { constructor() { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 9613246d6a6..9613246d6a6 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue index 48a5e220abf..0a9cdb12519 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue @@ -2,12 +2,12 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import query from '../queries/issues.query.graphql'; -import Suggestion from './item.vue'; +import TitleSuggestionsItem from './title_suggestions_item.vue'; export default { components: { - Suggestion, GlIcon, + TitleSuggestionsItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -66,7 +66,7 @@ export default { </script> <template> - <div v-show="showSuggestions" class="form-group row issuable-suggestions"> + <div v-show="showSuggestions" class="form-group row"> <div v-once class="col-form-label col-sm-2 pt-0"> {{ __('Similar issues') }} <gl-icon @@ -86,7 +86,7 @@ export default { 'gl-mb-3': index !== issues.length - 1, }" > - <suggestion :suggestion="suggestion" /> + <title-suggestions-item :suggestion="suggestion" /> </li> </ul> </div> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue index a01f4f747b9..a01f4f747b9 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue index 3a20ccba814..a70e79b70f9 100644 --- a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue +++ b/app/assets/javascripts/issues/new/components/type_popover.vue @@ -19,9 +19,9 @@ export default { <template> <span id="popovercontainer"> - <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> + <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> <gl-popover - target="issuable-type-info" + target="issue-type-info" container="popovercontainer" :title="$options.i18n.issueTypes" triggers="focus hover" diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issues/new/index.js index 8f7f317d6b4..59a7cbec627 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -1,14 +1,19 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import App from './components/app.vue'; +import TitleSuggestions from './components/title_suggestions.vue'; +import TypePopover from './components/type_popover.vue'; -Vue.use(VueApollo); +export function initTitleSuggestions() { + Vue.use(VueApollo); -export default function initIssuableSuggestions() { const el = document.getElementById('js-suggestions'); const issueTitle = document.getElementById('issue_title'); - const { projectPath } = el.dataset; + + if (!el) { + return undefined; + } + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); @@ -26,13 +31,26 @@ export default function initIssuableSuggestions() { this.search = issueTitle.value; }); }, - render(h) { - return h(App, { + render(createElement) { + return createElement(TitleSuggestions, { props: { - projectPath, + projectPath: el.dataset.projectPath, search: this.search, }, }); }, }); } + +export function initTypePopover() { + const el = document.getElementById('js-type-popover'); + + if (!el) { + return undefined; + } + + return new Vue({ + el, + render: (createElement) => createElement(TypePopover), + }); +} diff --git a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql b/app/assets/javascripts/issues/new/queries/issues.query.graphql index 2384b381344..dc0757b141f 100644 --- a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql +++ b/app/assets/javascripts/issues/new/queries/issues.query.graphql @@ -1,8 +1,10 @@ query issueSuggestion($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { + id issues(search: $search, sort: updated_desc, first: 5) { edges { node { + id iid title confidential @@ -14,6 +16,7 @@ query issueSuggestion($fullPath: ID!, $search: String) { createdAt updatedAt author { + id name username avatarUrl diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index 50835142d28..1d48446b083 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -2,8 +2,8 @@ import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { sprintf, __, n__ } from '~/locale'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { parseIssuableData } from '../../issue_show/utils/parse_data'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; export default { name: 'RelatedMergeRequests', diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index ce33cf7df1d..ce33cf7df1d 100644 --- a/app/assets/javascripts/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js index 94abb50de89..94abb50de89 100644 --- a/app/assets/javascripts/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js index 925cc36cd76..925cc36cd76 100644 --- a/app/assets/javascripts/related_merge_requests/store/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js index 31d4fe032e1..31d4fe032e1 100644 --- a/app/assets/javascripts/related_merge_requests/store/mutation_types.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js index 11ca28a5fb9..11ca28a5fb9 100644 --- a/app/assets/javascripts/related_merge_requests/store/mutations.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/issues/related_merge_requests/store/state.js index bc3468a025b..bc3468a025b 100644 --- a/app/assets/javascripts/related_merge_requests/store/state.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/state.js diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue index 1530e9a15b5..1530e9a15b5 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js index 8e9ee25e7a8..8e9ee25e7a8 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/index.js +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/issues/show.js index 24aa2f0da13..e43e56d7b4e 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/issues/show.js @@ -1,16 +1,15 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; -import initIssuableSidebar from '~/init_issuable_sidebar'; -import { IssuableType } from '~/issuable_show/constants'; -import Issue from '~/issue'; -import { initIncidentApp, initIncidentHeaderActions } from '~/issue_show/incident'; -import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue'; -import { parseIssuableData } from '~/issue_show/utils/parse_data'; +import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import Issue from '~/issues/issue'; +import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; +import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; -import initRelatedMergeRequestsApp from '~/related_merge_requests'; -import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; -import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; +import initRelatedMergeRequestsApp from '~/issues/related_merge_requests'; +import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace'; import ZenMode from '~/zen_mode'; export default function initShowIssue() { @@ -33,7 +32,7 @@ export default function initShowIssue() { break; } - initIssuableHeaderWarning(store); + initIssuableHeaderWarnings(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index d3b58ed3012..eeaf865a35f 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -2,18 +2,11 @@ import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import { - IssuableStatus, - IssuableStatusText, - IssuableType, - IssueTypePath, - IncidentTypePath, - IncidentType, - POLLING_DELAY, -} from '../constants'; +import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; @@ -296,13 +289,11 @@ export default { window.addEventListener('beforeunload', this.handleBeforeUnloadEvent); - eventHub.$on('delete.issuable', this.deleteIssuable); eventHub.$on('update.issuable', this.updateIssuable); eventHub.$on('close.form', this.closeForm); eventHub.$on('open.form', this.openForm); }, beforeDestroy() { - eventHub.$off('delete.issuable', this.deleteIssuable); eventHub.$off('update.issuable', this.updateIssuable); eventHub.$off('close.form', this.closeForm); eventHub.$off('open.form', this.openForm); @@ -425,25 +416,6 @@ export default { }); }, - deleteIssuable(payload) { - return this.service - .deleteIssuable(payload) - .then((res) => res.data) - .then((data) => { - // Stop the poll so we don't get 404's with the issuable not existing - this.poll.stop(); - - visitUrl(data.web_url); - }) - .catch(() => { - createFlash({ - message: sprintf(__('Error deleting %{issuableType}'), { - issuableType: this.issuableType, - }), - }); - }); - }, - hideStickyHeader() { this.isStickyHeaderShowing = false; }, @@ -482,6 +454,7 @@ export default { <div> <div v-if="canUpdate && showForm"> <form-component + :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" :can-destroy="canDestroy" diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue new file mode 100644 index 00000000000..26862346b86 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -0,0 +1,71 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __, sprintf } from '~/locale'; + +export default { + actionCancel: { text: __('Cancel') }, + csrf, + components: { + GlModal, + }, + props: { + issuePath: { + type: String, + required: true, + }, + issueType: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + computed: { + actionPrimary() { + return { + attributes: { variant: 'danger' }, + text: this.title, + }; + }, + bodyText() { + return this.issueType.toLowerCase() === 'epic' + ? __('Delete this epic and all descendants?') + : sprintf(__('%{issuableType} will be removed! Are you sure?'), { + issuableType: capitalizeFirstCharacter(this.issueType), + }); + }, + }, + methods: { + submitForm() { + this.$emit('delete'); + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :modal-id="modalId" + size="sm" + :title="title" + @primary="submitForm" + > + <form ref="form" :action="issuePath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input type="hidden" name="destroy_confirm" value="true" /> + {{ bodyText }} + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 9dc122d426c..7be4c13f544 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -3,7 +3,7 @@ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; -import TaskList from '../../task_list'; +import TaskList from '~/task_list'; import animateMixin from '../mixins/animate'; export default { @@ -133,7 +133,7 @@ export default { } }, }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, }; </script> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 5b7d232fde7..4daf6f2b61b 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -1,10 +1,12 @@ <script> -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; const issuableTypes = { issue: __('Issue'), @@ -12,20 +14,26 @@ const issuableTypes = { incident: __('Incident'), }; +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); + export default { components: { + DeleteIssueModal, GlButton, - GlModal, }, directives: { GlModal: GlModalDirective, }, - mixins: [updateMixin], + mixins: [trackingMixin, updateMixin], props: { canDestroy: { type: Boolean, required: true, }, + endpoint: { + required: true, + type: String, + }, formState: { type: Object, required: true, @@ -65,27 +73,9 @@ export default { issuableType: this.typeToShow.toLowerCase(), }); }, - deleteIssuableModalText() { - return this.issuableType === 'epic' - ? __('Delete this epic and all descendants?') - : sprintf(__('%{issuableType} will be removed! Are you sure?'), { - issuableType: this.typeToShow, - }); - }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, - modalActionProps() { - return { - primary: { - text: this.deleteIssuableButtonText, - attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }], - }, - cancel: { - text: __('Cancel'), - }, - }; - }, shouldShowDeleteButton() { return this.canDestroy && this.showDeleteButton; }, @@ -101,7 +91,7 @@ export default { }, deleteIssuable() { this.deleteLoading = true; - eventHub.$emit('delete.issuable', { destroy_confirm: true }); + eventHub.$emit('delete.issuable'); }, }, }; @@ -135,22 +125,17 @@ export default { variant="danger" class="qa-delete-button" data-testid="issuable-delete-button" + @click="track('click_button')" > {{ deleteIssuableButtonText }} </gl-button> - <gl-modal - ref="removeModal" + <delete-issue-modal + :issue-path="endpoint" + :issue-type="typeToShow" :modal-id="modalId" - size="sm" - :action-primary="modalActionProps.primary" - :action-cancel="modalActionProps.cancel" - @primary="deleteIssuable" - > - <template #modal-title>{{ deleteIssuableButtonText }}</template> - <div> - <p class="gl-mb-1">{{ deleteIssuableModalText }}</p> - </div> - </gl-modal> + :title="deleteIssuableButtonText" + @delete="deleteIssuable" + /> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 64f61a1b88e..0da1900a6d0 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 5476a1ef897..5476a1ef897 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue index 35e7860cd9b..9ce49b65a1a 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; -import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue index a73926575d0..a73926575d0 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issues/show/components/fields/title.vue diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 9110a6924b4..9110a6924b4 100644 --- a/app/assets/javascripts/issue_show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 001e8abb941..6447ec85b4e 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -2,7 +2,7 @@ import { GlAlert } from '@gitlab/ui'; import $ from 'jquery'; import Autosave from '~/autosave'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -26,6 +26,10 @@ export default { type: Boolean, required: true, }, + endpoint: { + type: String, + required: true, + }, formState: { type: Object, required: true, @@ -213,6 +217,7 @@ export default { :enable-autocomplete="enableAutocomplete" /> <edit-actions + :endpoint="endpoint" :form-state="formState" :can-destroy="canDestroy" :show-delete-button="showDeleteButton" diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 2c314ce1c3f..700ef92a0f3 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -1,31 +1,38 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableType } from '~/issuable_show/constants'; -import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus } from '~/issues/constants'; +import { IssueStateEvent } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; + +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); export default { - components: { - GlButton, - GlDropdown, - GlDropdownItem, - GlLink, - GlModal, - }, actionCancel: { text: __('Cancel'), }, actionPrimary: { text: __('Yes, close issue'), }, + deleteModalId: 'delete-modal-id', i18n: { promoteErrorMessage: __( 'Something went wrong while promoting the issue to an epic. Please try again.', @@ -34,10 +41,26 @@ export default { 'The issue was successfully promoted to an epic. Redirecting to epic...', ), }, + components: { + DeleteIssueModal, + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [trackingMixin], inject: { canCreateIssue: { default: false, }, + canDestroyIssue: { + default: false, + }, canPromoteToEpic: { default: false, }, @@ -56,6 +79,9 @@ export default { isIssueAuthor: { default: false, }, + issuePath: { + default: '', + }, issueType: { default: IssuableType.Issue, }, @@ -78,10 +104,21 @@ export default { isClosed() { return this.openState === IssuableStatus.Closed; }, + issueTypeText() { + const issueTypeTexts = { + [IssuableType.Issue]: s__('HeaderAction|issue'), + [IssuableType.Incident]: s__('HeaderAction|incident'), + }; + + return issueTypeTexts[this.issueType] ?? this.issueType; + }, buttonText() { return this.isClosed - ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType }) - : sprintf(__('Close %{issueType}'), { issueType: this.issueType }); + ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText }) + : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText }); + }, + deleteButtonText() { + return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText }); }, qaSelector() { return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; @@ -132,8 +169,7 @@ export default { }) .then(({ data }) => { if (data.updateIssue.errors.length) { - createFlash({ message: data.updateIssue.errors.join('. ') }); - return; + throw new Error(); } const payload = { @@ -166,8 +202,7 @@ export default { }) .then(({ data }) => { if (data.promoteToEpic.errors.length) { - createFlash({ message: data.promoteToEpic.errors.join('; ') }); - return; + throw new Error(); } createFlash({ @@ -219,6 +254,16 @@ export default { > {{ __('Submit as spam') }} </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> </gl-dropdown> <gl-button @@ -262,6 +307,16 @@ export default { > {{ __('Submit as spam') }} </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> </gl-dropdown> <gl-modal @@ -279,5 +334,12 @@ export default { </li> </ul> </gl-modal> + + <delete-issue-modal + :issue-path="issuePath" + :issue-type="issueType" + :modal-id="$options.deleteModalId" + :title="deleteButtonText" + /> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql index 938b90b3f7c..d88633f2ae9 100644 --- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -1,5 +1,6 @@ query getAlert($iid: String!, $fullPath: ID!) { project(fullPath: $fullPath) { + id issue(iid: $iid) { id alertManagementAlert { diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue index 96f187f26dd..d509f0dbc09 100644 --- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue @@ -5,7 +5,7 @@ import { formatDate } from '~/lib/utils/datetime_utility'; export default { components: { GlLink, - IncidentSla: () => import('ee_component/issue_show/components/incidents/incident_sla.vue'), + IncidentSla: () => import('ee_component/issues/show/components/incidents/incident_sla.vue'), }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 84107d3eaca..4790062ab7d 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -16,7 +16,7 @@ export default { GlTab, GlTabs, HighlightBar, - MetricsTab: () => import('ee_component/issue_show/components/incidents/metrics_tab.vue'), + MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'), }, inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], apollo: { diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 4b99888ae73..4b99888ae73 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issues/show/components/pinned_links.vue index d38189307bd..d38189307bd 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issues/show/components/pinned_links.vue diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 5e92211685a..5e92211685a 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issues/show/constants.js index ef9699deb42..35f3bcdad70 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issues/show/constants.js @@ -1,24 +1,5 @@ import { __ } from '~/locale'; -export const IssuableStatus = { - Closed: 'closed', - Open: 'opened', - Reopened: 'reopened', -}; - -export const IssuableStatusText = { - [IssuableStatus.Closed]: __('Closed'), - [IssuableStatus.Open]: __('Open'), - [IssuableStatus.Reopened]: __('Open'), -}; - -export const IssuableType = { - Issue: 'issue', - Epic: 'epic', - MergeRequest: 'merge_request', - Alert: 'alert', -}; - export const IssueStateEvent = { Close: 'CLOSE', Reopen: 'REOPEN', @@ -39,8 +20,3 @@ export const IncidentType = 'incident'; export const issueState = { issueType: undefined, isDirty: false }; export const POLLING_DELAY = 2000; - -export const WorkspaceType = { - project: 'project', - group: 'group', -}; diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issues/show/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/issuable_show/event_hub.js +++ b/app/assets/javascripts/issues/show/event_hub.js diff --git a/app/assets/javascripts/issue_show/graphql.js b/app/assets/javascripts/issues/show/graphql.js index 5b8630f7d63..5b8630f7d63 100644 --- a/app/assets/javascripts/issue_show/graphql.js +++ b/app/assets/javascripts/issues/show/graphql.js diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issues/show/incident.js index 3aff2d9c54a..a260c31e1da 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issues/show/incident.js @@ -81,12 +81,14 @@ export function initIncidentHeaderActions(store) { store, provide: { canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReportSpam: parseBoolean(el.dataset.canReportSpam), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), iid: el.dataset.iid, isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, issueType: el.dataset.issueType, newIssuePath: el.dataset.newIssuePath, projectPath: el.dataset.projectPath, diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issues/show/issue.js index 25cc51478ff..60e90934af8 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issues/show/issue.js @@ -44,6 +44,7 @@ export function initIssuableApp(issuableData, store) { isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, + id: this.getNoteableData?.id, }, }); }, @@ -65,12 +66,14 @@ export function initIssueHeaderActions(store) { store, provide: { canCreateIssue: parseBoolean(el.dataset.canCreateIssue), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReportSpam: parseBoolean(el.dataset.canReportSpam), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), iid: el.dataset.iid, isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, issueType: el.dataset.issueType, newIssuePath: el.dataset.newIssuePath, projectPath: el.dataset.projectPath, diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issues/show/mixins/animate.js index 4816393da1f..4816393da1f 100644 --- a/app/assets/javascripts/issue_show/mixins/animate.js +++ b/app/assets/javascripts/issues/show/mixins/animate.js diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js index 72be65b426f..72be65b426f 100644 --- a/app/assets/javascripts/issue_show/mixins/update.js +++ b/app/assets/javascripts/issues/show/mixins/update.js diff --git a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql index 33b737d2315..33b737d2315 100644 --- a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql +++ b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql index 12d05af0f5e..e3e3a2bc667 100644 --- a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql @@ -1,6 +1,7 @@ mutation promoteToEpic($input: PromoteToEpicInput!) { promoteToEpic(input: $input) { epic { + id webPath } errors diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql index ec8d8f32d8b..ec8d8f32d8b 100644 --- a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql diff --git a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql index d91ca746066..d91ca746066 100644 --- a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issues/show/services/index.js index b1deeaae0fc..dba07f623f9 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issues/show/services/index.js @@ -1,4 +1,4 @@ -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; export default class Service { constructor(endpoint) { diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js index a50913d3455..a50913d3455 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issues/show/stores/index.js diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js index f1e6bd2419a..f1e6bd2419a 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issues/show/utils/parse_data.js diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js index c5811290e61..c5811290e61 100644 --- a/app/assets/javascripts/issue_show/utils/update_description.js +++ b/app/assets/javascripts/issues/show/utils/update_description.js diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index 6dc7460b037..6476d5be38c 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -28,7 +28,7 @@ import { convertToCamelCase } from '~/lib/utils/text_utility'; import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility'; import { sprintf, __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssueAssignees from '~/issuable/components/issue_assignees.vue'; export default { i18n: { diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 62b52afdaca..71136bf0159 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils'; import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { sortOrderMap, @@ -21,12 +21,12 @@ import { PAGE_SIZE_MANUAL, LOADING_LIST_ITEMS_LENGTH, } from '../constants'; -import issueableEventHub from '../eventhub'; +import issuableEventHub from '../eventhub'; import { emptyStateHelper } from '../service_desk_helper'; import Issuable from './issuable.vue'; /** - * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead + * @deprecated Use app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue instead */ export default { LOADING_LIST_ITEMS_LENGTH, @@ -192,7 +192,7 @@ export default { // We need to call nextTick here to wait for all of the boxes to be checked and rendered // before we query the dom in issuable_bulk_update_actions.js. this.$nextTick(() => { - issueableEventHub.$emit('issuables:updateBulkEdit'); + issuableEventHub.$emit('issuables:updateBulkEdit'); }); }, issuables() { @@ -203,7 +203,7 @@ export default { }, mounted() { if (this.canBulkEdit) { - this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', (val) => { + this.unsubscribeToggleBulkEdit = issuableEventHub.$on('issuables:toggleBulkEdit', (val) => { this.isBulkEditing = val; }); } @@ -211,7 +211,7 @@ export default { }, beforeDestroy() { // eslint-disable-next-line @gitlab/no-global-event-off - issueableEventHub.$off('issuables:toggleBulkEdit'); + issuableEventHub.$off('issuables:toggleBulkEdit'); }, methods: { isSelected(issuableId) { diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue index 4a2f7861492..aece7372182 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -7,25 +7,16 @@ import { isInPast, isToday, } from '~/lib/utils/datetime_utility'; -import { convertToCamelCase } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; export default { components: { GlLink, GlIcon, - IssueHealthStatus: () => - import('ee_component/related_items_tree/components/issue_health_status.vue'), - WeightCount: () => import('ee_component/issues/components/weight_count.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, - inject: { - hasIssuableHealthStatusFeature: { - default: false, - }, - }, props: { issue: { type: Object, @@ -54,12 +45,6 @@ export default { timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; }, - showHealthStatus() { - return this.hasIssuableHealthStatusFeature && this.issue.healthStatus; - }, - healthStatus() { - return convertToCamelCase(this.issue.healthStatus); - }, }, methods: { milestoneRemainingTime(dueDate, startDate) { @@ -114,7 +99,6 @@ export default { <gl-icon name="timer" /> {{ timeEstimate }} </span> - <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" /> - <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" /> + <slot></slot> </span> </template> diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 7f2082e5b90..6ced1080b71 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -8,17 +8,20 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { orderBy } from 'lodash'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; -import createFlash from '~/flash'; +import IssueCardTimeInfo from 'ee_else_ce/issues_list/components/issue_card_time_info.vue'; +import createFlash, { FLASH_TYPES } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; -import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; -import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import { CREATED_DESC, i18n, @@ -31,14 +34,11 @@ import { TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_EPIC, - TOKEN_TYPE_ITERATION, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, - TOKEN_TYPE_WEIGHT, UPDATED_DESC, urlSortParams, } from '~/issues_list/constants'; @@ -61,39 +61,29 @@ import { TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, - TOKEN_TITLE_EPIC, - TOKEN_TITLE_ITERATION, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, - TOKEN_TITLE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; -import searchIterationsQuery from '../queries/search_iterations.query.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; import searchUsersQuery from '../queries/search_users.query.graphql'; -import IssueCardTimeInfo from './issue_card_time_info.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; const AuthorToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); -const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'); -const IterationToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'); const LabelToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); const ReleaseToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); -const WeightToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'); export default { i18n, @@ -109,7 +99,6 @@ export default { IssuableList, IssueCardTimeInfo, NewIssueDropdown, - BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -133,9 +122,6 @@ export default { fullPath: { default: '', }, - groupPath: { - default: '', - }, hasAnyIssues: { default: false, }, @@ -148,15 +134,18 @@ export default { hasIssueWeightsFeature: { default: false, }, - hasIterationsFeature: { - default: false, - }, hasMultipleIssueAssigneesFeature: { default: false, }, initialEmail: { default: '', }, + isAnonymousSearchDisabled: { + default: false, + }, + isIssueRepositioningDisabled: { + default: false, + }, isProject: { default: false, }, @@ -182,21 +171,43 @@ export default { default: '', }, }, + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, data() { const state = getParameterByName(PARAM_STATE); - const sortKey = getSortKey(getParameterByName(PARAM_SORT)); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + const isSearchDisabled = + this.isAnonymousSearchDisabled && + !this.isSignedIn && + window.location.search.includes('search='); + + if (isSearchDisabled) { + this.showAnonymousSearchingMessage(); + } return { dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filterTokens: getFilterTokens(window.location.search), + filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), issues: [], issuesCounts: {}, + issuesError: null, pageInfo: {}, pageParams: getInitialPageParams(sortKey), showBulkEditSidebar: false, - sortKey: sortKey || defaultSortKey, + sortKey, state: state || IssuableStates.Opened, }; }, @@ -214,7 +225,8 @@ export default { this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { - createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error }); + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); }, skip() { return !this.hasAnyIssues; @@ -230,7 +242,8 @@ export default { return data[this.namespace] ?? {}; }, error(error) { - createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error }); + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); }, skip() { return !this.hasAnyIssues; @@ -306,6 +319,7 @@ export default { unique: true, defaultAuthors: [], fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, preloadedAuthors, }, { @@ -317,6 +331,7 @@ export default { unique: !this.hasMultipleIssueAssigneesFeature, defaultAuthors: DEFAULT_NONE_ANY, fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, preloadedAuthors, }, { @@ -325,6 +340,7 @@ export default { icon: 'clock', token: MilestoneToken, fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, }, { type: TOKEN_TYPE_LABEL, @@ -333,6 +349,7 @@ export default { token: LabelToken, defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, { type: TOKEN_TYPE_TYPE, @@ -354,6 +371,7 @@ export default { icon: 'rocket', token: ReleaseToken, fetchReleases: this.fetchReleases, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`, }); } @@ -365,6 +383,7 @@ export default { token: EmojiToken, unique: true, fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`, }); tokens.push({ @@ -381,42 +400,13 @@ export default { }); } - if (this.hasIterationsFeature) { - tokens.push({ - type: TOKEN_TYPE_ITERATION, - title: TOKEN_TITLE_ITERATION, - icon: 'iteration', - token: IterationToken, - fetchIterations: this.fetchIterations, - }); + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); } - if (this.groupPath) { - tokens.push({ - type: TOKEN_TYPE_EPIC, - title: TOKEN_TITLE_EPIC, - icon: 'epic', - token: EpicToken, - unique: true, - symbol: '&', - idProperty: 'id', - useIdValue: true, - recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`, - fullPath: this.groupPath, - }); - } + tokens.sort((a, b) => a.title.localeCompare(b.title)); - if (this.hasIssueWeightsFeature) { - tokens.push({ - type: TOKEN_TYPE_WEIGHT, - title: TOKEN_TITLE_WEIGHT, - icon: 'weight', - token: WeightToken, - unique: true, - }); - } - - return tokens; + return orderBy(tokens, ['title']); }, showPaginationControls() { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); @@ -481,7 +471,12 @@ export default { query: searchLabelsQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => data[this.namespace]?.labels.nodes); + .then(({ data }) => data[this.namespace]?.labels.nodes) + .then((labels) => + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), + ); }, fetchMilestones(search) { return this.$apollo @@ -491,20 +486,6 @@ export default { }) .then(({ data }) => data[this.namespace]?.milestones.nodes); }, - fetchIterations(search) { - const id = Number(search); - const variables = - !search || Number.isNaN(id) - ? { fullPath: this.fullPath, search, isProject: this.isProject } - : { fullPath: this.fullPath, id, isProject: this.isProject }; - - return this.$apollo - .query({ - query: searchIterationsQuery, - variables, - }) - .then(({ data }) => data[this.namespace]?.iterations.nodes); - }, fetchUsers(search) { return this.$apollo .query({ @@ -537,7 +518,7 @@ export default { async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { const initBulkUpdateSidebar = await import( - '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar' + '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar' ); initBulkUpdateSidebar.default.init('issuable_'); @@ -556,7 +537,14 @@ export default { } this.state = state; }, + handleDismissAlert() { + this.issuesError = null; + }, handleFilter(filter) { + if (this.isAnonymousSearchDisabled && !this.isSignedIn) { + this.showAnonymousSearchingMessage(); + return; + } this.pageParams = getInitialPageParams(this.sortKey); this.filterTokens = filter; }, @@ -607,15 +595,33 @@ export default { }); }) .catch((error) => { - createFlash({ message: this.$options.i18n.reorderError, captureError: true, error }); + this.issuesError = this.$options.i18n.reorderError; + Sentry.captureException(error); }); }, handleSort(sortKey) { + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + return; + } + if (this.sortKey !== sortKey) { this.pageParams = getInitialPageParams(sortKey); } this.sortKey = sortKey; }, + showAnonymousSearchingMessage() { + createFlash({ + message: this.$options.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }, + showIssueRepositioningMessage() { + createFlash({ + message: this.$options.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, @@ -634,6 +640,7 @@ export default { :sort-options="sortOptions" :initial-sort-by="sortKey" :issuables="issues" + :error="issuesError" label-filter-param="label_name" :tabs="$options.IssuableListTabs" :current-tab="state" @@ -647,6 +654,7 @@ export default { :has-previous-page="pageInfo.hasPreviousPage" :url-params="urlParams" @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" @filter="handleFilter" @next-page="handleNextPage" @previous-page="handlePreviousPage" @@ -727,12 +735,7 @@ export default { <gl-icon name="thumb-down" /> {{ issuable.downvotes }} </li> - <blocking-issues-count - class="blocking-issues gl-display-none gl-sm-display-block" - :blocking-issues-count="issuable.blockingCount" - :is-list-item="true" - data-testid="blocking-issues" - /> + <slot :issuable="issuable"></slot> </template> <template #empty-state> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index da9b96d0e22..c9eaf0b9908 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -66,6 +66,7 @@ export const availableSortOptionsJira = [ ]; export const i18n = { + anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), closed: __('CLOSED'), closedMoved: __('CLOSED (MOVED)'), @@ -75,6 +76,9 @@ export const i18n = { editIssues: __('Edit issues'), errorFetchingCounts: __('An error occurred while getting issue counts'), errorFetchingIssues: __('An error occurred while loading issues'), + issueRepositioningMessage: __( + 'Issues are being rebalanced at the moment, so manual reordering is disabled.', + ), jiraIntegrationMessage: s__( 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', ), @@ -133,6 +137,7 @@ export const DUE_DATE_VALUES = [ DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS, ]; +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; export const CREATED_ASC = 'CREATED_ASC'; export const CREATED_DESC = 'CREATED_DESC'; @@ -154,42 +159,28 @@ export const UPDATED_DESC = 'UPDATED_DESC'; export const WEIGHT_ASC = 'WEIGHT_ASC'; export const WEIGHT_DESC = 'WEIGHT_DESC'; -const PRIORITY_ASC_SORT = 'priority_asc'; -const CREATED_DATE_SORT = 'created_date'; -const CREATED_ASC_SORT = 'created_asc'; -const UPDATED_DESC_SORT = 'updated_desc'; -const UPDATED_ASC_SORT = 'updated_asc'; -const MILESTONE_SORT = 'milestone'; -const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc'; -const DUE_DATE_DESC_SORT = 'due_date_desc'; -const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc'; -const POPULARITY_ASC_SORT = 'popularity_asc'; -const WEIGHT_DESC_SORT = 'weight_desc'; -const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc'; -const TITLE_ASC_SORT = 'title_asc'; -const TITLE_DESC_SORT = 'title_desc'; - export const urlSortParams = { - [PRIORITY_ASC]: PRIORITY_ASC_SORT, - [PRIORITY_DESC]: PRIORITY, - [CREATED_ASC]: CREATED_ASC_SORT, - [CREATED_DESC]: CREATED_DATE_SORT, - [UPDATED_ASC]: UPDATED_ASC_SORT, - [UPDATED_DESC]: UPDATED_DESC_SORT, - [MILESTONE_DUE_ASC]: MILESTONE_SORT, - [MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT, - [DUE_DATE_ASC]: DUE_DATE, - [DUE_DATE_DESC]: DUE_DATE_DESC_SORT, - [POPULARITY_ASC]: POPULARITY_ASC_SORT, - [POPULARITY_DESC]: POPULARITY, - [LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT, - [LABEL_PRIORITY_DESC]: LABEL_PRIORITY, + [PRIORITY_ASC]: 'priority', + [PRIORITY_DESC]: 'priority_desc', + [CREATED_ASC]: 'created_asc', + [CREATED_DESC]: 'created_date', + [UPDATED_ASC]: 'updated_asc', + [UPDATED_DESC]: 'updated_desc', + [MILESTONE_DUE_ASC]: 'milestone', + [MILESTONE_DUE_DESC]: 'milestone_due_desc', + [DUE_DATE_ASC]: 'due_date', + [DUE_DATE_DESC]: 'due_date_desc', + [POPULARITY_ASC]: 'popularity_asc', + [POPULARITY_DESC]: 'popularity', + [LABEL_PRIORITY_ASC]: 'label_priority', + [LABEL_PRIORITY_DESC]: 'label_priority_desc', [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, - [WEIGHT_ASC]: WEIGHT, - [WEIGHT_DESC]: WEIGHT_DESC_SORT, - [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT, - [TITLE_ASC]: TITLE_ASC_SORT, - [TITLE_DESC]: TITLE_DESC_SORT, + [WEIGHT_ASC]: 'weight', + [WEIGHT_DESC]: 'weight_desc', + [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', + [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', }; export const MAX_LIST_SIZE = 10; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 59034964afb..9d2ec8b32d2 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; +import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; @@ -129,6 +129,8 @@ export function mountIssuesListApp() { hasMultipleIssueAssigneesFeature, importCsvIssuesPath, initialEmail, + isAnonymousSearchDisabled, + isIssueRepositioningDisabled, isProject, isSignedIn, jiraIntegrationPath, @@ -161,6 +163,8 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), + isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), isSignedIn: parseBoolean(isSignedIn), jiraIntegrationPath, diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql index 9866efbcecc..be8deb3fe97 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -26,6 +26,7 @@ query getIssues( $lastPageSize: Int ) { group(fullPath: $fullPath) @skip(if: $isProject) { + id issues( includeSubgroups: true search: $search @@ -56,6 +57,7 @@ query getIssues( } } project(fullPath: $fullPath) @include(if: $isProject) { + id issues( search: $search sort: $sort diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql index 5e755ec5870..1a345fd2877 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql @@ -16,6 +16,7 @@ query getIssuesCount( $not: NegatedIssueFilterInput ) { group(fullPath: $fullPath) @skip(if: $isProject) { + id openedIssues: issues( includeSubgroups: true state: opened @@ -69,6 +70,7 @@ query getIssuesCount( } } project(fullPath: $fullPath) @include(if: $isProject) { + id openedIssues: issues( state: opened search: $search diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql index 8c95e6114d3..a53dba8c7c8 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql @@ -1,9 +1,12 @@ query getIssuesListDetails($fullPath: ID!) { project(fullPath: $fullPath) { + id issues { nodes { + id labels { nodes { + id title color } diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql index 9c46cb3ef64..07dae3fd756 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -6,6 +6,7 @@ fragment IssueFragment on Issue { createdAt downvotes dueDate + hidden humanTimeEstimate mergeRequestsCount moved diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql deleted file mode 100644 index 4f7217be7f7..00000000000 --- a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql +++ /dev/null @@ -1,10 +0,0 @@ -fragment Iteration on Iteration { - id - title - startDate - dueDate - iterationCadence { - id - title - } -} diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql deleted file mode 100644 index 93600c62905..00000000000 --- a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -#import "./iteration.fragment.graphql" - -query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { - iterations(title: $search, id: $id, includeAncestors: true) { - nodes { - ...Iteration - } - } - } - project(fullPath: $fullPath) @include(if: $isProject) { - iterations(title: $search, id: $id, includeAncestors: true) { - nodes { - ...Iteration - } - } - } -} diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql index 1515bd91da3..44b57317161 100644 --- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql @@ -2,6 +2,7 @@ query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { nodes { ...Label @@ -9,6 +10,7 @@ query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) } } project(fullPath: $fullPath) @include(if: $isProject) { + id labels(searchTerm: $search, includeAncestorGroups: true) { nodes { ...Label diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql index 8c6c50e9dc2..e7eb08104a6 100644 --- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql @@ -2,6 +2,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) { nodes { ...Milestone @@ -9,6 +10,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa } } project(fullPath: $fullPath) @include(if: $isProject) { + id milestones(searchTitle: $search, includeAncestors: true) { nodes { ...Milestone diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql index 75463f643a2..bd2f9bc2340 100644 --- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql @@ -1,5 +1,6 @@ query searchProjects($fullPath: ID!, $search: String) { group(fullPath: $fullPath) { + id projects(search: $search, includeSubgroups: true) { nodes { id diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql index 0211fc66235..92517ad35d0 100644 --- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql @@ -2,8 +2,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id groupMembers(search: $search) { nodes { + id user { ...User } @@ -11,8 +13,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) } } project(fullPath: $fullPath) @include(if: $isProject) { + id projectMembers(search: $search) { nodes { + id user { ...User } diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index 0e57e2bff83..99946e4e851 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -1,5 +1,6 @@ import { API_PARAM, + BLOCKING_ISSUES_ASC, BLOCKING_ISSUES_DESC, CREATED_ASC, CREATED_DESC, @@ -143,7 +144,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: sortOptions.length + 1, title: __('Blocking'), sortDirection: { - ascending: BLOCKING_ISSUES_DESC, + ascending: BLOCKING_ISSUES_ASC, descending: BLOCKING_ISSUES_DESC, }, }); diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql index f3428e816d7..df72a1ca6e6 100644 --- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql @@ -5,6 +5,7 @@ query getProject( $branchNamesSearchPattern: String! ) { project(fullPath: $projectPath) { + id repository { branchNames( limit: $branchNamesLimit diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js index 04510fcff4b..a9a56a6362e 100644 --- a/app/assets/javascripts/jira_connect/branches/index.js +++ b/app/assets/javascripts/jira_connect/branches/index.js @@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); -export default async function initJiraConnectBranches() { +export default function initJiraConnectBranches() { const el = document.querySelector('.js-jira-connect-create-branch'); if (!el) { return null; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index c0504cbb645..7fd4cc38f11 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -7,6 +7,7 @@ import { SET_ALERT } from '../store/mutation_types'; import SubscriptionsList from './subscriptions_list.vue'; import AddNamespaceButton from './add_namespace_button.vue'; import SignInButton from './sign_in_button.vue'; +import UserLink from './user_link.vue'; export default { name: 'JiraConnectApp', @@ -18,6 +19,7 @@ export default { SubscriptionsList, AddNamespaceButton, SignInButton, + UserLink, }, inject: { usersPath: { @@ -74,6 +76,8 @@ export default { </template> </gl-alert> + <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" /> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7"> <template v-if="hasSubscriptions"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue new file mode 100644 index 00000000000..fad3d2616d8 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue @@ -0,0 +1,67 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; + +export default { + components: { + GlLink, + GlSprintf, + }, + inject: { + usersPath: { + default: '', + }, + gitlabUserPath: { + default: '', + }, + }, + props: { + userSignedIn: { + type: Boolean, + required: true, + }, + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + data() { + return { + signInURL: '', + }; + }, + computed: { + gitlabUserHandle() { + return `@${gon.current_username}`; + }, + }, + async created() { + this.signInURL = await getGitlabSignInURL(this.usersPath); + }, + i18n: { + signInText: __('Sign in to GitLab'), + signedInAsUserText: __('Signed in to GitLab as %{user_link}'), + }, +}; +</script> +<template> + <div class="jira-connect-user gl-font-base"> + <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText"> + <template #user_link> + <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank"> + {{ gitlabUserHandle }} + </gl-link> + </template> + </gl-sprintf> + + <gl-link + v-else-if="hasSubscriptions" + data-testid="sign-in-link" + :href="signInURL" + target="_blank" + > + {{ $options.i18n.signInText }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 8a7a80d885d..cd1fc1d4455 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -7,25 +7,11 @@ import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { getGitlabSignInURL, sizeToParent } from './utils'; +import { sizeToParent } from './utils'; const store = createStore(); -/** - * Add `return_to` query param to all HAML-defined GitLab sign in links. - */ -const updateSignInLinks = async () => { - await Promise.all( - Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).map(async (el) => { - const updatedLink = await getGitlabSignInURL(el.getAttribute('href')); - el.setAttribute('href', updatedLink); - }), - ); -}; - -export async function initJiraConnect() { - await updateSignInLinks(); - +export function initJiraConnect() { const el = document.querySelector('.js-jira-connect-app'); if (!el) { return null; @@ -35,7 +21,7 @@ export async function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset; + const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset; sizeToParent(); return new Vue({ @@ -46,6 +32,7 @@ export async function initJiraConnect() { subscriptions: JSON.parse(subscriptions), subscriptionsPath, usersPath, + gitlabUserPath, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql index 6fec07cc6f8..4c26399e16b 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -2,6 +2,7 @@ query getJiraImportDetails($fullPath: ID!) { project(fullPath: $fullPath) { + id jiraImportStatus jiraImports { nodes { diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql index fde2ebeff91..fe797879d07 100644 --- a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -2,6 +2,7 @@ fragment JiraImport on JiraImport { jiraProjectKey scheduledAt scheduledBy { + id name } } diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql index 6ea8963e6a6..7666fa3bd97 100644 --- a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql +++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql @@ -1,7 +1,9 @@ query jiraSearchProjectMembers($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { + id projectMembers(search: $search) { nodes { + id user { id name diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue new file mode 100644 index 00000000000..67c22712776 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/app.vue @@ -0,0 +1,20 @@ +<script> +import BridgeEmptyState from './components/empty_state.vue'; +import BridgeSidebar from './components/sidebar.vue'; + +export default { + name: 'BridgePageApp', + components: { + BridgeEmptyState, + BridgeSidebar, + }, +}; +</script> +<template> + <div> + <!-- TODO: get job details and show CI header --> + <!-- TODO: add downstream pipeline path --> + <bridge-empty-state downstream-pipeline-path="#" /> + <bridge-sidebar /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js new file mode 100644 index 00000000000..33310b3157a --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/constants.js @@ -0,0 +1 @@ +export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm']; diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue new file mode 100644 index 00000000000..bd07d863719 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/empty_state.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'BridgeEmptyState', + i18n: { + title: __('This job triggers a downstream pipeline'), + linkBtnText: __('View downstream pipeline'), + }, + components: { + GlButton, + }, + inject: { + emptyStateIllustrationPath: { + type: String, + require: true, + }, + }, + props: { + downstreamPipelinePath: { + type: String, + required: false, + default: undefined, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <gl-button + v-if="downstreamPipelinePath" + class="gl-mt-3" + category="secondary" + variant="confirm" + size="medium" + :href="downstreamPipelinePath" + > + {{ $options.i18n.linkBtnText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue new file mode 100644 index 00000000000..68b767408f0 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/sidebar.vue @@ -0,0 +1,98 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR } from '../../constants'; +import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants'; + +export default { + styles: { + top: '75px', + width: '290px', + }, + name: 'BridgeSidebar', + i18n: { + ...JOB_SIDEBAR, + retryButton: __('Retry'), + retryTriggerJob: __('Retry the trigger job'), + retryDownstreamPipeline: __('Retry the downstream pipeline'), + }, + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + components: { + GlButton, + GlDropdown, + GlDropdownItem, + TooltipOnTruncate, + }, + inject: { + buildName: { + type: String, + default: '', + }, + }, + data() { + return { + isSidebarExpanded: true, + }; + }, + created() { + window.addEventListener('resize', this.onResize); + }, + mounted() { + this.onResize(); + }, + methods: { + toggleSidebar() { + this.isSidebarExpanded = !this.isSidebarExpanded; + }, + onResize() { + const breakpoint = bp.getBreakpointSize(); + if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { + this.isSidebarExpanded = false; + } else if (!this.isSidebarExpanded) { + this.isSidebarExpanded = true; + } + }, + }, +}; +</script> +<template> + <aside + class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden" + :style="this.$options.styles" + :class="{ + 'gl-display-none': !isSidebarExpanded, + }" + > + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="buildName" truncate-target="child" + ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate"> + {{ buildName }} + </h4> + </tooltip-on-truncate> + <!-- TODO: implement retry actions --> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-dropdown + :text="$options.i18n.retryButton" + category="primary" + variant="confirm" + right + size="medium" + > + <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item> + <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item> + </gl-dropdown> + </div> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + data-testid="sidebar-expansion-toggle" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + <!-- TODO: get job details and show commit block, stage dropdown, jobs list --> + </aside> +</template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 6105299e15c..97141a27a5e 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -5,7 +5,7 @@ import { __, s__, sprintf } from '~/locale'; export default { i18n: { - eraseLogButtonLabel: s__('Job|Erase job log'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), showRawButtonLabel: s__('Job|Show complete raw'), diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 1b50006239c..9aa1503c7c3 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -2,7 +2,7 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_SIDEBAR } from '../constants'; import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index d90377029c5..5451cd21c14 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -20,6 +20,9 @@ export default { duration() { return timeIntervalInWords(this.job.duration); }, + durationTitle() { + return this.job.finished_at ? __('Duration') : __('Elapsed time'); + }, erasedAt() { return this.timeFormatted(this.job.erased_at); }, @@ -76,7 +79,7 @@ export default { <template> <div v-if="shouldRenderBlock"> - <detail-row v-if="job.duration" :value="duration" title="Duration" /> + <detail-row v-if="job.duration" :value="duration" :title="durationTitle" /> <detail-row v-if="job.finished_at" :value="finishedAt" diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 51251c0cacc..7dfa963a857 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -12,6 +12,7 @@ import { JOB_SCHEDULED, PLAY_JOB_CONFIRMATION_MESSAGE, RUN_JOB_NOW_HEADER_TITLE, + FILE_TYPE_ARCHIVE, } from '../constants'; import eventHub from '../event_hub'; import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql'; @@ -58,12 +59,21 @@ export default { }, }, computed: { + hasArtifacts() { + return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE); + }, artifactDownloadPath() { - return this.job.artifacts?.nodes[0]?.downloadPath; + return this.hasArtifacts.downloadPath; }, canReadJob() { return this.job.userPermissions?.readBuild; }, + canUpdateJob() { + return this.job.userPermissions?.updateBuild; + }, + canReadArtifacts() { + return this.job.userPermissions?.readJobArtifacts; + }, isActive() { return this.job.active; }, @@ -86,7 +96,7 @@ export default { return this.job.detailedStatus?.action?.method; }, shouldDisplayArtifacts() { - return this.job.userPermissions?.readJobArtifacts && this.job.artifacts?.nodes.length > 0; + return this.canReadArtifacts && this.hasArtifacts; }, }, methods: { @@ -139,7 +149,7 @@ export default { <template> <gl-button-group> - <template v-if="canReadJob"> + <template v-if="canReadJob && canUpdateJob"> <gl-button v-if="isActive" data-testid="cancel-button" diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue index 71f9397f5f5..1a6d1a341b0 100644 --- a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue @@ -35,10 +35,12 @@ export default { </script> <template> - <div class="gl-text-truncate"> - <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> - {{ pipelineId }} - </gl-link> + <div> + <div class="gl-text-truncate"> + <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> + {{ pipelineId }} + </gl-link> + </div> <div> <span>{{ __('created by') }}</span> <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index e5d1bc01cbf..962979ba573 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; export const GRAPHQL_PAGE_SIZE = 30; @@ -17,6 +18,9 @@ export const DEFAULT = 'default'; /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; +/* Artifact file types */ +export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; + /* i18n */ export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts'); export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); @@ -30,3 +34,66 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`, ); export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); + +/* Table constants */ + +const defaultTableClasses = { + tdClass: 'gl-p-5!', + thClass: DEFAULT_TH_CLASSES, +}; +// eslint-disable-next-line @gitlab/require-i18n-strings +const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; + +export const DEFAULT_FIELDS = [ + { + key: 'status', + label: __('Status'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'job', + label: __('Job'), + ...defaultTableClasses, + columnClass: 'gl-w-20p', + }, + { + key: 'pipeline', + label: __('Pipeline'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'stage', + label: __('Stage'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'name', + label: __('Name'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'duration', + label: __('Duration'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'coverage', + label: __('Coverage'), + tdClass: coverageTdClasses, + thClass: defaultTableClasses.thClass, + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, +]; + +export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index c8763d4767e..88937185a8c 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -7,6 +7,7 @@ query getJobs( $statuses: [CiJobStatus!] ) { project(fullPath: $fullPath) { + id jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { pageInfo { endCursor @@ -18,6 +19,7 @@ query getJobs( artifacts { nodes { downloadPath + fileType } } allowFailure @@ -27,6 +29,7 @@ query getJobs( triggered createdByTag detailedStatus { + id detailsPath group icon @@ -34,6 +37,7 @@ query getJobs( text tooltip action { + id buttonTitle icon method @@ -51,11 +55,13 @@ query getJobs( id path user { + id webPath avatarUrl } } stage { + id name } name @@ -70,6 +76,7 @@ query getJobs( userPermissions { readBuild readJobArtifacts + updateBuild } } } diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index 298c99c4162..f513d2090fa 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,75 +1,17 @@ <script> import { GlTable } from '@gitlab/ui'; -import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; import DurationCell from './cells/duration_cell.vue'; import JobCell from './cells/job_cell.vue'; import PipelineCell from './cells/pipeline_cell.vue'; - -const defaultTableClasses = { - tdClass: 'gl-p-5!', - thClass: DEFAULT_TH_CLASSES, -}; -// eslint-disable-next-line @gitlab/require-i18n-strings -const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; +import { DEFAULT_FIELDS } from './constants'; export default { i18n: { emptyText: s__('Jobs|No jobs to show'), }, - fields: [ - { - key: 'status', - label: __('Status'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'job', - label: __('Job'), - ...defaultTableClasses, - columnClass: 'gl-w-20p', - }, - { - key: 'pipeline', - label: __('Pipeline'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'stage', - label: __('Stage'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'name', - label: __('Name'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'duration', - label: __('Duration'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'coverage', - label: __('Coverage'), - tdClass: coverageTdClasses, - thClass: defaultTableClasses.thClass, - columnClass: 'gl-w-10p', - }, - { - key: 'actions', - label: '', - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - ], components: { ActionsCell, CiBadge, @@ -83,6 +25,11 @@ export default { type: Array, required: true, }, + tableFields: { + type: Array, + required: false, + default: () => DEFAULT_FIELDS, + }, }, methods: { formatCoverage(coverage) { @@ -95,7 +42,7 @@ export default { <template> <gl-table :items="jobs" - :fields="$options.fields" + :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :empty-text="$options.i18n.emptyText" show-empty diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 1fb6a6f9850..e078a6c2319 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import BridgeApp from './bridge/app.vue'; import JobApp from './components/job_app.vue'; import createStore from './store'; -export default () => { - const element = document.getElementById('js-job-vue-app'); - +const initializeJobPage = (element) => { const store = createStore(); // Let's start initializing the store (i.e. fetching data) right away @@ -51,3 +52,35 @@ export default () => { }, }); }; + +const initializeBridgePage = (el) => { + const { buildName, emptyStateIllustrationPath } = el.dataset; + + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + buildName, + emptyStateIllustrationPath, + }, + render(h) { + return h(BridgeApp); + }, + }); +}; + +export default () => { + const jobElement = document.getElementById('js-job-page'); + const bridgeElement = document.getElementById('js-bridge-page'); + + if (jobElement) { + initializeJobPage(jobElement); + } else { + initializeBridgePage(bridgeElement); + } +}; diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue index 1ff0938d086..1ff0938d086 100644 --- a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue +++ b/app/assets/javascripts/labels/components/delete_label_modal.vue diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue index e708cd32fff..e708cd32fff 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/labels/components/promote_label_modal.vue diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/labels/create_label_dropdown.js index 07fe2c7e01f..8c166158a44 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/labels/create_label_dropdown.js @@ -1,8 +1,8 @@ /* eslint-disable func-names */ import $ from 'jquery'; -import Api from './api'; -import { humanize } from './lib/utils/text_utility'; +import Api from '~/api'; +import { humanize } from '~/lib/utils/text_utility'; export default class CreateLabelDropdown { constructor($el, namespacePath, projectPath) { diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/labels/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/issue_show/event_hub.js +++ b/app/assets/javascripts/labels/event_hub.js diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js index 378259eb9c8..ea69e6585e6 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/labels/group_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { __ } from '~/locale'; import { fixTitle, hide } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; const tooltipTitles = { group: __('Unsubscribe at group level'), diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js new file mode 100644 index 00000000000..22a9c0a89c0 --- /dev/null +++ b/app/assets/javascripts/labels/index.js @@ -0,0 +1,137 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Translate from '~/vue_shared/translate'; +import DeleteLabelModal from './components/delete_label_modal.vue'; +import PromoteLabelModal from './components/promote_label_modal.vue'; +import eventHub from './event_hub'; +import GroupLabelSubscription from './group_label_subscription'; +import LabelManager from './label_manager'; +import ProjectLabelSubscription from './project_label_subscription'; + +export function initDeleteLabelModal(optionalProps = {}) { + new Vue({ + render(h) { + return h(DeleteLabelModal, { + props: { + selector: '.js-delete-label-modal-button', + ...optionalProps, + }, + }); + }, + }).$mount(); +} + +export function initLabels() { + if ($('.prioritized-labels').length) { + new LabelManager(); // eslint-disable-line no-new + } + $('.label-subscription').each((i, el) => { + const $el = $(el); + + if ($el.find('.dropdown-group-label').length) { + new GroupLabelSubscription($el); // eslint-disable-line no-new + } else { + new ProjectLabelSubscription($el); // eslint-disable-line no-new + } + }); +} + +export function initLabelIndex() { + Vue.use(Translate); + + initLabels(); + initDeleteLabelModal(); + + const onRequestFinished = ({ labelUrl, successful }) => { + const button = document.querySelector( + `.js-promote-project-label-button[data-url="${labelUrl}"]`, + ); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (labelUrl) => { + const button = document.querySelector( + `.js-promote-project-label-button[data-url="${labelUrl}"]`, + ); + button.setAttribute('disabled', ''); + eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); + }; + + const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); + + return new Vue({ + el: '#js-promote-label-modal', + data() { + return { + modalProps: { + labelTitle: '', + labelColor: '', + labelTextColor: '', + url: '', + groupName: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteLabelModal.props', this.setModalProps); + eventHub.$emit('promoteLabelModal.mounted'); + + promoteLabelButtons.forEach((button) => { + button.removeAttribute('disabled'); + button.addEventListener('click', () => { + this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal'); + eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); + + this.setModalProps({ + labelTitle: button.dataset.labelTitle, + labelColor: button.dataset.labelColor, + labelTextColor: button.dataset.labelTextColor, + url: button.dataset.url, + groupName: button.dataset.groupName, + }); + }); + }); + }, + beforeDestroy() { + eventHub.$off('promoteLabelModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement(PromoteLabelModal, { + props: this.modalProps, + }); + }, + }); +} + +export function initAdminLabels() { + const labelsContainer = document.querySelector('.js-admin-labels-container'); + const pagination = labelsContainer?.querySelector('.gl-pagination'); + const emptyState = document.querySelector('.js-admin-labels-empty-state'); + + function removeLabelSuccessCallback() { + this.closest('li').classList.add('gl-display-none!'); + + const labelsCount = document.querySelectorAll( + 'ul.manage-labels-list li:not(.gl-display-none\\!)', + ).length; + + // display the empty state if there are no more labels + if (labelsCount < 1 && !pagination && emptyState) { + emptyState.classList.remove('gl-display-none'); + labelsContainer.classList.add('gl-display-none'); + } + } + + document.querySelectorAll('.js-remove-label').forEach((row) => { + row.addEventListener('ajax:success', removeLabelSuccessCallback); + }); +} diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/labels/label_manager.js index e0068edbb9b..1927ac6e1ec 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/labels/label_manager.js @@ -3,9 +3,9 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import { dispose } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default class LabelManager { constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels/labels.js index cd8cf0d354c..cd8cf0d354c 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels/labels.js diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 68019a35dbb..9d8ee165df2 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -4,12 +4,12 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions'; +import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import CreateLabelDropdown from './create_label'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { sprintf, __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { sprintf, __ } from '~/locale'; +import CreateLabelDropdown from './create_label_dropdown'; export default class LabelsSelect { constructor(els, options = {}) { @@ -101,7 +101,7 @@ export default class LabelsSelect { if (IS_EE) { /** * For Scoped labels, the last label selected with the - * same key will be applied to the current issueable. + * same key will be applied to the current issuable. * * If these are the labels - priority::1, priority::2; and if * we apply them in the same order, only priority::2 will stick diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js index f7804c2faa4..b2612e9ede0 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/labels/project_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { fixTitle } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; const tooltipTitles = { group: { diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 47ede8cb1bb..47568f0ecff 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -3,7 +3,7 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util const defaultConfig = { // Safely allow SVG <use> tags - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], // Prevent possible XSS attacks with data-* attributes used by @rails/ujs // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a82dad7e2c9..7235b38848c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -735,3 +735,14 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i)); export const isLoggedIn = () => Boolean(window.gon?.current_user_id); + +/** + * This method takes in array of objects with snake_case + * property names and returns a new array of objects with + * camelCase property names + * + * @param {Array[Object]} array - Array to be converted + * @returns {Array[Object]} Converted array + */ +export const convertArrayOfObjectsToCamelCase = (array) => + array.map((o) => convertObjectPropsToCamelCase(o)); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 36c6545164e..a108b02bcbf 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -17,6 +17,7 @@ export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; +export const BV_COLLAPSE_STATE = 'bv::collapse::state'; export const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index f7687a929de..b52a736f153 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -89,3 +89,17 @@ export const getParents = (element) => { return parents; }; + +/** + * This method takes a HTML element and an object of attributes + * to save repeated calls to `setAttribute` when multiple + * attributes need to be set. + * + * @param {HTMLElement} el + * @param {Object} attributes + */ +export const setAttributes = (el, attributes) => { + Object.keys(attributes).forEach((key) => { + el.setAttribute(key, attributes[key]); + }); +}; diff --git a/app/assets/javascripts/lib/utils/intersection_observer.js b/app/assets/javascripts/lib/utils/intersection_observer.js new file mode 100644 index 00000000000..0959df9a186 --- /dev/null +++ b/app/assets/javascripts/lib/utils/intersection_observer.js @@ -0,0 +1,28 @@ +import { memoize } from 'lodash'; + +import { uuids } from './uuids'; + +export const create = memoize((options = {}) => { + const id = uuids()[0]; + + return { + id, + observer: new IntersectionObserver((entries) => { + entries.forEach((entry) => { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionUpdate`, { detail: { entry, observer: id } }), + ); + + if (entry.isIntersecting) { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionAppear`, { detail: { observer: id } }), + ); + } else { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionDisappear`, { detail: { observer: id } }), + ); + } + }); + }, options), + }; +}); diff --git a/app/assets/javascripts/lib/utils/navigation_utility.js b/app/assets/javascripts/lib/utils/navigation_utility.js index 1579b225e44..029e9f5fd9f 100644 --- a/app/assets/javascripts/lib/utils/navigation_utility.js +++ b/app/assets/javascripts/lib/utils/navigation_utility.js @@ -13,3 +13,42 @@ export default function findAndFollowLink(selector) { visitUrl(link); } } + +export function prefetchDocument(url) { + const newPrefetchLink = document.createElement('link'); + newPrefetchLink.rel = 'prefetch'; + newPrefetchLink.href = url; + newPrefetchLink.setAttribute('as', 'document'); + document.head.appendChild(newPrefetchLink); +} + +export function initPrefetchLinks(selector) { + document.querySelectorAll(selector).forEach((el) => { + let mouseOverTimer; + + const mouseOutHandler = () => { + if (mouseOverTimer) { + clearTimeout(mouseOverTimer); + mouseOverTimer = undefined; + } + }; + + const mouseOverHandler = () => { + el.addEventListener('mouseout', mouseOutHandler, { once: true, passive: true }); + + mouseOverTimer = setTimeout(() => { + if (el.href) prefetchDocument(el.href); + + // Only execute once + el.removeEventListener('mouseover', mouseOverHandler, true); + + mouseOverTimer = undefined; + }, 100); + }; + + el.addEventListener('mouseover', mouseOverHandler, { + capture: true, + passive: true, + }); + }); +} diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index e53a39cde06..12462a2575e 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,6 +1,6 @@ export const DASH_SCOPE = '-'; -const PATH_SEPARATOR = '/'; +export const PATH_SEPARATOR = '/'; const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); const SHA_REGEX = /[\da-f]{40}/gi; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e422d9b1a32..e221a54d9c6 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -14,9 +14,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; -import { initHeaderSearchApp } from '~/header_search'; +import { initPrefetchLinks } from '~/lib/utils/navigation_utility'; import initAlertHandler from './alert_handler'; -import { removeFlashClickListener } from './flash'; +import { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { logHelloDeferred } from './lib/logger/hello_deferred'; @@ -36,6 +36,7 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; +import { initCopyCodeButton } from './behaviors/copy_code'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; @@ -90,6 +91,7 @@ function deferredInitialisation() { initTopNav(); initBreadcrumbs(); initTodoToggle(); + initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); initServicePingConsent(); initUserPopovers(); @@ -97,25 +99,31 @@ function deferredInitialisation() { initPersistentUserCallouts(); initDefaultTrackers(); initFeatureHighlight(); - - if (gon.features?.newHeaderSearch) { - initHeaderSearchApp(); - } else { - const search = document.querySelector('#search'); - if (search) { - search.addEventListener( - 'focus', - () => { + initCopyCodeButton(); + + const search = document.querySelector('#search'); + if (search) { + search.addEventListener( + 'focus', + () => { + if (gon.features?.newHeaderSearch) { + import(/* webpackChunkName: 'globalSearch' */ '~/header_search') + .then(async ({ initHeaderSearchApp }) => { + await initHeaderSearchApp(); + document.querySelector('#search').focus(); + }) + .catch(() => {}); + } else { import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete') .then(({ default: initSearchAutocomplete }) => { const searchDropdown = initSearchAutocomplete(); searchDropdown.onSearchInputFocus(); }) .catch(() => {}); - }, - { once: true }, - ); - } + } + }, + { once: true }, + ); } addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -259,7 +267,7 @@ if (flashContainer && flashContainer.children.length) { flashContainer .querySelectorAll('.flash-alert, .flash-notice, .flash-success') .forEach((flashEl) => { - removeFlashClickListener(flashEl); + addDismissFlashClickListener(flashEl); }); } diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index 35966be7363..d092283338c 100644 --- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue @@ -53,6 +53,7 @@ export default { :title="s__('Member|Deny access')" :is-access-request="true" icon="close" + button-category="primary" /> </div> </action-button-group> diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 91062c222f4..ab9abfd38c6 100644 --- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -41,6 +41,8 @@ export default { <remove-member-button :member-id="member.id" :message="message" + icon="remove" + button-category="primary" :title="s__('Member|Revoke invite')" is-invite /> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index 69137ce615b..01606d07554 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -30,7 +30,17 @@ export default { icon: { type: String, required: false, - default: 'remove', + default: undefined, + }, + buttonText: { + type: String, + required: false, + default: '', + }, + buttonCategory: { + type: String, + required: false, + default: 'secondary', }, isAccessRequest: { type: Boolean, @@ -79,10 +89,12 @@ export default { <gl-button v-gl-tooltip variant="danger" + :category="buttonCategory" :title="title" :aria-label="title" :icon="icon" data-qa-selector="delete_member_button" @click="showRemoveMemberModal(modalData)" - /> + ><template v-if="buttonText">{{ buttonText }}</template></gl-button + > </template> diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 44d658c90a0..594da7f68cc 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -1,5 +1,5 @@ <script> -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import ActionButtonGroup from './action_button_group.vue'; import LeaveButton from './leave_button.vue'; @@ -23,6 +23,10 @@ export default { type: Boolean, required: true, }, + isInvitedUser: { + type: Boolean, + required: true, + }, permissions: { type: Object, required: true, @@ -56,6 +60,15 @@ export default { obstacles: parseUserDeletionObstacles(this.member.user), }; }, + removeMemberButtonText() { + return this.isInvitedUser ? null : __('Remove user'); + }, + removeMemberButtonIcon() { + return this.isInvitedUser ? 'remove' : ''; + }, + removeMemberButtonCategory() { + return this.isInvitedUser ? 'primary' : 'secondary'; + }, }, }; </script> @@ -70,6 +83,9 @@ export default { :member-type="member.type" :user-deletion-obstacles="userDeletionObstaclesUserData" :message="message" + :icon="removeMemberButtonIcon" + :button-text="removeMemberButtonText" + :button-category="removeMemberButtonCategory" :title="s__('Member|Remove member')" /> </div> diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index 6f15f079d2d..971b1a8435e 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -30,6 +30,10 @@ export default { type: Boolean, required: true, }, + isInvitedUser: { + type: Boolean, + required: true, + }, }, computed: { actionButtonComponent() { @@ -53,5 +57,6 @@ export default { :member="member" :permissions="permissions" :is-current-user="isCurrentUser" + :is-invited-user="isInvitedUser" /> </template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 202f3aa89e1..de733ae75df 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -8,6 +8,7 @@ import initUserPopovers from '~/user_popovers'; import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME, + TAB_QUERY_PARAM_VALUES, MEMBER_STATE_AWAITING, USER_STATE_BLOCKED_PENDING_APPROVAL, BADGE_LABELS_PENDING_OWNER_APPROVAL, @@ -82,6 +83,9 @@ export default { return paramName && currentPage && perPage && totalItems; }, + isInvitedUser() { + return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite; + }, }, mounted() { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); @@ -275,6 +279,7 @@ export default { <member-action-buttons :member-type="memberType" :is-current-user="isCurrentUser" + :is-invited-user="isInvitedUser" :permissions="permissions" :member="member" /> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index cf02c6fbd6b..8c96f8a017e 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import initIssuableSidebar from '../init_issuable_sidebar'; +import { initIssuableSidebar } from '~/issuable'; import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue'; import { createStore } from './store'; diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js deleted file mode 100644 index b4e53c1fab6..00000000000 --- a/app/assets/javascripts/milestone.js +++ /dev/null @@ -1,49 +0,0 @@ -import $ from 'jquery'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; - -export default class Milestone { - constructor() { - this.bindTabsSwitching(); - this.loadInitialTab(); - } - - bindTabsSwitching() { - return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { - const $target = $(e.target); - - window.location.hash = $target.attr('href'); - this.loadTab($target); - }); - } - - loadInitialTab() { - const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); - - if ($target.length) { - $target.tab('show'); - } else { - this.loadTab($('.js-milestone-tabs a.active')); - } - } - // eslint-disable-next-line class-methods-use-this - loadTab($target) { - const endpoint = $target.data('endpoint'); - const tabElId = $target.attr('href'); - - if (endpoint && !$target.hasClass('is-loaded')) { - axios - .get(endpoint) - .then(({ data }) => { - $(tabElId).html(data.html); - $target.addClass('is-loaded'); - }) - .catch(() => - createFlash({ - message: __('Error loading milestone tab'), - }), - ); - } - } -} diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index 34f9fe778ea..34f9fe778ea 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue index b41611001ab..b41611001ab 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/milestones/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/pages/milestones/shared/event_hub.js +++ b/app/assets/javascripts/milestones/event_hub.js diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/milestones/index.js index 3aeff2db2e0..2ca5f104b4f 100644 --- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js +++ b/app/assets/javascripts/milestones/index.js @@ -1,10 +1,58 @@ +import $ from 'jquery'; import Vue from 'vue'; +import initDatePicker from '~/behaviors/date_picker'; +import GLForm from '~/gl_form'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Milestone from '~/milestones/milestone'; +import Sidebar from '~/right_sidebar'; +import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; import Translate from '~/vue_shared/translate'; +import ZenMode from '~/zen_mode'; import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; import eventHub from './event_hub'; -export default () => { +export function initForm(initGFM = true) { + new ZenMode(); // eslint-disable-line no-new + initDatePicker(); + + // eslint-disable-next-line no-new + new GLForm($('.milestone-form'), { + emojis: true, + members: initGFM, + issues: initGFM, + mergeRequests: initGFM, + epics: initGFM, + milestones: initGFM, + labels: initGFM, + snippets: initGFM, + vulnerabilities: initGFM, + }); +} + +export function initShow() { + new Milestone(); // eslint-disable-line no-new + new Sidebar(); // eslint-disable-line no-new + new MountMilestoneSidebar(); // eslint-disable-line no-new +} + +export function initPromoteMilestoneModal() { + Vue.use(Translate); + + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + if (!promoteMilestoneModal) { + return null; + } + + return new Vue({ + el: promoteMilestoneModal, + render(createElement) { + return createElement(PromoteMilestoneModal); + }, + }); +} + +export function initDeleteMilestoneModal() { Vue.use(Translate); const onRequestFinished = ({ milestoneUrl, successful }) => { @@ -72,4 +120,4 @@ export default () => { }); }, }); -}; +} diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js new file mode 100644 index 00000000000..05102f73f92 --- /dev/null +++ b/app/assets/javascripts/milestones/milestone.js @@ -0,0 +1,49 @@ +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; + +export default class Milestone { + constructor() { + this.tabsEl = document.querySelector('.js-milestone-tabs'); + this.glTabs = new GlTabsBehavior(this.tabsEl); + this.loadedTabs = new WeakSet(); + + this.bindTabsSwitching(); + this.loadInitialTab(); + } + + bindTabsSwitching() { + this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => { + const tab = event.target; + const { activeTabPanel } = event.detail; + historyPushState(tab.getAttribute('href')); + this.loadTab(tab, activeTabPanel); + }); + } + + loadInitialTab() { + const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`); + this.glTabs.activateTab(tab || this.glTabs.activeTab); + } + loadTab(tab, tabPanel) { + const { endpoint } = tab.dataset; + + if (endpoint && !this.loadedTabs.has(tab)) { + axios + .get(endpoint) + .then(({ data }) => { + // eslint-disable-next-line no-param-reassign + tabPanel.innerHTML = sanitize(data.html); + this.loadedTabs.add(tab); + }) + .catch(() => + createFlash({ + message: __('Error loading milestone tab'), + }), + ); + } + } +} diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js index aa8a40b6a87..c95ec3dd10b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestones/milestone_select.js @@ -6,9 +6,9 @@ import { template, escape } from 'lodash'; import Api from '~/api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { __, sprintf } from '~/locale'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; -import axios from './lib/utils/axios_utils'; -import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; +import axios from '~/lib/utils/axios_utils'; +import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; export default class MilestoneSelect { constructor(currentProject, els, options = {}) { diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/utils.js index 3ae5e676138..3ae5e676138 100644 --- a/app/assets/javascripts/milestones/milestone_utils.js +++ b/app/assets/javascripts/milestones/utils.js diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql index 302383512d3..a61d601cd34 100644 --- a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql @@ -7,6 +7,7 @@ query getDashboardValidationWarnings( id environments(name: $environmentName) { nodes { + id name metricsDashboard(path: $dashboardPath) { path diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index 791fdf7660f..d99a3adb358 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -65,6 +65,9 @@ export default { return humanMRStates.open; } }, + title() { + return this.mergeRequest?.title || this.mergeRequestTitle; + }, showDetails() { return Object.keys(this.mergeRequest).length > 0; }, @@ -89,7 +92,7 @@ export default { <template> <gl-popover :target="target" boundary="viewport" placement="top" show> <div class="mr-popover"> - <div v-if="$apollo.loading"> + <div v-if="$apollo.queries.mergeRequest.loading"> <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" /> </div> <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between"> @@ -97,13 +100,13 @@ export default { <div :class="`issuable-status-box status-box ${statusBoxClass}`"> {{ stateHumanName }} </div> - <span class="text-secondary">Opened <time v-text="formattedTime"></time></span> + <span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span> </div> <ci-icon v-if="detailedStatus" :status="detailedStatus" /> </div> - <h5 class="my-2">{{ mergeRequestTitle }}</h5> + <h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <div class="text-secondary"> + <div class="gl-text-secondary"> {{ `${projectPath}!${mergeRequestIID}` }} </div> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql index 37d4bc88a69..b3e5d89d495 100644 --- a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql +++ b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql @@ -1,10 +1,15 @@ query mergeRequest($projectPath: ID!, $mergeRequestIID: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $mergeRequestIID) { + id + title createdAt state headPipeline { + id detailedStatus { + id icon group } diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 54fe9d19002..71894b4ff3e 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -98,6 +98,7 @@ export default class BranchGraph { let len = 0; let cuday = 0; let cumonth = ''; + let cuyear = ''; const { r } = this; r.rect(0, 0, 40, this.barHeight).attr({ fill: '#222', @@ -108,24 +109,21 @@ export default class BranchGraph { const ref = this.days; for (mm = 0, len = ref.length; mm < len; mm += 1) { const day = ref[mm]; - if (cuday !== day[0] || cumonth !== day[1]) { + if (cuday !== day[0] || cumonth !== day[1] || cuyear !== day[2]) { // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ font: '12px Monaco, monospace', fill: '#BBB', }); - [cuday] = day; } - if (cumonth !== day[1]) { + if (cumonth !== day[1] || cuyear !== day[2]) { // Months r.text(20, this.offsetY + this.unitTime * mm, day[1]).attr({ font: '12px Monaco, monospace', fill: '#EEE', }); - - // eslint-disable-next-line prefer-destructuring - cumonth = day[1]; } + [cuday, cumonth, cuyear] = day; } this.renderPartialGraph(); return this.bindEvents(); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4e31fdcd4f0..996c008b881 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -11,7 +11,6 @@ import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, convertToCamelCase, - splitCamelCase, slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; @@ -77,7 +76,15 @@ export default { ]), ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { - return splitCamelCase(this.noteableType).toLowerCase(); + const displayNameMap = { + [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue, + [constants.EPIC_NOTEABLE_TYPE]: this.$options.i18n.epic, + [constants.MERGE_REQUEST_NOTEABLE_TYPE]: this.$options.i18n.mergeRequest, + }; + + const noteableTypeKey = + constants.NOTEABLE_TYPE_MAPPING[this.noteableType] || constants.ISSUE_NOTEABLE_TYPE; + return displayNameMap[noteableTypeKey]; }, isLoggedIn() { return this.getUserData.id; @@ -103,15 +110,13 @@ export default { const openOrClose = this.isOpen ? 'close' : 'reopen'; if (this.note.length) { - return sprintf(this.$options.i18n.actionButtonWithNote, { + return sprintf(this.$options.i18n.actionButton.withNote[openOrClose], { actionText: this.commentButtonTitle, - openOrClose, noteable: this.noteableDisplayName, }); } - return sprintf(this.$options.i18n.actionButton, { - openOrClose: capitalizeFirstCharacter(openOrClose), + return sprintf(this.$options.i18n.actionButton.withoutNote[openOrClose], { noteable: this.noteableDisplayName, }); }, @@ -151,13 +156,8 @@ export default { draftEndpoint() { return this.getNotesData.draftsPath; }, - issuableTypeTitle() { - return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ? this.$options.i18n.mergeRequest - : this.$options.i18n.issue; - }, isIssue() { - return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; + return constants.NOTEABLE_TYPE_MAPPING[this.noteableType] === constants.ISSUE_NOTEABLE_TYPE; }, trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); @@ -329,7 +329,7 @@ export default { <template> <div> <note-signed-out-widget v-if="!isLoggedIn" /> - <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> + <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="noteableDisplayName" /> <ul v-else-if="canCreateNote" class="notes notes-form timeline"> <timeline-entry-item class="note-form"> <gl-alert diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index b04aa74d46e..b2d5910fd3f 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,5 +1,8 @@ <script> -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; @@ -17,6 +20,9 @@ export default { DiffViewer, ImageDiffOverlay, }, + directives: { + SafeHtml, + }, props: { discussion: { type: Object, @@ -92,11 +98,7 @@ export default { > <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td> <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td> - <td - :class="line.type" - class="line_content" - v-html="trimChar(line.rich_text) /* eslint-disable-line vue/no-v-html */" - ></td> + <td v-safe-html="trimChar(line.rich_text)" :class="line.type" class="line_content"></td> </tr> </template> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 88f053aed67..102afaf308f 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -39,7 +39,7 @@ export default { }; }, computed: { - ...mapGetters(['getNotesDataByProp', 'timelineEnabled']), + ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']), currentFilter() { if (!this.currentValue) return this.filters[0]; return this.filters.find((filter) => filter.value === this.currentValue); @@ -119,6 +119,7 @@ export default { class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container" data-qa-selector="discussion_filter_dropdown" :text="currentFilter.title" + :disabled="isLoading" > <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> <gl-dropdown-item diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index 2f215e36d5b..8ac3f6bea68 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -1,7 +1,6 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import Issuable from '~/vue_shared/mixins/issuable'; import issuableStateMixin from '../mixins/issuable_state'; export default { @@ -9,8 +8,17 @@ export default { GlIcon, GlLink, }, - mixins: [Issuable, issuableStateMixin], + mixins: [issuableStateMixin], + props: { + issuableType: { + required: true, + type: String, + }, + }, computed: { + issuableDisplayName() { + return this.issuableType.replace(/_/g, ' '); + }, projectArchivedWarning() { return __('This project is archived and cannot be commented on.'); }, diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index d1df4eb848b..6fcfa66ea49 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,6 +1,5 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlIntersectionObserver } from '@gitlab/ui'; import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; @@ -17,9 +16,7 @@ export default { ToggleRepliesWidget, NoteEditedText, DiscussionNotesRepliesWrapper, - GlIntersectionObserver, }, - inject: ['discussionObserverHandler'], props: { discussion: { type: Object, @@ -57,11 +54,7 @@ export default { }, }, computed: { - ...mapGetters([ - 'userCanReply', - 'previousUnresolvedDiscussionId', - 'firstUnresolvedDiscussionId', - ]), + ...mapGetters(['userCanReply']), hasReplies() { return Boolean(this.replies.length); }, @@ -84,20 +77,9 @@ export default { url: this.discussion.discussion_path, }; }, - isFirstUnresolved() { - return this.firstUnresolvedDiscussionId === this.discussion.id; - }, - }, - observerOptions: { - threshold: 0, - rootMargin: '0px 0px -50% 0px', }, methods: { - ...mapActions([ - 'toggleDiscussion', - 'setSelectedCommentPositionHover', - 'setCurrentDiscussionId', - ]), + ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -128,18 +110,6 @@ export default { this.setSelectedCommentPositionHover(); } }, - observerTriggered(entry) { - this.discussionObserverHandler({ - entry, - isFirstUnresolved: this.isFirstUnresolved, - currentDiscussion: { ...this.discussion }, - isDiffsPage: !this.isOverviewTab, - functions: { - setCurrentDiscussionId: this.setCurrentDiscussionId, - getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId, - }, - }); - }, }, }; </script> @@ -152,35 +122,33 @@ export default { @mouseleave="handleMouseLeave(discussion)" > <template v-if="shouldGroupReplies"> - <gl-intersection-observer :options="$options.observerOptions" @update="observerTriggered"> - <component - :is="componentName(firstNote)" - :note="componentData(firstNote)" - :line="line || diffLine" - :discussion-file="discussion.diff_file" - :commit="commit" - :help-page-path="helpPagePath" - :show-reply-button="userCanReply" - :discussion-root="true" - :discussion-resolve-path="discussion.resolve_path" - :is-overview-tab="isOverviewTab" - @handleDeleteNote="$emit('deleteNote')" - @startReplying="$emit('startReplying')" - > - <template #discussion-resolved-text> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" - /> - </template> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </component> - </gl-intersection-observer> + <component + :is="componentName(firstNote)" + :note="componentData(firstNote)" + :line="line || diffLine" + :discussion-file="discussion.diff_file" + :commit="commit" + :help-page-path="helpPagePath" + :show-reply-button="userCanReply" + :discussion-root="true" + :discussion-resolve-path="discussion.resolve_path" + :is-overview-tab="isOverviewTab" + @handleDeleteNote="$emit('deleteNote')" + @startReplying="$emit('startReplying')" + > + <template #discussion-resolved-text> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> + </template> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> + </component> <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> <toggle-replies-widget v-if="hasReplies" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index c09582d6287..f465ad23a06 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -149,7 +149,7 @@ export default { }, }, safeHtmlConfig: { - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 77f796fe8b0..8e32c3b3073 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -223,17 +223,20 @@ export default { }) .catch((err) => { this.removePlaceholderNotes(); - const msg = __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ); - createFlash({ - message: msg, - parent: this.$el, - }); + this.handleSaveError(err); // The 'err' parameter is being used in JH, don't remove it this.$refs.noteForm.note = noteText; callback(err); }); }, + handleSaveError() { + const msg = __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); + createFlash({ + message: msg, + parent: this.$el, + }); + }, deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); }, @@ -280,6 +283,7 @@ export default { v-if="showDraft(discussion.reply_id)" :key="`draft_${discussion.id}`" :draft="draftForDiscussion(discussion.reply_id)" + :line="line" /> <div v-else-if="canShowReplyActions && showReplies" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index e35d8d94289..3250a4818c7 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -331,17 +331,20 @@ export default { this.isEditing = true; this.setSelectedCommentPositionHover(); this.$nextTick(() => { - const msg = __('Something went wrong while editing your comment. Please try again.'); - createFlash({ - message: msg, - parent: this.$el, - }); + this.handleUpdateError(response); // The 'response' parameter is being used in JH, don't remove it this.recoverNoteContent(noteText); callback(); }); } }); }, + handleUpdateError() { + const msg = __('Something went wrong while editing your comment. Please try again.'); + createFlash({ + message: msg, + parent: this.$el, + }); + }, formCancelHandler({ shouldConfirm, isDirty }) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert @@ -388,7 +391,7 @@ export default { <div v-if="showMultiLineComment" data-testid="multiline-comment" - class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + class="gl-mb-5 gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-pb-4" > <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <template #startLine> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 3ab3e7a20d4..c4924cd41f5 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -8,7 +8,6 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import draftNote from '../../batch_comments/components/draft_note.vue'; -import { discussionIntersectionObserverHandlerFactory } from '../../diffs/utils/discussions'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; @@ -39,9 +38,6 @@ export default { TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, props: { noteableData: { type: Object, @@ -108,6 +104,10 @@ export default { }); } + if (this.sortDirDesc) { + return skeletonNotes.concat(this.discussions); + } + return this.discussions.concat(skeletonNotes); }, canReply() { diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 047c04c8482..52dadc7b4c3 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; import { defaultClient as gqlClient } from '~/sidebar/graphql'; diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 1ffb94d11ad..951fa9733d4 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -9,15 +9,27 @@ export const COMMENT_FORM = { issue: __('issue'), startThread: __('Start thread'), mergeRequest: __('merge request'), + epic: __('epic'), bodyPlaceholder: __('Write a comment or drag your files here…'), confidential: s__('Notes|Make this comment confidential'), - confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'), + confidentialVisibility: s__( + 'Notes|Confidential comments are only visible to members with the role of Reporter or higher', + ), discussionThatNeedsResolution: __( 'Discuss a specific suggestion or question that needs to be resolved.', ), discussion: __('Discuss a specific suggestion or question.'), actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'), - actionButton: __('%{openOrClose} %{noteable}'), + actionButton: { + withNote: { + reopen: __('%{actionText} & reopen %{noteable}'), + close: __('%{actionText} & close %{noteable}'), + }, + withoutNote: { + reopen: __('Reopen %{noteable}'), + close: __('Close %{noteable}'), + }, + }, submitButton: { startThread: __('Start thread'), comment: __('Comment'), diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index c862a29ad9c..50b05ea9d69 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -601,7 +601,8 @@ export const setLoadingState = ({ commit }, data) => { commit(types.SET_NOTES_LOADING_STATE, data); }; -export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) => { +export const filterDiscussion = ({ commit, dispatch }, { path, filter, persistFilter }) => { + commit(types.CLEAR_DISCUSSIONS); dispatch('setLoadingState', true); dispatch('fetchDiscussions', { path, filter, persistFilter }) .then(() => { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index fcd2846ff0d..ebda08a3d62 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -1,6 +1,7 @@ export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; export const ADD_OR_UPDATE_DISCUSSIONS = 'ADD_OR_UPDATE_DISCUSSIONS'; +export const CLEAR_DISCUSSIONS = 'CLEAR_DISCUSSIONS'; export const DELETE_NOTE = 'DELETE_NOTE'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_DATA'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 1a99750ddb3..ba19ecd0c04 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -129,6 +129,10 @@ export default { Object.assign(state, { userData: data }); }, + [types.CLEAR_DISCUSSIONS](state) { + state.discussions = []; + }, + [types.ADD_OR_UPDATE_DISCUSSIONS](state, discussionsData) { discussionsData.forEach((d) => { const discussion = { ...d }; diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js deleted file mode 100644 index 2911cf70a33..00000000000 --- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import PackagesListApp from './components/packages_list_app.vue'; -import { createStore } from './stores'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-packages-list'); - const store = createStore(); - store.dispatch('setInitialState', el.dataset); - - return new Vue({ - el, - store, - components: { - PackagesListApp, - }, - render(createElement) { - return createElement('packages-list-app'); - }, - }); -}; diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js deleted file mode 100644 index c284b8358b4..00000000000 --- a/app/assets/javascripts/packages/shared/constants.js +++ /dev/null @@ -1,49 +0,0 @@ -import { s__ } from '~/locale'; - -export const PackageType = { - CONAN: 'conan', - MAVEN: 'maven', - NPM: 'npm', - NUGET: 'nuget', - PYPI: 'pypi', - COMPOSER: 'composer', - RUBYGEMS: 'rubygems', - GENERIC: 'generic', - DEBIAN: 'debian', - HELM: 'helm', -}; - -// we want this separated from the main dictionary to avoid it being pulled in the search of package -export const TERRAFORM_PACKAGE_TYPE = 'terraform_module'; - -export const TrackingActions = { - DELETE_PACKAGE: 'delete_package', - REQUEST_DELETE_PACKAGE: 'request_delete_package', - CANCEL_DELETE_PACKAGE: 'cancel_delete_package', - PULL_PACKAGE: 'pull_package', - DELETE_PACKAGE_FILE: 'delete_package_file', - REQUEST_DELETE_PACKAGE_FILE: 'request_delete_package_file', - CANCEL_DELETE_PACKAGE_FILE: 'cancel_delete_package_file', -}; - -export const TrackingCategories = { - [PackageType.MAVEN]: 'MavenPackages', - [PackageType.NPM]: 'NpmPackages', - [PackageType.CONAN]: 'ConanPackages', -}; - -export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; -export const DELETE_PACKAGE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package.', -); -export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', -); -export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', -); - -export const PACKAGE_ERROR_STATUS = 'error'; -export const PACKAGE_DEFAULT_STATUS = 'default'; -export const PACKAGE_HIDDEN_STATUS = 'hidden'; -export const PACKAGE_PROCESSING_STATUS = 'processing'; diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js deleted file mode 100644 index 7e86e5b2991..00000000000 --- a/app/assets/javascripts/packages/shared/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -import { s__ } from '~/locale'; -import { PackageType, TrackingCategories } from './constants'; - -export const packageTypeToTrackCategory = (type) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `UI::${TrackingCategories[type]}`; - -export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); - -export const getPackageTypeLabel = (packageType) => { - switch (packageType) { - case PackageType.CONAN: - return s__('PackageRegistry|Conan'); - case PackageType.MAVEN: - return s__('PackageRegistry|Maven'); - case PackageType.NPM: - return s__('PackageRegistry|npm'); - case PackageType.NUGET: - return s__('PackageRegistry|NuGet'); - case PackageType.PYPI: - return s__('PackageRegistry|PyPI'); - case PackageType.RUBYGEMS: - return s__('PackageRegistry|RubyGems'); - case PackageType.COMPOSER: - return s__('PackageRegistry|Composer'); - case PackageType.GENERIC: - return s__('PackageRegistry|Generic'); - case PackageType.DEBIAN: - return s__('PackageRegistry|Debian'); - case PackageType.HELM: - return s__('PackageRegistry|Helm'); - default: - return null; - } -}; - -export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { - if (isGroup) { - return `/${projectPath}/commit/${pipeline.sha}`; - } - - return `../commit/${pipeline.sha}`; -}; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue index f857c96c9d1..7a8a1bbcf09 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue @@ -82,6 +82,7 @@ export default { ref="deleteModal" modal-id="delete-tag-modal" ok-variant="danger" + size="sm" :action-primary="{ text: __('Delete'), attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }], diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index e9e36151fe6..d988ad8d8ca 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -46,7 +46,6 @@ export default { data() { return { containerRepository: {}, - fetchTagsCount: false, }; }, apollo: { 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 3e19a646f53..2d32295b537 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 @@ -1,7 +1,8 @@ <script> -import { GlButton, GlKeysetPagination } from '@gitlab/ui'; import createFlash from '~/flash'; +import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, @@ -16,11 +17,10 @@ import TagsLoader from './tags_loader.vue'; export default { name: 'TagsList', components: { - GlButton, - GlKeysetPagination, TagsListRow, EmptyState, TagsLoader, + RegistryList, }, inject: ['config'], props: { @@ -61,11 +61,13 @@ export default { }, data() { return { - selectedItems: {}, containerRepository: {}, }; }, computed: { + listTitle() { + return n__('%d tag', '%d tags', this.tags.length); + }, tags() { return this.containerRepository?.tags?.nodes || []; }, @@ -78,18 +80,9 @@ export default { first: GRAPHQL_PAGE_SIZE, }; }, - hasSelectedItems() { - return this.tags.some((tag) => this.selectedItems[tag.name]); - }, showMultiDeleteButton() { return this.tags.some((tag) => tag.canDelete) && !this.isMobile; }, - multiDeleteButtonIsDisabled() { - return !this.hasSelectedItems || this.disabled; - }, - showPagination() { - return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; - }, hasNoTags() { return this.tags.length === 0; }, @@ -98,19 +91,13 @@ export default { }, }, methods: { - updateSelectedItems(name) { - this.$set(this.selectedItems, name, !this.selectedItems[name]); - }, - mapTagsToBeDleeted(items) { - return this.tags.filter((tag) => items[tag.name]); - }, fetchNextPage() { this.$apollo.queries.containerRepository.fetchMore({ variables: { after: this.tagsPageInfo?.endCursor, first: GRAPHQL_PAGE_SIZE, }, - updateQuery(previousResult, { fetchMoreResult }) { + updateQuery(_, { fetchMoreResult }) { return fetchMoreResult; }, }); @@ -122,7 +109,7 @@ export default { before: this.tagsPageInfo?.startCursor, last: GRAPHQL_PAGE_SIZE, }, - updateQuery(previousResult, { fetchMoreResult }) { + updateQuery(_, { fetchMoreResult }) { return fetchMoreResult; }, }); @@ -137,42 +124,27 @@ export default { <template v-else> <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> <template v-else> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> - <h5 data-testid="list-title"> - {{ $options.i18n.TAGS_LIST_TITLE }} - </h5> - - <gl-button - v-if="showMultiDeleteButton" - :disabled="multiDeleteButtonIsDisabled" - category="secondary" - variant="danger" - @click="$emit('delete', mapTagsToBeDleeted(selectedItems))" - > - {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} - </gl-button> - </div> - <tags-list-row - v-for="(tag, index) in tags" - :key="tag.path" - :tag="tag" - :first="index === 0" - :selected="selectedItems[tag.name]" - :is-mobile="isMobile" - :disabled="disabled" - @select="updateSelectedItems(tag.name)" - @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))" - /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - :has-next-page="tagsPageInfo.hasNextPage" - :has-previous-page="tagsPageInfo.hasPreviousPage" - class="gl-mt-3" - @prev="fetchPreviousPage" - @next="fetchNextPage" - /> - </div> + <registry-list + :title="listTitle" + :pagination="tagsPageInfo" + :items="tags" + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + @delete="$emit('delete', $event)" + > + <template #default="{ selectItem, isSelected, item, first }"> + <tags-list-row + :tag="item" + :first="first" + :selected="isSelected(item)" + :is-mobile="isMobile" + :disabled="disabled" + @select="selectItem(item)" + @delete="$emit('delete', [item])" + /> + </template> + </registry-list> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql index 01cb7fa1cab..bc34e9b5ef2 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql @@ -9,6 +9,7 @@ query getContainerRepositoriesDetails( $sort: ContainerRepositorySort ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { + id containerRepositories( name: $name after: $after @@ -24,6 +25,7 @@ query getContainerRepositoriesDetails( } } group(fullPath: $fullPath) @include(if: $isGroupPage) { + id containerRepositories( name: $name after: $after diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index b5a99fd9ac1..916740f41b8 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -11,6 +11,7 @@ query getContainerRepositoryDetails($id: ID!) { expirationPolicyStartedAt expirationPolicyCleanupStatus project { + id visibility path containerExpirationPolicy { diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index a703c2dd0ac..502382010f9 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -9,6 +9,7 @@ query getContainerRepositoryTags( ) { containerRepository(id: $id) { id + tagsCount tags(after: $after, before: $before, first: $first, last: $last) { nodes { digest 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 feabc4f770b..bc6e3091f0e 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 @@ -25,9 +25,11 @@ import { UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, ROOT_IMAGE_TEXT, + GRAPHQL_PAGE_SIZE, } from '../constants/index'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; +import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; export default { name: 'RegistryDetailsPage', @@ -133,8 +135,8 @@ export default { awaitRefetchQueries: true, refetchQueries: [ { - query: getContainerRepositoryDetailsQuery, - variables: this.queryVariables, + query: getContainerRepositoryTagsQuery, + variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, }, ], }); 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 73b957f42f2..3274de05803 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 @@ -388,6 +388,7 @@ export default { <template #default="{ doDelete }"> <gl-modal ref="deleteModal" + size="sm" modal-id="delete-image-modal" :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }" @primary="doDelete" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 71e8cf4f634..eb112238c11 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -1,11 +1,11 @@ <script> import { GlAlert, + GlEmptyState, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf, - GlEmptyState, } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -36,15 +36,15 @@ export default { proxyNotAvailableText: s__( 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', ), - proxyDisabledText: s__( - 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.', - ), proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), pageTitle: s__('DependencyProxy|Dependency Proxy'), noManifestTitle: s__('DependencyProxy|There are no images in the cache'), }, + links: { + DEPENDENCY_PROXY_DOCS_PATH, + }, data() { return { group: {}, @@ -70,9 +70,7 @@ export default { }, ]; }, - dependencyProxyEnabled() { - return this.group?.dependencyProxySetting?.enabled; - }, + queryVariables() { return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; }, @@ -122,7 +120,7 @@ export default { <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> - <div v-else-if="dependencyProxyEnabled" data-testid="main-area"> + <div v-else data-testid="main-area"> <gl-form-group :label="$options.i18n.proxyImagePrefix"> <gl-form-input-group readonly @@ -161,8 +159,5 @@ export default { :title="$options.i18n.noManifestTitle" /> </div> - <gl-alert v-else :dismissible="false" data-testid="proxy-disabled"> - {{ $options.i18n.proxyDisabledText }} - </gl-alert> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql index 63d5469c955..9241dccb2d5 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -8,6 +8,7 @@ query getDependencyProxyDetails( $before: String ) { group(fullPath: $fullPath) { + id dependencyProxyBlobCount dependencyProxyTotalSize dependencyProxyImagePrefix @@ -16,6 +17,7 @@ query getDependencyProxyDetails( } dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) { nodes { + id createdAt imageName } 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 6016757c1b9..f198d2e1bfa 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 @@ -16,10 +16,13 @@ import { s__, __ } from '~/locale'; import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import PackageListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import PackageListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { + TRACKING_ACTIONS, + SHOW_DELETE_SUCCESS_ALERT, +} from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; import PackageFiles from './package_files.vue'; import PackageHistory from './package_history.vue'; @@ -44,7 +47,7 @@ export default { GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], - trackingActions: { ...TrackingActions }, + trackingActions: { ...TRACKING_ACTIONS }, data() { return { fileToDelete: null, @@ -68,7 +71,7 @@ export default { }, tracking() { return { - category: packageTypeToTrackCategory(this.packageEntity.package_type), + category: TRACK_CATEGORY, }; }, hasVersions() { @@ -86,7 +89,7 @@ export default { } }, async confirmPackageDeletion() { - this.track(TrackingActions.DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE); await this.deletePackage(); const returnTo = !this.groupListUrl || document.referrer.includes(this.projectName) @@ -96,12 +99,12 @@ export default { window.location.replace(`${returnTo}?${modalQuery}`); }, handleFileDelete(file) { - this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE); + this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE); this.fileToDelete = { ...file }; this.$refs.deleteFileModal.show(); }, confirmFileDelete() { - this.track(TrackingActions.DELETE_PACKAGE_FILE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE_FILE); this.deletePackageFile(this.fileToDelete.id); this.fileToDelete = null; }, @@ -203,6 +206,7 @@ export default { <gl-modal ref="deleteModal" + size="sm" modal-id="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" @@ -223,6 +227,7 @@ export default { <gl-modal ref="deleteFileModal" + size="sm" modal-id="delete-file-modal" :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js index a03fa8d9d63..26d4aa13715 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js @@ -4,7 +4,7 @@ import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, -} from '~/packages/shared/constants'; +} from '~/packages_and_registries/shared/constants'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue index 4928da862ea..c611f92036d 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; -import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants'; -import { sortableFields } from '~/packages/list/utils'; +import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue index 2a479c65d0c..2a479c65d0c 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue diff --git a/app/assets/javascripts/packages/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index 23ba070aa26..a5f367bc1f6 100644 --- a/app/assets/javascripts/packages/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -3,10 +3,10 @@ import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import PackagesListRow from '../../shared/components/package_list_row.vue'; -import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; -import { TrackingActions } from '../../shared/constants'; -import { packageTypeToTrackCategory } from '../../shared/utils'; +import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; export default { components: { @@ -49,27 +49,24 @@ export default { return this.itemToBeDeleted?.name ?? ''; }, tracking() { - const category = this.itemToBeDeleted - ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type) - : undefined; return { - category, + category: TRACK_CATEGORY, }; }, }, methods: { setItemToBeDeleted(item) { this.itemToBeDeleted = { ...item }; - this.track(TrackingActions.REQUEST_DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE); this.$refs.packageListDeleteModal.show(); }, deleteItemConfirmation() { this.$emit('package:delete', this.itemToBeDeleted); - this.track(TrackingActions.DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE); this.itemToBeDeleted = null; }, deleteItemCanceled() { - this.track(TrackingActions.CANCEL_DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE); this.itemToBeDeleted = null; }, }, @@ -111,6 +108,7 @@ export default { <gl-modal ref="packageListDeleteModal" + size="sm" modal-id="confirm-delete-pacakge" ok-variant="danger" @ok="deleteItemConfirmation" diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 31d90fa4dee..462618a7f12 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -4,13 +4,16 @@ import { mapActions, mapState } from 'vuex'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { + SHOW_DELETE_SUCCESS_ALERT, + FILTERED_SEARCH_TERM, +} from '~/packages_and_registries/shared/constants'; + import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'; -import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; -import PackageList from './packages_list.vue'; +import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; +import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; +import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; export default { components: { diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js index 4f5071e784b..7af3fc1c2db 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js @@ -1,10 +1,8 @@ -import { __, s__ } from '~/locale'; -import { PackageType } from '../shared/constants'; +import { __ } from '~/locale'; export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __( 'Something went wrong while fetching the packages list.', ); -export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully'); export const DEFAULT_PAGE = 1; @@ -17,14 +15,12 @@ export const LIST_KEY_PROJECT = 'project_path'; export const LIST_KEY_VERSION = 'version'; export const LIST_KEY_PACKAGE_TYPE = 'type'; export const LIST_KEY_CREATED_AT = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; export const LIST_LABEL_NAME = __('Name'); export const LIST_LABEL_PROJECT = __('Project'); export const LIST_LABEL_VERSION = __('Version'); export const LIST_LABEL_PACKAGE_TYPE = __('Type'); export const LIST_LABEL_CREATED_AT = __('Published'); -export const LIST_LABEL_ACTIONS = ''; // The following is not translated because it is used to build a JavaScript exception error message export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; @@ -52,48 +48,4 @@ export const SORT_FIELDS = [ }, ]; -export const PACKAGE_TYPES = [ - { - title: s__('PackageRegistry|Composer'), - type: PackageType.COMPOSER, - }, - { - title: s__('PackageRegistry|Conan'), - type: PackageType.CONAN, - }, - { - title: s__('PackageRegistry|Generic'), - type: PackageType.GENERIC, - }, - - { - title: s__('PackageRegistry|Maven'), - type: PackageType.MAVEN, - }, - { - title: s__('PackageRegistry|npm'), - type: PackageType.NPM, - }, - { - title: s__('PackageRegistry|NuGet'), - type: PackageType.NUGET, - }, - { - title: s__('PackageRegistry|PyPI'), - type: PackageType.PYPI, - }, - { - title: s__('PackageRegistry|RubyGems'), - type: PackageType.RUBYGEMS, - }, - { - title: s__('PackageRegistry|Debian'), - type: PackageType.DEBIAN, - }, - { - title: s__('PackageRegistry|Helm'), - type: PackageType.HELM, - }, -]; - export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } }); diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 81f587971c2..488860e5bc2 100644 --- a/app/assets/javascripts/packages/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -1,7 +1,7 @@ import Api from '~/api'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_SUCCESS_MESSAGE, diff --git a/app/assets/javascripts/packages/list/stores/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js index 482c111b58b..5989303280e 100644 --- a/app/assets/javascripts/packages/list/stores/getters.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js @@ -1,4 +1,4 @@ -import { beautifyPath } from '../../shared/utils'; +import { beautifyPath } from '~/packages_and_registries/shared/utils'; import { LIST_KEY_PROJECT } from '../constants'; export default (state) => diff --git a/app/assets/javascripts/packages/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js index 1d6a4bf831d..1d6a4bf831d 100644 --- a/app/assets/javascripts/packages/list/stores/index.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js index 561ad97f7e3..561ad97f7e3 100644 --- a/app/assets/javascripts/packages/list/stores/mutation_types.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js index 98165e581b0..98165e581b0 100644 --- a/app/assets/javascripts/packages/list/stores/mutations.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js index 60f02eddc9f..60f02eddc9f 100644 --- a/app/assets/javascripts/packages/list/stores/state.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js index 537b30d2ca4..537b30d2ca4 100644 --- a/app/assets/javascripts/packages/list/utils.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js index 7e6e98f4fb5..1467218dd41 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { s__ } from '~/locale'; -import PackagesListApp from '~/packages/list/components/packages_list_app.vue'; -import { createStore } from '~/packages/list/stores'; +import PackagesListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue'; +import { createStore } from '~/packages_and_registries/infrastructure_registry/list/stores'; import Translate from '~/vue_shared/translate'; Vue.use(Translate); @@ -18,9 +18,6 @@ export default () => { PackagesListApp, }, provide: { - titleComponent: 'InfrastructureTitle', - searchComponent: 'InfrastructureSearch', - iconComponent: 'InfrastructureIconAndName', emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'), noResultsText: s__( 'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.', diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js new file mode 100644 index 00000000000..ab52ec01d40 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js @@ -0,0 +1 @@ +export const TRACK_CATEGORY = 'UI::TerraformPackages'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue index 3100a1a7296..3100a1a7296 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue index eee0e470c7b..3c6b8344c34 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue @@ -3,11 +3,14 @@ import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gi import { s__ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants'; -import { getPackageTypeLabel } from '../utils'; -import PackagePath from './package_path.vue'; -import PackageTags from './package_tags.vue'; -import PublishMethod from './publish_method.vue'; +import { + PACKAGE_ERROR_STATUS, + PACKAGE_DEFAULT_STATUS, +} from '~/packages_and_registries/shared/constants'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; +import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue'; export default { name: 'PackageListRow', @@ -20,23 +23,12 @@ export default { PackagePath, PublishMethod, ListItem, - PackageIconAndName: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'), - InfrastructureIconAndName: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue' - ), + InfrastructureIconAndName, }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], - inject: { - iconComponent: { - from: 'iconComponent', - default: 'PackageIconAndName', - }, - }, props: { packageEntity: { type: Object, @@ -63,9 +55,6 @@ export default { }, }, computed: { - packageType() { - return getPackageTypeLabel(this.packageEntity.package_type); - }, hasPipeline() { return Boolean(this.packageEntity.pipeline); }, @@ -130,9 +119,7 @@ export default { </gl-sprintf> </div> - <component :is="iconComponent" v-if="showPackageType"> - {{ packageType }} - </component> + <infrastructure-icon-and-name v-if="showPackageType" /> <package-path v-if="hasProjectLink" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue index bcbeec72961..d49c1be5202 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -15,7 +15,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; @@ -304,6 +304,7 @@ export default { <template #default="{ deletePackage }"> <gl-modal ref="deleteModal" + size="sm" modal-id="delete-modal" data-testid="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" @@ -327,6 +328,7 @@ export default { <gl-modal ref="deleteFileModal" + size="sm" modal-id="delete-file-modal" :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index 44d7807639d..118c509828c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -3,7 +3,7 @@ import { GlIcon, GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/u import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index d218a405af6..1afd1b69db0 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -1,8 +1,8 @@ <script> import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { PACKAGE_DEFAULT_STATUS } from '../../constants'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 195ff7af583..6fd96c0654f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -1,16 +1,16 @@ <script> import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS, } from '~/packages_and_registries/package_registry/constants'; -import { getPackageTypeLabel } from '~/packages/shared/utils'; -import PackagePath from '~/packages/shared/components/package_path.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; -import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -40,7 +40,7 @@ export default { }, computed: { packageType() { - return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase()); + return getPackageTypeLabel(this.packageEntity.packageType); }, packageLink() { const { project, id } = this.packageEntity; @@ -64,6 +64,7 @@ export default { }, i18n: { erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), + createdAt: __('Created %{timestamp}'), }, }; </script> @@ -127,8 +128,8 @@ export default { </template> <template #right-secondary> - <span> - <gl-sprintf :message="__('Created %{timestamp}')"> + <span data-testid="created-date"> + <gl-sprintf :message="$options.i18n.createdAt"> <template #timestamp> <timeago-tooltip :time="packageEntity.createdAt" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 2a946544c2f..298ed9bccdb 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -2,7 +2,7 @@ import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui'; import { s__ } from '~/locale'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, @@ -124,6 +124,7 @@ export default { <gl-modal v-model="showDeleteModal" modal-id="confirm-delete-pacakge" + size="sm" ok-variant="danger" @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 9fd8880861c..ab6541e4264 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -1,4 +1,15 @@ import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, +} from '~/packages_and_registries/shared/constants'; export const PACKAGE_TYPE_CONAN = 'CONAN'; export const PACKAGE_TYPE_MAVEN = 'MAVEN'; @@ -11,14 +22,6 @@ export const PACKAGE_TYPE_GENERIC = 'GENERIC'; export const PACKAGE_TYPE_DEBIAN = 'DEBIAN'; export const PACKAGE_TYPE_HELM = 'HELM'; -export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; -export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; -export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; -export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; -export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; -export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; -export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; - export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction'; export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation'; export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation'; @@ -134,3 +137,8 @@ export const PACKAGE_TYPES = [ s__('PackageRegistry|Debian'), s__('PackageRegistry|Helm'), ]; + +// links + +export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index'); +export const PACKAGE_HELP_URL = helpPagePath('user/packages/index'); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index aaf0eb54aff..66315fda9e9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -7,20 +7,24 @@ fragment PackageData on Package { status tags { nodes { + id name } } - pipelines { + pipelines(last: 1) { nodes { + id sha ref commitPath user { + id name } } } project { + id fullPath webUrl } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 14aa14e9822..08ea0938a59 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -8,6 +8,7 @@ query getPackageDetails($id: ID!) { updatedAt status project { + id path } tags(first: 10) { @@ -25,9 +26,11 @@ query getPackageDetails($id: ID!) { commitPath path user { + id name } project { + id name webUrl } @@ -86,15 +89,18 @@ query getPackageDetails($id: ID!) { } } ... on PypiMetadata { + id requiredPython } ... on ConanMetadata { + id packageChannel packageUsername recipe recipePath } ... on MavenMetadata { + id appName appGroup appVersion @@ -102,6 +108,7 @@ query getPackageDetails($id: ID!) { } ... on NugetMetadata { + id iconUrl licenseUrl projectUrl diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index e3115365f8b..4b913590949 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -14,6 +14,7 @@ query getPackages( $before: String ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { + id packages( sort: $sort packageName: $packageName @@ -33,6 +34,7 @@ query getPackages( } } group(fullPath: $fullPath) @include(if: $isGroupPage) { + id packages( sort: $groupSort packageName: $packageName diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js new file mode 100644 index 00000000000..7ec931ff9a0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; +import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue'; +import createRouter from './router'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-vue-packages-list'); + const { endpoint, resourceId, fullPath, pageType, emptyListIllustration } = el.dataset; + const router = createRouter(endpoint); + + const isGroupPage = pageType === 'groups'; + + return new Vue({ + el, + router, + apolloProvider, + provide: { + resourceId, + fullPath, + emptyListIllustration, + isGroupPage, + }, + render(createElement) { + return createElement(PackageRegistry); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue new file mode 100644 index 00000000000..a14d0c32cbe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue @@ -0,0 +1,5 @@ +<template> + <div> + <router-view /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js deleted file mode 100644 index d797a0a5327..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; -import PackagesListApp from '../components/list/app.vue'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-packages-list'); - - const isGroupPage = el.dataset.pageType === 'groups'; - - return new Vue({ - el, - apolloProvider, - provide: { - ...el.dataset, - isGroupPage, - }, - render(createElement) { - return createElement(PackagesListApp); - }, - }); -}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 11eeaf933ff..38df701157a 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -3,19 +3,21 @@ import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, DELETE_PACKAGE_SUCCESS_MESSAGE, + EMPTY_LIST_HELP_URL, + PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; -import PackageTitle from './package_title.vue'; -import PackageSearch from './package_search.vue'; -import PackageList from './packages_list.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; +import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; export default { components: { @@ -27,13 +29,7 @@ export default { PackageSearch, DeletePackage, }, - inject: [ - 'packageHelpUrl', - 'emptyListIllustration', - 'emptyListHelpUrl', - 'isGroupPage', - 'fullPath', - ], + inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], data() { return { packages: {}, @@ -156,12 +152,16 @@ export default { 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', ), }, + links: { + EMPTY_LIST_HELP_URL, + PACKAGE_HELP_URL, + }, }; </script> <template> <div> - <package-title :help-url="packageHelpUrl" :count="packagesCount" /> + <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> <package-search @update="handleSearchUpdate" /> <delete-package @@ -185,7 +185,9 @@ export default { <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> <gl-sprintf v-else :message="$options.i18n.noResultsText"> <template #noPackagesLink="{ content }"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.EMPTY_LIST_HELP_URL" target="_blank">{{ + content + }}</gl-link> </template> </gl-sprintf> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/router.js b/app/assets/javascripts/packages_and_registries/package_registry/router.js new file mode 100644 index 00000000000..ea5b740e879 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/router.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import List from '~/packages_and_registries/package_registry/pages/list.vue'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const router = new VueRouter({ + base, + mode: 'history', + routes: [ + { + name: 'list', + path: '/', + component: List, + }, + ], + }); + + return router; +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index 9b5a0d221b8..85a7aeb5561 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -18,9 +18,10 @@ export default () => { el, apolloProvider, provide: { + groupPath: el.dataset.groupPath, + groupDependencyProxyPath: el.dataset.groupDependencyProxyPath, defaultExpanded: parseBoolean(el.dataset.defaultExpanded), dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable), - groupPath: el.dataset.groupPath, }, render(createElement) { return createElement(SettingsApp); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue index 5815c6393a7..fd62fe144b2 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -2,9 +2,14 @@ import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; -import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + updateGroupDependencyProxySettingsOptimisticResponse, + updateDependencyProxyImageTtlGroupPolicyOptimisticResponse, +} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; import { DEPENDENCY_PROXY_HEADER, @@ -19,21 +24,34 @@ export default { GlSprintf, GlLink, SettingsBlock, + SettingsTitles, }, i18n: { DEPENDENCY_PROXY_HEADER, DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, - label: s__('DependencyProxy|Enable Proxy'), + enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'), + enabledProxyHelpText: s__( + 'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}', + ), + storageSettingsTitle: s__('DependencyProxy|Storage settings'), + ttlPolicyEnabledLabel: s__('DependencyProxy|Clear the Dependency Proxy cache automatically'), + ttlPolicyEnabledHelpText: s__( + 'DependencyProxy|When enabled, images older than 90 days will be removed from the cache.', + ), }, links: { DEPENDENCY_PROXY_DOCS_PATH, }, - inject: ['defaultExpanded', 'groupPath'], + inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'], props: { dependencyProxySettings: { type: Object, required: true, }, + dependencyProxyImageTtlPolicy: { + type: Object, + required: true, + }, isLoading: { type: Boolean, required: false, @@ -49,26 +67,35 @@ export default { this.updateSettings({ enabled }); }, }, + ttlEnabled: { + get() { + return this.dependencyProxyImageTtlPolicy.enabled; + }, + set(enabled) { + const payload = { + enabled, + ttl: 90, // hardocded TTL for the MVC version + }; + this.updateDependencyProxyImageTtlGroupPolicy(payload); + }, + }, + helpText() { + return this.enabled ? this.$options.i18n.enabledProxyHelpText : ''; + }, }, methods: { - async updateSettings(payload) { + mutationVariables(payload) { + return { + input: { + groupPath: this.groupPath, + ...payload, + }, + }; + }, + async executeMutation(config, resource) { try { - const { data } = await this.$apollo.mutate({ - mutation: updateDependencyProxySettings, - variables: { - input: { - groupPath: this.groupPath, - ...payload, - }, - }, - update: updateGroupPackageSettings(this.groupPath), - optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({ - ...this.dependencyProxySettings, - ...payload, - }), - }); - - if (data.updateDependencyProxySettings?.errors?.length > 0) { + const { data } = await this.$apollo.mutate(config); + if (data[resource]?.errors.length > 0) { throw new Error(); } else { this.$emit('success'); @@ -77,6 +104,32 @@ export default { this.$emit('error'); } }, + async updateSettings(payload) { + const apolloConfig = { + mutation: updateDependencyProxySettings, + variables: this.mutationVariables(payload), + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({ + ...this.dependencyProxySettings, + ...payload, + }), + }; + + this.executeMutation(apolloConfig, 'updateDependencyProxySettings'); + }, + async updateDependencyProxyImageTtlGroupPolicy(payload) { + const apolloConfig = { + mutation: updateDependencyProxyImageTtlGroupPolicy, + variables: this.mutationVariables(payload), + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateDependencyProxyImageTtlGroupPolicyOptimisticResponse({ + ...this.dependencyProxyImageTtlPolicy, + ...payload, + }), + }; + + this.executeMutation(apolloConfig, 'updateDependencyProxyImageTtlGroupPolicy'); + }, }, }; </script> @@ -91,7 +144,11 @@ export default { <span data-testid="description"> <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION"> <template #docLink="{ content }"> - <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link> + <gl-link + data-testid="description-link" + :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH" + >{{ content }}</gl-link + > </template> </gl-sprintf> </span> @@ -101,9 +158,31 @@ export default { <gl-toggle v-model="enabled" :disabled="isLoading" - :label="$options.i18n.label" + :label="$options.i18n.enabledProxyLabel" + :help="helpText" data-qa-selector="dependency_proxy_setting_toggle" data-testid="dependency-proxy-setting-toggle" + > + <template #help> + <span class="gl-overflow-break-word gl-max-w-100vw gl-display-inline-block"> + <gl-sprintf :message="$options.i18n.enabledProxyHelpText"> + <template #link="{ content }"> + <gl-link data-testid="toggle-help-link" :href="groupDependencyProxyPath">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + </gl-toggle> + + <settings-titles :title="$options.i18n.storageSettingsTitle" class="gl-my-6" /> + <gl-toggle + v-model="ttlEnabled" + :disabled="isLoading" + :label="$options.i18n.ttlPolicyEnabledLabel" + :help="$options.i18n.ttlPolicyEnabledHelpText" + data-testid="dependency-proxy-ttl-policies-toggle" /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index b45cedcdd66..64c12b4be6a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -37,6 +37,9 @@ export default { dependencyProxySettings() { return this.group?.dependencyProxySetting || {}; }, + dependencyProxyImageTtlPolicy() { + return this.group?.dependencyProxyImageTtlPolicy || {}; + }, isLoading() { return this.$apollo.queries.group.loading; }, @@ -82,6 +85,7 @@ export default { <dependency-proxy-settings v-if="dependencyProxyAvailable" :dependency-proxy-settings="dependencyProxySettings" + :dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy" :is-loading="isLoading" @success="handleSuccess" @error="handleError" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue index 3f0ab7686e5..1e93875c1e3 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue @@ -8,7 +8,8 @@ export default { }, subTitle: { type: String, - required: true, + required: false, + default: '', }, }, }; @@ -16,10 +17,10 @@ export default { <template> <div> - <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"> + <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3"> {{ title }} </h5> - <p>{{ subTitle }}</p> + <p v-if="subTitle">{{ subTitle }}</p> <slot></slot> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql new file mode 100644 index 00000000000..81250f52dfb --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateDependencyProxyImageTtlGroupPolicy( + $input: UpdateDependencyProxyImageTtlGroupPolicyInput! +) { + updateDependencyProxyImageTtlGroupPolicy(input: $input) { + dependencyProxyImageTtlPolicy { + enabled + ttl + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql index d3edebfbe20..404d9d26d49 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -1,8 +1,13 @@ query getGroupPackagesSettings($fullPath: ID!) { group(fullPath: $fullPath) { + id dependencyProxySetting { enabled } + dependencyProxyImageTtlPolicy { + ttl + enabled + } packageSettings { mavenDuplicatesAllowed mavenDuplicateExceptionRegex diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js index fe94203f51b..c7b0899fa4c 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js @@ -19,6 +19,11 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated ...updatedData.updateDependencyProxySettings.dependencyProxySetting, }; } + if (updatedData.updateDependencyProxyImageTtlGroupPolicy) { + draftState.group.dependencyProxyImageTtlPolicy = { + ...updatedData.updateDependencyProxyImageTtlGroupPolicy.dependencyProxyImageTtlPolicy, + }; + } }); client.writeQuery({ diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js index a30d8ca0b81..92f6e117911 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js @@ -21,3 +21,15 @@ export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) => }, }, }); + +export const updateDependencyProxyImageTtlGroupPolicyOptimisticResponse = (changes) => ({ + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateDependencyProxyImageTtlGroupPolicy: { + __typename: 'UpdateDependencyProxyImageTtlGroupPolicyPayload', + errors: [], + dependencyProxyImageTtlPolicy: { + ...changes, + }, + }, +}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql index c171be0ad07..6a862da92df 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql @@ -2,6 +2,7 @@ query getProjectExpirationPolicy($projectPath: ID!) { project(fullPath: $projectPath) { + id containerExpirationPolicy { ...ContainerExpirationPolicyFields } diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue index 105f7bbe132..105f7bbe132 100644 --- a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue index 6fb001e5e92..6fb001e5e92 100644 --- a/app/assets/javascripts/packages/shared/components/package_path.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue index 5ec950e4d45..5ec950e4d45 100644 --- a/app/assets/javascripts/packages/shared/components/package_tags.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue index cf555f46f8c..cf555f46f8c 100644 --- a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue index 8a66a33f2ab..8a66a33f2ab 100644 --- a/app/assets/javascripts/packages/shared/components/publish_method.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue new file mode 100644 index 00000000000..79381f82009 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -0,0 +1,124 @@ +<script> +import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui'; +import { filter } from 'lodash'; +import { __ } from '~/locale'; + +export default { + name: 'RegistryList', + components: { + GlButton, + GlFormCheckbox, + GlKeysetPagination, + }, + props: { + title: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + hiddenDelete: { + type: Boolean, + default: false, + required: false, + }, + pagination: { + type: Object, + required: false, + default: () => ({}), + }, + items: { + type: Array, + required: false, + default: () => [], + }, + idProperty: { + type: String, + required: false, + default: 'id', + }, + }, + data() { + return { + selectedReferences: {}, + }; + }, + computed: { + showPagination() { + return this.pagination.hasPreviousPage || this.pagination.hasNextPage; + }, + disableDeleteButton() { + return this.isLoading || filter(this.selectedReferences).length === 0; + }, + selectedItems() { + return this.items.filter(this.isSelected); + }, + selectAll: { + get() { + return this.items.every(this.isSelected); + }, + set(value) { + this.items.forEach((item) => { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, value); + }); + }, + }, + }, + methods: { + selectItem(item) { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, !this.selectedReferences[id]); + }, + isSelected(item) { + const id = item[this.idProperty]; + return this.selectedReferences[id]; + }, + }, + i18n: { + deleteSelected: __('Delete Selected'), + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center"> + <gl-form-checkbox v-if="!hiddenDelete" v-model="selectAll" class="gl-ml-2 gl-pt-2"> + <span class="gl-font-weight-bold">{{ title }}</span> + </gl-form-checkbox> + + <gl-button + v-if="!hiddenDelete" + :disabled="disableDeleteButton" + category="secondary" + variant="danger" + @click="$emit('delete', selectedItems)" + > + {{ $options.i18n.deleteSelected }} + </gl-button> + </div> + + <div v-for="(item, index) in items" :key="index"> + <slot + :select-item="selectItem" + :is-selected="isSelected" + :item="item" + :first="index === 0" + ></slot> + </div> + + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pagination" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js index 7d2971bd8c7..afc72a2c627 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants.js @@ -1,3 +1,39 @@ +import { s__ } from '~/locale'; + export const FILTERED_SEARCH_TERM = 'filtered-search-term'; export const FILTERED_SEARCH_TYPE = 'type'; export const HISTORY_PIPELINES_LIMIT = 5; + +export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; +export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; +export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; +export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; +export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; +export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; +export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; + +export const TRACKING_ACTIONS = { + DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE: REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE: CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE: PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE: DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE: REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE: CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, +}; + +export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; +export const DELETE_PACKAGE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package.', +); +export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package file.', +); +export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( + 'PackageRegistry|Package file deleted successfully', +); + +export const PACKAGE_ERROR_STATUS = 'error'; +export const PACKAGE_DEFAULT_STATUS = 'default'; +export const PACKAGE_HIDDEN_STATUS = 'hidden'; +export const PACKAGE_PROCESSING_STATUS = 'processing'; diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 93eb90535d1..cf18f655e79 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -28,3 +28,13 @@ export const extractFilterAndSorting = (queryObject) => { } return { filters, sorting }; }; + +export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); + +export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { + if (isGroup) { + return `/${projectPath}/commit/${pipeline.sha}`; + } + + return `../commit/${pipeline.sha}`; +}; diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js index 8002fa8bf78..8485b460261 100644 --- a/app/assets/javascripts/pages/admin/integrations/edit/index.js +++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js @@ -1,15 +1,11 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -function initIntegrations() { - const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); - if (prometheusSettingsWrapper) { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); - prometheusMetrics.loadActiveMetrics(); - } +const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; +const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); +if (prometheusSettingsWrapper) { + const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector); + prometheusMetrics.loadActiveMetrics(); } - -initIntegrations(); diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js index f7c25347e75..a3b9c43388a 100644 --- a/app/assets/javascripts/pages/admin/labels/edit/index.js +++ b/app/assets/javascripts/pages/admin/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '../../../../labels'; +import Labels from '~/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js index 0ceab3b922f..132fe5ce8fc 100644 --- a/app/assets/javascripts/pages/admin/labels/index/index.js +++ b/app/assets/javascripts/pages/admin/labels/index/index.js @@ -1,23 +1,3 @@ -function initLabels() { - const pagination = document.querySelector('.labels .gl-pagination'); - const emptyState = document.querySelector('.labels .nothing-here-block.hidden'); +import { initAdminLabels } from '~/labels'; - function removeLabelSuccessCallback() { - this.closest('li').classList.add('gl-display-none!'); - - const labelsCount = document.querySelectorAll( - 'ul.manage-labels-list li:not(.gl-display-none\\!)', - ).length; - - // display the empty state if there are no more labels - if (labelsCount < 1 && !pagination && emptyState) { - emptyState.classList.remove('hidden'); - } - } - - document.querySelectorAll('.js-remove-label').forEach((row) => { - row.addEventListener('ajax:success', removeLabelSuccessCallback); - }); -} - -initLabels(); +initAdminLabels(); diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js index f7c25347e75..a3b9c43388a 100644 --- a/app/assets/javascripts/pages/admin/labels/new/index.js +++ b/app/assets/javascripts/pages/admin/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '../../../../labels'; +import Labels from '~/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js deleted file mode 100644 index b8080ddff77..00000000000 --- a/app/assets/javascripts/pages/admin/services/edit/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; - -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); diff --git a/app/assets/javascripts/pages/admin/services/index/index.js b/app/assets/javascripts/pages/admin/services/index/index.js deleted file mode 100644 index b695cf70c5d..00000000000 --- a/app/assets/javascripts/pages/admin/services/index/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; - -const callout = document.querySelector('.js-service-templates-deprecated'); -PersistentUserCallout.factory(callout); diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js deleted file mode 100644 index a9773807212..00000000000 --- a/app/assets/javascripts/pages/constants.js +++ /dev/null @@ -1,6 +0,0 @@ -export const FILTERED_SEARCH = { - MERGE_REQUESTS: 'merge_requests', - ISSUES: 'issues', - ADMIN_RUNNERS: 'admin/runners', - GROUP_RUNNERS_ANCHOR: 'runners-settings', -}; diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 3e09b1796b1..d0903ad53bc 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 6c134e4fad6..1350837476b 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,6 +1,6 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 1f3e458fe17..d1ff7ec336c 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -1,4 +1,4 @@ -import Milestone from '~/milestone'; +import Milestone from '~/milestones/milestone'; import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; diff --git a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue deleted file mode 100644 index 99461475af0..00000000000 --- a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue +++ /dev/null @@ -1,102 +0,0 @@ -<script> -import { GlBanner } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import Tracking from '~/tracking'; - -const trackingMixin = Tracking.mixin(); - -export default { - components: { - GlBanner, - }, - mixins: [trackingMixin], - inject: { - svgPath: { - default: '', - }, - preferencesBehaviorPath: { - default: '', - }, - calloutsPath: { - default: '', - }, - calloutsFeatureId: { - default: '', - }, - trackLabel: { - default: '', - }, - }, - i18n: { - title: s__('CustomizeHomepageBanner|Do you want to customize this page?'), - body: s__( - 'CustomizeHomepageBanner|This page shows a list of your projects by default but it can be changed to show projects\' activity, groups, your to-do list, assigned issues, assigned merge requests, and more. You can change this under "Homepage content" in your preferences', - ), - button_text: s__('CustomizeHomepageBanner|Go to preferences'), - }, - data() { - return { - visible: true, - tracking: { - label: this.trackLabel, - }, - }; - }, - created() { - this.$nextTick(() => { - this.addTrackingAttributesToButton(); - }); - }, - mounted() { - this.trackOnShow(); - }, - methods: { - handleClose() { - axios - .post(this.calloutsPath, { - feature_name: this.calloutsFeatureId, - }) - .catch((e) => { - // eslint-disable-next-line @gitlab/require-i18n-strings, no-console - console.error('Failed to dismiss banner.', e); - }); - - this.visible = false; - this.track('click_dismiss'); - }, - trackOnShow() { - if (this.visible) this.track('show_home_page_banner'); - }, - addTrackingAttributesToButton() { - // we can't directly add these on the button like we need to due to - // button not being modifiable currently - // https://gitlab.com/gitlab-org/gitlab-ui/-/blob/9209ec424e5cca14bc8a1b5c9fa12636d8c83dad/src/components/base/banner/banner.vue#L60 - const button = this.$refs.banner.$el.querySelector( - `[href='${this.preferencesBehaviorPath}']`, - ); - - if (button) { - button.setAttribute('data-track-action', 'click_go_to_preferences'); - button.setAttribute('data-track-label', this.trackLabel); - } - }, - }, -}; -</script> - -<template> - <gl-banner - v-if="visible" - ref="banner" - :title="$options.i18n.title" - :button-text="$options.i18n.button_text" - :button-link="preferencesBehaviorPath" - :svg-path="svgPath" - @close="handleClose" - > - <p> - {{ $options.i18n.body }} - </p> - </gl-banner> -</template> diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js index c34d15b869a..6c9378b7231 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js @@ -1,5 +1,3 @@ import ProjectsList from '~/projects_list'; -import initCustomizeHomepageBanner from './init_customize_homepage_banner'; new ProjectsList(); // eslint-disable-line no-new -initCustomizeHomepageBanner(); diff --git a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js b/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js deleted file mode 100644 index 8cdcd3134ee..00000000000 --- a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import CustomizeHomepageBanner from './components/customize_homepage_banner.vue'; - -export default () => { - const el = document.querySelector('.js-customize-homepage-banner'); - - if (!el) { - return false; - } - - return new Vue({ - el, - provide: { ...el.dataset }, - render: (createElement) => createElement(CustomizeHomepageBanner), - }); -}; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 8c9f23732aa..966d55e5587 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,8 +1,8 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js index 95c2c7cd7d0..bf670e8576f 100644 --- a/app/assets/javascripts/pages/groups/labels/index/index.js +++ b/app/assets/javascripts/pages/groups/labels/index/index.js @@ -1,5 +1,4 @@ -import initDeleteLabelModal from '~/delete_label_modal'; -import initLabels from '~/init_labels'; +import { initDeleteLabelModal, initLabels } from '~/labels'; initLabels(); initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 02a0a50f984..cb38ee1c6e0 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,7 +1,7 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/groups/milestones/edit/index.js +++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/groups/milestones/new/index.js +++ b/app/assets/javascripts/pages/groups/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index 914e2831185..f2ab5d78374 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,5 +1,4 @@ -import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init'; -import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; +import { initDeleteMilestoneModal, initShow } from '~/milestones'; -initMilestonesShow(); +initShow(); initDeleteMilestoneModal(); diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js index f9eecff4ac4..174973a9fad 100644 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -1,3 +1,3 @@ -import packageList from '~/packages_and_registries/package_registry/pages/list'; +import packageApp from '~/packages_and_registries/package_registry/index'; -packageList(); +packageApp(); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index a8d7a83cdd6..5d8ee146e62 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,7 +1,7 @@ import initVariableList from '~/ci_variable_list'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js index a8698e10c57..8485b460261 100644 --- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js +++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js @@ -1,11 +1,11 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); +const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; +const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); if (prometheusSettingsWrapper) { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector); prometheusMetrics.loadActiveMetrics(); } diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js deleted file mode 100644 index 9ccc9123506..00000000000 --- a/app/assets/javascripts/pages/help/ui/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initUIKit from '~/ui_development_kit'; - -initUIKit(); 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 ec3cf4a8a92..0ec382983a5 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 @@ -7,7 +7,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/components/import_status.vue'; -import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { DEFAULT_ERROR } from '../utils/error_messages'; @@ -166,7 +166,6 @@ export default { </gl-table> <pagination-bar :page-info="pageInfo" - :items-count="historyItems.length" class="gl-m-0 gl-mt-3" @set-page="paginationConfig.page = $event" @set-page-size="paginationConfig.perPage = $event" diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js deleted file mode 100644 index dabfe32848b..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import initDeleteMilestoneModal from './delete_milestone_modal_init'; -import initPromoteMilestoneModal from './promote_milestone_modal_init'; - -export default () => { - initDeleteMilestoneModal(); - initPromoteMilestoneModal(); -}; diff --git a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js deleted file mode 100644 index b2a896a3265..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable no-new */ - -import Milestone from '~/milestone'; -import Sidebar from '~/right_sidebar'; -import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; - -export default () => { - new Milestone(); - new Sidebar(); - new MountMilestoneSidebar(); -}; diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js deleted file mode 100644 index 5472b8c684f..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; - -Vue.use(Translate); - -export default () => { - const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); - if (!promoteMilestoneModal) { - return null; - } - - return new Vue({ - el: promoteMilestoneModal, - render(createElement) { - return createElement(PromoteMilestoneModal); - }, - }); -}; diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index fdbfc35456f..37e9b7e99d4 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,4 +1,5 @@ -import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens'; initExpiresAtField(); initProjectsField(); +initTokensApp(); diff --git a/app/assets/javascripts/pages/projects/constants.js b/app/assets/javascripts/pages/projects/constants.js deleted file mode 100644 index 8dc765e5d10..00000000000 --- a/app/assets/javascripts/pages/projects/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const ISSUABLE_INDEX = { - MERGE_REQUEST: 'merge_request_', - ISSUE: 'issue_', -}; diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index f4beefea90c..100ca5b36d9 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,13 +1,14 @@ import { PROJECT_BADGE } from '~/badges/constants'; import initLegacyConfirmDangerModal from '~/confirm_danger_modal'; +import initConfirmDanger from '~/init_confirm_danger'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import initProjectDeleteButton from '~/projects/project_delete_button'; import initServiceDesk from '~/projects/settings_service_desk'; +import initTransferProjectForm from '~/projects/settings/init_transfer_project_form'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; -import setupTransferEdit from '~/transfer_edit'; import UserCallout from '~/user_callout'; import initTopicsTokenSelector from '~/projects/settings/topics'; import initProjectPermissionsSettings from '../shared/permissions'; @@ -15,6 +16,7 @@ import initProjectLoadingSpinner from '../shared/save_project_loader'; initFilePickers(); initLegacyConfirmDangerModal(); +initConfirmDanger(); initSettingsPanels(); initProjectDeleteButton(); mountBadgeSettings(PROJECT_BADGE); @@ -24,7 +26,7 @@ initServiceDesk(); initProjectLoadingSpinner(); initProjectPermissionsSettings(); -setupTransferEdit('.js-project-transfer-form', 'select.select2'); +initTransferProjectForm(); dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form')); diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index a75b68873ef..4633eaef8f9 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -1,6 +1,6 @@ import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../../issues/show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 8cd703133f5..e937713044c 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,12 +1,11 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; -import initIssuableByEmail from '~/issuable/init_issuable_by_email'; -import IssuableIndex from '~/issuable_index'; +import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; -import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; @@ -21,7 +20,7 @@ if (gon.features?.vueIssuesList) { useDefaultState: true, }); - new IssuableIndex(ISSUABLE_INDEX.ISSUE); // eslint-disable-line no-new + issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.ISSUE); new UsersSelect(); // eslint-disable-line no-new initCsvImportExportButtons(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); 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 d906c579697..69639d17f8a 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,14 +1,7 @@ import { mountIssuablesListApp } from '~/issues_list'; -import FilteredSearchServiceDesk from './filtered_search'; +import { initFilteredSearchServiceDesk } from '~/issues/init_filtered_search_service_desk'; -const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, -); - -if (document.querySelector('.filtered-search')) { - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); -} +initFilteredSearchServiceDesk(); if (gon.features?.vueIssuablesList) { mountIssuablesListApp(); diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 1282d2aa303..d0b1942f2a4 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,7 +1,7 @@ import { store } from '~/notes/stores'; import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(store); diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index 3b7562deed9..c4d7af39767 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 94ab0d64de4..1f8ff7e0bb1 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,83 +1,3 @@ -import Vue from 'vue'; -import initDeleteLabelModal from '~/delete_label_modal'; -import initLabels from '~/init_labels'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import Translate from '~/vue_shared/translate'; -import PromoteLabelModal from '../components/promote_label_modal.vue'; -import eventHub from '../event_hub'; +import { initLabelIndex } from '~/labels'; -Vue.use(Translate); - -const initLabelIndex = () => { - initLabels(); - initDeleteLabelModal(); - - const onRequestFinished = ({ labelUrl, successful }) => { - const button = document.querySelector( - `.js-promote-project-label-button[data-url="${labelUrl}"]`, - ); - - if (!successful) { - button.removeAttribute('disabled'); - } - }; - - const onRequestStarted = (labelUrl) => { - const button = document.querySelector( - `.js-promote-project-label-button[data-url="${labelUrl}"]`, - ); - button.setAttribute('disabled', ''); - eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); - }; - - const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); - - return new Vue({ - el: '#js-promote-label-modal', - data() { - return { - modalProps: { - labelTitle: '', - labelColor: '', - labelTextColor: '', - url: '', - groupName: '', - }, - }; - }, - mounted() { - eventHub.$on('promoteLabelModal.props', this.setModalProps); - eventHub.$emit('promoteLabelModal.mounted'); - - promoteLabelButtons.forEach((button) => { - button.removeAttribute('disabled'); - button.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal'); - eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); - - this.setModalProps({ - labelTitle: button.dataset.labelTitle, - labelColor: button.dataset.labelColor, - labelTextColor: button.dataset.labelTextColor, - url: button.dataset.url, - groupName: button.dataset.groupName, - }); - }); - }); - }, - beforeDestroy() { - eventHub.$off('promoteLabelModal.props', this.setModalProps); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement(PromoteLabelModal, { - props: this.modalProps, - }); - }, - }); -}; initLabelIndex(); diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/projects/labels/new/index.js +++ b/app/assets/javascripts/pages/projects/labels/new/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index 95afcb6bda8..42c40cda601 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -1,18 +1,21 @@ <script> -import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui'; import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; export default { - components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard }, + components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard }, i18n: { title: s__('LearnGitLab|Learn GitLab'), description: s__( 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', ), percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), + successfulInvitations: s__( + "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.", + ), }, props: { actions: { @@ -28,12 +31,22 @@ export default { required: false, default: false, }, + project: { + required: true, + type: Object, + }, + }, + data() { + return { + showSuccessfulInvitationsAlert: false, + actionsData: this.actions, + }; }, maxValue: Object.keys(ACTION_LABELS).length, actionSections: Object.keys(ACTION_SECTIONS), computed: { progressValue() { - return Object.values(this.actions).filter((a) => a.completed).length; + return Object.values(this.actionsData).filter((a) => a.completed).length; }, progressPercentage() { return Math.round((this.progressValue / this.$options.maxValue) * 100); @@ -43,14 +56,23 @@ export default { if (this.inviteMembersOpen) { this.openInviteMembersModal('celebrate'); } + + eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); + }, + beforeDestroy() { + eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); }, methods: { openInviteMembersModal(mode) { eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' }); }, + handleShowSuccessfulInvitationsAlert() { + this.showSuccessfulInvitationsAlert = true; + this.markActionAsCompleted('userAdded'); + }, actionsFor(section) { const actions = Object.fromEntries( - Object.entries(this.actions).filter( + Object.entries(this.actionsData).filter( ([action]) => ACTION_LABELS[action].section === section, ), ); @@ -59,11 +81,34 @@ export default { svgFor(section) { return this.sections[section].svg; }, + markActionAsCompleted(completedAction) { + Object.keys(this.actionsData).forEach((action) => { + if (action === completedAction) { + this.actionsData[action].completed = true; + this.modifySidebarPercentage(); + } + }); + }, + modifySidebarPercentage() { + const el = document.querySelector('.sidebar-top-level-items .active .count'); + el.textContent = `${this.progressPercentage}%`; + }, }, }; </script> <template> <div> + <gl-alert + v-if="showSuccessfulInvitationsAlert" + class="gl-mt-5" + @dismiss="showSuccessfulInvitationsAlert = false" + > + <gl-sprintf :message="$options.i18n.successfulInvitations"> + <template #projectName> + <strong>{{ project.name }}</strong> + </template> + </gl-sprintf> + </gl-alert> <div class="row"> <div class="gl-mb-7 gl-ml-5"> <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 0995947f3e7..3a401f5cb31 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -1,5 +1,7 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; +import { isExperimentVariant } from '~/experimentation/utils'; +import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; import { ACTION_LABELS } from '../constants'; @@ -24,6 +26,20 @@ export default { trialOnly() { return ACTION_LABELS[this.action].trialRequired; }, + showInviteModalLink() { + return ( + this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') + ); + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { + inviteeType: 'members', + source: 'learn_gitlab', + tasksToBeDoneEnabled: true, + }); + }, }, }; </script> @@ -33,18 +49,27 @@ export default { <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> {{ $options.i18n.ACTION_LABELS[action].title }} </span> - <span v-else> - <gl-link - target="_blank" - :href="value.url" - data-track-action="click_link" - :data-track-label="$options.i18n.ACTION_LABELS[action].title" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - data-track-experiment="change_continuous_onboarding_link_urls" - > - {{ $options.i18n.ACTION_LABELS[action].title }} - </gl-link> - </span> + <gl-link + v-else-if="showInviteModalLink" + data-track-action="click_link" + :data-track-label="$options.i18n.ACTION_LABELS[action].title" + data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" + data-testid="invite-for-help-continuous-onboarding-experiment-link" + @click="openModal" + > + {{ $options.i18n.ACTION_LABELS[action].title }} + </gl-link> + <gl-link + v-else + target="_blank" + :href="value.url" + data-track-action="click_link" + :data-track-label="$options.i18n.ACTION_LABELS[action].title" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + data-track-experiment="change_continuous_onboarding_link_urls" + > + {{ $options.i18n.ACTION_LABELS[action].title }} + </gl-link> <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} </span> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index ea9eec2595f..1f91cc46946 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -12,17 +12,18 @@ function initLearnGitlab() { const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); + const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); const { inviteMembersOpen } = el.dataset; return new Vue({ el, render(createElement) { return createElement(LearnGitlab, { - props: { actions, sections, inviteMembersOpen }, + props: { actions, sections, project, inviteMembersOpen }, }); }, }); } -initInviteMembersModal(); initLearnGitlab(); +initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index d279086df7b..acd1731a700 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,17 +1,17 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; -import initIssuableByEmail from '~/issuable/init_issuable_by_email'; -import IssuableIndex from '~/issuable_index'; -import { FILTERED_SEARCH } from '~/pages/constants'; -import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; -new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new +issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.MERGE_REQUEST); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); +IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 7d5719cf8a8..ebf7c266482 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -1,13 +1,13 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; import GLForm from '~/gl_form'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new Diff(); 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 99094617b0a..c548ea9bb80 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 @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; -import initIssuableSidebar from '~/init_issuable_sidebar'; +import { initIssuableSidebar } from '~/issuable'; import StatusBox from '~/issuable/components/status_box.vue'; import createDefaultClient from '~/lib/graphql'; import initSourcegraph from '~/sourcegraph'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql index b5a82b9428e..1edb37a228d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql +++ b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql @@ -1,6 +1,8 @@ query getMergeRequestState($projectPath: ID!, $iid: String!) { workspace: project(fullPath: $projectPath) { + id issuable: mergeRequest(iid: $iid) { + id state } } diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 25dede33880..7f49eb60c5c 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,8 +1,8 @@ import { initReviewBar } from '~/batch_comments'; +import { initIssuableHeaderWarnings } from '~/issuable'; import initMrNotes from '~/mr_notes'; import store from '~/mr_notes/stores'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initShow from '../init_merge_request_show'; initMrNotes(); @@ -11,5 +11,5 @@ initShow(); requestIdleCallback(() => { initSidebarBundle(store); initReviewBar(); - initIssuableHeaderWarning(store); + initIssuableHeaderWarnings(store); }); diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/projects/milestones/edit/index.js +++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js index 150b506b121..ef1c9ab83db 100644 --- a/app/assets/javascripts/pages/projects/milestones/index/index.js +++ b/app/assets/javascripts/pages/projects/milestones/index/index.js @@ -1,3 +1,4 @@ -import milestones from '~/pages/milestones/shared'; +import { initDeleteMilestoneModal, initPromoteMilestoneModal } from '~/milestones'; -milestones(); +initDeleteMilestoneModal(); +initPromoteMilestoneModal(); diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/projects/milestones/new/index.js +++ b/app/assets/javascripts/pages/projects/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js index 3c755e9b98c..16aac7748da 100644 --- a/app/assets/javascripts/pages/projects/milestones/show/index.js +++ b/app/assets/javascripts/pages/projects/milestones/show/index.js @@ -1,5 +1,5 @@ -import milestones from '~/pages/milestones/shared'; -import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; +import { initDeleteMilestoneModal, initPromoteMilestoneModal, initShow } from '~/milestones'; -initMilestonesShow(); -milestones(); +initShow(); +initDeleteMilestoneModal(); +initPromoteMilestoneModal(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index f9eecff4ac4..174973a9fad 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,3 +1,3 @@ -import packageList from '~/packages_and_registries/package_registry/pages/list'; +import packageApp from '~/packages_and_registries/package_registry/index'; -packageList(); +packageApp(); diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js deleted file mode 100644 index e5ab5d43bbf..00000000000 --- a/app/assets/javascripts/pages/projects/path_locks/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; - -document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index 03ffc323fc0..a2b18d86240 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -1,9 +1,8 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusAlerts from '~/prometheus_alerts'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js deleted file mode 100644 index 9cd80b85c8a..00000000000 --- a/app/assets/javascripts/pages/projects/usage_quotas/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; -import storageCounter from '~/projects/storage_counter'; -import initSearchSettings from '~/search_settings'; - -const initLinkedTabs = () => { - if (!document.querySelector('.js-usage-quota-tabs')) { - return false; - } - - return new LinkedTabs({ - defaultAction: '#storage-quota-tab', - parentEl: '.js-usage-quota-tabs', - hashedTabs: true, - }); -}; - -const initVueApp = () => { - storageCounter('js-project-storage-count-app'); -}; - -initVueApp(); -initLinkedTabs(); -initSearchSettings(); 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 6f19a9f4379..b29e9455755 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, @@ -46,7 +47,7 @@ export default { newPage: s__( 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.', ), - moreInformation: s__('WikiPage|More Information.'), + learnMore: s__('WikiPage|Learn more.'), }, }, format: { @@ -104,6 +105,8 @@ export default { newPage: s__('WikiPage|Create page'), }, cancel: s__('WikiPage|Cancel'), + editSourceButtonText: s__('WikiPage|Edit source'), + editRichTextButtonText: s__('WikiPage|Edit rich text'), }, contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629', components: { @@ -123,7 +126,7 @@ export default { directives: { GlModalDirective, }, - mixins: [trackingMixin], + mixins: [trackingMixin, glFeatureFlagMixin()], inject: ['formatOptions', 'pageInfo'], data() { return { @@ -131,7 +134,6 @@ export default { format: this.pageInfo.format || 'markdown', content: this.pageInfo.content || '', isContentEditorAlertDismissed: false, - isContentEditorLoading: true, useContentEditor: false, commitMessage: '', isDirty: false, @@ -164,6 +166,11 @@ export default { linkExample() { return MARKDOWN_LINK_TEXT[this.format]; }, + toggleEditingModeButtonText() { + return this.isContentEditorActive + ? this.$options.i18n.editSourceButtonText + : this.$options.i18n.editRichTextButtonText; + }, submitButtonText() { return this.pageInfo.persisted ? this.$options.i18n.submitButton.existingPage @@ -188,7 +195,23 @@ export default { return this.format === 'markdown'; }, showContentEditorAlert() { - return this.isMarkdownFormat && !this.useContentEditor && !this.isContentEditorAlertDismissed; + return ( + !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && + this.isMarkdownFormat && + !this.useContentEditor && + !this.isContentEditorAlertDismissed + ); + }, + showSwitchEditingModeButton() { + return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat; + }, + displayWikiSpecificMarkdownHelp() { + return !this.isContentEditorActive; + }, + displaySwitchBackToClassicEditorMessage() { + return ( + !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive + ); }, disableSubmitButton() { return this.noContent || !this.title || this.contentEditorRenderFailed; @@ -212,6 +235,14 @@ export default { .then(({ data }) => data.body); }, + toggleEditingMode() { + if (this.useContentEditor) { + this.content = this.contentEditor.getSerializedContent(); + } + + this.useContentEditor = !this.useContentEditor; + }, + async handleFormSubmit(e) { e.preventDefault(); @@ -311,8 +342,11 @@ export default { trackWikiFormat() { this.track(WIKI_FORMAT_UPDATED_ACTION, { label: WIKI_FORMAT_LABEL, - value: this.format, - extra: { project_path: this.pageInfo.path, old_format: this.pageInfo.format }, + extra: { + project_path: this.pageInfo.path, + old_format: this.pageInfo.format, + value: this.format, + }, }); }, @@ -371,10 +405,9 @@ export default { <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600"> <gl-icon class="gl-mr-n1" name="bulb" /> {{ titleHelpText }} - <gl-link :href="helpPath" target="_blank" - ><gl-icon name="question-o" /> - {{ $options.i18n.title.helpText.moreInformation }}</gl-link - > + <gl-link :href="helpPath" target="_blank"> + {{ $options.i18n.title.helpText.learnMore }} + </gl-link> </span> </div> </div> @@ -405,6 +438,19 @@ export default { }}</label> </div> <div class="col-sm-10"> + <div + v-if="showSwitchEditingModeButton" + class="gl-display-flex gl-justify-content-end gl-mb-3" + > + <gl-button + data-testid="toggle-editing-mode-button" + data-qa-selector="editing_mode_button" + :data-qa-mode="toggleEditingModeButtonText" + variant="link" + @click="toggleEditingMode" + >{{ toggleEditingModeButtonText }}</gl-button + > + </div> <gl-alert v-if="showContentEditorAlert" class="gl-mb-6" @@ -498,7 +544,7 @@ export default { <div class="error-alert"></div> <div class="form-text gl-text-gray-600"> - <gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText"> + <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText"> <template #linkExample ><code>{{ linkExample }}</code></template > @@ -513,7 +559,7 @@ export default { ></template > </gl-sprintf> - <span v-else> + <span v-if="displaySwitchBackToClassicEditorMessage"> {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} <gl-button variant="link" @click="confirmSwitchToOldEditor">{{ $options.i18n.contentEditor.switchToOldEditor.label diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index 6a64538abfe..644eccc0232 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -45,7 +45,7 @@ export default { .promise.then(this.renderPages) .then((pages) => { this.pages = pages; - this.$emit('pdflabload'); + this.$emit('pdflabload', pages.length); }) .catch((error) => { this.$emit('pdflaberror', error); diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index 905a5f2d271..9f82d4a5395 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -73,7 +73,7 @@ export default { }); }, onReset() { - this.$emit('cancel'); + this.$emit('resetContent'); }, scrollIntoView() { this.$el.scrollIntoView({ behavior: 'smooth' }); @@ -86,7 +86,7 @@ export default { startMergeRequest: __('Start a %{new_merge_request} with these changes'), newMergeRequest: __('new merge request'), commitChanges: __('Commit changes'), - cancel: __('Cancel'), + resetContent: __('Reset'), }, }; </script> @@ -148,7 +148,7 @@ export default { {{ $options.i18n.commitChanges }} </gl-button> <gl-button type="reset" category="secondary" class="gl-mr-3"> - {{ $options.i18n.cancel }} + {{ $options.i18n.resetContent }} </gl-button> </div> </gl-form> diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 14c11099756..54c9688d88f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -8,10 +8,10 @@ import { COMMIT_SUCCESS, } from '../../constants'; import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; -import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql'; -import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql'; -import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql'; -import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; +import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql'; +import updateLastCommitBranchMutation from '../../graphql/mutations/client/update_last_commit_branch.mutation.graphql'; +import updatePipelineEtag from '../../graphql/mutations/client/update_pipeline_etag.mutation.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; import CommitForm from './commit_form.vue'; @@ -60,6 +60,9 @@ export default { apollo: { currentBranch: { query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, }, computed: { @@ -87,7 +90,7 @@ export default { try { const { data: { - commitCreate: { errors }, + commitCreate: { errors, commitPipelinePath: pipelineEtag }, }, } = await this.$apollo.mutate({ mutation: commitCIFile, @@ -101,14 +104,12 @@ export default { content: this.ciFileContent, lastCommitId: this.commitSha, }, - update(_, { data }) { - const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath; - if (pipelineEtag) { - this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag }); - } - }, }); + if (pipelineEtag) { + this.updatePipelineEtag(pipelineEtag); + } + if (errors?.length) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); } else if (openMergeRequest) { @@ -127,9 +128,6 @@ export default { this.isSaving = false; } }, - onCommitCancel() { - this.$emit('resetContent'); - }, updateCurrentBranch(currentBranch) { this.$apollo.mutate({ mutation: updateCurrentBranchMutation, @@ -142,6 +140,9 @@ export default { variables: { lastCommitBranch }, }); }, + updatePipelineEtag(pipelineEtag) { + this.$apollo.mutate({ mutation: updatePipelineEtag, variables: { pipelineEtag } }); + }, }, }; </script> @@ -153,7 +154,6 @@ export default { :is-saving="isSaving" :scroll-to-commit-form="scrollToCommitForm" v-on="$listeners" - @cancel="onCommitCancel" @submit="onCommitSubmit" /> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index d7594fb318a..7bc096ce2c8 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -90,7 +90,7 @@ export default { <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json> <aside aria-live="polite" - class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-3 gl-overflow-y-auto" + class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-200 gl-overflow-y-auto" :style="rootStyle" > <gl-button @@ -98,6 +98,7 @@ export default { class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex" :class="buttonClass" :title="__('Toggle sidebar')" + data-qa-selector="toggle_sidebar_collapse_button" @click="toggleDrawer" > <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text"> @@ -105,7 +106,12 @@ export default { </span> <gl-icon data-testid="toggle-icon" :name="buttonIconName" /> </gl-button> - <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content"> + <div + v-if="isExpanded" + class="gl-h-full gl-p-5" + data-testid="drawer-content" + data-qa-selector="drawer_content" + > <getting-started-card class="gl-mb-4" /> <first-pipeline-card class="gl-mb-4" /> <visualize-and-lint-card class="gl-mb-4" /> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 7b8e97b573e..92fa411d5af 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -19,7 +19,7 @@ export default { if (this.glFeatures.schemaLinting) { const editorInstance = this.$refs.editor.getEditor(); - editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); + editorInstance.use({ definition: CiSchemaExtension }); editorInstance.registerCiSchema(); } }, diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index baf1d17b233..4f79a81d539 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -18,10 +18,10 @@ import { BRANCH_SEARCH_DEBOUNCE, DEFAULT_FAILURE, } from '~/pipeline_editor/constants'; -import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql'; -import getCurrentBranchQuery from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; -import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; +import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; export default { i18n: { @@ -61,8 +61,8 @@ export default { }, data() { return { - branchSelected: null, availableBranches: [], + branchSelected: null, filteredBranches: [], isSearchingBranches: false, pageLimit: this.paginationLimit, @@ -93,15 +93,25 @@ export default { }, }, currentBranch: { - query: getCurrentBranchQuery, + query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, lastCommitBranch: { - query: getLastCommitBranchQuery, - result({ data: { lastCommitBranch } }) { - if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { - return; + query: getLastCommitBranch, + update(data) { + return data.workBranches.lastCommit.name; + }, + result({ data }) { + if (data) { + const { name: lastCommitBranch } = data.workBranches.lastCommit; + if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { + return; + } + + this.availableBranches.unshift(lastCommitBranch); } - this.availableBranches.unshift(lastCommitBranch); }, }, }, @@ -109,12 +119,12 @@ export default { branches() { return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches; }, - isBranchesLoading() { - return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; - }, enableBranchSwitcher() { return this.branches.length > 0 || this.searchTerm.length > 0; }, + isBranchesLoading() { + return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; + }, }, watch: { shouldLoadNewBranch(flag) { @@ -247,6 +257,7 @@ export default { <gl-infinite-scroll :fetched-items="branches.length" :max-list-height="250" + data-qa-selector="branch_menu_container" @bottomReached="fetchNextBranches" > <template #items> @@ -255,7 +266,7 @@ export default { :key="branch" :is-checked="currentBranch === branch" :is-check-item="true" - data-qa-selector="menu_branch_button" + data-qa-selector="branch_menu_item_button" @click="selectBranch(branch)" > {{ branch }} diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 6fe1459c80c..16ad648afca 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -3,8 +3,8 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; -import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility, @@ -21,9 +21,6 @@ export const i18n = { ), viewBtn: s__('Pipeline|View pipeline'), viewCommit: s__('Pipeline|View commit'), - pipelineNotTriggeredMsg: s__( - 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.', - ), }; export default { @@ -51,6 +48,9 @@ export default { apollo: { pipelineEtag: { query: getPipelineEtag, + update(data) { + return data.etags.pipeline; + }, }, pipeline: { context() { @@ -79,22 +79,16 @@ export default { result(res) { if (res.data?.project?.pipeline) { this.hasError = false; - } else { - this.hasError = true; - this.pipelineNotTriggered = true; } }, error() { this.hasError = true; - this.networkError = true; }, pollInterval: POLL_INTERVAL, }, }, data() { return { - networkError: false, - pipelineNotTriggered: false, hasError: false, }; }, @@ -148,16 +142,8 @@ export default { </div> </template> <template v-else-if="hasError"> - <div v-if="networkError"> - <gl-icon class="gl-mr-auto" name="warning-solid" /> - <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> - </div> - <div v-else> - <gl-icon class="gl-mr-auto" name="information-o" /> - <span data-testid="pipeline-not-triggered-error-msg"> - {{ $options.i18n.pipelineNotTriggeredMsg }} - </span> - </div> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> </template> <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 611b78b3c5e..833d784f940 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -1,8 +1,8 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING, @@ -43,6 +43,9 @@ export default { apollo: { appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, }, computed: { diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 7f6dce05b6e..13e254f138a 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; import CiLintResultsParam from './ci_lint_results_param.vue'; import CiLintResultsValue from './ci_lint_results_value.vue'; @@ -36,7 +36,7 @@ export default { GlAlert, GlLink, GlSprintf, - GlTable, + GlTableLite, CiLintWarnings, CiLintResultsValue, CiLintResultsParam, @@ -129,7 +129,7 @@ export default { @dismiss="isWarningDismissed = true" /> - <gl-table + <gl-table-lite v-if="shouldShowTable" :items="jobs" :fields="$options.fields" @@ -142,6 +142,6 @@ export default { <template #cell(value)="{ item }"> <ci-lint-results-value :item="item" :dry-run="dryRun" /> </template> - </gl-table> + </gl-table-lite> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 0cd0d17d944..3f50a1225d8 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -17,7 +17,7 @@ import { TABS_INDEX, VISUALIZE_TAB, } from '../constants'; -import getAppStatus from '../graphql/queries/client/app_status.graphql'; +import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; @@ -91,6 +91,9 @@ export default { apollo: { appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, }, computed: { diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql index 5091d63111f..5091d63111f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql index 7487e328668..7487e328668 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql index b722c147f5f..b722c147f5f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql index 9561312f2b6..9561312f2b6 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql index 9025f00b343..9025f00b343 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 94e6facabfd..77a3cdf586c 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -19,7 +19,10 @@ mutation commitCIFile( ] } ) { + __typename commit { + __typename + id sha } commitPipelinePath diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql index 46e9b108b41..359b4a846c7 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql @@ -5,6 +5,7 @@ query getAvailableBranches( $searchPattern: String! ) { project(fullPath: $projectFullPath) { + id repository { branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern) } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql index 5500244b430..5928d90f7c4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql @@ -1,8 +1,10 @@ query getBlobContent($projectPath: ID!, $path: String!, $ref: String) { project(fullPath: $projectPath) { + id repository { blobs(paths: [$path], ref: $ref) { nodes { + id rawBlob } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql index df7de6a1f54..df7de6a1f54 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql deleted file mode 100644 index 938f36c7d5c..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getAppStatus { - appStatus @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql new file mode 100644 index 00000000000..0df8cafa3cb --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql @@ -0,0 +1,5 @@ +query getAppStatus { + app @client { + status + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql deleted file mode 100644 index acd46013f5b..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getCurrentBranch { - currentBranch @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql new file mode 100644 index 00000000000..1f4f9d26f24 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql @@ -0,0 +1,7 @@ +query getCurrentBranch { + workBranches @client { + current { + name + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql index e8a32d728d5..a83129759de 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql @@ -1,3 +1,7 @@ query getLastCommitBranchQuery { - lastCommitBranch @client + workBranches @client { + lastCommit { + name + } + } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql deleted file mode 100644 index b9946a9e233..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getPipelineEtag { - pipelineEtag @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql new file mode 100644 index 00000000000..8df6e74a5d9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql @@ -0,0 +1,5 @@ +query getPipelineEtag { + etags @client { + pipeline + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql index 88825718f7b..a34c8f365f4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql @@ -1,5 +1,6 @@ query getTemplate($projectPath: ID!, $templateName: String!) { project(fullPath: $projectPath) { + id ciTemplate(name: $templateName) { content } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql index 02d49507947..d62fda40237 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql @@ -1,8 +1,10 @@ query getLatestCommitSha($projectPath: ID!, $ref: String) { project(fullPath: $projectPath) { + id repository { tree(ref: $ref) { lastCommit { + id sha } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql index 34e98ae3eb3..021b858d72e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql @@ -1,14 +1,17 @@ query getPipeline($fullPath: ID!, $sha: String!) { project(fullPath: $fullPath) { + id pipeline(sha: $sha) { id iid status commit { + id title webPath } detailedStatus { + id detailsPath icon group diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index e4965e00af3..fa1c70c1994 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,8 +1,8 @@ import axios from '~/lib/utils/axios_utils'; -import getAppStatus from './queries/client/app_status.graphql'; -import getCurrentBranchQuery from './queries/client/current_branch.graphql'; -import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; -import getPipelineEtag from './queries/client/pipeline_etag.graphql'; +import getAppStatus from './queries/client/app_status.query.graphql'; +import getCurrentBranch from './queries/client/current_branch.query.graphql'; +import getLastCommitBranch from './queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './queries/client/pipeline_etag.query.graphql'; export const resolvers = { Mutation: { @@ -35,25 +35,51 @@ export const resolvers = { updateAppStatus: (_, { appStatus }, { cache }) => { cache.writeQuery({ query: getAppStatus, - data: { appStatus }, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, + }, }); }, updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ - query: getCurrentBranchQuery, - data: { currentBranch }, + query: getCurrentBranch, + data: { + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: currentBranch, + }, + }, + }, }); }, updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ - query: getLastCommitBranchQuery, - data: { lastCommitBranch }, + query: getLastCommitBranch, + data: { + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: lastCommitBranch, + }, + }, + }, }); }, updatePipelineEtag: (_, { pipelineEtag }, { cache }) => { cache.writeQuery({ query: getPipelineEtag, - data: { pipelineEtag }, + data: { + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, + }, }); }, }, diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql index f4f65262158..508ff22c46e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql @@ -1,7 +1,23 @@ -type BlobContent { - rawData: String! +type PipelineEditorApp { + status: String! +} + +type BranchList { + current: WorkBranch! + lastCommit: WorkBranch! +} + +type EtagValues { + pipeline: String! +} + +type WorkBranch { + name: String! + commit: String } extend type Query { - blobContent: BlobContent + app: PipelineEditorApp + etags: EtagValues + workBranches: BranchList } diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 4f7f2743aca..ee93e327b76 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -5,10 +5,10 @@ import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { EDITOR_APP_STATUS_LOADING } from './constants'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; -import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; -import getAppStatus from './graphql/queries/client/app_status.graphql'; -import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql'; -import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; @@ -68,28 +68,46 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { cache.writeQuery({ query: getAppStatus, data: { - appStatus: EDITOR_APP_STATUS_LOADING, + app: { + __typename: 'PipelineEditorApp', + status: EDITOR_APP_STATUS_LOADING, + }, }, }); cache.writeQuery({ query: getCurrentBranch, data: { - currentBranch: initialBranchName || defaultBranch, + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: initialBranchName || defaultBranch, + }, + }, }, }); cache.writeQuery({ - query: getPipelineEtag, + query: getLastCommitBranch, data: { - pipelineEtag, + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: '', + }, + }, }, }); cache.writeQuery({ - query: getLastCommitBranchQuery, + query: getPipelineEtag, data: { - lastCommitBranch: '', + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, }, }); diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 68db5d8078f..e397054f06a 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,8 +1,8 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; @@ -17,11 +17,11 @@ import { LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; -import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql'; -import getBlobContent from './graphql/queries/blob_content.graphql'; -import getCiConfigData from './graphql/queries/ci_config.graphql'; -import getAppStatus from './graphql/queries/client/app_status.graphql'; -import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; +import updateAppStatus from './graphql/mutations/client/update_app_status.mutation.graphql'; +import getBlobContent from './graphql/queries/blob_content.query.graphql'; +import getCiConfigData from './graphql/queries/ci_config.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; import getTemplate from './graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; @@ -30,6 +30,7 @@ export default { components: { ConfirmUnsavedChangesDialog, GlLoadingIcon, + GlModal, PipelineEditorEmptyState, PipelineEditorHome, PipelineEditorMessages, @@ -54,6 +55,7 @@ export default { lastCommittedContent: '', shouldSkipStartScreen: false, showFailure: false, + showResetComfirmationModal: false, showStartScreen: false, showSuccess: false, starterTemplate: '', @@ -158,6 +160,9 @@ export default { }, appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, commitSha: { query: getLatestCommitShaQuery, @@ -182,6 +187,9 @@ export default { }, currentBranch: { query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, starterTemplate: { query: getTemplate, @@ -220,9 +228,18 @@ export default { }, }, i18n: { - tabEdit: s__('Pipelines|Edit'), - tabGraph: s__('Pipelines|Visualize'), - tabLint: s__('Pipelines|Lint'), + resetModal: { + actionPrimary: { + text: __('Reset file'), + }, + actionCancel: { + text: __('Cancel'), + }, + body: s__( + 'Pipeline Editor|Are you sure you want to reset the file to its last committed version?', + ), + title: __('Discard changes'), + }, }, watch: { isEmpty(flag) { @@ -242,15 +259,24 @@ export default { hideSuccess() { this.showSuccess = false; }, + confirmReset() { + if (this.hasUnsavedChanges) { + this.showResetComfirmationModal = true; + } + }, async refetchContent() { this.$apollo.queries.initialCiFileContent.skip = false; await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showFailure = true; - this.failureType = type; - this.failureReasons = reasons; + const isCurrentFailure = this.failureType === type && this.failureReasons[0] === reasons[0]; + + if (!isCurrentFailure) { + this.showFailure = true; + this.failureType = type; + this.failureReasons = reasons; + window.scrollTo({ top: 0, behavior: 'smooth' }); + } }, reportSuccess(type) { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -258,6 +284,7 @@ export default { this.successType = type; }, resetContent() { + this.showResetComfirmationModal = false; this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { @@ -331,12 +358,22 @@ export default { :has-unsaved-changes="hasUnsavedChanges" :is-new-ci-config-file="isNewCiConfigFile" @commit="updateOnCommit" - @resetContent="resetContent" + @resetContent="confirmReset" @showError="showErrorAlert" @refetchContent="refetchContent" @updateCiConfig="updateCiConfig" @updateCommitSha="updateCommitSha" /> + <gl-modal + v-model="showResetComfirmationModal" + modal-id="reset-content" + :title="$options.i18n.resetModal.title" + :action-cancel="$options.i18n.resetModal.actionCancel" + :action-primary="$options.i18n.resetModal.actionPrimary" + @primary="resetContent" + > + {{ $options.i18n.resetModal.body }} + </gl-modal> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index 3c78b655dc7..1920fed84ec 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; @@ -7,8 +7,9 @@ export default { name: 'GraphViewSelector', components: { GlAlert, + GlButton, + GlButtonGroup, GlLoadingIcon, - GlSegmentedControl, GlToggle, }, props: { @@ -96,6 +97,9 @@ export default { this.hoverTipDismissed = true; this.$emit('dismissHoverTip'); }, + isCurrentType(type) { + return this.segmentSelectedType === type; + }, /* In both toggle methods, we use setTimeout so that the loading indicator displays, then the work is done to update the DOM. The process is: @@ -110,11 +114,14 @@ export default { See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details. */ - toggleView(type) { - this.isSwitcherLoading = true; - setTimeout(() => { - this.$emit('updateViewType', type); - }); + setViewType(type) { + if (!this.isCurrentType(type)) { + this.isSwitcherLoading = true; + this.segmentSelectedType = type; + setTimeout(() => { + this.$emit('updateViewType', type); + }); + } }, toggleShowLinksActive(val) { this.isToggleLoading = true; @@ -136,14 +143,16 @@ export default { size="lg" /> <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span> - <gl-segmented-control - v-model="segmentSelectedType" - :options="viewTypesList" - :disabled="isSwitcherLoading" - data-testid="pipeline-view-selector" - class="gl-mx-4" - @input="toggleView" - /> + <gl-button-group class="gl-mx-4"> + <gl-button + v-for="viewType in viewTypesList" + :key="viewType.value" + :selected="isCurrentType(viewType.value)" + @click="setViewType(viewType.value)" + > + {{ viewType.text }} + </gl-button> + </gl-button-group> <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> <gl-toggle diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 6f4360649ff..12c3f9a7f40 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -67,7 +67,7 @@ export default { :class="cssClassJobName" class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!" > - <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between"> <job-item :type="$options.jobItemTypes.jobDropdown" :group-tooltip="tooltipText" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 0216b2717ed..ee58dcc4882 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -203,7 +203,7 @@ export default { <template> <div :id="computedJobId" - class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full" + class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width" data-qa-selector="job_item_container" > <component @@ -223,12 +223,12 @@ export default { > <div class="ci-job-name-component gl-display-flex gl-align-items-center"> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> - <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full"> - <div class="gl-text-truncate gl-w-70p gl-line-height-normal">{{ job.name }}</div> + <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 v-if="showStageName" data-testid="stage-name-in-job" - class="gl-text-truncate gl-w-70p gl-font-sm gl-text-gray-500 gl-line-height-normal" + class="gl-text-truncate gl-pr-9 gl-font-sm gl-text-gray-500 gl-line-height-normal" > {{ stageName }} </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index be47799868b..e0c1dcc5be5 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -124,7 +124,7 @@ export default { <div ref="linkedPipeline" v-gl-tooltip - class="gl-pipeline-job-width" + class="gl-downstream-pipeline-job-width" :title="tooltipText" data-qa-selector="child_pipeline" @mouseover="onDownstreamHovered" @@ -134,7 +134,7 @@ export default { class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1" :class="{ 'gl-pl-9': isUpstream }" > - <div class="gl-display-flex"> + <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width"> <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" @@ -142,7 +142,9 @@ export default { css-classes="gl-top-0 gl-pr-2" /> <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div> - <div class="gl-display-flex gl-flex-direction-column gl-w-13"> + <div + class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate" + > <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} </span> diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue new file mode 100644 index 00000000000..ffac8206b58 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -0,0 +1,121 @@ +<script> +import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import produce from 'immer'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import eventHub from '~/jobs/components/table/event_hub'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants'; +import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql'; + +export default { + fields: JOBS_TAB_FIELDS, + components: { + GlIntersectionObserver, + GlLoadingIcon, + GlSkeletonLoader, + JobsTable, + }, + inject: { + fullPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + apollo: { + jobs: { + query: getPipelineJobs, + variables() { + return { + ...this.queryVariables, + }; + }, + update(data) { + return data.project?.pipeline?.jobs?.nodes || []; + }, + result({ data }) { + this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; + }, + error() { + createFlash({ message: __('An error occured while fetching the pipelines jobs.') }); + }, + }, + }, + data() { + return { + jobs: [], + jobsPageInfo: {}, + firstLoad: true, + }; + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + iid: this.pipelineIid, + }; + }, + }, + mounted() { + eventHub.$on('jobActionPerformed', this.handleJobAction); + }, + beforeDestroy() { + eventHub.$off('jobActionPerformed', this.handleJobAction); + }, + methods: { + handleJobAction() { + this.firstLoad = true; + + this.$apollo.queries.jobs.refetch(); + }, + fetchMoreJobs() { + this.firstLoad = false; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + ...this.queryVariables, + after: this.jobsPageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, (draftData) => { + draftData.project.pipeline.jobs.nodes = [ + ...previousResult.project.pipeline.jobs.nodes, + ...draftData.project.pipeline.jobs.nodes, + ]; + }); + return results; + }, + }); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> + </div> + + <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" /> + + <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon v-if="$apollo.loading" size="md" /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 836333c8bde..793e343a02a 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -1,5 +1,5 @@ <script> -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 78771b6a072..64210576b29 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -25,7 +25,7 @@ export default { // The max width and the width make sure the ellipsis to work and the min width // is for when there is less text than the stage column width (which the width 100% does not fix) jobWrapperClasses: - 'gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', + 'gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', props: { pipelineData: { required: true, diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue index 367a18af248..e485b38ce11 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue @@ -1,6 +1,6 @@ <script> import { capitalize, escape } from 'lodash'; -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index 7552ddb61dc..afcb04cd7eb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -15,7 +15,7 @@ import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; import JobItem from './job_item.vue'; @@ -98,6 +98,9 @@ export default { // warn the pipelines table to update this.$emit('pipelineActionRequestComplete'); }, + stageAriaLabel(title) { + return sprintf(__('View Stage: %{title}'), { title }); + }, }, }; </script> @@ -106,9 +109,10 @@ export default { <gl-dropdown ref="dropdown" v-gl-tooltip.hover.ds0 + v-gl-tooltip="stage.title" data-testid="mini-pipeline-graph-dropdown" - :title="stage.title" variant="link" + :aria-label="stageAriaLabel(stage.title)" :lazy="true" :popper-opts="{ placement: 'bottom' }" :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]" diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql index 887c217da41..2a0b13dd0cc 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -1,19 +1,24 @@ query getDagVisData($projectPath: ID!, $iid: ID!) { project(fullPath: $projectPath) { + id pipeline(iid: $iid) { id stages { nodes { + id name groups { nodes { + id name size jobs { nodes { + id name needs { nodes { + id name } } diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 8fcae9dbad8..47bc167ca52 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -1,5 +1,6 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { + id pipeline(iid: $iid) { id iid @@ -11,6 +12,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { updatePipeline } detailedStatus { + id detailsPath icon group diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql new file mode 100644 index 00000000000..5fe47e09d9c --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql @@ -0,0 +1,70 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs(after: $after, first: 20) { + pageInfo { + ...PageInfo + } + nodes { + artifacts { + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index ee9560e36c4..ae8b2503c79 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineJobsApp } from './pipeline_details_jobs'; import { apolloProvider } from './pipeline_shared_client'; import { createTestDetails } from './pipeline_test_details'; @@ -11,6 +12,7 @@ const SELECTORS = { PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TESTS: '#js-pipeline-tests-detail', + PIPELINE_JOBS: '#js-pipeline-jobs-vue', }; export default async function initPipelineDetailsBundle() { @@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading the Test Reports tab.'), }); } + + try { + if (gon.features?.jobsTabVue) { + createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); + } + } catch { + createFlash({ + message: __('An error occurred while loading the Jobs tab.'), + }); + } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js new file mode 100644 index 00000000000..a1294a484f0 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_jobs.js @@ -0,0 +1,34 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import JobsApp from './components/jobs/jobs_app.vue'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineJobsApp = (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath, pipelineIid } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + pipelineIid, + }, + render(createElement) { + return createElement(JobsApp); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js index d553bca360e..eb3673461bd 100644 --- a/app/assets/javascripts/projects/commit/constants.js +++ b/app/assets/javascripts/projects/commit/constants.js @@ -11,7 +11,7 @@ export const I18N_MODAL = { 'ChangeTypeAction|Your changes will be committed to %{branchName} because a merge request is open.', ), branchInFork: s__( - 'ChangeTypeAction|A new branch will be created in your fork and a new merge request will be started.', + 'ChangeTypeAction|GitLab will create a branch in your fork and start a merge request.', ), newMergeRequest: __('new merge request'), actionCancelText: __('Cancel'), diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql index ee18c70b6fd..c6a0d48626a 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql @@ -1,15 +1,19 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { + id pipeline(iid: $iid) { + id path downstream { nodes { id path project { + id name } detailedStatus { + id group icon label @@ -20,9 +24,11 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { id path project { + id name } detailedStatus { + id group icon label diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index e0ba60074af..f4a21c6057c 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -8,7 +8,7 @@ import { GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; @@ -36,7 +36,9 @@ export default { }; }, skip() { - return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + const hasNotEnoughSearchCharacters = + this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + return this.shouldSkipQuery || hasNotEnoughSearchCharacters; }, debounce: DEBOUNCE_DELAY, }, @@ -52,7 +54,7 @@ export default { data() { return { currentUser: {}, - groupToFilterBy: undefined, + groupPathToFilterBy: undefined, search: '', selectedNamespace: this.namespaceId ? { @@ -63,6 +65,7 @@ export default { id: this.userNamespaceId, fullPath: this.userNamespaceFullPath, }, + shouldSkipQuery: true, }; }, computed: { @@ -73,10 +76,8 @@ export default { return this.currentUser.namespace || {}; }, filteredGroups() { - return this.groupToFilterBy - ? this.userGroups.filter((group) => - group.fullPath.startsWith(this.groupToFilterBy.fullPath), - ) + return this.groupPathToFilterBy + ? this.userGroups.filter((group) => group.fullPath.startsWith(this.groupPathToFilterBy)) : this.userGroups; }, hasGroupMatches() { @@ -85,7 +86,7 @@ export default { hasNamespaceMatches() { return ( this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) && - !this.groupToFilterBy + !this.groupPathToFilterBy ); }, hasNoMatches() { @@ -99,7 +100,10 @@ export default { eventHub.$off('select-template', this.handleSelectTemplate); }, methods: { - focusInput() { + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } this.$refs.search.focusInput(); }, handleDropdownItemClick(namespace) { @@ -111,13 +115,9 @@ export default { }); this.setNamespace(namespace); }, - handleSelectTemplate(groupId) { - this.groupToFilterBy = this.userGroups.find( - (group) => getIdFromGraphQLId(group.id) === groupId, - ); - if (this.groupToFilterBy) { - this.setNamespace(this.groupToFilterBy); - } + handleSelectTemplate(id, fullPath) { + this.groupPathToFilterBy = fullPath.split(PATH_SEPARATOR).shift(); + this.setNamespace({ id, fullPath }); }, setNamespace({ id, fullPath }) { this.selectedNamespace = { @@ -137,7 +137,7 @@ export default { toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" data-qa-selector="select_namespace_dropdown" @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" - @shown="focusInput" + @shown="handleDropdownShown" > <gl-search-box-by-type ref="search" diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql index 74febec5a51..568e05d1966 100644 --- a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql +++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql @@ -1,5 +1,6 @@ query searchNamespacesWhereUserCanCreateProjects($search: String) { currentUser { + id groups(permissionScope: CREATE_PROJECTS, search: $search) { nodes { id diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 7379d5caed7..d4b1f7e57d8 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; +import API from '~/api'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import PipelineCharts from './pipeline_charts.vue'; @@ -13,6 +14,9 @@ export default { LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'), ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'), }, + piplelinesTabEvent: 'p_analytics_ci_cd_pipelines', + deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency', + leadTimeTabEvent: 'p_analytics_ci_cd_lead_time', inject: { shouldRenderDoraCharts: { type: Boolean, @@ -60,20 +64,35 @@ export default { updateHistory({ url: path, title: window.title }); } }, + trackTabClick(tab) { + API.trackRedisHllUserEvent(tab); + }, }, }; </script> <template> <div> <gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange"> - <gl-tab :title="__('Pipelines')"> + <gl-tab + :title="__('Pipelines')" + data-testid="pipelines-tab" + @click="trackTabClick($options.piplelinesTabEvent)" + > <pipeline-charts /> </gl-tab> <template v-if="shouldRenderDoraCharts"> - <gl-tab :title="__('Deployment frequency')"> + <gl-tab + :title="__('Deployment frequency')" + data-testid="deployment-frequency-tab" + @click="trackTabClick($options.deploymentFrequencyTabEvent)" + > <deployment-frequency-charts /> </gl-tab> - <gl-tab :title="__('Lead time')"> + <gl-tab + :title="__('Lead time')" + data-testid="lead-time-tab" + @click="trackTabClick($options.leadTimeTabEvent)" + > <lead-time-charts /> </gl-tab> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue index 7bc3b787f75..5383a6cdddf 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -1,10 +1,19 @@ <script> +import { GlLink } from '@gitlab/ui'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { s__, n__ } from '~/locale'; const defaultPrecision = 2; export default { + components: { + GlLink, + }, + inject: { + failedPipelinesLink: { + default: '', + }, + }, props: { counts: { type: Object, @@ -27,6 +36,7 @@ export default { { title: s__('PipelineCharts|Failed:'), value: n__('1 pipeline', '%d pipelines', this.counts.failed), + link: this.failedPipelinesLink, }, { title: s__('PipelineCharts|Success ratio:'), @@ -39,10 +49,13 @@ export default { </script> <template> <ul> - <template v-for="({ title, value }, index) in statistics"> + <template v-for="({ title, value, link }, index) in statistics"> <li :key="index"> <span>{{ title }}</span> - <strong>{{ value }}</strong> + <gl-link v-if="link" :href="link"> + {{ value }} + </gl-link> + <strong v-else>{{ value }}</strong> </li> </template> </ul> diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql index d68df689f5f..ac7fe51384c 100644 --- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql @@ -1,5 +1,6 @@ query getPipelineCountByStatus($projectPath: ID!) { project(fullPath: $projectPath) { + id totalPipelines: pipelines { count } diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql index 18b645f8831..46e8a6dc87d 100644 --- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql @@ -1,5 +1,6 @@ query getProjectPipelineStatistics($projectPath: ID!) { project(fullPath: $projectPath) { + id pipelineAnalytics { weekPipelinesTotals weekPipelinesLabels diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 003b61d94b1..94d32609e5d 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -11,7 +11,7 @@ const apolloProvider = new VueApollo({ }); const mountPipelineChartsApp = (el) => { - const { projectPath } = el.dataset; + const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary); @@ -25,8 +25,11 @@ const mountPipelineChartsApp = (el) => { apolloProvider, provide: { projectPath, + failedPipelinesLink, shouldRenderDoraCharts, shouldRenderQualitySummary, + coverageChartPath, + defaultBranch, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue new file mode 100644 index 00000000000..b98e1101884 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -0,0 +1,63 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; + +export default { + name: 'TransferProjectForm', + components: { + GlFormGroup, + NamespaceSelect, + ConfirmDanger, + }, + props: { + namespaces: { + type: Object, + required: true, + }, + confirmationPhrase: { + type: String, + required: true, + }, + confirmButtonText: { + type: String, + required: true, + }, + }, + data() { + return { selectedNamespace: null }; + }, + computed: { + hasSelectedNamespace() { + return Boolean(this.selectedNamespace?.id); + }, + }, + methods: { + handleSelect(selectedNamespace) { + this.selectedNamespace = selectedNamespace; + this.$emit('selectNamespace', selectedNamespace.id); + }, + }, +}; +</script> +<template> + <div> + <gl-form-group> + <namespace-select + class="qa-namespaces-list" + data-testid="transfer-project-namespace" + :full-width="true" + :data="namespaces" + :selected-namespace="selectedNamespace" + @select="handleSelect" + /> + </gl-form-group> + <confirm-danger + button-class="qa-transfer-button" + :disabled="!hasSelectedNamespace" + :phrase="confirmationPhrase" + :button-text="confirmButtonText" + @confirm="$emit('confirm')" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js new file mode 100644 index 00000000000..47b49031dc9 --- /dev/null +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TransferProjectForm from './components/transfer_project_form.vue'; + +const prepareNamespaces = (rawNamespaces = '') => { + const data = JSON.parse(rawNamespaces); + return { + group: data?.group.map(convertObjectPropsToCamelCase), + user: data?.user.map(convertObjectPropsToCamelCase), + }; +}; + +export default () => { + const el = document.querySelector('.js-transfer-project-form'); + if (!el) { + return false; + } + + const { + targetFormId = null, + targetHiddenInputId = null, + buttonText: confirmButtonText = '', + phrase: confirmationPhrase = '', + confirmDangerMessage = '', + namespaces = '', + } = el.dataset; + + return new Vue({ + el, + provide: { + confirmDangerMessage, + }, + render(createElement) { + return createElement(TransferProjectForm, { + props: { + confirmButtonText, + confirmationPhrase, + namespaces: prepareNamespaces(namespaces), + }, + on: { + selectNamespace: (id) => { + if (targetHiddenInputId && document.getElementById(targetHiddenInputId)) { + document.getElementById(targetHiddenInputId).value = id; + } + }, + confirm: () => { + if (targetFormId) document.getElementById(targetFormId)?.submit(); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index b8053bf9ab5..e5ddfe82e3b 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,5 +1,15 @@ <script> -import { GlButton, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; +import { + GlButton, + GlToggle, + GlLoadingIcon, + GlSprintf, + GlFormInputGroup, + GlFormGroup, + GlFormInput, + GlLink, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; @@ -15,6 +25,8 @@ export default { GlLoadingIcon, GlSprintf, GlFormInput, + GlFormGroup, + GlFormInputGroup, GlLink, ServiceDeskTemplateDropdown, }, @@ -88,6 +100,16 @@ export default { hasCustomEmail() { return this.customEmail && this.customEmail !== this.incomingEmail; }, + emailSuffixHelpUrl() { + return helpPagePath('user/project/service_desk.html', { + anchor: 'configuring-a-custom-email-address-suffix', + }); + }, + customEmailAddressHelpUrl() { + return helpPagePath('user/project/service_desk.html', { + anchor: 'using-a-custom-email-address', + }); + }, }, methods: { onCheckboxToggle(isChecked) { @@ -132,101 +154,122 @@ export default { </label> <div v-if="isEnabled" class="row mt-3"> <div class="col-md-9 mb-0"> - <strong - id="incoming-email-describer" - class="gl-display-block gl-mb-1" - data-testid="incoming-email-describer" + <gl-form-group + :label="__('Email address to use for Support Desk')" + label-for="incoming-email" + data-testid="incoming-email-label" > - {{ __('Email address to use for Support Desk') }} - </strong> - <template v-if="email"> - <div class="input-group"> - <input + <gl-form-input-group v-if="email"> + <gl-form-input + id="incoming-email" ref="service-desk-incoming-email" type="text" - class="form-control" data-testid="incoming-email" :placeholder="__('Incoming email')" :aria-label="__('Incoming email')" aria-describedby="incoming-email-describer" :value="email" - disabled="true" + :disabled="true" /> - <div class="input-group-append"> + <template #append> <clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" /> - </div> - </div> - <span v-if="hasCustomEmail" class="form-text text-muted"> - <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> - <template #email> - <code>{{ incomingEmail }}</code> + </template> + </gl-form-input-group> + <template v-if="email && hasCustomEmail" #description> + <span class="gl-mt-2 d-inline-block"> + <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> + <template #email> + <code>{{ incomingEmail }}</code> + </template> + </gl-sprintf> + </span> + </template> + <template v-if="!email"> + <gl-loading-icon size="sm" :inline="true" /> + <span class="sr-only">{{ __('Fetching incoming email') }}</span> + </template> + </gl-form-group> + + <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError"> + <gl-form-input + v-if="hasProjectKeySupport" + id="service-desk-project-suffix" + v-model.trim="projectKey" + data-testid="project-suffix" + @blur="validateProjectKey" + /> + + <template v-if="hasProjectKeySupport" #description> + <gl-sprintf + :message=" + __('Add a suffix to Service Desk email address. %{linkStart}Learn more.%{linkEnd}') + " + > + <template #link="{ content }"> + <gl-link + :href="emailSuffixHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> </template> </gl-sprintf> - </span> - </template> - <template v-else> - <gl-loading-icon size="sm" :inline="true" /> - <span class="sr-only">{{ __('Fetching incoming email') }}</span> - </template> + </template> + <template v-else #description> + <gl-sprintf + :message=" + __( + 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + :href="customEmailAddressHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + + <template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback> + {{ projectKeyError }} + </template> + </gl-form-group> - <label for="service-desk-project-suffix" class="mt-3"> - {{ __('Project name suffix') }} - </label> - <gl-form-input - v-if="hasProjectKeySupport" - id="service-desk-project-suffix" - v-model.trim="projectKey" - data-testid="project-suffix" - class="form-control" + <gl-form-group + :label="__('Template to append to all Service Desk issues')" :state="!projectKeyError" - @blur="validateProjectKey" - /> - <span v-if="hasProjectKeySupport && projectKeyError" class="form-text text-danger"> - {{ projectKeyError }} - </span> - <span - v-if="hasProjectKeySupport" - class="form-text text-muted" - :class="{ 'gl-mt-2!': hasProjectKeySupport && projectKeyError }" + class="mt-3" > - {{ __('A string appended to the project path to form the Service Desk email address.') }} - </span> - <span v-else class="form-text text-muted"> - <gl-sprintf - :message=" - __( - 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/project/service_desk.html#using-a-custom-email-address" - target="_blank" - class="gl-text-blue-600 font-size-inherit" - >{{ content }} - </gl-link> - </template> - </gl-sprintf> - </span> + <service-desk-template-dropdown + :selected-template="selectedTemplate" + :selected-file-template-project-id="selectedFileTemplateProjectId" + :templates="templates" + @change="templateChange" + /> + </gl-form-group> + + <gl-form-group + :label="__('Email display name')" + label-for="service-desk-email-from-name" + :state="!projectKeyError" + class="mt-3" + > + <gl-form-input + v-if="hasProjectKeySupport" + id="service-desk-email-from-name" + v-model.trim="outgoingName" + data-testid="email-from-name" + /> - <label for="service-desk-template-select" class="mt-3"> - {{ __('Template to append to all Service Desk issues') }} - </label> - <service-desk-template-dropdown - :selected-template="selectedTemplate" - :selected-file-template-project-id="selectedFileTemplateProjectId" - :templates="templates" - @change="templateChange" - /> + <template v-if="hasProjectKeySupport" #description> + {{ __('Emails sent from Service Desk have this name.') }} + </template> + </gl-form-group> - <label for="service-desk-email-from-name" class="mt-3"> - {{ __('Email display name') }} - </label> - <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" /> - <span class="form-text text-muted"> - {{ __('Emails sent from Service Desk have this name.') }} - </span> <div class="gl-display-flex gl-justify-content-end"> <gl-button variant="success" diff --git a/app/assets/javascripts/projects/storage_counter/components/app.vue b/app/assets/javascripts/projects/storage_counter/components/app.vue deleted file mode 100644 index 1a911ea3d9b..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/app.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { sprintf } from '~/locale'; -import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; -import { - ERROR_MESSAGE, - LEARN_MORE_LABEL, - USAGE_QUOTAS_LABEL, - TOTAL_USAGE_TITLE, - TOTAL_USAGE_SUBTITLE, - TOTAL_USAGE_DEFAULT_TEXT, - HELP_LINK_ARIA_LABEL, -} from '../constants'; -import getProjectStorageCount from '../queries/project_storage.query.graphql'; -import { parseGetProjectStorageResults } from '../utils'; -import StorageTable from './storage_table.vue'; - -export default { - name: 'StorageCounterApp', - components: { - GlAlert, - GlLink, - GlLoadingIcon, - StorageTable, - UsageGraph, - }, - inject: ['projectPath', 'helpLinks'], - apollo: { - project: { - query: getProjectStorageCount, - variables() { - return { - fullPath: this.projectPath, - }; - }, - update(data) { - return parseGetProjectStorageResults(data, this.helpLinks); - }, - error() { - this.error = ERROR_MESSAGE; - }, - }, - }, - data() { - return { - project: {}, - error: '', - }; - }, - computed: { - totalUsage() { - return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT; - }, - storageTypes() { - return this.project?.storage?.storageTypes || []; - }, - }, - methods: { - clearError() { - this.error = ''; - }, - helpLinkAriaLabel(linkTitle) { - return sprintf(HELP_LINK_ARIA_LABEL, { - linkTitle, - }); - }, - }, - LEARN_MORE_LABEL, - USAGE_QUOTAS_LABEL, - TOTAL_USAGE_TITLE, - TOTAL_USAGE_SUBTITLE, -}; -</script> -<template> - <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="md" /> - <gl-alert v-else-if="error" variant="danger" @dismiss="clearError"> - {{ error }} - </gl-alert> - <div v-else> - <div class="gl-pt-5 gl-px-3"> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> - <div> - <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p> - <p class="gl-m-0 gl-text-gray-400"> - {{ $options.TOTAL_USAGE_SUBTITLE }} - <gl-link - :href="helpLinks.usageQuotasHelpPagePath" - target="_blank" - :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)" - data-testid="usage-quotas-help-link" - > - {{ $options.LEARN_MORE_LABEL }} - </gl-link> - </p> - </div> - <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> - {{ totalUsage }} - </p> - </div> - </div> - <div v-if="project.statistics" class="gl-w-full"> - <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> - </div> - <storage-table :storage-types="storageTypes" /> - </div> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue deleted file mode 100644 index a42a9711572..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlLink, GlIcon, GlTableLite as GlTable, GlSprintf } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { thWidthClass } from '~/lib/utils/table_utility'; -import { sprintf } from '~/locale'; -import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants'; -import StorageTypeIcon from './storage_type_icon.vue'; - -export default { - name: 'StorageTable', - components: { - GlLink, - GlIcon, - GlTable, - GlSprintf, - StorageTypeIcon, - }, - props: { - storageTypes: { - type: Array, - required: true, - }, - }, - methods: { - helpLinkAriaLabel(linkTitle) { - return sprintf(HELP_LINK_ARIA_LABEL, { - linkTitle, - }); - }, - }, - projectTableFields: [ - { - key: 'storageType', - label: PROJECT_TABLE_LABELS.STORAGE_TYPE, - thClass: thWidthClass(90), - sortable: true, - }, - { - key: 'value', - label: PROJECT_TABLE_LABELS.VALUE, - thClass: thWidthClass(10), - sortable: true, - formatter: (value) => { - return numberToHumanSize(value, 1); - }, - }, - ], -}; -</script> -<template> - <gl-table :items="storageTypes" :fields="$options.projectTableFields"> - <template #cell(storageType)="{ item }"> - <div class="gl-display-flex gl-flex-direction-row"> - <storage-type-icon - :name="item.storageType.id" - :data-testid="`${item.storageType.id}-icon`" - /> - <div> - <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> - {{ item.storageType.name }} - <gl-link - v-if="item.storageType.helpPath" - :href="item.storageType.helpPath" - target="_blank" - :aria-label="helpLinkAriaLabel(item.storageType.name)" - :data-testid="`${item.storageType.id}-help-link`" - > - <gl-icon name="question" :size="12" /> - </gl-link> - </p> - <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> - {{ item.storageType.description }} - </p> - <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> - <gl-icon name="warning" :size="12" /> - <gl-sprintf :message="item.storageType.warningMessage"> - <template #warningLink="{ content }"> - <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> - </div> - </template> - </gl-table> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue deleted file mode 100644 index bc7cd42df1e..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; - -export default { - components: { GlIcon }, - props: { - name: { - type: String, - required: false, - default: '', - }, - }, - methods: { - iconName(storageTypeName) { - const defaultStorageTypeIcon = 'disk'; - const storageTypeIconMap = { - lfsObjectsSize: 'doc-image', - snippetsSize: 'snippet', - uploadsSize: 'upload', - repositorySize: 'infrastructure-registry', - packagesSize: 'package', - }; - - return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; - }, - }, -}; -</script> -<template> - <span - class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1" - > - <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" /> - </span> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js deleted file mode 100644 index df4b1800dff..00000000000 --- a/app/assets/javascripts/projects/storage_counter/constants.js +++ /dev/null @@ -1,61 +0,0 @@ -import { s__, __ } from '~/locale'; - -export const PROJECT_STORAGE_TYPES = [ - { - id: 'buildArtifactsSize', - name: s__('UsageQuota|Artifacts'), - description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), - warningMessage: s__( - 'UsageQuota|Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.', - ), - warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380', - }, - { - id: 'lfsObjectsSize', - name: s__('UsageQuota|LFS storage'), - description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), - }, - { - id: 'packagesSize', - name: s__('UsageQuota|Packages'), - description: s__('UsageQuota|Code packages and container images.'), - }, - { - id: 'repositorySize', - name: s__('UsageQuota|Repository'), - description: s__('UsageQuota|Git repository.'), - }, - { - id: 'snippetsSize', - name: s__('UsageQuota|Snippets'), - description: s__('UsageQuota|Shared bits of code and text.'), - }, - { - id: 'uploadsSize', - name: s__('UsageQuota|Uploads'), - description: s__('UsageQuota|File attachments and smaller design graphics.'), - }, - { - id: 'wikiSize', - name: s__('UsageQuota|Wiki'), - description: s__('UsageQuota|Wiki content.'), - }, -]; - -export const PROJECT_TABLE_LABELS = { - STORAGE_TYPE: s__('UsageQuota|Storage type'), - VALUE: s__('UsageQuota|Usage'), -}; - -export const ERROR_MESSAGE = s__( - 'UsageQuota|Something went wrong while fetching project storage statistics', -); - -export const LEARN_MORE_LABEL = __('Learn more.'); -export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); -export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); -export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A'); -export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); -export const TOTAL_USAGE_SUBTITLE = s__( - 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', -); diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js deleted file mode 100644 index 15796bc1870..00000000000 --- a/app/assets/javascripts/projects/storage_counter/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import StorageCounterApp from './components/app.vue'; - -Vue.use(VueApollo); - -export default (containerId = 'js-project-storage-count-app') => { - const el = document.getElementById(containerId); - - if (!el) { - return false; - } - - const { - projectPath, - usageQuotasHelpPagePath, - buildArtifactsHelpPagePath, - lfsObjectsHelpPagePath, - packagesHelpPagePath, - repositoryHelpPagePath, - snippetsHelpPagePath, - uploadsHelpPagePath, - wikiHelpPagePath, - } = el.dataset; - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - return new Vue({ - el, - apolloProvider, - provide: { - projectPath, - helpLinks: { - usageQuotasHelpPagePath, - buildArtifactsHelpPagePath, - lfsObjectsHelpPagePath, - packagesHelpPagePath, - repositoryHelpPagePath, - snippetsHelpPagePath, - uploadsHelpPagePath, - wikiHelpPagePath, - }, - }, - render(createElement) { - return createElement(StorageCounterApp); - }, - }); -}; diff --git a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql b/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql deleted file mode 100644 index a4f2c529522..00000000000 --- a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query getProjectStorageCount($fullPath: ID!) { - project(fullPath: $fullPath) { - id - statistics { - buildArtifactsSize - pipelineArtifactsSize - lfsObjectsSize - packagesSize - repositorySize - snippetsSize - storageSize - uploadsSize - wikiSize - } - } -} diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js deleted file mode 100644 index 9fca9d88f46..00000000000 --- a/app/assets/javascripts/projects/storage_counter/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { PROJECT_STORAGE_TYPES } from './constants'; - -/** - * This method parses the results from `getProjectStorageCount` call. - * - * @param {Object} data graphql result - * @returns {Object} - */ -export const parseGetProjectStorageResults = (data, helpLinks) => { - const projectStatistics = data?.project?.statistics; - if (!projectStatistics) { - return {}; - } - const { storageSize, ...storageStatistics } = projectStatistics; - const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => { - const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`); - const helpPath = helpLinks[helpPathKey]; - - return types.concat({ - storageType: { - ...currentType, - helpPath, - }, - value: storageStatistics[currentType.id], - }); - }, []); - - return { - storage: { - totalUsage: numberToHumanSize(storageSize, 1), - storageTypes, - }, - statistics: projectStatistics, - }; -}; diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index abbd612d3ec..61bd2bf5e8e 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; +import relatedIssuableMixin from '~/issuable/mixins/related_issuable_mixin'; export default { name: 'IssueToken', 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 a21e294a34a..58138655241 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; import sortableConfig from '~/sortable/sortable_config'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; export default { name: 'RelatedIssuesList', diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index 3a927dfc756..8a5613c75d2 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql @@ -35,6 +35,7 @@ fragment Release on Release { __typename nodes { __typename + id filepath collectedAt sha @@ -52,12 +53,14 @@ fragment Release on Release { } commit { __typename + id sha webUrl title } author { __typename + id webUrl avatarUrl username diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 75a73acb9ae..1823a327350 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -18,6 +18,7 @@ fragment ReleaseForEditing on Release { } milestones { nodes { + id title } } diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index c69481150e0..7f67f7d11a3 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -16,6 +16,7 @@ query allReleasesDeprecated( ) { project(fullPath: $fullPath) { __typename + id releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { __typename nodes { diff --git a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql index c80d6e753ab..dab92d5d41c 100644 --- a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql @@ -2,6 +2,7 @@ query oneRelease($fullPath: ID!, $tagName: String!) { project(fullPath: $fullPath) { + id release(tagName: $tagName) { ...Release } diff --git a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql index 767ba4aeca0..962d554303a 100644 --- a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql @@ -2,6 +2,7 @@ query oneReleaseForEditing($fullPath: ID!, $tagName: String!) { project(fullPath: $fullPath) { + id release(tagName: $tagName) { ...ReleaseForEditing } diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index 504efaea8cc..5fd9cfd4e53 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -52,14 +52,9 @@ export const loadCommits = async (projectPath, path, ref, offset) => { } // We fetch in batches of 25, so this ensures we don't refetch - Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => { - addRequestedOffset(offset - i); - addRequestedOffset(offset + i); - }); + Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => addRequestedOffset(offset + i)); - // Since a user could scroll either up or down, we want to support lazy loading in both directions - const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE); - const commitsBatchDown = await fetchData(projectPath, path, ref, offset); + const commits = await fetchData(projectPath, path, ref, offset); - return commitsBatchUp.concat(commitsBatchDown); + return commits; }; diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 4e7ca7b17e4..6f540bf8ece 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -53,6 +53,10 @@ export default { type: Boolean, required: true, }, + canPushToBranch: { + type: Boolean, + required: true, + }, emptyRepo: { type: Boolean, required: true, @@ -83,6 +87,9 @@ export default { deleteModalTitle() { return sprintf(__('Delete %{name}'), { name: this.name }); }, + lockBtnQASelector() { + return this.canLock ? 'lock_button' : 'disabled_lock_button'; + }, }, }; </script> @@ -98,6 +105,7 @@ export default { :is-locked="isLocked" :can-lock="canLock" data-testid="lock" + :data-qa-selector="lockBtnQASelector" /> <gl-button v-gl-modal="replaceModalId" data-testid="replace"> {{ $options.i18n.replace }} @@ -125,6 +133,7 @@ export default { :target-branch="targetBranch || ref" :original-branch="originalBranch || ref" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :empty-repo="emptyRepo" /> </div> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 2cc5a8a79d2..f3fa4526999 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -106,6 +106,7 @@ export default { ideForkAndEditPath: '', storedExternally: false, canModifyBlob: false, + canCurrentUserPushToBranch: false, rawPath: '', externalStorageUrl: '', replacePath: '', @@ -156,11 +157,18 @@ export default { }, canLock() { const { pushCode, downloadCode } = this.project.userPermissions; + const currentUsername = window.gon?.current_username; + + if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) { + return false; + } return pushCode && downloadCode; }, - isLocked() { - return this.project.pathLocks.nodes.some((node) => node.path === this.path); + pathLockedByUser() { + const pathLock = this.project.pathLocks.nodes.find((node) => node.path === this.path); + + return pathLock ? pathLock.user : null; }, showForkSuggestion() { const { createMergeRequestIn, forkProject } = this.project.userPermissions; @@ -266,9 +274,10 @@ export default { :replace-path="blobInfo.replacePath" :delete-path="blobInfo.webPath" :can-push-code="project.userPermissions.pushCode" + :can-push-to-branch="blobInfo.canCurrentUserPushToBranch" :empty-repo="project.repository.empty" :project-path="projectPath" - :is-locked="isLocked" + :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" /> </template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index c5209d97abb..8f6f2d15215 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -3,8 +3,11 @@ export const loadViewer = (type) => { case 'empty': return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue'); case 'text': - return gon.features.refactorTextViewer - ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue') + return gon.features.highlightJs + ? () => + import( + /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue' + ) : null; case 'download': return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue'); @@ -12,6 +15,8 @@ export const loadViewer = (type) => { return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue'); case 'video': return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue'); + case 'pdf': + return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue'); default: return null; } @@ -21,8 +26,7 @@ export const viewerProps = (type, blob) => { return { text: { content: blob.rawTextBlob, - fileName: blob.name, - readOnly: true, + autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145) }, download: { fileName: blob.name, @@ -36,5 +40,9 @@ export const viewerProps = (type, blob) => { video: { url: blob.rawPath, }, + pdf: { + url: blob.rawPath, + fileSize: blob.rawSize, + }, }[type]; }; diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue new file mode 100644 index 00000000000..803a357df52 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue @@ -0,0 +1,50 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import PdfViewer from '~/blob/pdf/pdf_viewer.vue'; +import { __ } from '~/locale'; +import { PDF_MAX_FILE_SIZE, PDF_MAX_PAGE_LIMIT } from '../../constants'; + +export default { + components: { GlButton, PdfViewer }, + i18n: { + tooLargeDescription: __('This PDF is too large to display. Please download to view.'), + tooLargeButtonText: __('Download PDF'), + }, + props: { + url: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: true, + }, + }, + data() { + return { totalPages: 0 }; + }, + computed: { + tooLargeToDisplay() { + return this.fileSize > PDF_MAX_FILE_SIZE || this.totalPages > PDF_MAX_PAGE_LIMIT; + }, + }, + methods: { + handleOnLoad(totalPages) { + this.totalPages = totalPages; + }, + }, +}; +</script> +<template> + <div> + <pdf-viewer v-if="!tooLargeToDisplay" :pdf="url" @pdflabload="handleOnLoad" /> + + <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-5"> + <p>{{ $options.i18n.tooLargeDescription }}</p> + + <gl-button icon="download" category="secondary" variant="confirm" :href="url" download>{{ + $options.i18n.tooLargeButtonText + }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue deleted file mode 100644 index 57fc979a56e..00000000000 --- a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -export default { - components: { - SourceEditor: () => - import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), - }, - props: { - content: { - type: String, - required: true, - }, - fileName: { - type: String, - required: true, - }, - readOnly: { - type: Boolean, - required: true, - }, - }, -}; -</script> -<template> - <source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" /> -</template> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 4a8cedb60b4..0d3dc06c2c8 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -71,6 +71,10 @@ export default { type: Boolean, required: true, }, + canPushToBranch: { + type: Boolean, + required: true, + }, emptyRepo: { type: Boolean, required: true, @@ -176,9 +180,12 @@ export default { </template> <template v-else> <input type="hidden" name="original_branch" :value="originalBranch" /> - <!-- Once "push to branch" permission is made available, will need to add to conditional - Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> - <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> + <input + v-if="createNewMr || !canPushToBranch" + type="hidden" + name="create_merge_request" + value="1" + /> <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message" @@ -188,6 +195,7 @@ export default { v-model="form.fields['commit_message'].value" v-validation:[form.showValidation] name="commit_message" + data-qa-selector="commit_message_field" :state="form.fields['commit_message'].state" :disabled="loading" required diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 62066973ee6..43e114a91d3 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -111,7 +111,7 @@ export default { </script> <template> - <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> + <div class="well-segment commit gl-p-5 gl-w-full"> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <template v-else-if="commit"> <user-avatar-link diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index bd06c064ab7..8fcec5fb893 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -13,7 +13,7 @@ import { import { escapeRegExp } from 'lodash'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; -import { TREE_PAGE_SIZE } from '~/repository/constants'; +import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -128,6 +128,7 @@ export default { return { commit: null, hasRowAppeared: false, + delayedRowAppear: null, }; }, computed: { @@ -202,14 +203,19 @@ export default { rowAppeared() { this.hasRowAppeared = true; + if (this.commitInfo) { + return; + } + if (this.glFeatures.lazyLoadCommits) { - this.$emit('row-appear', { - rowNumber: this.rowNumber, - hasCommit: Boolean(this.commitInfo), - }); + this.delayedRowAppear = setTimeout( + () => this.$emit('row-appear', this.rowNumber), + ROW_APPEAR_DELAY, + ); } }, rowDisappeared() { + clearTimeout(this.delayedRowAppear); this.hasRowAppeared = false; }, }, diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index ffe8d5531f8..130ebf77361 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -3,7 +3,12 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g import createFlash from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '../../locale'; -import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants'; +import { + TREE_PAGE_SIZE, + TREE_INITIAL_FETCH_COUNT, + TREE_PAGE_LIMIT, + COMMIT_BATCH_SIZE, +} from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; @@ -151,11 +156,19 @@ export default { .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) .find(({ hasNextPage }) => hasNextPage); }, - loadCommitData({ rowNumber = 0, hasCommit } = {}) { - if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) { + handleRowAppear(rowNumber) { + if (!this.glFeatures.lazyLoadCommits || isRequested(rowNumber)) { return; } + // Since a user could scroll either up or down, we want to support lazy loading in both directions + this.loadCommitData(rowNumber); + + if (rowNumber - COMMIT_BATCH_SIZE >= 0) { + this.loadCommitData(rowNumber - COMMIT_BATCH_SIZE); + } + }, + loadCommitData(rowNumber) { loadCommits(this.projectPath, this.path, this.ref, rowNumber) .then(this.setCommitData) .catch(() => {}); @@ -182,7 +195,7 @@ export default { :has-more="hasShowMore" :commits="commits" @showMore="handleShowMore" - @row-appear="loadCommitData" + @row-appear="handleRowAppear" /> <file-preview v-if="readme" :blob="readme" /> </div> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 11e5b5608cb..b56c9ce5247 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -24,10 +24,10 @@ import { } from '../constants'; const PRIMARY_OPTIONS_TEXT = __('Upload file'); -const MODAL_TITLE = __('Upload New File'); +const MODAL_TITLE = __('Upload new file'); const REMOVE_FILE_TEXT = __('Remove file'); const NEW_BRANCH_IN_FORK = __( - 'A new branch will be created in your fork and a new merge request will be started.', + 'GitLab will create a branch in your fork and start a merge request.', ); const ERROR_MESSAGE = __('Error uploading file. Please try again.'); diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 152fabbd7cc..d01757d6141 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -11,7 +11,7 @@ export const COMMIT_LABEL = __('Commit message'); export const TARGET_BRANCH_LABEL = __('Target branch'); export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); export const NEW_BRANCH_IN_FORK = __( - 'A new branch will be created in your fork and a new merge request will be started.', + 'GitLab will create a branch in your fork and start a merge request.', ); export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52; @@ -20,3 +20,8 @@ export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72; export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width'; export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.'); + +export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB +export const PDF_MAX_PAGE_LIMIT = 50; + +export const ROW_APPEAR_DELAY = 150; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 45e026ad695..197b19387cf 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -188,5 +188,5 @@ export default function setupVueRepositoryList() { }, }); - return { router, data: dataset }; + return { router, data: dataset, apolloProvider, projectPath }; } diff --git a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql index eaebc4ddf17..0851564bb24 100644 --- a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql +++ b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql @@ -4,6 +4,7 @@ mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) { id pathLocks { nodes { + id path } } diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index cbdc62624d4..6bf674eb3f1 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,5 +1,5 @@ <script> -import TreeContent from '../components/tree_content.vue'; +import TreeContent from 'jh_else_ce/repository/components/tree_content.vue'; import preloadMixin from '../mixins/preload'; import { updateElementsVisibility } from '../utils/dom'; diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index cf3892802fd..45d1ba80917 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -9,13 +9,19 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { } pathLocks { nodes { + id path + user { + id + username + } } } repository { empty blobs(paths: [$filePath], ref: $ref) { nodes { + id webPath name size @@ -28,6 +34,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { forkAndEditPath ideForkAndEditPath canModifyBlob + canCurrentUserPushToBranch storedExternally rawPath replacePath diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 3c8533dd06d..ee9533bbec3 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,7 +3,6 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { hide, fixTitle } from '~/tooltips'; -import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; @@ -127,14 +126,6 @@ Sidebar.prototype.openDropdown = function (blockOrName) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); } - - // Wait for the sidebar to trigger('click') open - // so it doesn't cause our dropdown to close preemptively - setTimeout(() => { - if (!gon.features?.labelsWidget && !$block.hasClass('labels-select-wrapper')) { - $block.find('.js-sidebar-dropdown-toggle').trigger('click'); - } - }, DEBOUNCE_DROPDOWN_DELAY); }; Sidebar.prototype.setCollapseAfterUpdate = function ($block) { diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 3edb658eaf5..f8220553db6 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -3,12 +3,12 @@ import { GlBadge, GlLink } from '@gitlab/ui'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; -import { sprintf, __ } from '~/locale'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; +import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -38,6 +38,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, + RunnerOnlineStat, RunnerPagination, RunnerTypeTabs, }, @@ -110,17 +111,12 @@ export default { noRunnersFound() { return !this.runnersLoading && !this.runners.items.length; }, - activeRunnersMessage() { - return sprintf(__('Runners currently online: %{active_runners_count}'), { - active_runners_count: this.activeRunnersCount, - }); - }, searchTokens() { return [ statusTokenConfig, { ...tagTokenConfig, - recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, + recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, }, ]; }, @@ -165,6 +161,8 @@ export default { </script> <template> <div> + <runner-online-stat class="gl-py-6 gl-px-5" :value="activeRunnersCount" /> + <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -194,11 +192,7 @@ export default { v-model="search" :tokens="searchTokens" :namespace="$options.filteredSearchNamespace" - > - <template #runner-count> - {{ activeRunnersMessage }} - </template> - </runner-filtered-search-bar> + /> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index c4bddb7b398..33f7a67aba4 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,27 +1,29 @@ <script> -import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerDeleteModal from '../runner_delete_modal.vue'; -const i18n = { - I18N_EDIT: __('Edit'), - I18N_PAUSE: __('Pause'), - I18N_RESUME: __('Resume'), - I18N_REMOVE: __('Remove'), - I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'), -}; +const I18N_EDIT = __('Edit'); +const I18N_PAUSE = __('Pause'); +const I18N_RESUME = __('Resume'); +const I18N_DELETE = s__('Runners|Delete runner'); +const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); export default { name: 'RunnerActionsCell', components: { GlButton, GlButtonGroup, + RunnerDeleteModal, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, props: { runner: { @@ -48,21 +50,29 @@ export default { // mouseout listeners don't run leaving the tooltip stuck return ''; } - return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME; + return this.isActive ? I18N_PAUSE : I18N_RESUME; }, deleteTitle() { - // Prevent a "sticky" tooltip: If element gets removed, - // mouseout listeners don't run and leaving the tooltip stuck - return this.deleting ? '' : i18n.I18N_REMOVE; + if (this.deleting) { + // Prevent a "sticky" tooltip: If this button is disabled, + // mouseout listeners don't run leaving the tooltip stuck + return ''; + } + return I18N_DELETE; + }, + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; }, }, methods: { async onToggleActive() { this.updating = true; - // TODO In HAML iteration we had a confirmation modal via: - // data-confirm="_('Are you sure?')" - // this may not have to ported, this is an easily reversible operation - try { const toggledActive = !this.runner.active; @@ -91,12 +101,8 @@ export default { }, async onDelete() { - // TODO Replace confirmation with gl-modal - // eslint-disable-next-line no-alert - if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) { - return; - } - + // Deleting stays "true" until this row is removed, + // should only change back if the operation fails. this.deleting = true; try { const { @@ -115,11 +121,13 @@ export default { }); if (errors && errors.length) { throw new Error(errors.join(' ')); + } else { + // Use $root to have the toast message stay after this element is removed + this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName })); } } catch (e) { - this.onError(e); - } finally { this.deleting = false; + this.onError(e); } }, @@ -133,14 +141,15 @@ export default { captureException({ error, component: this.$options.name }); }, }, - i18n, + I18N_EDIT, + I18N_DELETE, }; </script> <template> <gl-button-group> <!-- - This button appears for administratos: those with + This button appears for administrators: those with access to the adminUrl. More advanced permissions policies will allow more granular permissions. @@ -148,16 +157,14 @@ export default { --> <gl-button v-if="runner.adminUrl" - v-gl-tooltip.hover.viewport + v-gl-tooltip.hover.viewport="$options.I18N_EDIT" :href="runner.adminUrl" - :title="$options.i18n.I18N_EDIT" - :aria-label="$options.i18n.I18N_EDIT" + :aria-label="$options.I18N_EDIT" icon="pencil" data-testid="edit-runner" /> <gl-button - v-gl-tooltip.hover.viewport - :title="toggleActiveTitle" + v-gl-tooltip.hover.viewport="toggleActiveTitle" :aria-label="toggleActiveTitle" :icon="toggleActiveIcon" :loading="updating" @@ -165,14 +172,20 @@ export default { @click="onToggleActive" /> <gl-button - v-gl-tooltip.hover.viewport - :title="deleteTitle" + v-gl-tooltip.hover.viewport="deleteTitle" + v-gl-modal="runnerDeleteModalId" :aria-label="deleteTitle" icon="close" :loading="deleting" variant="danger" data-testid="delete-runner" - @click="onDelete" + /> + + <runner-delete-modal + :ref="runnerDeleteModalId" + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + @primary="onDelete" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue index 9ba1192bc8c..473cd7e9794 100644 --- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -1,14 +1,12 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue'; +import RunnerStatusBadge from '../runner_status_badge.vue'; import RunnerPausedBadge from '../runner_paused_badge.vue'; -import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants'; - export default { components: { - RunnerContactedStateBadge, + RunnerStatusBadge, RunnerPausedBadge, }, directives: { @@ -25,16 +23,12 @@ export default { return !this.runner.active; }, }, - i18n: { - I18N_LOCKED_RUNNER_DESCRIPTION, - I18N_PAUSED_RUNNER_DESCRIPTION, - }, }; </script> <template> <div> - <runner-contacted-state-badge :runner="runner" size="sm" /> + <runner-status-badge :runner="runner" size="sm" /> <runner-paused-badge v-if="paused" size="sm" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 3b476997915..937ec631633 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import RunnerName from '../runner_name.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; diff --git a/app/assets/javascripts/runner/components/runner_delete_modal.vue b/app/assets/javascripts/runner/components/runner_delete_modal.vue new file mode 100644 index 00000000000..8be216a7eb5 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_delete_modal.vue @@ -0,0 +1,51 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +const I18N_TITLE = s__('Runners|Delete runner %{name}?'); +const I18N_BODY = s__( + 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', +); +const I18N_PRIMARY = s__('Runners|Delete runner'); +const I18N_CANCEL = __('Cancel'); + +export default { + components: { + GlModal, + }, + props: { + runnerName: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(I18N_TITLE, { name: this.runnerName }); + }, + }, + methods: { + onPrimary() { + this.$refs.modal.hide(); + }, + }, + actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } }, + actionCancel: { text: I18N_CANCEL }, + I18N_BODY, +}; +</script> + +<template> + <gl-modal + ref="modal" + size="sm" + :title="title" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + v-bind="$attrs" + v-on="$listeners" + @primary="onPrimary" + > + {{ $options.I18N_BODY }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index a9dfec35479..f0f8bbdf5df 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -76,24 +76,18 @@ export default { }; </script> <template> - <div + <filtered-search class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1" - > - <filtered-search - v-bind="$attrs" - :namespace="namespace" - recent-searches-storage-key="runners-search" - :sort-options="$options.sortOptions" - :initial-filter-value="initialFilterValue" - :tokens="tokens" - :initial-sort-by="initialSortBy" - :search-input-placeholder="__('Search or filter results...')" - data-testid="runners-filtered-search" - @onFilter="onFilter" - @onSort="onSort" - /> - <div class="gl-text-right" data-testid="runner-count"> - <slot name="runner-count"></slot> - </div> - </div> + v-bind="$attrs" + :namespace="namespace" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :tokens="tokens" + :initial-sort-by="initialSortBy" + :search-input-placeholder="__('Search or filter results...')" + data-testid="runners-filtered-search" + @onFilter="onFilter" + @onSort="onSort" + /> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index f8dbc469c22..023308dbac2 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,27 +1,26 @@ <script> import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __, s__ } from '~/locale'; +import { formatNumber, __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; -const tableField = ({ key, label = '', width = 10 }) => { +const tableField = ({ key, label = '', thClasses = [] }) => { return { key, label, thClass: [ - `gl-w-${width}p`, 'gl-bg-transparent!', 'gl-border-b-solid!', 'gl-border-b-gray-100!', - 'gl-py-5!', - 'gl-px-0!', 'gl-border-b-1!', + ...thClasses, ], - tdClass: ['gl-py-5!', 'gl-px-1!'], tdAttr: { 'data-testid': `td-${key}`, }, @@ -32,6 +31,7 @@ export default { components: { GlTable, GlSkeletonLoader, + TooltipOnTruncate, TimeAgo, RunnerActionsCell, RunnerSummaryCell, @@ -53,6 +53,12 @@ export default { }, }, methods: { + formatJobCount(jobCount) { + if (jobCount > RUNNER_JOB_COUNT_LIMIT) { + return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; + } + return formatNumber(jobCount); + }, runnerTrAttr(runner) { if (runner) { return { @@ -64,10 +70,11 @@ export default { }, fields: [ tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner ID'), width: 30 }), + tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'version', label: __('Version') }), tableField({ key: 'ipAddress', label: __('IP Address') }), - tableField({ key: 'tagList', label: __('Tags'), width: 20 }), + tableField({ key: 'jobCount', label: __('Jobs') }), + tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'contactedAt', label: __('Last contact') }), tableField({ key: 'actions', label: '' }), ], @@ -82,6 +89,7 @@ export default { :tbody-tr-attr="runnerTrAttr" data-testid="runner-list" stacked="md" + primary-key="id" fixed > <template v-if="!runners.length" #table-busy> @@ -101,11 +109,19 @@ export default { </template> <template #cell(version)="{ item: { version } }"> - {{ version }} + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version"> + {{ version }} + </tooltip-on-truncate> </template> <template #cell(ipAddress)="{ item: { ipAddress } }"> - {{ ipAddress }} + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> + {{ ipAddress }} + </tooltip-on-truncate> + </template> + + <template #cell(jobCount)="{ item: { jobCount } }"> + {{ formatJobCount(jobCount) }} </template> <template #cell(tagList)="{ item: { tagList } }"> diff --git a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index b4727f832f8..0823876a187 100644 --- a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -1,14 +1,17 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { - I18N_ONLINE_RUNNER_DESCRIPTION, - I18N_OFFLINE_RUNNER_DESCRIPTION, + I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, + I18N_STALE_RUNNER_DESCRIPTION, STATUS_ONLINE, - STATUS_OFFLINE, STATUS_NOT_CONNECTED, + STATUS_NEVER_CONTACTED, + STATUS_OFFLINE, + STATUS_STALE, } from '../constants'; export default { @@ -29,31 +32,39 @@ export default { if (this.runner.contactedAt) { return getTimeago().format(this.runner.contactedAt); } - return null; + // Prevent "just now" from being rendered, in case data is missing. + return __('n/a'); }, badge() { - switch (this.runner.status) { + switch (this.runner?.status) { case STATUS_ONLINE: return { variant: 'success', label: s__('Runners|online'), - tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, { + tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, { timeAgo: this.contactedAtTimeAgo, }), }; + case STATUS_NOT_CONNECTED: + case STATUS_NEVER_CONTACTED: + return { + variant: 'muted', + label: s__('Runners|not connected'), + tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + }; case STATUS_OFFLINE: return { variant: 'muted', label: s__('Runners|offline'), - tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, { + tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, { timeAgo: this.contactedAtTimeAgo, }), }; - case STATUS_NOT_CONNECTED: + case STATUS_STALE: return { - variant: 'muted', - label: s__('Runners|not connected'), - tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + variant: 'warning', + label: s__('Runners|stale'), + tooltip: I18N_STALE_RUNNER_DESCRIPTION, }; default: return null; diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 9963048ae1d..4b356fa47ed 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -7,6 +7,7 @@ import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED, + STATUS_STALE, PARAM_KEY_STATUS, } from '../../constants'; @@ -16,6 +17,7 @@ const options = [ { value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, { value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') }, + { value: STATUS_STALE, title: s__('Runners|Stale') }, ]; export const statusTokenConfig = { diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index ab67ac608e2..7461308ab91 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -68,7 +68,6 @@ export default { :config="config" :suggestions-loading="loading" :suggestions="tags" - :recent-suggestions-storage-key="config.recentTokenValuesStorageKey" @fetch-suggestions="fetchTags" v-on="$listeners" > diff --git a/app/assets/javascripts/runner/components/stat/runner_online_stat.vue b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue new file mode 100644 index 00000000000..b92b9badef0 --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue @@ -0,0 +1,17 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; + +export default { + components: { + GlSingleStat, + }, +}; +</script> +<template> + <gl-single-stat + v-bind="$attrs" + variant="success" + :title="s__('Runners|Online Runners')" + :meta-text="s__('Runners|online')" + /> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 3952e2398e0..355f3054917 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,6 +1,7 @@ import { s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; +export const RUNNER_JOB_COUNT_LIMIT = 1000; export const GROUP_RUNNER_COUNT_LIMIT = 1000; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); @@ -14,15 +15,18 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); // Status -export const I18N_ONLINE_RUNNER_DESCRIPTION = s__( +export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( 'Runners|Runner is online; last contact was %{timeAgo}', ); -export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__( - 'Runners|No recent contact from this runner; last contact was %{timeAgo}', -); export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__( 'Runners|This runner has never connected to this instance', ); +export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( + 'Runners|No recent contact from this runner; last contact was %{timeAgo}', +); +export const I18N_STALE_RUNNER_DESCRIPTION = s__( + 'Runners|No contact from this runner in over 3 months', +); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); @@ -54,9 +58,12 @@ export const PROJECT_TYPE = 'PROJECT_TYPE'; export const STATUS_ACTIVE = 'ACTIVE'; export const STATUS_PAUSED = 'PAUSED'; + export const STATUS_ONLINE = 'ONLINE'; -export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; +export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; +export const STATUS_OFFLINE = 'OFFLINE'; +export const STATUS_STALE = 'STALE'; // CiRunnerAccessLevel diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql index 3e5109b1ac4..6da9e276f74 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -13,6 +13,7 @@ query getGroupRunners( $sort: CiRunnerSort ) { group(fullPath: $groupFullPath) { + id runners( membership: DESCENDANTS before: $before diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql index c294cb9bf22..59c55eae060 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -1,6 +1,8 @@ #import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" query getRunner($id: CiRunnerID!) { + # We have an id in deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available runner(id: $id) { ...RunnerDetails } diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 98f2dab26ca..169f6ffd2ea 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -8,7 +8,8 @@ fragment RunnerNode on CiRunner { ipAddress active locked + jobCount tagList contactedAt - status + status(legacyMode: null) } diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql index ea622fd4958..8d1b75828be 100644 --- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql @@ -5,6 +5,8 @@ mutation runnerUpdate($input: RunnerUpdateInput!) { runnerUpdate(input: $input) { + # We have an id in deep nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available runner { ...RunnerDetails } diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index c3dfa885f27..a58a53a6a0d 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -9,6 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; +import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -35,6 +36,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, + RunnerOnlineStat, RunnerPagination, RunnerTypeTabs, }, @@ -145,6 +147,8 @@ export default { <template> <div> + <runner-online-stat class="gl-py-6 gl-px-5" :value="groupRunnersCount" /> + <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs v-model="search" @@ -164,11 +168,7 @@ export default { v-model="search" :tokens="searchTokens" :namespace="filteredSearchNamespace" - > - <template #runner-count> - {{ runnerCountMessage }} - </template> - </runner-filtered-search-bar> + /> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index bc13150c99c..75d2b324623 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,12 +1,14 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; import FeatureCard from './feature_card.vue'; +import TrainingProviderList from './training_provider_list.vue'; import SectionLayout from './section_layout.vue'; import UpgradeBanner from './upgrade_banner.vue'; @@ -23,6 +25,8 @@ export const i18n = { any subsequent feature branch you create will include the scan.`, ), securityConfiguration: __('Security Configuration'), + vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), + securityTraining: s__('SecurityConfiguration|Security training'), }; export default { @@ -40,7 +44,9 @@ export default { SectionLayout, UpgradeBanner, UserCalloutDismisser, + TrainingProviderList, }, + mixins: [glFeatureFlagsMixin()], inject: ['projectPath'], props: { augmentedSecurityFeatures: { @@ -231,6 +237,17 @@ export default { </template> </section-layout> </gl-tab> + <gl-tab + v-if="glFeatures.secureVulnerabilityTraining" + data-testid="vulnerability-management-tab" + :title="$options.i18n.vulnerabilityManagement" + > + <section-layout :heading="$options.i18n.securityTraining"> + <template #features> + <training-provider-list /> + </template> + </section-layout> + </gl-tab> </gl-tabs> </article> </template> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 9c80506549e..dd8ba72ad1f 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -22,6 +22,7 @@ import configureSecretDetectionMutation from '../graphql/configure_secret_detect /** * Translations & helpPagePaths for Security Configuration Page + * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. */ export const SAST_NAME = __('Static Application Security Testing (SAST)'); @@ -138,6 +139,18 @@ export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath( 'user/compliance/license_compliance/index', ); +export const SCANNER_NAMES_MAP = { + SAST: SAST_SHORT_NAME, + SAST_IAC: SAST_IAC_NAME, + DAST: DAST_SHORT_NAME, + API_FUZZING: API_FUZZING_NAME, + CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, + CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, + COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, + SECRET_DETECTION: SECRET_DETECTION_NAME, + DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, +}; + export const securityFeatures = [ { name: SAST_NAME, @@ -156,27 +169,23 @@ export const securityFeatures = [ // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 canEnableByMergeRequest: true, }, - ...(gon?.features?.configureIacScanningViaMr - ? [ - { - name: SAST_IAC_NAME, - shortName: SAST_IAC_SHORT_NAME, - description: SAST_IAC_DESCRIPTION, - helpPath: SAST_IAC_HELP_PATH, - configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST_IAC, + { + name: SAST_IAC_NAME, + shortName: SAST_IAC_SHORT_NAME, + description: SAST_IAC_DESCRIPTION, + helpPath: SAST_IAC_HELP_PATH, + configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST_IAC, - // This field is currently hardcoded because SAST IaC is always available. - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 - available: true, + // This field is currently hardcoded because SAST IaC is always available. + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 + available: true, - // This field will eventually come from the backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, - }, - ] - : []), + // This field will eventually come from the backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, + }, { name: DAST_NAME, shortName: DAST_SHORT_NAME, @@ -278,21 +287,17 @@ export const featureToMutationMap = { }, }), }, - ...(gon?.features?.configureIacScanningViaMr - ? { - [REPORT_TYPE_SAST_IAC]: { - mutationId: 'configureSastIac', - getMutationPayload: (projectPath) => ({ - mutation: configureSastIacMutation, - variables: { - input: { - projectPath, - }, - }, - }), + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, }, - } - : {}), + }, + }), + }, [REPORT_TYPE_SECRET_DETECTION]: { mutationId: 'configureSecretDetection', getMutationPayload: (projectPath) => ({ diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue new file mode 100644 index 00000000000..509377a63e8 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -0,0 +1,61 @@ +<script> +import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; + +export default { + components: { + GlCard, + GlToggle, + GlLink, + GlSkeletonLoader, + }, + apollo: { + securityTrainingProviders: { + query: securityTrainingProvidersQuery, + }, + }, + data() { + return { + securityTrainingProviders: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.securityTrainingProviders.loading; + }, + }, +}; +</script> + +<template> + <div + v-if="isLoading" + class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" + > + <gl-skeleton-loader :width="350" :height="44"> + <rect width="200" height="8" x="10" y="0" rx="4" /> + <rect width="300" height="8" x="10" y="15" rx="4" /> + <rect width="100" height="8" x="10" y="35" rx="4" /> + </gl-skeleton-loader> + </div> + <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> + <li + v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders" + :key="id" + class="gl-mb-6" + > + <gl-card> + <div class="gl-display-flex"> + <gl-toggle :value="isEnabled" :label="__('Training mode')" label-position="hidden" /> + <div class="gl-ml-5"> + <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> + <p> + {{ description }} + <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> + </p> + </div> + </div> + </gl-card> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql new file mode 100644 index 00000000000..e0c5715ba8e --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql @@ -0,0 +1,9 @@ +query Query { + securityTrainingProviders @client { + name + id + description + isEnabled + url + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index a8623b468f2..c86ff1a58f2 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -2,10 +2,39 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; +import { __ } from '~/locale'; import SecurityConfigurationApp from './components/app.vue'; import { securityFeatures, complianceFeatures } from './components/constants'; import { augmentFeatures } from './utils'; +// Note: this is behind a feature flag and only a placeholder +// until the actual GraphQL fields have been added +// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480 +export const tempResolvers = { + Query: { + securityTrainingProviders() { + return [ + { + __typename: 'SecurityTrainingProvider', + id: 101, + name: __('Kontra'), + description: __('Interactive developer security education.'), + url: 'https://application.security/', + isEnabled: false, + }, + { + __typename: 'SecurityTrainingProvider', + id: 102, + name: __('SecureCodeWarrior'), + description: __('Security training with guide and learning pathways.'), + url: 'https://www.securecodewarrior.com/', + isEnabled: true, + }, + ]; + }, + }, +}; + export const initSecurityConfiguration = (el) => { if (!el) { return null; @@ -14,7 +43,7 @@ export const initSecurityConfiguration = (el) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient(tempResolvers), }); const { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index ec6b93c6193..47231497b8f 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,4 +1,5 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => { const featuresByType = features.reduce((acc, feature) => { @@ -24,3 +25,13 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features = augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)), }; }; + +/** + * Converts a list of security scanner IDs (such as SAST_IAC) into a list of their translated + * names defined in the SCANNER_NAMES_MAP constant (eg. IaC Scanning). + * + * @param {String[]} scannerNames + * @returns {String[]} + */ +export const translateScannerNames = (scannerNames = []) => + scannerNames.map((scannerName) => SCANNER_NAMES_MAP[scannerName] || scannerName); diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 0021fe909e5..e41f3aa5c9d 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -236,6 +236,8 @@ export default { }, statusTimeRanges, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + actionPrimary: { text: s__('SetStatusModal|Set status') }, + actionSecondary: { text: s__('SetStatusModal|Remove status') }, }; </script> @@ -243,14 +245,13 @@ export default { <gl-modal :title="s__('SetStatusModal|Set a status')" :modal-id="modalId" - :ok-title="s__('SetStatusModal|Set status')" - :cancel-title="s__('SetStatusModal|Remove status')" - ok-variant="success" + :action-primary="$options.actionPrimary" + :action-secondary="$options.actionSecondary" modal-class="set-user-status-modal" @shown="setupEmojiListAndAutocomplete" @hide="hideEmojiMenu" - @ok="setStatus" - @cancel="removeStatus" + @primary="setStatus" + @secondary="removeStatus" > <div> <input diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js deleted file mode 100644 index 3ca9288b156..00000000000 --- a/app/assets/javascripts/shared/milestones/form.js +++ /dev/null @@ -1,22 +0,0 @@ -import $ from 'jquery'; -import initDatePicker from '~/behaviors/date_picker'; -import GLForm from '../../gl_form'; -import ZenMode from '../../zen_mode'; - -export default (initGFM = true) => { - new ZenMode(); // eslint-disable-line no-new - initDatePicker(); - - // eslint-disable-next-line no-new - new GLForm($('.milestone-form'), { - emojis: true, - members: initGFM, - issues: initGFM, - mergeRequests: initGFM, - epics: initGFM, - milestones: initGFM, - labels: initGFM, - snippets: initGFM, - vulnerabilities: initGFM, - }); -}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index e7ef731eed8..2387fe64b8f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> import produce from 'immer'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { assigneesQueries } from '~/sidebar/constants'; export default { diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 20667e695ce..6a74ab83c22 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -110,7 +110,7 @@ export default { <template> <div v-gl-tooltip="tooltipOptions" - :class="{ 'multiple-users': hasMoreThanOneAssignee }" + :class="{ 'multiple-users gl-relative': hasMoreThanOneAssignee }" :title="tooltipTitle" class="sidebar-collapsed-icon sidebar-collapsed-user" > diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 1b28ba2afd1..5b4dc20e9c8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -3,7 +3,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, n__ } from '~/locale'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; 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 8d5c3b2def3..a27dbee31ec 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,6 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue index 38ba468d197..42e56906e2c 100644 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -64,7 +64,7 @@ export default { <gl-button :loading="loading" :variant="user.attention_requested ? 'warning' : 'default'" - :icon="user.attention_requested ? 'star' : 'star-o'" + :icon="user.attention_requested ? 'attention-solid' : 'attention'" :aria-label="tooltipTitle" size="small" category="tertiary" 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 1fb4bd26533..209d1cca360 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 createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '~/sidebar/constants'; diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue new file mode 100644 index 00000000000..6d4da104952 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -0,0 +1,131 @@ +<script> +import { GlIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui'; +import { __, n__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql'; + +export default { + components: { + GlIcon, + GlPopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issueId: { + type: String, + required: true, + }, + }, + data() { + return { + contacts: [], + }; + }, + apollo: { + contacts: { + query: getIssueCrmContactsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.issue?.customerRelationsContacts?.nodes; + }, + error(error) { + createFlash({ + message: __('Something went wrong trying to load issue contacts.'), + error, + captureError: true, + }); + }, + subscribeToMore: { + document: issueCrmContactsSubscription, + variables() { + return this.queryVariables; + }, + updateQuery(prev, { subscriptionData }) { + const draftData = subscriptionData?.data?.issueCrmContactsUpdated; + if (prev && draftData) return { issue: draftData }; + return prev; + }, + }, + }, + }, + computed: { + shouldShowContacts() { + return this.contacts?.length; + }, + queryVariables() { + return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) }; + }, + contactsLabel() { + return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), { + count: this.contactCount, + }); + }, + contactCount() { + return this.contacts?.length || 0; + }, + }, + methods: { + shouldShowPopover(contact) { + return this.popOverData(contact).length > 0; + }, + divider(index) { + if (index < this.contactCount - 1) return ','; + return ''; + }, + popOverData(contact) { + return [contact.organization?.name, contact.email, contact.phone, contact.description].filter( + Boolean, + ); + }, + }, + i18n: { + help: __('Work in progress- click here to find out more'), + }, +}; +</script> + +<template> + <div> + <div v-gl-tooltip.left.viewport :title="contactsLabel" class="sidebar-collapsed-icon"> + <gl-icon name="users" /> + <span> {{ contactCount }} </span> + </div> + <div + v-gl-tooltip.left.viewport="$options.i18n.help" + class="hide-collapsed help-button float-right" + > + <a href="https://gitlab.com/gitlab-org/gitlab/-/issues/2256"><gl-icon name="question-o" /></a> + </div> + <div class="title hide-collapsed gl-mb-2 gl-line-height-20"> + {{ contactsLabel }} + </div> + <div class="hide-collapsed gl-display-flex gl-flex-wrap"> + <div + v-for="(contact, index) in contacts" + :id="`contact_container_${index}`" + :key="index" + class="gl-pr-2" + > + <span :id="`contact_${index}`" class="gl-font-weight-bold" + >{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</span + > + <gl-popover + v-if="shouldShowPopover(contact)" + :target="`contact_${index}`" + :container="`contact_container_${index}`" + triggers="hover focus" + placement="top" + > + <div v-for="row in popOverData(contact)" :key="row">{{ row }}</div> + </gl-popover> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql new file mode 100644 index 00000000000..30a0af10d56 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql @@ -0,0 +1,7 @@ +#import "./issue_crm_contacts.fragment.graphql" + +query issueCrmContacts($id: IssueID!) { + issue(id: $id) { + ...CrmContacts + } +} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql new file mode 100644 index 00000000000..750e1f1d1af --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql @@ -0,0 +1,17 @@ +fragment CrmContacts on Issue { + id + customerRelationsContacts { + nodes { + id + firstName + lastName + email + phone + description + organization { + id + name + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql new file mode 100644 index 00000000000..f3b6e4ec06f --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql @@ -0,0 +1,9 @@ +#import "./issue_crm_contacts.fragment.graphql" + +subscription issueCrmContactsUpdated($id: IssuableID!) { + issueCrmContactsUpdated(issuableId: $id) { + ... on Issue { + ...CrmContacts + } + } +} diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 1ff24dec884..404bcc3122a 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; @@ -124,6 +124,9 @@ export default { isLoading() { return this.$apollo.queries.issuable.loading || this.loading; }, + initialLoading() { + return this.$apollo.queries.issuable.loading; + }, hasDate() { return this.dateValue !== null; }, @@ -151,7 +154,7 @@ export default { }; }, dataTestId() { - return this.dateType === dateTypes.start ? 'start-date' : 'due-date'; + return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date'; }, }, methods: { @@ -266,15 +269,15 @@ export default { </gl-popover> </template> <template #collapsed> - <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon"> + <div v-gl-tooltip.viewport.left :title="dateLabel" class="sidebar-collapsed-icon"> <gl-icon :size="16" name="calendar" /> <span class="collapse-truncated-title">{{ formattedDate }}</span> </div> <sidebar-inherit-date - v-if="canInherit" + v-if="canInherit && !initialLoading" :issuable="issuable" - :is-loading="isLoading" :date-type="dateType" + :is-loading="isLoading" @reset-date="setDate(null)" @set-date="setFixedDate" /> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue index b6bfacb2e47..77f8e125dce 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue @@ -17,8 +17,9 @@ export default { type: Object, }, isLoading: { - required: true, + required: false, type: Boolean, + default: false, }, dateType: { type: String, @@ -31,6 +32,7 @@ export default { return this.issuable?.[dateFields[this.dateType].isDateFixed] || false; }, set(fixed) { + if (fixed === this.issuable[dateFields[this.dateType].isDateFixed]) return; this.$emit('set-date', fixed); }, }, diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue deleted file mode 100644 index 5cd4a1a5192..00000000000 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ /dev/null @@ -1,192 +0,0 @@ -<script> -import $ from 'jquery'; -import { camelCase, difference, union } from 'lodash'; -import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; -import { __ } from '~/locale'; -import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; -import { toLabelGid } from '~/sidebar/utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; -import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -const mutationMap = { - [IssuableType.Issue]: { - mutation: updateIssueLabelsMutation, - mutationName: 'updateIssue', - }, - [IssuableType.MergeRequest]: { - mutation: updateMergeRequestLabelsMutation, - mutationName: 'mergeRequestSetLabels', - }, -}; - -export default { - components: { - LabelsSelect, - LabelsSelectWidget, - }, - variant: DropdownVariant.Sidebar, - mixins: [glFeatureFlagMixin()], - inject: [ - 'allowLabelCreate', - 'allowLabelEdit', - 'allowScopedLabels', - 'iid', - 'fullPath', - 'initiallySelectedLabels', - 'issuableType', - 'labelsFetchPath', - 'labelsManagePath', - 'projectIssuesPath', - 'projectPath', - ], - data() { - return { - isLabelsSelectInProgress: false, - selectedLabels: this.initiallySelectedLabels, - LabelType, - }; - }, - methods: { - handleDropdownClose() { - $(this.$el).trigger('hidden.gl.dropdown'); - }, - getUpdateVariables(labels) { - let labelIds = []; - - if (this.glFeatures.labelsWidget) { - labelIds = labels.map(({ id }) => toLabelGid(id)); - } else { - const currentLabelIds = this.selectedLabels.map((label) => label.id); - const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id); - const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id); - - labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map( - toLabelGid, - ); - } - - switch (this.issuableType) { - case IssuableType.Issue: - return { - iid: this.iid, - projectPath: this.projectPath, - labelIds, - }; - case IssuableType.MergeRequest: - return { - iid: this.iid, - labelIds, - operationMode: MutationOperationMode.Replace, - projectPath: this.projectPath, - }; - default: - return {}; - } - }, - handleUpdateSelectedLabels(dropdownLabels) { - this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels)); - }, - getRemoveVariables(labelId) { - switch (this.issuableType) { - case IssuableType.Issue: - return { - iid: this.iid, - projectPath: this.projectPath, - removeLabelIds: [labelId], - }; - case IssuableType.MergeRequest: - return { - iid: this.iid, - labelIds: [toLabelGid(labelId)], - operationMode: MutationOperationMode.Remove, - projectPath: this.projectPath, - }; - default: - return {}; - } - }, - handleLabelRemove(labelId) { - this.updateSelectedLabels(this.getRemoveVariables(labelId)); - }, - updateSelectedLabels(inputVariables) { - this.isLabelsSelectInProgress = true; - - this.$apollo - .mutate({ - mutation: mutationMap[this.issuableType].mutation, - variables: { input: inputVariables }, - }) - .then(({ data }) => { - const { mutationName } = mutationMap[this.issuableType]; - - if (data[mutationName]?.errors?.length) { - throw new Error(); - } - - const issuableType = camelCase(this.issuableType); - this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map((label) => ({ - ...label, - id: getIdFromGraphQLId(label.id), - })); - }) - .catch(() => createFlash({ message: __('An error occurred while updating labels.') })) - .finally(() => { - this.isLabelsSelectInProgress = false; - }); - }, - }, -}; -</script> - -<template> - <labels-select-widget - v-if="glFeatures.labelsWidget" - class="block labels js-labels-block" - :iid="iid" - :full-path="fullPath" - :allow-label-remove="allowLabelEdit" - :allow-multiselect="true" - :footer-create-label-title="__('Create project label')" - :footer-manage-label-title="__('Manage project labels')" - :labels-create-title="__('Create project label')" - :labels-filter-base-path="projectIssuesPath" - :variant="$options.variant" - :issuable-type="issuableType" - workspace-type="project" - :attr-workspace-path="fullPath" - :label-create-type="LabelType.project" - data-qa-selector="labels_block" - > - {{ __('None') }} - </labels-select-widget> - <labels-select - v-else - class="block labels js-labels-block" - :allow-label-remove="allowLabelEdit" - :allow-label-create="allowLabelCreate" - :allow-label-edit="allowLabelEdit" - :allow-multiselect="true" - :allow-scoped-labels="allowScopedLabels" - :footer-create-label-title="__('Create project label')" - :footer-manage-label-title="__('Manage project labels')" - :labels-create-title="__('Create project label')" - :labels-fetch-path="labelsFetchPath" - :labels-filter-base-path="projectIssuesPath" - :labels-manage-path="labelsManagePath" - :labels-select-in-progress="isLabelsSelectInProgress" - :selected-labels="selectedLabels" - :variant="$options.sidebar" - data-qa-selector="labels_block" - @onDropdownClose="handleDropdownClose" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" - > - {{ __('None') }} - </labels-select> -</template> diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql index 2a1bcdf7136..cb9ee6abc9b 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql @@ -1,6 +1,7 @@ mutation updateIssueLocked($input: IssueSetLockedInput!) { issueSetLocked(input: $input) { issue { + id discussionLocked } errors diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql index 8590c8e71a6..11eb3611006 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql @@ -1,6 +1,7 @@ mutation updateMergeRequestLocked($input: MergeRequestSetLockedInput!) { mergeRequestSetLocked(input: $input) { mergeRequest { + id discussionLocked } errors diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 9554a98121f..60d8fb4d408 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -89,7 +89,7 @@ export default { <template> <div v-gl-tooltip="tooltipOptions" - :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :class="{ 'multiple-users gl-relative': hasMoreThanOneReviewer }" :title="tooltipTitle" class="sidebar-collapsed-icon sidebar-collapsed-user" > diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql index 750e757971f..c9d36dfdb67 100644 --- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql @@ -3,6 +3,7 @@ mutation updateIssuableSeverity($projectPath: ID!, $severity: IssuableSeverity!, errors issue { iid + id severity } } diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 5dc93476120..86e46016534 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -5,7 +5,7 @@ import { GlLoadingIcon, GlTooltip, GlSprintf, - GlLink, + GlButton, } from '@gitlab/ui'; import createFlash from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; @@ -20,7 +20,7 @@ export default { GlSprintf, GlDropdown, GlDropdownItem, - GlLink, + GlButton, SeverityToken, }, inject: ['canUpdate'], @@ -150,23 +150,25 @@ export default { <div class="hide-collapsed"> <p - class="gl-line-height-20 gl-mb-0 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" + class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" > {{ $options.i18n.SEVERITY }} - <gl-link + <gl-button v-if="canUpdate" + category="tertiary" + size="small" data-testid="editButton" - href="#" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ $options.i18n.EDIT }} - </gl-link> + </gl-button> </p> <gl-dropdown :class="dropdownClass" block + :header-text="__('Assign severity')" :text="selectedItem.label" toggle-class="dropdown-menu-toggle gl-mb-2" @keydown.esc.native="hideDropdown" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 0ba8c4f8907..da792b3a2aa 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -12,11 +12,12 @@ import { } from '@gitlab/ui'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; -import { __, s__, sprintf } from '~/locale'; +import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { + dropdowni18nText, Tracking, IssuableAttributeState, IssuableAttributeType, @@ -24,14 +25,11 @@ import { noAttributeId, defaultEpicSort, epicIidPattern, -} from '~/sidebar/constants'; +} from 'ee_else_ce/sidebar/constants'; export default { noAttributeId, - IssuableAttributeState, - issuableAttributesQueries, i18n: { - [IssuableAttributeType.Milestone]: __('Milestone'), expired: __('(expired)'), none: __('None'), }, @@ -53,14 +51,24 @@ export default { isClassicSidebar: { default: false, }, + issuableAttributesQueries: { + default: issuableAttributesQueries, + }, + issuableAttributesState: { + default: IssuableAttributeState, + }, + widgetTitleText: { + default: { + [IssuableAttributeType.Milestone]: __('Milestone'), + expired: __('(expired)'), + none: __('None'), + }, + }, }, props: { issuableAttribute: { type: String, required: true, - validator(value) { - return [IssuableAttributeType.Milestone].includes(value); - }, }, workspacePath: { required: true, @@ -132,13 +140,13 @@ export default { return { fullPath: this.attrWorkspacePath, title: this.searchTerm, - state: this.$options.IssuableAttributeState[this.issuableAttribute], + state: this.issuableAttributesState[this.issuableAttribute], }; } const variables = { fullPath: this.attrWorkspacePath, - state: this.$options.IssuableAttributeState[this.issuableAttribute], + state: this.issuableAttributesState[this.issuableAttribute], sort: defaultEpicSort, }; @@ -180,7 +188,7 @@ export default { }, computed: { issuableAttributeQuery() { - return this.$options.issuableAttributesQueries[this.issuableAttribute]; + return this.issuableAttributesQueries[this.issuableAttribute]; }, attributeTitle() { return this.currentAttribute?.title || this.i18n.noAttribute; @@ -189,9 +197,7 @@ export default { return this.currentAttribute?.webUrl; }, dropdownText() { - return this.currentAttribute - ? this.currentAttribute?.title - : this.$options.i18n[this.issuableAttribute]; + return this.currentAttribute ? this.currentAttribute?.title : this.attributeTypeTitle; }, loading() { return this.$apollo.queries.currentAttribute.loading; @@ -200,7 +206,7 @@ export default { return this.attributesList.length === 0; }, attributeTypeTitle() { - return this.$options.i18n[this.issuableAttribute]; + return this.widgetTitleText[this.issuableAttribute]; }, attributeTypeIcon() { return this.icon || this.issuableAttribute; @@ -209,37 +215,10 @@ export default { return timeFor(this.currentAttribute?.dueDate); }, i18n() { - return { - noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { - issuableAttribute: this.issuableAttribute, - }), - updateError: sprintf( - s__( - 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - listFetchError: sprintf( - s__( - 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - currentFetchError: sprintf( - s__( - 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - }; + return dropdowni18nText(this.issuableAttribute, this.issuableType); }, isEpic() { + // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 return this.issuableAttribute === IssuableType.Epic; }, }, @@ -252,7 +231,7 @@ export default { const selectedAttribute = Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId); - this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none; + this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.widgetTitleText.none; const { current } = this.issuableAttributeQuery; const { mutation } = current[this.issuableType]; 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 bc7e377a966..701833c4e95 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 { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 9a9d03353dc..91c67a03dfb 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { s__, __ } from '~/locale'; import { timeTrackingQueries } from '~/sidebar/constants'; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index ac34a75ac5c..0238fb8e8d5 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,5 +1,6 @@ +import { s__, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import { IssuableType, WorkspaceType } from '~/issue_show/constants'; +import { IssuableType, WorkspaceType } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; @@ -272,3 +273,35 @@ export const todoMutations = { [TodoMutationTypes.Create]: todoCreateMutation, [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, }; + +export function dropdowni18nText(issuableAttribute, issuableType) { + return { + noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { + issuableAttribute, + }), + assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { + issuableAttribute, + }), + noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { + issuableAttribute, + }), + updateError: sprintf( + s__( + 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', + ), + { issuableAttribute, issuableType }, + ), + listFetchError: sprintf( + s__( + 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', + ), + { issuableAttribute, issuableType }, + ), + currentFetchError: sprintf( + s__( + 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', + ), + { issuableAttribute, issuableType }, + ), + }; +} diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index 6a670db2d38..5b2ce3fe446 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,7 +1,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import VueApollo from 'vue-apollo'; -import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql'; +import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 270b22fcdf9..1947c4801db 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import timeTracker from './components/time_tracking/time_tracker.vue'; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 898be4a97ce..cbe40d0bfbe 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -5,13 +5,14 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { isInIssuePage, isInDesignPage, isInIncidentPage, parseBoolean, } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; @@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid import { apolloProvider } from '~/sidebar/graphql'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; +import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; -import SidebarLabels from './components/labels/sidebar_labels.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; @@ -34,6 +36,7 @@ import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subsc import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import { IssuableAttributeType } from './constants'; import SidebarMoveIssue from './lib/sidebar_move_issue'; +import CrmContacts from './components/crm_contacts/crm_contacts.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -205,6 +208,28 @@ function mountReviewersComponent(mediator) { } } +function mountCrmContactsComponent() { + const el = document.getElementById('js-issue-crm-contacts'); + + if (!el) return; + + const { issueId } = el.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + CrmContacts, + }, + render: (createElement) => + createElement('crm-contacts', { + props: { + issueId, + }, + }), + }); +} + function mountMilestoneSelect() { const el = document.querySelector('.js-milestone-select'); @@ -241,7 +266,6 @@ function mountMilestoneSelect() { export function mountSidebarLabels() { const el = document.querySelector('.js-sidebar-labels'); - const { fullPath } = getSidebarOptions(); if (!el) { return false; @@ -250,22 +274,43 @@ export function mountSidebarLabels() { return new Vue({ el, apolloProvider, + + components: { + LabelsSelectWidget, + }, provide: { ...el.dataset, - fullPath, + canUpdate: parseBoolean(el.dataset.canEdit), allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelEdit: parseBoolean(el.dataset.canEdit), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), - initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), - variant: DropdownVariant.Sidebar, - canUpdate: parseBoolean(el.dataset.canEdit), isClassicSidebar: true, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue - : IssuableType.MergeRequest, }, - render: (createElement) => createElement(SidebarLabels), + render: (createElement) => + createElement('labels-select-widget', { + props: { + iid: String(el.dataset.iid), + fullPath: el.dataset.projectPath, + allowLabelRemove: parseBoolean(el.dataset.canEdit), + allowMultiselect: true, + footerCreateLabelTitle: __('Create project label'), + footerManageLabelTitle: __('Manage project labels'), + labelsCreateTitle: __('Create project label'), + labelsFilterBasePath: el.dataset.projectIssuesPath, + variant: DropdownVariant.Sidebar, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, + workspaceType: 'project', + attrWorkspacePath: el.dataset.projectPath, + labelCreateType: LabelType.project, + }, + class: ['block labels js-labels-block'], + scopedSlots: { + default: () => __('None'), + }, + }), }); } @@ -535,6 +580,7 @@ export function mountSidebar(mediator, store) { mountAssigneesComponentDeprecated(mediator); } mountReviewersComponent(mediator); + mountCrmContactsComponent(); mountSidebarLabels(); mountMilestoneSelect(); mountConfidentialComponent(mediator); diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql index 7a1fdb40e93..4998b2af666 100644 --- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql @@ -1,6 +1,7 @@ query epicConfidential($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql index f60f44abebd..00529042e92 100644 --- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql @@ -1,6 +1,7 @@ query epicDueDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql index fbebc50ab08..dada7ffc034 100644 --- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -4,6 +4,7 @@ query epicParticipants($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql index bd10f09aed8..f35ca896ef8 100644 --- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql @@ -1,6 +1,7 @@ query epicReference($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql index c6c24fd3d95..85fc7de8d02 100644 --- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql @@ -1,6 +1,7 @@ query epicStartDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql index 9f1967e1685..a8fe6b8ddc3 100644 --- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql @@ -1,6 +1,7 @@ query epicSubscribed($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id emailsDisabled issuable: epic(iid: $iid) { __typename diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql index 1e6f9bad5b2..b0ba724e727 100644 --- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql @@ -1,6 +1,7 @@ query epicTodos($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql index 47ce094418c..a58a04d87c4 100644 --- a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql @@ -3,6 +3,7 @@ subscription issuableAssigneesUpdated($issuableId: IssuableID!) { issuableAssigneesUpdated(issuableId: $issuableId) { ... on Issue { + id assignees { nodes { ...User diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql index 92cabf46af7..e578cf3bda5 100644 --- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql @@ -1,6 +1,7 @@ query issueConfidential($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql index 6d3f782bd0a..48cbff252b3 100644 --- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql @@ -1,6 +1,7 @@ query issueDueDate($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql index db4f58a4f69..c3128d6d961 100644 --- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql @@ -1,5 +1,6 @@ query issueReference($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { + id __typename issuable: issue(iid: $iid) { __typename diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql index 7d38b5d3bd8..e2722fc86a4 100644 --- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql @@ -1,6 +1,7 @@ query issueSubscribed($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql index 7ac989b5c63..059361dd370 100644 --- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql @@ -1,6 +1,7 @@ query issueTimeTracking($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql index 783d36352fe..5cd5d81c439 100644 --- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql @@ -1,6 +1,7 @@ query issueTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql index 5c0edf5acee..b0a16677cf2 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql @@ -3,6 +3,7 @@ query mergeRequestMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql index 7979a1ccb3e..7c78f812b67 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql @@ -1,6 +1,7 @@ query mergeRequestReference($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql index 3b54a2e529b..d5e27ca7b69 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql @@ -1,6 +1,7 @@ query mergeRequestSubscribed($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql index b1ab1bcbe87..d480ff3d5ba 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql @@ -1,6 +1,7 @@ query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql index 93a1c9ea925..65b9ef45260 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql @@ -1,6 +1,7 @@ query mergeRequestTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql index 2bc42a0b011..c7f3adc9aca 100644 --- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql @@ -3,6 +3,7 @@ query projectIssueMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql index a3ab1ebc872..d9eab18628d 100644 --- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -3,6 +3,7 @@ query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: project(fullPath: $fullPath) { __typename + id attributes: milestones( searchTitle: $title state: $state diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql index dd85eb1631b..90d1a7794ea 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql @@ -1,6 +1,8 @@ query sidebarDetails($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { + id issue(iid: $iid) { + id iid } } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql index 02498b18832..0505f88773d 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql @@ -1,6 +1,8 @@ query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { + id mergeRequest(iid: $iid) { + id iid # currently unused. } } diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql index 2e6bc8c36ba..809cb2c9f76 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql @@ -1,6 +1,7 @@ mutation updateEpicTitle($input: UpdateEpicInput!) { updateIssuableTitle: updateEpic(input: $input) { epic { + id title } errors diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql index 016c31ea096..a48c9e96fc2 100644 --- a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql @@ -1,7 +1,7 @@ mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) { - mergeRequestSetLabels(input: $input) { + updateIssuableLabels: mergeRequestSetLabels(input: $input) { errors - mergeRequest { + issuable: mergeRequest { id labels { nodes { diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 86580744ccc..a49ddac8c89 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -79,6 +79,20 @@ export default class SidebarMediator { }), ); } else { + const currentUserId = gon.current_user_id; + + if (currentUserId !== user.id) { + const currentUserReviewerOrAssignee = isReviewer + ? this.store.findReviewer({ id: currentUserId }) + : this.store.findAssignee({ id: currentUserId }); + + if (currentUserReviewerOrAssignee?.attention_requested) { + // Update current users attention_requested state + this.store.updateReviewer(currentUserId, 'attention_requested'); + this.store.updateAssignee(currentUserId, 'attention_requested'); + } + } + toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); } diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index f07fb9d926a..e3aa29d5f89 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -230,7 +230,7 @@ export default { <gl-button category="primary" type="submit" - variant="success" + variant="confirm" :disabled="updatePrevented" data-qa-selector="submit_button" data-testid="snippet-submit-btn" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 8481ac2b9c9..86cbc2c31b3 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -90,7 +90,7 @@ export default { }; </script> <template> - <article class="file-holder snippet-file-content"> + <figure class="file-holder snippet-file-content" :aria-label="__('Code snippet')"> <blob-header :blob="blob" :active-viewer-type="viewer.type" @@ -105,5 +105,5 @@ export default { @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" /> - </article> + </figure> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index a5c98a7ad90..9b24c8afe37 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -113,7 +113,7 @@ export default { href: this.snippet.project ? joinPaths(this.snippet.project.webUrl, '-/snippets/new') : joinPaths('/', gon.relative_url_root, '/-/snippets/new'), - variant: 'success', + variant: 'confirm', category: 'secondary', }, { diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql deleted file mode 100644 index 64bb2315c1b..00000000000 --- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql +++ /dev/null @@ -1,6 +0,0 @@ -fragment SnippetProject on Snippet { - project { - fullPath - webUrl - } -} diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql index f688868d1b9..8640c4725f4 100644 --- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -2,6 +2,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) { createSnippet(input: $input) { errors snippet { + id webUrl } } diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql index 548725f7357..99242c5d500 100644 --- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -2,6 +2,7 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) { updateSnippet(input: $input) { errors snippet { + id webUrl } } diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql index cfe30c601ed..c8c4195e1cd 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql @@ -1,5 +1,6 @@ query sourceContent($project: ID!, $sourcePath: String!) { project(fullPath: $project) { + id fullPath file(path: $sourcePath) @client { title diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js new file mode 100644 index 00000000000..3b84d7394d4 --- /dev/null +++ b/app/assets/javascripts/tabs/constants.js @@ -0,0 +1,20 @@ +export const ACTIVE_TAB_CLASSES = Object.freeze([ + 'active', + 'gl-tab-nav-item-active', + 'gl-tab-nav-item-active-indigo', +]); + +export const ACTIVE_PANEL_CLASS = 'active'; + +export const KEY_CODE_LEFT = 'ArrowLeft'; +export const KEY_CODE_UP = 'ArrowUp'; +export const KEY_CODE_RIGHT = 'ArrowRight'; +export const KEY_CODE_DOWN = 'ArrowDown'; + +export const ATTR_ARIA_CONTROLS = 'aria-controls'; +export const ATTR_ARIA_LABELLEDBY = 'aria-labelledby'; +export const ATTR_ARIA_SELECTED = 'aria-selected'; +export const ATTR_ROLE = 'role'; +export const ATTR_TABINDEX = 'tabindex'; + +export const TAB_SHOWN_EVENT = 'gl-tab-shown'; diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js new file mode 100644 index 00000000000..44937e593e0 --- /dev/null +++ b/app/assets/javascripts/tabs/index.js @@ -0,0 +1,239 @@ +import { uniqueId } from 'lodash'; +import { + ACTIVE_TAB_CLASSES, + ATTR_ROLE, + ATTR_ARIA_CONTROLS, + ATTR_TABINDEX, + ATTR_ARIA_SELECTED, + ATTR_ARIA_LABELLEDBY, + ACTIVE_PANEL_CLASS, + KEY_CODE_LEFT, + KEY_CODE_UP, + KEY_CODE_RIGHT, + KEY_CODE_DOWN, + TAB_SHOWN_EVENT, +} from './constants'; + +export { TAB_SHOWN_EVENT }; + +/** + * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and + * `gl_tab_link_to` Rails helpers. + * + * Example using `href` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#foo', item_active: true do + * = _('Foo') + * = gl_tab_link_to '#bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * Example using `aria-controls` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do + * = _('Foo') + * = gl_tab_link_to '#', 'aria-controls': 'bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot + * easily be rewritten in Vue. + * + * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not + * work correctly. + * + * Tab panels must exist somewhere in the page for the tabs to control. Tab panels + * must: + * - be immediate children of a `.tab-content` element + * - have the `tab-pane` class + * - if the panel is active, have the `active` class + * - have a unique `id` attribute + * + * In order to associate tabs with panels, the tabs must reference their panel's + * `id` by having one of the following attributes: + * - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value) + * - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`) + * + * Exactly one tab/panel must be active in the original markup. + * + * Call the `destroy` method on an instance to remove event listeners that were + * added during construction. Other DOM mutations (like ARIA attributes) are + * _not_ reverted. + */ +export class GlTabsBehavior { + /** + * Create a GlTabsBehavior instance. + * + * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper. + */ + constructor(el) { + if (!el) { + throw new Error('Cannot instantiate GlTabsBehavior without an element'); + } + + this.destroyFns = []; + this.tabList = el; + this.tabs = this.getTabs(); + this.activeTab = null; + + this.setAccessibilityAttrs(); + this.bindEvents(); + } + + setAccessibilityAttrs() { + this.tabList.setAttribute(ATTR_ROLE, 'tablist'); + this.tabs.forEach((tab) => { + if (!tab.hasAttribute('id')) { + tab.setAttribute('id', uniqueId('gl_tab_nav__tab_')); + } + + if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) { + this.activeTab = tab; + tab.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tab.removeAttribute(ATTR_TABINDEX); + } else { + tab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + tab.setAttribute(ATTR_TABINDEX, '-1'); + } + + tab.setAttribute(ATTR_ROLE, 'tab'); + tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation'); + + const tabPanel = this.getPanelForTab(tab); + if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) { + tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id); + } + + tabPanel.setAttribute(ATTR_ROLE, 'tabpanel'); + tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id); + }); + } + + bindEvents() { + this.tabs.forEach((tab) => { + this.bindEvent(tab, 'click', (event) => { + event.preventDefault(); + + if (tab !== this.activeTab) { + this.activateTab(tab); + } + }); + + this.bindEvent(tab, 'keydown', (event) => { + const { code } = event; + if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) { + event.preventDefault(); + this.activatePreviousTab(); + } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) { + event.preventDefault(); + this.activateNextTab(); + } + }); + }); + } + + bindEvent(el, ...args) { + el.addEventListener(...args); + + this.destroyFns.push(() => { + el.removeEventListener(...args); + }); + } + + activatePreviousTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex <= 0) return; + + const previousTab = this.tabs[currentTabIndex - 1]; + this.activateTab(previousTab); + previousTab.focus(); + } + + activateNextTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex >= this.tabs.length - 1) return; + + const nextTab = this.tabs[currentTabIndex + 1]; + this.activateTab(nextTab); + nextTab.focus(); + } + + getTabs() { + return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item')); + } + + // eslint-disable-next-line class-methods-use-this + getPanelForTab(tab) { + const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS); + + if (ariaControls) { + return document.querySelector(`#${ariaControls}`); + } + + return document.querySelector(tab.getAttribute('href')); + } + + activateTab(tabToActivate) { + // Deactivate active tab first + this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + this.activeTab.setAttribute(ATTR_TABINDEX, '-1'); + this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES); + + const activePanel = this.getPanelForTab(this.activeTab); + activePanel.classList.remove(ACTIVE_PANEL_CLASS); + + // Now activate the given tab/panel + tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tabToActivate.removeAttribute(ATTR_TABINDEX); + tabToActivate.classList.add(...ACTIVE_TAB_CLASSES); + + const tabPanel = this.getPanelForTab(tabToActivate); + tabPanel.classList.add(ACTIVE_PANEL_CLASS); + + this.activeTab = tabToActivate; + + this.dispatchTabShown(tabToActivate, tabPanel); + } + + // eslint-disable-next-line class-methods-use-this + dispatchTabShown(tab, activeTabPanel) { + const event = new CustomEvent(TAB_SHOWN_EVENT, { + bubbles: true, + detail: { + activeTabPanel, + }, + }); + + tab.dispatchEvent(event); + } + + destroy() { + this.destroyFns.forEach((destroy) => destroy()); + } +} diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql index 70ba5c960be..bb1e7195b17 100644 --- a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql +++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql @@ -1,23 +1,23 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" fragment StateVersion on TerraformStateVersion { + id downloadPath serial updatedAt - createdByUser { ...User } - job { + id detailedStatus { + id detailsPath group icon label text } - pipeline { id path diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql index 9453e32b1b5..4d26ea88ddf 100644 --- a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql +++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql @@ -3,13 +3,12 @@ query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { project(fullPath: $projectPath) { + id terraformStates(first: $first, last: $last, before: $before, after: $after) { count - nodes { ...State } - pageInfo { ...PageInfo } diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index 321315d531b..4f3f1365f4a 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -122,7 +122,6 @@ export default function simulateDrag(options) { const firstRect = getRect(firstEl); const lastRect = getRect(lastEl); - const startTime = new Date().getTime(); const duration = options.duration || 1000; simulateEvent(fromEl, 'pointerdown', { @@ -140,8 +139,28 @@ export default function simulateDrag(options) { toRect.cy = lastRect.y + lastRect.h + 50; } - const dragInterval = setInterval(() => { - const progress = (new Date().getTime() - startTime) / duration; + let startTime; + + // Called within dragFn when the drag should finish + const finishFn = () => { + if (options.ondragend) options.ondragend(); + + if (options.performDrop) { + simulateEvent(toEl, 'mouseup'); + } + + window.SIMULATE_DRAG_ACTIVE = 0; + }; + + const dragFn = (timestamp) => { + if (!startTime) { + startTime = timestamp; + } + + const elapsed = timestamp - startTime; + + // Make sure that progress maxes at 1 + const progress = Math.min(elapsed / duration, 1); const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress; const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress; const overEl = fromEl.ownerDocument.elementFromPoint(x, y); @@ -152,16 +171,15 @@ export default function simulateDrag(options) { }); if (progress >= 1) { - if (options.ondragend) options.ondragend(); - - if (options.performDrop) { - simulateEvent(toEl, 'mouseup'); - } - - clearInterval(dragInterval); - window.SIMULATE_DRAG_ACTIVE = 0; + // finish on next frame, so we can pause in the correct position for a frame + requestAnimationFrame(finishFn); + } else { + requestAnimationFrame(dragFn); } - }, 100); + }; + + // Start the drag animation + requestAnimationFrame(dragFn); return { target: fromEl, diff --git a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql index d4f559c3701..0e5334b468f 100644 --- a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql +++ b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql @@ -1,5 +1,6 @@ query getCIJobTokenScope($fullPath: ID!) { project(fullPath: $fullPath) { + id ciCdSettings { jobTokenScopeEnabled } diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql index bec0710a1dd..664991bc110 100644 --- a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql +++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql @@ -1,8 +1,10 @@ query getProjectsWithCIJobTokenScope($fullPath: ID!) { project(fullPath: $fullPath) { + id ciJobTokenScope { projects { nodes { + id name fullPath } diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js deleted file mode 100644 index 1a3fd6c77ed..00000000000 --- a/app/assets/javascripts/ui_development_kit.js +++ /dev/null @@ -1,28 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import Api from './api'; - -export default () => { - initDeprecatedJQueryDropdown($('#js-project-dropdown'), { - data: (term, callback) => { - Api.projects( - term, - { - order_by: 'last_activity_at', - }, - (data) => { - callback(data); - }, - ); - }, - text: (project) => project.name_with_namespace || project.name, - selectable: true, - fieldName: 'author_id', - filterable: true, - search: { - fields: ['name_with_namespace'], - }, - id: (data) => data.id, - isSelected: (data) => data.id === 2, - }); -}; diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue index a8dde1f681e..e982d10f63b 100644 --- a/app/assets/javascripts/user_lists/components/add_user_modal.vue +++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue @@ -19,7 +19,7 @@ export default { modalOptions: { actionPrimary: { text: s__('UserLists|Add'), - attributes: [{ 'data-testid': 'confirm-add-user-ids' }], + attributes: [{ 'data-testid': 'confirm-add-user-ids', variant: 'confirm' }], }, actionCancel: { text: s__('UserLists|Cancel'), diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue index 4cf3f3010b9..e86b3f81daa 100644 --- a/app/assets/javascripts/user_lists/components/user_list.vue +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -105,7 +105,7 @@ export default { <gl-button v-gl-modal="$options.ADD_USER_MODAL_ID" data-testid="add-users" - variant="success" + variant="confirm" > {{ $options.translations.addUserButtonLabel }} </gl-button> diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js index abc1dd75645..b44f787cf30 100644 --- a/app/assets/javascripts/vue_alerts.js +++ b/app/assets/javascripts/vue_alerts.js @@ -1,7 +1,17 @@ import Vue from 'vue'; +import Cookies from 'js-cookie'; import { parseBoolean } from '~/lib/utils/common_utils'; import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue'; +const getCookieExpirationPeriod = (expirationPeriod) => { + const defaultExpirationPeriod = 30; + const alertExpirationPeriod = Number(expirationPeriod); + + return !expirationPeriod || Number.isNaN(alertExpirationPeriod) + ? defaultExpirationPeriod + : alertExpirationPeriod; +}; + const mountVueAlert = (el) => { const props = { html: el.innerHTML, @@ -10,11 +20,25 @@ const mountVueAlert = (el) => { ...el.dataset, dismissible: parseBoolean(el.dataset.dismissible), }; + const { dismissCookieName, dismissCookieExpire } = el.dataset; return new Vue({ el, - render(h) { - return h(DismissibleAlert, { props, attrs }); + render(createElement) { + return createElement(DismissibleAlert, { + props, + attrs, + on: { + alertDismissed() { + if (!dismissCookieName) { + return; + } + Cookies.set(dismissCookieName, true, { + expires: getCookieExpirationPeriod(dismissCookieExpire), + }); + }, + }, + }); }, }); }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index f4f611dfd1b..e115710b5d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { MANUAL_DEPLOY, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 6f10f788952..549cf64fb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -9,17 +9,20 @@ import { GlIntersectionObserver, } from '@gitlab/ui'; import { once } from 'lodash'; +import * as Sentry from '@sentry/browser'; import api from '~/api'; import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; -import { EXTENSION_ICON_CLASS } from '../../constants'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import StatusIcon from './status_icon.vue'; import Actions from './actions.vue'; +import { generateText } from './utils'; export const LOADING_STATES = { collapsedLoading: 'collapsedLoading', collapsedError: 'collapsedError', expandedLoading: 'expandedLoading', + expandedError: 'expandedError', }; export default { @@ -40,8 +43,8 @@ export default { data() { return { loadingState: LOADING_STATES.collapsedLoading, - collapsedData: null, - fullData: null, + collapsedData: {}, + fullData: [], isCollapsed: true, showFade: false, }; @@ -53,6 +56,9 @@ export default { widgetLoadingText() { return this.$options.i18n?.loading || __('Loading...'); }, + widgetErrorText() { + return this.$options.i18n?.error || __('Failed to load'); + }, isLoadingSummary() { return this.loadingState === LOADING_STATES.collapsedLoading; }, @@ -60,11 +66,16 @@ export default { return this.loadingState === LOADING_STATES.expandedLoading; }, isCollapsible() { - if (this.isLoadingSummary) { - return false; - } - - return true; + return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError; + }, + hasFullData() { + return this.fullData.length > 0; + }, + hasFetchError() { + return ( + this.loadingState === LOADING_STATES.collapsedError || + this.loadingState === LOADING_STATES.expandedError + ); }, collapseButtonLabel() { return sprintf( @@ -75,6 +86,7 @@ export default { ); }, statusIconName() { + if (this.hasFetchError) return EXTENSION_ICONS.error; if (this.isLoadingSummary) return null; return this.statusIcon(this.collapsedData); @@ -82,6 +94,20 @@ export default { tertiaryActionsButtons() { return this.tertiaryButtons ? this.tertiaryButtons() : undefined; }, + hydratedSummary() { + const structuredOutput = this.summary(this.collapsedData); + const summary = { + subject: generateText( + typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject, + ), + }; + + if (structuredOutput.meta) { + summary.meta = generateText(structuredOutput.meta); + } + + return summary; + }, }, watch: { isCollapsed(newVal) { @@ -93,15 +119,7 @@ export default { }, }, mounted() { - this.fetchCollapsedData(this.$props) - .then((data) => { - this.collapsedData = data; - this.loadingState = null; - }) - .catch((e) => { - this.loadingState = LOADING_STATES.collapsedError; - throw e; - }); + this.loadCollapsedData(); }, methods: { triggerRedisTracking: once(function triggerRedisTracking() { @@ -114,8 +132,22 @@ export default { this.triggerRedisTracking(); }, + loadCollapsedData() { + this.loadingState = LOADING_STATES.collapsedLoading; + + this.fetchCollapsedData(this.$props) + .then((data) => { + this.collapsedData = data; + this.loadingState = null; + }) + .catch((e) => { + this.loadingState = LOADING_STATES.collapsedError; + + Sentry.captureException(e); + }); + }, loadAllData() { - if (this.fullData) return; + if (this.hasFullData) return; this.loadingState = LOADING_STATES.expandedLoading; @@ -125,10 +157,14 @@ export default { this.fullData = data; }) .catch((e) => { - this.loadingState = null; - throw e; + this.loadingState = LOADING_STATES.expandedError; + + Sentry.captureException(e); }); }, + isArray(arr) { + return Array.isArray(arr); + }, appear(index) { if (index === this.fullData.length - 1) { this.showFade = false; @@ -139,6 +175,7 @@ export default { this.showFade = true; } }, + generateText, }, EXTENSION_ICON_CLASS, }; @@ -153,20 +190,29 @@ export default { :icon-name="statusIconName" /> <div - class="media-body gl-display-flex gl-flex-direction-row!" + class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" > <div class="gl-flex-grow-1"> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> - <div v-else v-safe-html="summary(collapsedData)"></div> + <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> + <div v-else> + <span v-safe-html="hydratedSummary.subject"></span> + <template v-if="hydratedSummary.meta"> + <br /> + <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> + </template> + </div> </div> <actions :widget="$options.label || $options.name" :tertiary-buttons="tertiaryActionsButtons" /> - <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"> + <div + v-if="isCollapsible" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" + > <gl-button - v-if="isCollapsible" v-gl-tooltip :title="collapseButtonLabel" :aria-expanded="`${!isCollapsed}`" @@ -189,7 +235,7 @@ export default { <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> <smart-virtual-list - v-else-if="fullData" + v-else-if="hasFullData" :length="fullData.length" :remain="20" :size="32" @@ -203,37 +249,64 @@ export default { :class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, }" - class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + class="gl-py-3 gl-pl-7" data-testid="extension-list-item" > - <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> - <gl-intersection-observer - :options="{ rootMargin: '100px', thresholds: 0.1 }" - class="gl-flex-wrap gl-display-flex gl-w-full" - @appear="appear(index)" - @disappear="disappear(index)" - > - <div - v-safe-html="data.text" - class="gl-mr-4 gl-display-flex gl-align-items-center" - ></div> - <div v-if="data.link"> - <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + <div class="gl-w-full"> + <div v-if="data.header" class="gl-mb-2"> + <template v-if="isArray(data.header)"> + <component + :is="headerI === 0 ? 'strong' : 'span'" + v-for="(header, headerI) in data.header" + :key="headerI" + v-safe-html="generateText(header)" + class="gl-display-block" + /> + </template> + <strong v-else v-safe-html="generateText(data.header)"></strong> + </div> + <div class="gl-display-flex"> + <status-icon + v-if="data.icon" + :icon-name="data.icon.name" + :size="12" + class="gl-pl-0" + /> + <gl-intersection-observer + :options="{ rootMargin: '100px', thresholds: 0.1 }" + class="gl-w-full" + @appear="appear(index)" + @disappear="disappear(index)" + > + <div class="gl-flex-wrap gl-display-flex gl-w-full"> + <div class="gl-mr-4 gl-display-flex gl-align-items-center"> + <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> + </div> + <div v-if="data.link"> + <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + </div> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + <actions + :widget="$options.label || $options.name" + :tertiary-buttons="data.actions" + class="gl-ml-auto" + /> + </div> + <p + v-if="data.subtext" + v-safe-html="generateText(data.subtext)" + class="gl-m-0 gl-font-sm" + ></p> + </gl-intersection-observer> </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> - {{ data.badge.text }} - </gl-badge> - <actions - :widget="$options.label || $options.name" - :tertiary-buttons="data.actions" - class="gl-ml-auto" - /> - </gl-intersection-observer> + </div> </li> </smart-virtual-list> <div :class="{ show: showFade }" - class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7" + class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none" ></div> </div> </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js new file mode 100644 index 00000000000..8ba13cf8252 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js @@ -0,0 +1,62 @@ +const TEXT_STYLES = { + success: { + start: '%{success_start}', + end: '%{success_end}', + }, + danger: { + start: '%{danger_start}', + end: '%{danger_end}', + }, + critical: { + start: '%{critical_start}', + end: '%{critical_end}', + }, + same: { + start: '%{same_start}', + end: '%{same_end}', + }, + strong: { + start: '%{strong_start}', + end: '%{strong_end}', + }, + small: { + start: '%{small_start}', + end: '%{small_end}', + }, +}; + +const getStartTag = (tag) => TEXT_STYLES[tag].start; +const textStyleTags = { + [getStartTag('success')]: '<span class="gl-font-weight-bold gl-text-green-500">', + [getStartTag('danger')]: '<span class="gl-font-weight-bold gl-text-red-500">', + [getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">', + [getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">', + [getStartTag('strong')]: '<span class="gl-font-weight-bold">', + [getStartTag('small')]: '<span class="gl-font-sm">', +}; + +export const generateText = (text) => { + if (typeof text !== 'string') return null; + + return text + .replace( + new RegExp( + `(${Object.values(TEXT_STYLES) + .reduce((acc, i) => [...acc, ...Object.values(i)], []) + .join('|')})`, + 'gi', + ), + (replace) => { + const replacement = textStyleTags[replace]; + + // If the replacement tag ends with a `_end` then we can just return `</span>` + // unless we have a replacement, for cases were we want to change the HTML tag + if (!replacement && replace.endsWith('_end}')) { + return '</span>'; + } + + return replacement; + }, + ) + .replace(/%{([a-z]|_)+}/g, ''); // Filter out any tags we don't know about +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 9070cb1fe65..235a200b747 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -13,7 +13,7 @@ import { import { constructWebIDEPath } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; 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 f7c952f9ef6..c0b80eef082 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 @@ -15,7 +15,7 @@ import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mi import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { MT_MERGE_STRATEGY } from '../constants'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index c314261d3f5..730d11b1208 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,9 +1,13 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { s__, n__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'MRWidgetRelatedLinks', + directives: { + SafeHtml, + }, mixins: [glFeatureFlagMixin()], props: { relatedLinks: { @@ -43,14 +47,14 @@ export default { :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" > {{ closesText }} - <span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="relatedLinks.closing"></span> </p> <p v-if="relatedLinks.mentioned" :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" > {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} - <span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="relatedLinks.mentioned"></span> </p> <p v-if="relatedLinks.assignToMe && showAssignToMe" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index 3eda2828e97..18761d04c2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -41,7 +41,6 @@ export default { rows="7" @input="$emit('input', $event.target.value)" ></textarea> - <slot name="text-muted"></slot> </div> </li> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 503ddf8a396..ce572f8b0bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -9,7 +9,7 @@ export default { pipelineFailed: s__( 'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.', ), - approvalNeeded: s__('mrWidget|You can only merge once this merge request is approved.'), + approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'), unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'), }, components: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 29c26f4fb3e..13b1e49f44e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -20,7 +20,7 @@ export default { </div> <div class="media-body"> <span class="bold"> - {{ s__('mrWidget|This project is archived, write access has been disabled') }} + {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 1596f852b74..7a002d41ac0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -117,11 +117,12 @@ export default { </span> <template v-else> <span class="bold"> - {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!canMerge">.</span> + {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} <span v-if="!canMerge"> {{ - s__(`mrWidget|Resolve these conflicts or ask someone - with write access to this repository to merge it locally`) + s__( + `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`, + ) }} </span> </span> 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 9f2870d8d69..01e8303f513 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 @@ -121,9 +121,6 @@ export default { if (res.merge_error && res.merge_error.length) { this.rebasingError = res.merge_error; - createFlash({ - message: __('Something went wrong. Please try again.'), - }); } eventHub.$emit('MRWidgetRebaseSuccess'); 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 d2cc99302a9..8830128b7d6 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 @@ -181,9 +181,16 @@ export default { return this.mr.canRemoveSourceBranch; }, commitTemplateHelpPage() { - return helpPagePath('user/project/merge_requests/commit_templates.md', { - anchor: 'merge-commit-message-template', - }); + return helpPagePath('user/project/merge_requests/commit_templates.md'); + }, + commitTemplateHintText() { + if (this.shouldShowSquashEdit && this.shouldShowMergeEdit) { + return this.$options.i18n.mergeAndSquashCommitTemplatesHintText; + } + if (this.shouldShowSquashEdit) { + return this.$options.i18n.squashCommitTemplateHintText; + } + return this.$options.i18n.mergeCommitTemplateHintText; }, commits() { if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -287,7 +294,7 @@ export default { return false; } - return enableSquashBeforeMerge && this.commitsCount > 1; + return enableSquashBeforeMerge; }, shouldShowMergeControls() { if (this.glFeatures.restructuredMrWidget) { @@ -509,6 +516,12 @@ export default { mergeCommitTemplateHintText: s__( 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}', ), + squashCommitTemplateHintText: s__( + 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more.%{linkEnd}', + ), + mergeAndSquashCommitTemplatesHintText: s__( + 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}', + ), }, }; </script> @@ -590,13 +603,7 @@ export default { :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }" class="gl-display-flex gl-align-items-center gl-flex-wrap" > - <merge-train-helper-icon - v-if="shouldRenderMergeTrainHelperIcon" - :merge-train-when-pipeline-succeeds-docs-path=" - mr.mergeTrainWhenPipelineSucceedsDocsPath - " - class="gl-mx-3" - /> + <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -680,23 +687,22 @@ export default { :label="__('Merge commit message')" input-id="merge-message-edit" class="gl-m-0! gl-p-0!" - > - <template #text-muted> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> - <template #link="{ content }"> - <gl-link - :href="commitTemplateHelpPage" - class="inline-link" - target="_blank" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - </commit-edit> + /> + <li class="gl-m-0! gl-p-0!"> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link + :href="commitTemplateHelpPage" + class="inline-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> </ul> </div> <div @@ -798,19 +804,18 @@ export default { v-model="commitMessage" :label="__('Merge commit message')" input-id="merge-message-edit" - > - <template #text-muted> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> - <template #link="{ content }"> - <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - </commit-edit> + /> + <li> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> </ul> </commits-header> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index fa4f8b76cb9..ba831a33b73 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -165,13 +165,12 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="canUpdate" status="warning" /> <div class="media-body"> - <div class="gl-ml-3 float-left"> + <div class="float-left"> <span class="gl-font-weight-bold"> - {{ __('This merge request is still a draft.') }} + {{ + __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") + }} </span> - <span class="gl-display-block text-muted">{{ - __("Draft merge requests can't be merged.") - }}</span> </div> <gl-button v-if="canUpdate" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue index 87a310efe78..1e5f7361966 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -20,8 +20,8 @@ export default { 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', ), generationErrored: s__('Terraform|Generating the report caused an error.'), - namedReportFailed: s__('Terraform|The report %{name} failed to generate.'), - namedReportGenerated: s__('Terraform|The report %{name} was generated in your pipelines.'), + namedReportFailed: s__('Terraform|The job %{name} failed to generate a report.'), + namedReportGenerated: s__('Terraform|The job %{name} generated a report.'), reportFailed: s__('Terraform|A report failed to generate.'), reportGenerated: s__('Terraform|A report was generated in your pipelines.'), }, diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index d0c6cf12e25..2edccce7f4e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -50,6 +50,18 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [ message: s__('mrWidget|Merging! This is going to be great…'), emoji: 'heart_eyes', }, + { + message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'), + emoji: 'rocket', + }, + { + message: s__('mrWidget|Merging! The changes are leaving the station…'), + emoji: 'bullettrain_front', + }, + { + message: s__('mrWidget|Merging! Take a deep breath and relax…'), + emoji: 'sunglasses', + }, ]; const STATE_MACHINE = { @@ -146,4 +158,7 @@ export const EXTENSION_ICON_CLASS = { severityUnknown: 'gl-text-gray-400', }; +export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; +export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; + export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 9cbc0b0e5d1..ba3336df2eb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -2,6 +2,7 @@ import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; +import { n__, sprintf } from '~/locale'; export default { // Give the extension a name @@ -20,7 +21,14 @@ export default { // Small summary text to be displayed in the collapsed state // Receives the collapsed data as an argument summary(count) { - return 'Summary text<br/>Second line'; + return sprintf( + n__( + 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change', + 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes', + changesFound, + ), + { changesFound }, + ); }, // Status icon to be used next to the summary text // Receives the collapsed data as an argument @@ -57,9 +65,13 @@ export default { .query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } }) .then(({ data }) => { // Return some transformed data to be rendered in the expanded state - return data.project.issues.nodes.map((issue) => ({ + return data.project.issues.nodes.map((issue, i) => ({ id: issue.id, // Required: The ID of the object - text: issue.title, // Required: The text to get used on each row + header: ['New', 'This is an %{strong_start}issue%{strong_end} row'], + text: + '%{critical_start}1 Critical%{critical_end}, %{danger_start}1 High%{danger_end}, and %{strong_start}1 Other%{strong_end}. %{small_start}Some smaller text%{small_end}', // Required: The text to get used on each row + subtext: + 'Reported resource changes: %{strong_start}2%{strong_end} to add, 0 to change, 0 to delete', // Optional: The sub-text to get displayed below each rows main content // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql index 690f571c083..5c54560bd02 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql @@ -1,5 +1,6 @@ query getAllIssues($projectPath: ID!) { project(fullPath: $projectPath) { + id issues { nodes { id diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql index da1cace4598..bf278e1ea85 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql @@ -1,5 +1,6 @@ query getProjectIssues($projectPath: ID!) { project(fullPath: $projectPath) { + id issues { count } diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 83789f10285..fa618756bb5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,6 +1,8 @@ import { __ } from '~/locale'; -export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); +export const MERGE_DISABLED_TEXT = __( + 'Merge blocked: all merge request dependencies must be merged or closed.', +); export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __( "Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.", ); diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index bfb1517be81..0b8396b4461 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -1,9 +1,11 @@ query getState($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id archived onlyAllowMergeIfPipelineSucceeds mergeRequest(iid: $iid) { + id autoMergeEnabled commitCount conflicts diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql index ae2a67440fe..7ca3ff39fbe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql @@ -1,6 +1,8 @@ query userPermissionsQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { canMerge pushToSourceBranch diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql index ad715599eb1..fc25e699e39 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql @@ -1,4 +1,5 @@ fragment autoMergeEnabled on MergeRequest { + id autoMergeStrategy mergeUser { id diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql index e0215fbd969..2d79d35cf24 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -2,6 +2,7 @@ query autoMergeEnabled($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { ...autoMergeEnabled } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql index 2fe0d174b67..da8aeab9dcb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql @@ -1,6 +1,8 @@ query autoMergeFailedQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id mergeError } } 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 e66ac01ab12..faf21b28f86 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 @@ -1,6 +1,8 @@ query workInProgress($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id shouldBeRebased sourceBranchProtected } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql index 0983c28448e..54f2233439f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql @@ -1,6 +1,8 @@ query mrUserPermission($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { updateMergeRequest } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql index ea95218aec6..4d87d55f671 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql @@ -1,6 +1,8 @@ query missingBranchQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id sourceBranchExists } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql index 21c3ffd8321..73c9e77b7bc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql @@ -1,6 +1,8 @@ query getReadyToMergeStatus($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { canMerge } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index b2a1be5c5a9..d85794f7245 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,8 +1,10 @@ fragment ReadyToMerge on Project { + id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { + id autoMergeEnabled shouldRemoveSourceBranch forceRemoveSourceBranch @@ -26,6 +28,7 @@ fragment ReadyToMerge on Project { mergeError commitsWithoutMergeCommits { nodes { + id sha shortId title diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql index a8c7d2610bf..283177267d4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql @@ -1,6 +1,8 @@ query rebaseQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id rebaseInProgress targetBranch userPermissions { diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql index 200fb1b7ca5..022629bb802 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql @@ -1,6 +1,7 @@ mutation toggleDraftStatus($projectPath: ID!, $iid: String!, $draft: Boolean!) { mergeRequestSetDraft(input: { projectPath: $projectPath, iid: $iid, draft: $draft }) { mergeRequest { + id mergeableDiscussionsState title draft 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 10a2907c81a..57af869a0ba 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 @@ -59,7 +59,6 @@ export default class MergeRequestStore { this.sourceBranch = data.source_branch; this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; - this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merged_commit_sha; this.mergeCommitSha = data.merged_commit_sha; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index c24318cb9ad..489d4afa41f 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -220,16 +220,17 @@ export default { class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" > {{ __('Assignee') }} - <a + <gl-button v-if="isEditable" ref="editButton" - class="btn-link" - href="#" + category="tertiary" + size="small" + class="gl-text-black-normal!" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ __('Edit') }} - </a> + </gl-button> </p> <gl-dropdown 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 eaa5fc5af04..c512585b980 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 @@ -100,7 +100,8 @@ export default { <gl-button v-if="isEditable" class="gl-text-black-normal!" - variant="link" + category="tertiary" + size="small" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql index f0095abfca1..0460d250f75 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql @@ -2,6 +2,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) { createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { errors issue { + id iid webUrl } diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql index 0c26fcc0ab2..0ea209ffd39 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql @@ -3,6 +3,7 @@ query alertDetailsAssignees($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { + id alertManagementAlerts(iid: $alertId) { nodes { ...AlertDetailItem diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue new file mode 100644 index 00000000000..ffbcdefc924 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue @@ -0,0 +1,133 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { GlFormInput } from '@gitlab/ui'; +import { + DurationParseError, + outputChronicDuration, + parseChronicDuration, +} from '~/chronic_duration'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormInput, + }, + model: { + prop: 'value', + event: 'change', + }, + props: { + value: { + type: Number, + required: false, + default: null, + }, + name: { + type: String, + required: false, + default: null, + }, + integerRequired: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + numberData: this.value, + humanReadableData: this.convertDuration(this.value), + isValueValid: this.value === null ? null : true, + }; + }, + computed: { + numberValue: { + get() { + return this.numberData; + }, + set(value) { + if (this.numberData !== value) { + this.numberData = value; + this.humanReadableData = this.convertDuration(value); + this.isValueValid = value === null ? null : true; + } + this.emitEvents(); + }, + }, + humanReadableValue: { + get() { + return this.humanReadableData; + }, + set(value) { + this.humanReadableData = value; + try { + if (value === '') { + this.numberData = null; + this.isValueValid = null; + } else { + this.numberData = parseChronicDuration(value, { + keepZero: true, + raiseExceptions: true, + }); + this.isValueValid = true; + } + } catch (e) { + if (e instanceof DurationParseError) { + this.isValueValid = false; + } else { + Sentry.captureException(e); + } + } + this.emitEvents(true); + }, + }, + isValidDecimal() { + return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData); + }, + feedback() { + if (this.isValueValid === false) { + return this.$options.i18n.INVALID_INPUT_FEEDBACK; + } + if (!this.isValidDecimal) { + return this.$options.i18n.INVALID_DECIMAL_FEEDBACK; + } + return ''; + }, + }, + i18n: { + INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'), + INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'), + }, + watch: { + value() { + this.numberValue = this.value; + }, + }, + mounted() { + this.emitEvents(); + }, + methods: { + convertDuration(value) { + return value === null ? '' : outputChronicDuration(value); + }, + emitEvents(emitChange = false) { + if (emitChange && this.isValueValid !== false && this.isValidDecimal) { + this.$emit('change', this.numberData); + } + const { feedback } = this; + this.$refs.text.$el.setCustomValidity(feedback); + this.$refs.hidden.setCustomValidity(feedback); + this.$emit('valid', { + valid: this.isValueValid && this.isValidDecimal, + feedback, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" /> + <input ref="hidden" type="hidden" :name="name" :value="numberValue" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index fe329b18f30..400be3ef688 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -66,6 +66,11 @@ export default { required: false, default: 'medium', }, + variant: { + type: String, + required: false, + default: 'default', + }, }, computed: { clipboardText() { @@ -92,6 +97,7 @@ export default { :size="size" icon="copy-to-clipboard" :aria-label="__('Copy this value')" + :variant="variant" v-on="$listeners" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 5f50a699034..ebbc1bfb037 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { isString, isEmpty } from 'lodash'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index 4c07cf44fed..f93415ced45 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -26,6 +26,11 @@ export default { type: String, required: true, }, + buttonClass: { + type: String, + required: false, + default: '', + }, buttonTestid: { type: String, required: false, @@ -39,7 +44,7 @@ export default { <div> <gl-button v-gl-modal="$options.modalId" - class="gl-button" + :class="buttonClass" variant="danger" :disabled="disabled" :data-testid="buttonTestid" diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 30c96daf7e3..5bbe44b20b3 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -47,7 +47,7 @@ export default { actionPrimary() { return { text: this.confirmButtonText, - attributes: [{ variant: 'danger', disabled: !this.isValid }], + attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }], }; }, }, @@ -95,7 +95,7 @@ export default { <gl-form-input id="confirm_name_input" v-model="confirmationPhrase" - class="form-control" + class="form-control qa-confirm-input" data-testid="confirm-danger-input" type="text" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 7c1d3772acd..72504e5bc50 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -2,10 +2,13 @@ import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub'; +import DomElementListener from './dom_element_listener.vue'; export default { components: { GlModal, + DomElementListener, }, directives: { SafeHtml, @@ -30,18 +33,35 @@ export default { }; }, mounted() { - document.querySelectorAll(this.selector).forEach((button) => { - button.addEventListener('click', (e) => { - e.preventDefault(); - - this.path = button.dataset.path; - this.method = button.dataset.method; - this.modalAttributes = JSON.parse(button.dataset.modalAttributes); - this.openModal(); - }); - }); + eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); + }, + destroyed() { + eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); }, methods: { + onButtonPress(e) { + const element = e.currentTarget; + + if (!element.dataset.path) { + return; + } + + const modalAttributes = element.dataset.modalAttributes + ? JSON.parse(element.dataset.modalAttributes) + : {}; + + this.onOpenEvent({ + path: element.dataset.path, + method: element.dataset.method, + modalAttributes, + }); + }, + onOpenEvent({ path, method, modalAttributes }) { + this.path = path; + this.method = method; + this.modalAttributes = modalAttributes; + this.openModal(); + }, openModal() { this.$refs.modal.show(); }, @@ -61,21 +81,23 @@ export default { </script> <template> - <gl-modal - ref="modal" - :modal-id="modalId" - v-bind="modalAttributes" - @primary="submitModal" - @cancel="closeModal" - > - <form ref="form" :action="path" method="post"> - <!-- Rails workaround for <form method="delete" /> + <dom-element-listener :selector="selector" @click.prevent="onButtonPress"> + <gl-modal + ref="modal" + :modal-id="modalId" + v-bind="modalAttributes" + @primary="submitModal" + @cancel="closeModal" + > + <form ref="form" :action="path" method="post"> + <!-- Rails workaround for <form method="delete" /> https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee --> - <input type="hidden" name="_method" :value="method" /> - <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> - <div v-else>{{ modalAttributes.message }}</div> - </form> - </gl-modal> + <input type="hidden" name="_method" :value="method" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> + <div v-else>{{ modalAttributes.message }}</div> + </form> + </gl-modal> + </dom-element-listener> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js new file mode 100644 index 00000000000..f8d9d410ace --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN'); diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 1a96cabf755..e546ca57c5e 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -3,7 +3,7 @@ import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitl import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { defaultTimeRanges, diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue index 320e0654aab..cb038a8c4e1 100644 --- a/app/assets/javascripts/design_management/components/design_note_pin.vue +++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue @@ -10,13 +10,24 @@ export default { props: { position: { type: Object, - required: true, + required: false, + default: null, }, label: { type: Number, required: false, default: null, }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isInactive: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isNewNote() { @@ -36,10 +47,13 @@ export default { :style="position" :aria-label="pinLabel" :class="{ - 'btn-transparent comment-indicator gl-p-0': isNewNote, - 'js-image-badge badge badge-pill': !isNewNote, + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge design-note-pin': !isNewNote, + resolved: isResolved, + inactive: isInactive, + 'gl-absolute': position, }" - class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0!" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 52371e42ba1..0621ec14c6c 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -24,6 +24,7 @@ export default { methods: { dismiss() { this.isDismissed = true; + this.$emit('alertDismissed'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue new file mode 100644 index 00000000000..ca427ed4897 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue @@ -0,0 +1,28 @@ +<script> +export default { + props: { + selector: { + type: String, + required: true, + }, + }, + mounted() { + this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => { + return Object.entries(this.$listeners).map(([key, value]) => { + button.addEventListener(key, value); + return () => { + button.removeEventListener(key, value); + }; + }); + }); + }, + destroyed() { + this.disposables.forEach((x) => { + x(); + }); + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index e1e71639115..8686d317c8a 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -6,15 +6,10 @@ const fileExtensionIcons = { jade: 'pug', pug: 'pug', md: 'markdown', - 'md.rendered': 'markdown', markdown: 'markdown', - 'markdown.rendered': 'markdown', mdown: 'markdown', - 'mdown.rendered': 'markdown', mkd: 'markdown', - 'mkd.rendered': 'markdown', mkdn: 'markdown', - 'mkdn.rendered': 'markdown', rst: 'markdown', blink: 'blink', css: 'css', @@ -23,7 +18,6 @@ const fileExtensionIcons = { less: 'less', json: 'json', yaml: 'yaml', - 'YAML-tmLanguage': 'yaml', yml: 'yaml', xml: 'xml', plist: 'xml', @@ -85,10 +79,7 @@ const fileExtensionIcons = { props: 'settings', toml: 'settings', prefs: 'settings', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', ts: 'typescript', - 'd.ts': 'typescript-def', marko: 'markojs', pdf: 'pdf', xlsx: 'table', @@ -99,7 +90,6 @@ const fileExtensionIcons = { vscodeignore: 'vscode', vsixmanifest: 'vscode', vsix: 'vscode', - 'code-workplace': 'vscode', suo: 'visualstudio', sln: 'visualstudio', csproj: 'visualstudio', @@ -118,7 +108,6 @@ const fileExtensionIcons = { xz: 'zip', bzip2: 'zip', gzip: 'zip', - '7z': 'zip', rar: 'zip', tgz: 'zip', exe: 'exe', @@ -129,7 +118,6 @@ const fileExtensionIcons = { c: 'c', m: 'c', h: 'h', - 'c++': 'cpp', cc: 'cpp', cpp: 'cpp', mm: 'cpp', @@ -231,7 +219,6 @@ const fileExtensionIcons = { m2v: 'movie', vdi: 'virtual', vbox: 'virtual', - 'vbox-prev': 'virtual', ics: 'email', mp3: 'music', flac: 'music', @@ -277,44 +264,12 @@ const fileExtensionIcons = { ml: 'ocaml', mli: 'ocaml', cmx: 'ocaml', - 'js.map': 'javascript-map', - 'css.map': 'css-map', lock: 'lock', hbs: 'handlebars', mustache: 'handlebars', pl: 'perl', pm: 'perl', hx: 'haxe', - 'spec.ts': 'test-ts', - 'test.ts': 'test-ts', - 'ts.snap': 'test-ts', - 'spec.tsx': 'test-jsx', - 'test.tsx': 'test-jsx', - 'tsx.snap': 'test-jsx', - 'spec.jsx': 'test-jsx', - 'test.jsx': 'test-jsx', - 'jsx.snap': 'test-jsx', - 'spec.js': 'test-js', - 'test.js': 'test-js', - 'js.snap': 'test-js', - 'routing.ts': 'angular-routing', - 'routing.js': 'angular-routing', - 'module.ts': 'angular', - 'module.js': 'angular', - 'ng-template': 'angular', - 'component.ts': 'angular-component', - 'component.js': 'angular-component', - 'guard.ts': 'angular-guard', - 'guard.js': 'angular-guard', - 'service.ts': 'angular-service', - 'service.js': 'angular-service', - 'pipe.ts': 'angular-pipe', - 'pipe.js': 'angular-pipe', - 'filter.js': 'angular-pipe', - 'directive.ts': 'angular-directive', - 'directive.js': 'angular-directive', - 'resolver.ts': 'angular-resolver', - 'resolver.js': 'angular-resolver', pp: 'puppet', ex: 'elixir', exs: 'elixir', @@ -345,11 +300,8 @@ const fileExtensionIcons = { haml: 'haml', yang: 'yang', tf: 'terraform', - 'tf.json': 'terraform', tfvars: 'terraform', tfstate: 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', applescript: 'applescript', cake: 'cake', feature: 'cucumber', @@ -376,16 +328,68 @@ const fileExtensionIcons = { kv: 'kivy', graphcool: 'graphcool', sbt: 'sbt', + cr: 'crystal', + cu: 'cuda', + cuh: 'cuda', + log: 'log', +}; + +const twoFileExtensionIcons = { + 'gradle.kts': 'gradle', + 'md.rendered': 'markdown', + 'markdown.rendered': 'markdown', + 'mdown.rendered': 'markdown', + 'mkd.rendered': 'markdown', + 'mkdn.rendered': 'markdown', + 'YAML-tmLanguage': 'yaml', + 'sln.dotsettings': 'settings', + 'sln.dotsettings.user': 'settings', + 'd.ts': 'typescript-def', + 'code-workplace': 'vscode', + '7z': 'zip', + 'c++': 'cpp', + 'vbox-prev': 'virtual', + 'js.map': 'javascript-map', + 'css.map': 'css-map', + 'spec.ts': 'test-ts', + 'test.ts': 'test-ts', + 'ts.snap': 'test-ts', + 'spec.tsx': 'test-jsx', + 'test.tsx': 'test-jsx', + 'tsx.snap': 'test-jsx', + 'spec.jsx': 'test-jsx', + 'test.jsx': 'test-jsx', + 'jsx.snap': 'test-jsx', + 'spec.js': 'test-js', + 'test.js': 'test-js', + 'js.snap': 'test-js', + 'routing.ts': 'angular-routing', + 'routing.js': 'angular-routing', + 'module.ts': 'angular', + 'module.js': 'angular', + 'ng-template': 'angular', + 'component.ts': 'angular-component', + 'component.js': 'angular-component', + 'guard.ts': 'angular-guard', + 'guard.js': 'angular-guard', + 'service.ts': 'angular-service', + 'service.js': 'angular-service', + 'pipe.ts': 'angular-pipe', + 'pipe.js': 'angular-pipe', + 'filter.js': 'angular-pipe', + 'directive.ts': 'angular-directive', + 'directive.js': 'angular-directive', + 'resolver.ts': 'angular-resolver', + 'resolver.js': 'angular-resolver', + 'tf.json': 'terraform', + 'blade.php': 'laravel', + 'inky.php': 'laravel', 'reducer.ts': 'ngrx-reducer', 'rootReducer.ts': 'ngrx-reducer', 'state.ts': 'ngrx-state', 'actions.ts': 'ngrx-actions', 'effects.ts': 'ngrx-effects', - cr: 'crystal', 'drone.yml': 'drone', - cu: 'cuda', - cuh: 'cuda', - log: 'log', }; const fileNameIcons = { @@ -598,6 +602,9 @@ const fileNameIcons = { export default function getIconForFile(name) { return ( - fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || '' + fileNameIcons[name] || + twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || + fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || + '' ); } diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0b0a416b7ef..2227047a909 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -146,6 +146,7 @@ export default { ref="textOutput" :style="levelIndentation" class="file-row-name" + :title="file.name" data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index d9290e86bca..810d9f782b9 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -2,7 +2,6 @@ import { __ } from '~/locale'; export const DEBOUNCE_DELAY = 200; export const MAX_RECENT_TOKENS_SIZE = 3; -export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21; export const FILTER_NONE = 'None'; export const FILTER_ANY = 'Any'; @@ -24,22 +23,11 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; -export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ - { value: FILTER_CURRENT, text: __('Current') }, -]); - export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') }, { value: FILTER_STARTED, text: __('Started'), title: __('Started') }, ]); -export const DEFAULT_MILESTONES_GRAPHQL = [ - { value: 'any', text: __('Any'), title: __('Any') }, - { value: 'none', text: __('None'), title: __('None') }, - { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') }, - { value: '#started', text: __('Started'), title: __('Started') }, -]; - export const SortDirection = { descending: 'descending', ascending: 'ascending', @@ -56,6 +44,3 @@ export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); -export const TOKEN_TITLE_ITERATION = __('Iteration'); -export const TOKEN_TITLE_EPIC = __('Epic'); -export const TOKEN_TITLE_WEIGHT = __('Weight'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql deleted file mode 100644 index 9e9bda8ad3e..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql +++ /dev/null @@ -1,15 +0,0 @@ -fragment EpicNode on Epic { - id - iid - group { - fullPath - } - title - state - reference - referencePath: reference(full: true) - webPath - webUrl - createdAt - closedAt -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql deleted file mode 100644 index 4bb4b586fc9..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "./epic.fragment.graphql" - -query searchEpics($fullPath: ID!, $search: String, $state: EpicState) { - group(fullPath: $fullPath) { - epics( - search: $search - state: $state - includeAncestorGroups: true - includeDescendantGroups: false - ) { - nodes { - ...EpicNode - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index b3b3d5c88c6..06478a89721 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -87,7 +87,6 @@ export default { :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchAuthors" v-on="$listeners" > 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 cee7c40aa83..bbc1888bc0b 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 @@ -4,12 +4,17 @@ import { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; +import { + getRecentlyUsedSuggestions, + setTokenValueToRecentlyUsed, + stripQuotes, +} from '../filtered_search_utils'; export default { components: { @@ -17,6 +22,7 @@ export default { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, }, props: { @@ -57,11 +63,6 @@ export default { required: false, default: () => [], }, - recentSuggestionsStorageKey: { - type: String, - required: false, - default: '', - }, valueIdentifier: { type: String, required: false, @@ -76,14 +77,14 @@ export default { data() { return { searchKey: '', - recentSuggestions: this.recentSuggestionsStorageKey - ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) + recentSuggestions: this.config.recentSuggestionsStorageKey + ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) : [], }; }, computed: { isRecentSuggestionsEnabled() { - return Boolean(this.recentSuggestionsStorageKey); + return Boolean(this.config.recentSuggestionsStorageKey); }, recentTokenIds() { return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); @@ -119,6 +120,9 @@ export default { showDefaultSuggestions() { return this.availableDefaultSuggestions.length > 0; }, + showNoMatchesText() { + return this.searchKey && !this.availableSuggestions.length; + }, showRecentSuggestions() { return ( this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey @@ -163,11 +167,20 @@ export default { this.searchKey = data; if (!this.suggestionsLoading && !this.activeTokenValue) { - const search = this.searchTerm ? this.searchTerm : data; + let search = this.searchTerm ? this.searchTerm : data; + + if (search.startsWith('"') && search.endsWith('"')) { + search = stripQuotes(search); + } else if (search.startsWith('"')) { + search = search.slice(1, search.length); + } + this.$emit('fetch-suggestions', search); } }, DEBOUNCE_DELAY), - handleTokenValueSelected(activeTokenValue) { + handleTokenValueSelected(selectedValue) { + const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); + // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value @@ -177,7 +190,7 @@ export default { activeTokenValue && !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) ) { - setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue); + setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue); } }, }, @@ -192,7 +205,7 @@ export default { v-bind="$attrs" v-on="$listeners" @input="handleInput" - @select="handleTokenValueSelected(activeTokenValue)" + @select="handleTokenValueSelected" > <template #view-token="viewTokenProps"> <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> @@ -222,6 +235,9 @@ export default { :suggestions="preloadedSuggestions" ></slot> <gl-loading-icon v-if="suggestionsLoading" size="sm" /> + <gl-dropdown-text v-else-if="showNoMatchesText"> + {{ __('No matches found') }} + </gl-dropdown-text> <template v-else> <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue deleted file mode 100644 index 9c2f5306654..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ /dev/null @@ -1,129 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import searchEpicsQuery from '../queries/search_epics.query.graphql'; - -import BaseToken from './base_token.vue'; - -export default { - prefix: '&', - separator: '::', - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - active: { - type: Boolean, - required: true, - }, - }, - data() { - return { - epics: this.config.initialEpics || [], - loading: false, - }; - }, - computed: { - idProperty() { - return this.config.idProperty || 'iid'; - }, - currentValue() { - const epicIid = Number(this.value.data); - if (epicIid) { - return epicIid; - } - return this.value.data; - }, - defaultEpics() { - return this.config.defaultEpics || DEFAULT_NONE_ANY; - }, - availableDefaultEpics() { - if (this.value.operator === OPERATOR_IS_NOT) { - return this.defaultEpics.filter( - (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), - ); - } - return this.defaultEpics; - }, - }, - methods: { - fetchEpics(search = '') { - return this.$apollo - .query({ - query: searchEpicsQuery, - variables: { fullPath: this.config.fullPath, search }, - }) - .then(({ data }) => data.group?.epics.nodes); - }, - fetchEpicsBySearchTerm(search) { - this.loading = true; - this.fetchEpics(search) - .then((response) => { - this.epics = Array.isArray(response) ? response : response?.data; - }) - .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) - .finally(() => { - this.loading = false; - }); - }, - getActiveEpic(epics, data) { - if (data && epics.length) { - return epics.find((epic) => this.getValue(epic) === data); - } - return undefined; - }, - getValue(epic) { - return this.getEpicIdProperty(epic).toString(); - }, - displayValue(epic) { - return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${ - epic?.title - }`; - }, - getEpicIdProperty(epic) { - return getIdFromGraphQLId(epic[this.idProperty]); - }, - }, -}; -</script> - -<template> - <base-token - :config="config" - :value="value" - :active="active" - :suggestions-loading="loading" - :suggestions="epics" - :get-active-token-value="getActiveEpic" - :default-suggestions="availableDefaultEpics" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - search-by="title" - @fetch-suggestions="fetchEpicsBySearchTerm" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion - v-for="epic in suggestions" - :key="epic.id" - :value="getValue(epic)" - > - {{ epic.title }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue deleted file mode 100644 index aff93ebc9c0..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEFAULT_ITERATIONS } from '../constants'; - -export default { - components: { - BaseToken, - GlDropdownDivider, - GlDropdownSectionHeader, - GlFilteredSearchSuggestion, - }, - mixins: [glFeatureFlagMixin()], - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - iterations: this.config.initialIterations || [], - loading: false, - }; - }, - computed: { - defaultIterations() { - return this.config.defaultIterations || DEFAULT_ITERATIONS; - }, - }, - methods: { - getActiveIteration(iterations, data) { - return iterations.find((iteration) => this.getValue(iteration) === data); - }, - groupIterationsByCadence(iterations) { - const cadences = []; - iterations.forEach((iteration) => { - if (!iteration.iterationCadence) { - return; - } - const { title } = iteration.iterationCadence; - const cadenceIteration = { - id: iteration.id, - title: iteration.title, - period: this.getIterationPeriod(iteration), - }; - const cadence = cadences.find((cad) => cad.title === title); - if (cadence) { - cadence.iterations.push(cadenceIteration); - } else { - cadences.push({ title, iterations: [cadenceIteration] }); - } - }); - return cadences; - }, - fetchIterations(searchTerm) { - this.loading = true; - this.config - .fetchIterations(searchTerm) - .then((response) => { - this.iterations = Array.isArray(response) ? response : response.data; - }) - .catch(() => { - createFlash({ message: __('There was a problem fetching iterations.') }); - }) - .finally(() => { - this.loading = false; - }); - }, - getValue(iteration) { - return String(getIdFromGraphQLId(iteration.id)); - }, - /** - * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 - * This method also exists as a utility function in ee/../iterations/utils.js - * Remove the duplication when iteration token is moved to EE. - */ - getIterationPeriod({ startDate, dueDate }) { - const start = formatDate(startDate, 'mmm d, yyyy', true); - const due = formatDate(dueDate, 'mmm d, yyyy', true); - return `${start} - ${due}`; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultIterations" - :suggestions="iterations" - :suggestions-loading="loading" - :get-active-token-value="getActiveIteration" - @fetch-suggestions="fetchIterations" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? activeTokenValue.title : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)"> - <gl-dropdown-divider v-if="index !== 0" :key="index" /> - <gl-dropdown-section-header - :key="cadence.title" - class="gl-overflow-hidden" - :title="cadence.title" - > - {{ cadence.title }} - </gl-dropdown-section-header> - <gl-filtered-search-suggestion - v-for="iteration in cadence.iterations" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400"> - {{ iteration.period }} - </div> - </gl-filtered-search-suggestion> - </template> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index c31f3a25fb1..3f7a8920f48 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -104,7 +104,6 @@ export default { :suggestions="labels" :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchLabels" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 523438f459c..0d3394788fa 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_MILESTONES } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue deleted file mode 100644 index 280fb234576..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants'; - -const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString()); - -export default { - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - weights, - }; - }, - computed: { - defaultWeights() { - return this.config.defaultWeights || DEFAULT_NONE_ANY; - }, - }, - methods: { - getActiveWeight(weightSuggestions, data) { - return weightSuggestions.find((weight) => weight === data); - }, - updateWeights(searchTerm) { - const weight = parseInt(searchTerm, 10); - this.weights = Number.isNaN(weight) ? weights : [String(weight)]; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultWeights" - :suggestions="weights" - :get-active-token-value="getActiveWeight" - @fetch-suggestions="updateWeights" - v-on="$listeners" - > - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight"> - {{ weight }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js new file mode 100644 index 00000000000..cdd7a074f34 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js @@ -0,0 +1,27 @@ +import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; + +export default { + component: InputCopyToggleVisibility, + title: 'vue_shared/components/form/input_copy_toggle_visibility', +}; + +const defaultProps = { + value: 'hR8x1fuJbzwu5uFKLf9e', + formInputGroupProps: { class: 'gl-form-input-xl' }, +}; + +const Template = (args, { argTypes }) => ({ + components: { InputCopyToggleVisibility }, + props: Object.keys(argTypes), + template: `<input-copy-toggle-visibility + :value="value" + :initial-visibility="initialVisibility" + :show-toggle-visibility-button="showToggleVisibilityButton" + :show-copy-button="showCopyButton" + :form-input-group-props="formInputGroupProps" + :copy-button-title="copyButtonTitle" + />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; 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 new file mode 100644 index 00000000000..06949b59823 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -0,0 +1,127 @@ +<script> +import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + name: 'InputCopyToggleVisibility', + i18n: { + toggleVisibilityLabelHide: __('Click to hide'), + toggleVisibilityLabelReveal: __('Click to reveal'), + }, + components: { + GlFormInputGroup, + GlFormGroup, + GlButton, + ClipboardButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + initialVisibility: { + type: Boolean, + required: false, + default: false, + }, + showToggleVisibilityButton: { + type: Boolean, + required: false, + default: true, + }, + showCopyButton: { + type: Boolean, + required: false, + default: true, + }, + copyButtonTitle: { + type: String, + required: false, + default: __('Copy'), + }, + formInputGroupProps: { + type: Object, + required: false, + default() { + return {}; + }, + }, + }, + data() { + return { + valueIsVisible: this.initialVisibility, + }; + }, + computed: { + toggleVisibilityLabel() { + return this.valueIsVisible + ? this.$options.i18n.toggleVisibilityLabelHide + : this.$options.i18n.toggleVisibilityLabelReveal; + }, + toggleVisibilityIcon() { + return this.valueIsVisible ? 'eye-slash' : 'eye'; + }, + computedValueIsVisible() { + return !this.showToggleVisibilityButton || this.valueIsVisible; + }, + displayedValue() { + return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); + }, + }, + methods: { + handleToggleVisibilityButtonClick() { + this.valueIsVisible = !this.valueIsVisible; + + this.$emit('visibility-change', this.valueIsVisible); + }, + handleCopyButtonClick() { + this.$emit('copy'); + }, + handleFormInputCopy(event) { + if (this.computedValueIsVisible) { + return; + } + + event.clipboardData.setData('text/plain', this.value); + event.preventDefault(); + }, + }, +}; +</script> +<template> + <gl-form-group v-bind="$attrs"> + <gl-form-input-group + :value="displayedValue" + input-class="gl-font-monospace! gl-cursor-default!" + select-on-click + readonly + v-bind="formInputGroupProps" + @copy="handleFormInputCopy" + > + <template v-if="showToggleVisibilityButton || showCopyButton" #append> + <gl-button + v-if="showToggleVisibilityButton" + v-gl-tooltip.hover="toggleVisibilityLabel" + :aria-label="toggleVisibilityLabel" + :icon="toggleVisibilityIcon" + @click="handleToggleVisibilityButtonClick" + /> + <clipboard-button + v-if="showCopyButton" + :text="value" + :title="copyButtonTitle" + @click="handleCopyButtonClick" + /> + </template> + </gl-form-input-group> + <template v-for="slot in Object.keys($slots)" #[slot]> + <slot :name="slot"></slot> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 6ace0bd88f8..9bff469b670 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -5,6 +5,7 @@ import { GlSafeHtmlDirective, GlAvatarLink, GlAvatarLabeled, + GlTooltip, } from '@gitlab/ui'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '../../emoji'; @@ -26,6 +27,7 @@ export default { GlButton, GlAvatarLink, GlAvatarLabeled, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js deleted file mode 100644 index 28aa93d6680..00000000000 --- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import IssuableHeaderWarnings from './issuable_header_warnings.vue'; - -export default function issuableHeaderWarnings(store) { - const el = document.getElementById('js-issuable-header-warnings'); - - if (!el) { - return false; - } - - const { hidden } = el.dataset; - - return new Vue({ - el, - store, - provide: { hidden: parseBoolean(hidden) }, - render(createElement) { - return createElement(IssuableHeaderWarnings); - }, - }); -} diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue new file mode 100644 index 00000000000..7e17cca3dcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue @@ -0,0 +1,57 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + lines: { + type: Number, + required: true, + }, + }, + data() { + return { + currentlyHighlightedLine: null, + }; + }, + mounted() { + this.scrollToLine(); + }, + methods: { + scrollToLine(hash = window.location.hash) { + const lineToHighlight = hash && this.$el.querySelector(hash); + + if (!lineToHighlight) { + return; + } + + if (this.currentlyHighlightedLine) { + this.currentlyHighlightedLine.classList.remove('hll'); + } + + lineToHighlight.classList.add('hll'); + this.currentlyHighlightedLine = lineToHighlight; + lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, + }, +}; +</script> +<template> + <div class="line-numbers"> + <gl-link + v-for="line in lines" + :id="`L${line}`" + :key="line" + class="diff-line-num" + :href="`#L${line}`" + :data-line-number="line" + @click="scrollToLine(`#L${line}`)" + > + <gl-icon :size="12" name="link" /> + {{ line }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index e36cfb3b275..2f6776f835e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -165,6 +165,6 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md"></div> + <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 912aa8ce294..f1c293c87f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,18 +1,13 @@ <script> import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; -import { isExperimentVariant } from '~/experimentation/utils'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; export default { - inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT, components: { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon, - InviteMembersTrigger, }, props: { markdownDocsPath: { @@ -34,9 +29,6 @@ export default { hasQuickActionsDocsPath() { return this.quickActionsDocsPath !== ''; }, - inviteCommentEnabled() { - return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link'); - }, }, }; </script> @@ -67,16 +59,6 @@ export default { </template> </div> <span v-if="canAttachFile" class="uploading-container"> - <invite-members-trigger - v-if="inviteCommentEnabled" - classes="gl-mr-3 gl-vertical-align-text-bottom" - :display-text="s__('InviteMember|Invite Member')" - icon="assignee" - variant="link" - :track-experiment="$options.inviteMembersInComment" - :trigger-source="$options.inviteMembersInComment" - data-track-action="comment_invite_click" - /> <span class="uploading-progress-container hide"> <gl-icon name="media" /> <span class="attaching-file-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue new file mode 100644 index 00000000000..7d2af7983d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + DEFAULT_TEXT: __('Select a new namespace'), + GROUPS: __('Groups'), + USERS: __('Users'), +}; + +const filterByName = (data, searchTerm = '') => + data.filter((d) => d.humanName.toLowerCase().includes(searchTerm)); + +export default { + name: 'NamespaceSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + props: { + data: { + type: Object, + required: true, + }, + fullWidth: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchTerm: '', + selectedNamespace: null, + }; + }, + computed: { + hasUserNamespaces() { + return this.data.user?.length; + }, + hasGroupNamespaces() { + return this.data.group?.length; + }, + filteredGroupNamespaces() { + if (!this.hasGroupNamespaces) return []; + return filterByName(this.data.group, this.searchTerm); + }, + filteredUserNamespaces() { + if (!this.hasUserNamespaces) return []; + return filterByName(this.data.user, this.searchTerm); + }, + selectedNamespaceText() { + return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT; + }, + }, + methods: { + handleSelect(item) { + this.selectedNamespace = item; + this.$emit('select', item); + }, + }, + i18n, +}; +</script> +<template> + <gl-dropdown :text="selectedNamespaceText" :block="fullWidth"> + <template #header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups"> + <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredGroupNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + <div v-if="hasUserNamespaces" class="qa-namespaces-list-users"> + <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredUserNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 9ea14ed506c..624dbcc6d8e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -39,6 +39,11 @@ export default { required: false, default: null, }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters(['getUserData']), @@ -46,9 +51,10 @@ export default { return renderMarkdown(this.note.body); }, avatarSize() { - if (this.line) { - return 16; + if (this.line && !this.isOverviewTab) { + return 24; } + return 40; }, }, diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 8877cfa39fb..1963d1aa7fe 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -141,6 +141,7 @@ export default { variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" + class="gl-vertical-align-text-bottom" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -149,6 +150,7 @@ export default { :icon="showLines ? 'chevron-up' : 'chevron-down'" variant="link" data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom" @click="toggleDiff" > {{ __('Compare changes') }} diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js new file mode 100644 index 00000000000..e31446f4bb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js @@ -0,0 +1,40 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import PaginationBar from './pagination_bar.vue'; + +export default { + component: PaginationBar, + title: 'vue_shared/components/pagination_bar/pagination_bar', +}; + +const Template = (args, { argTypes }) => ({ + components: { PaginationBar }, + props: Object.keys(argTypes), + template: `<pagination-bar v-bind="$props" v-on="{ 'set-page-size': setPageSize, 'set-page': setPage }" />`, +}); + +export const Default = Template.bind({}); + +Default.args = { + pageInfo: { + perPage: 20, + page: 2, + total: 83, + totalPages: 5, + }, + pageSizes: [20, 50, 100], +}; + +Default.argTypes = { + pageInfo: { + description: 'Page info object', + control: { type: 'object' }, + }, + pageSizes: { + description: 'Array of possible page sizes', + control: { type: 'array' }, + }, + + // events + setPageSize: { action: 'set-page-size' }, + setPage: { action: 'set-page' }, +}; diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index 33bd3e08bb1..b4d565991f5 100644 --- a/app/assets/javascripts/import_entities/components/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -23,10 +23,6 @@ export default { type: Array, default: () => DEFAULT_PAGE_SIZES, }, - itemsCount: { - required: true, - type: Number, - }, }, computed: { @@ -35,9 +31,10 @@ export default { }, paginationInfo() { - const { page, perPage } = this.pageInfo; + const { page, perPage, totalPages, total } = this.pageInfo; + const itemsCount = page === totalPages ? total - (page - 1) * perPage : perPage; const start = (page - 1) * perPage + 1; - const end = start + this.itemsCount - 1; + const end = start + itemsCount - 1; return { start, end }; }, @@ -45,8 +42,24 @@ export default { methods: { setPage(page) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when selected page is updated + * + * @event set-page + **/ this.$emit('set-page', page); }, + + setPageSize(pageSize) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when page size is updated + * + * @event set-page-size + **/ + this.$emit('set-page-size', pageSize); + }, }, }; </script> @@ -54,7 +67,7 @@ export default { <template> <div class="gl-display-flex gl-align-items-center"> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> - <gl-dropdown category="tertiary" class="gl-ml-auto"> + <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> <template #button-content> <span class="gl-font-weight-bold"> <gl-sprintf :message="__('%{count} items per page')"> @@ -65,7 +78,7 @@ export default { </span> <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> </template> - <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)"> + <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)"> <gl-sprintf :message="__('%{count} items per page')"> <template #count> {{ size }} diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 933a215112b..6bb321713d5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -54,10 +54,10 @@ export default { class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1" :class="optionalClasses" > - <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> <div v-if="$slots['left-action']" - class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2" + class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> </div> @@ -105,7 +105,7 @@ export default { </div> <div v-if="$slots['right-action']" - class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue index 93396219a54..4c2816b63b2 100644 --- a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { name: 'MetadataItem', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue deleted file mode 100644 index a1dca65a423..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { dateInWords, timeFor } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; - -export default { - name: 'SidebarCollapsedGroupedDatePicker', - components: { - collapsedCalendarIcon, - }, - mixins: [timeagoMixin], - props: { - collapsed: { - type: Boolean, - required: false, - default: true, - }, - minDate: { - type: Date, - required: false, - default: null, - }, - maxDate: { - type: Date, - required: false, - default: null, - }, - disableClickableIcons: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - hasMinAndMaxDates() { - return this.minDate && this.maxDate; - }, - hasNoMinAndMaxDates() { - return !this.minDate && !this.maxDate; - }, - showMinDateBlock() { - return this.minDate || this.hasNoMinAndMaxDates; - }, - showFromText() { - return !this.maxDate && this.minDate; - }, - iconClass() { - const disabledClass = this.disableClickableIcons ? 'disabled' : ''; - return `sidebar-collapsed-icon calendar-icon ${disabledClass}`; - }, - }, - methods: { - toggleSidebar() { - this.$emit('toggleCollapse'); - }, - dateText(dateType = 'min') { - const date = this[`${dateType}Date`]; - const dateWords = dateInWords(date, true); - const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords; - - return date ? parsedDateWords : __('None'); - }, - tooltipText(dateType = 'min') { - const defaultText = dateType === 'min' ? __('Start date') : __('Due date'); - const date = this[`${dateType}Date`]; - const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date); - const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : ''; - - if (date) { - return [defaultText, dateText].join('<br />'); - } - return __('Start and due date'); - }, - }, -}; -</script> - -<template> - <div class="block sidebar-grouped-item gl-cursor-pointer" role="button" @click="toggleSidebar"> - <collapsed-calendar-icon - v-if="showMinDateBlock" - :container-class="iconClass" - :tooltip-text="tooltipText('min')" - > - <span class="sidebar-collapsed-value"> - <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span> - </span> - </collapsed-calendar-icon> - <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div> - <collapsed-calendar-icon - v-if="maxDate" - :container-class="iconClass" - :tooltip-text="tooltipText('max')" - > - <span class="sidebar-collapsed-value"> - <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span> - </span> - </collapsed-calendar-icon> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 4234bc72f3a..7e259cb8b96 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -179,6 +179,8 @@ export default { document.addEventListener('mousedown', this.handleDocumentMousedown); document.addEventListener('click', this.handleDocumentClick); + + this.updateLabelsSetState(); }, beforeDestroy() { document.removeEventListener('mousedown', this.handleDocumentMousedown); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index f7485de0342..13a6dd43207 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -172,6 +172,13 @@ export default { showDropdown() { this.$refs.dropdown.show(); }, + clearSearch() { + if (!this.allowMultiselect || this.isStandalone) { + return; + } + this.searchKey = ''; + this.setFocus(); + }, }, }; </script> @@ -188,12 +195,12 @@ export default { > <template #header> <dropdown-header - v-if="!isStandalone" ref="header" - v-model="searchKey" + :search-key="searchKey" :labels-create-title="labelsCreateTitle" :labels-list-title="labelsListTitle" :show-dropdown-contents-create-view="showDropdownContentsCreateView" + :is-standalone="isStandalone" @toggleDropdownContentsCreateView="toggleDropdownContent" @closeDropdown="$emit('closeDropdown')" @input="debouncedSearchKeyUpdate" @@ -210,6 +217,7 @@ export default { :attr-workspace-path="attrWorkspacePath" :label-create-type="labelCreateType" @hideCreateView="toggleDropdownContent" + @input="clearSearch" /> </template> <template #footer> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue index 10064b01648..7a0f20b0c83 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -6,9 +6,6 @@ export default { GlButton, GlSearchBoxByType, }, - model: { - prop: 'searchKey', - }, props: { labelsCreateTitle: { type: String, @@ -31,6 +28,11 @@ export default { type: String, required: true, }, + isStandalone: { + type: Boolean, + required: false, + default: false, + }, }, computed: { dropdownTitle() { @@ -47,7 +49,11 @@ export default { <template> <div data-testid="dropdown-header"> - <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"> + <div + v-if="!isStandalone" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-header-title" + > <gl-button v-if="showDropdownContentsCreateView" :aria-label="__('Go back')" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index aed5bc303ee..57ee816c4c7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,10 +1,15 @@ <script> -import { GlLabel } from '@gitlab/ui'; +import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { s__, sprintf } from '~/locale'; export default { + directives: { + GlTooltip: GlTooltipDirective, + }, components: { + GlIcon, GlLabel, }, inject: ['allowScopedLabels'], @@ -35,6 +40,23 @@ export default { sortedSelectedLabels() { return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); }, + labelsList() { + const labelsString = this.selectedLabels.length + ? this.selectedLabels + .slice(0, 5) + .map((label) => label.title) + .join(', ') + : s__('LabelSelect|Labels'); + + if (this.selectedLabels.length > 5) { + return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { + labelsString, + remainingLabelCount: this.selectedLabels.length - 5, + }); + } + + return labelsString; + }, }, methods: { labelFilterUrl(label) { @@ -48,6 +70,9 @@ export default { removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, + handleCollapsedClick() { + this.$emit('onCollapsedValueClick'); + }, }, }; </script> @@ -57,16 +82,30 @@ export default { :class="{ 'has-labels': selectedLabels.length, }" - class="hide-collapsed value issuable-show-labels js-value" + class="value issuable-show-labels js-value" data-testid="value-wrapper" > - <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder"> + <div + v-gl-tooltip.left.viewport + :title="labelsList" + class="sidebar-collapsed-icon" + @click="handleCollapsedClick" + > + <gl-icon name="labels" /> + <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span> + </div> + <span + v-if="!selectedLabels.length" + class="text-secondary hide-collapsed" + data-testid="empty-placeholder" + > <slot></slot> </span> <template v-else> <gl-label v-for="label in sortedSelectedLabels" :key="label.id" + class="hide-collapsed" data-qa-selector="selected_label_content" :data-qa-label-name="label.title" :title="label.title" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue deleted file mode 100644 index 122250d1ce7..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue +++ /dev/null @@ -1,55 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; - -export default { - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlIcon, - }, - props: { - labels: { - type: Array, - required: true, - }, - }, - computed: { - labelsList() { - const labelsString = this.labels.length - ? this.labels - .slice(0, 5) - .map((label) => label.title) - .join(', ') - : s__('LabelSelect|Labels'); - - if (this.labels.length > 5) { - return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { - labelsString, - remainingLabelCount: this.labels.length - 5, - }); - } - - return labelsString; - }, - }, - methods: { - handleClick() { - this.$emit('onValueClick'); - }, - }, -}; -</script> - -<template> - <div - v-gl-tooltip.left.viewport - :title="labelsList" - class="sidebar-collapsed-icon" - @click="handleClick" - > - <gl-icon name="labels" /> - <span>{{ labels.length }}</span> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql index c130cc426dc..c442c17eb88 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -2,6 +2,7 @@ query epicLabels($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { + id issuable: epic(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql index 45fcb50732e..cb054e2968f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql @@ -1,8 +1,8 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" mutation updateEpicLabels($input: UpdateEpicInput!) { - updateEpic(input: $input) { - epic { + updateIssuableLabels: updateEpic(input: $input) { + issuable: epic { id labels { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql index e471d279b24..2904857270e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql @@ -2,6 +2,7 @@ query issueLabels($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { + id issuable: issue(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql index dd80e89c8a7..e0cdfd91658 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -2,6 +2,7 @@ query mergeRequestLabels($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 97a65c13933..3adda69b892 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -2,14 +2,13 @@ import { debounce } from 'lodash'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { issuableLabelsQueries } from '~/sidebar/constants'; import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; -import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -20,7 +19,6 @@ export default { components: { DropdownValue, DropdownContents, - DropdownValueCollapsed, SidebarEditableItem, }, inject: { @@ -225,15 +223,13 @@ export default { variables: { input: inputVariables }, }) .then(({ data }) => { - const { mutationName } = issuableLabelsQueries[this.issuableType]; - - if (data[mutationName]?.errors?.length) { + if (data.updateIssuableLabels?.errors?.length) { throw new Error(); } this.$emit('updateSelectedLabels', { - id: data[mutationName]?.[this.issuableType]?.id, - labels: data[mutationName]?.[this.issuableType]?.labels?.nodes, + id: data.updateIssuableLabels?.issuable?.id, + labels: data.updateIssuableLabels?.issuable?.labels?.nodes, }); }) .catch((error) => @@ -288,18 +284,14 @@ export default { <template> <div - class="labels-select-wrapper position-relative" + class="labels-select-wrapper gl-relative" :class="{ 'is-standalone': isDropdownVariantStandalone(variant), 'is-embedded': isDropdownVariantEmbedded(variant), }" + data-qa-selector="labels_block" > <template v-if="isDropdownVariantSidebar(variant)"> - <dropdown-value-collapsed - ref="dropdownButtonCollapsed" - :labels="issuableLabels" - @onValueClick="handleCollapsedValueClick" - /> <sidebar-editable-item ref="editable" :title="__('Labels')" @@ -315,6 +307,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" + @onCollapsedValueClick="handleCollapsedValueClick" > <slot></slot> </dropdown-value> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql index d99fc125012..bb6c7181e5c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql @@ -7,6 +7,7 @@ query alertAssignees( $iid: String! ) { workspace: project(fullPath: $fullPath) { + id issuable: alertManagementAlert(domain: $domain, iid: $iid) { iid assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql index 93b9833bb7d..be270e440ed 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql @@ -4,6 +4,7 @@ query issueAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 48787305459..96a40e597ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -4,6 +4,7 @@ query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 53f7381760e..81e19e48d75 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -3,6 +3,7 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 6adbd4098f2..3496d5f4a2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -3,6 +3,7 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id participants { diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index fdf0c9baee3..8a0fef36079 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -96,6 +96,7 @@ export default { :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading + data-qa-selector="source_editor_container" @[$options.readyEvent]="$emit($options.readyEvent)" > <pre class="editor-loading-content">{{ value }}</pre> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue new file mode 100644 index 00000000000..8f0d051543f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue @@ -0,0 +1,88 @@ +<script> +import { GlSafeHtmlDirective } from '@gitlab/ui'; +import LineNumbers from '~/vue_shared/components/line_numbers.vue'; + +export default { + components: { + LineNumbers, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + content: { + type: String, + required: true, + }, + language: { + type: String, + required: false, + default: 'plaintext', + }, + autoDetect: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + languageDefinition: null, + hljs: null, + }; + }, + computed: { + lineNumbers() { + return this.content.split('\n').length; + }, + highlightedContent() { + let highlightedContent; + + if (this.hljs) { + if (this.autoDetect) { + highlightedContent = this.hljs.highlightAuto(this.content).value; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; + } + } + + return highlightedContent; + }, + }, + async mounted() { + this.hljs = await this.loadHighlightJS(); + + if (!this.autoDetect) { + this.languageDefinition = await this.loadLanguage(); + } + }, + methods: { + loadHighlightJS() { + // With auto-detect enabled we load all common languages else we load only the core (smallest footprint) + return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await import(`highlight.js/lib/languages/${this.language}`); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> +<template> + <div class="file-content code" :class="$options.userColorScheme"> + <line-numbers :lines="lineNumbers" /> + <pre + class="code gl-pl-3!" + ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code> + </pre> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js deleted file mode 100644 index 00aa5519ec6..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable @gitlab/require-i18n-strings */ -import '@gitlab/ui/dist/utility_classes.css'; -import UsageGraph from './usage_graph.vue'; - -export default { - component: UsageGraph, - title: 'vue_shared/components/storage_counter/usage_graph', -}; - -const Template = (args, { argTypes }) => ({ - components: { UsageGraph }, - props: Object.keys(argTypes), - template: '<usage-graph v-bind="$props" />', -}); - -export const Default = Template.bind({}); -Default.argTypes = { - rootStorageStatistics: { - description: 'The statistics object with all its fields', - type: { name: 'object', required: true }, - defaultValue: { - buildArtifactsSize: 400000, - pipelineArtifactsSize: 38000, - lfsObjectsSize: 4800000, - packagesSize: 3800000, - repositorySize: 39000000, - snippetsSize: 2000112, - storageSize: 39930000, - uploadsSize: 7000, - wikiSize: 300000, - }, - }, - limit: { - description: - 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution', - defaultValue: 0, - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue deleted file mode 100644 index c33d065ff4b..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { s__ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - rootStorageStatistics: { - required: true, - type: Object, - }, - limit: { - required: true, - type: Number, - }, - }, - computed: { - storageTypes() { - const { - buildArtifactsSize, - pipelineArtifactsSize, - lfsObjectsSize, - packagesSize, - repositorySize, - storageSize, - wikiSize, - snippetsSize, - uploadsSize, - } = this.rootStorageStatistics; - const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; - - if (storageSize === 0) { - return null; - } - - return [ - { - name: s__('UsageQuota|Repositories'), - style: this.usageStyle(this.barRatio(repositorySize)), - class: 'gl-bg-data-viz-blue-500', - size: repositorySize, - }, - { - name: s__('UsageQuota|LFS Objects'), - style: this.usageStyle(this.barRatio(lfsObjectsSize)), - class: 'gl-bg-data-viz-orange-600', - size: lfsObjectsSize, - }, - { - name: s__('UsageQuota|Packages'), - style: this.usageStyle(this.barRatio(packagesSize)), - class: 'gl-bg-data-viz-aqua-500', - size: packagesSize, - }, - { - name: s__('UsageQuota|Artifacts'), - style: this.usageStyle(this.barRatio(artifactsSize)), - class: 'gl-bg-data-viz-green-600', - size: artifactsSize, - tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), - }, - { - name: s__('UsageQuota|Wikis'), - style: this.usageStyle(this.barRatio(wikiSize)), - class: 'gl-bg-data-viz-magenta-500', - size: wikiSize, - }, - { - name: s__('UsageQuota|Snippets'), - style: this.usageStyle(this.barRatio(snippetsSize)), - class: 'gl-bg-data-viz-orange-800', - size: snippetsSize, - }, - { - name: s__('UsageQuota|Uploads'), - style: this.usageStyle(this.barRatio(uploadsSize)), - class: 'gl-bg-data-viz-aqua-700', - size: uploadsSize, - }, - ] - .filter((data) => data.size !== 0) - .sort((a, b) => b.size - a.size); - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - usageStyle(ratio) { - return { flex: ratio }; - }, - barRatio(size) { - let max = this.rootStorageStatistics.storageSize; - - if (this.limit !== 0 && max <= this.limit) { - max = this.limit; - } - - return size / max; - }, - }, -}; -</script> -<template> - <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> - <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="storage-type-usage gl-h-full gl-display-inline-block" - :class="storageType.class" - :style="storageType.style" - data-testid="storage-type-usage" - ></div> - </div> - <div class="row py-0"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="col-md-auto gl-display-flex gl-align-items-center" - data-testid="storage-type-legend" - > - <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> - <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> - {{ storageType.name }} - </span> - <span class="gl-text-gray-500 gl-font-sm"> - {{ formatSize(storageType.size) }} - </span> - <span - v-if="storageType.tooltip" - v-gl-tooltip - :title="storageType.tooltip" - :aria-label="storageType.tooltip" - class="gl-ml-2" - > - <gl-icon name="question" :size="12" /> - </span> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue deleted file mode 100644 index c5fdb5fc242..00000000000 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { isFunction } from 'lodash'; -import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; - -export default { - directives: { - GlTooltip, - }, - props: { - title: { - type: String, - required: false, - default: '', - }, - placement: { - type: String, - required: false, - default: 'top', - }, - truncateTarget: { - type: [String, Function], - required: false, - default: '', - }, - }, - data() { - return { - showTooltip: false, - }; - }, - watch: { - title() { - // Wait on $nextTick in case of slot width changes - this.$nextTick(this.updateTooltip); - }, - }, - mounted() { - this.updateTooltip(); - }, - methods: { - selectTarget() { - if (isFunction(this.truncateTarget)) { - return this.truncateTarget(this.$el); - } else if (this.truncateTarget === 'child') { - return this.$el.childNodes[0]; - } - - return this.$el; - }, - updateTooltip() { - const target = this.selectTarget(); - this.showTooltip = hasHorizontalOverflow(target); - }, - }, -}; -</script> - -<template> - <span - v-if="showTooltip" - v-gl-tooltip="{ placement }" - :title="title" - class="js-show-tooltip gl-min-w-0" - > - <slot></slot> - </span> - <span v-else class="gl-min-w-0"> <slot></slot> </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js new file mode 100644 index 00000000000..f27901a30a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js @@ -0,0 +1,88 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import TooltipOnTruncate from './tooltip_on_truncate.vue'; + +const defaultWidth = '250px'; + +export default { + component: TooltipOnTruncate, + title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue', +}; + +const createStory = ({ ...options }) => { + return (_, { argTypes }) => { + const comp = { + components: { TooltipOnTruncate }, + props: Object.keys(argTypes), + template: ` + <div class="gl-bg-blue-50" :style="{ width }"> + <tooltip-on-truncate :title="title" :placement="placement" class="gl-display-block gl-text-truncate"> + {{title}} + </tooltip-on-truncate> + </div> + `, + ...options, + }; + + return comp; + }; +}; + +export const Default = createStory(); +Default.args = { + width: defaultWidth, + title: 'Hover on this text to see the content in a tooltip.', +}; + +export const NoOverflow = createStory(); +NoOverflow.args = { + width: defaultWidth, + title: "Short text doesn't need a tooltip.", +}; + +export const Placement = createStory(); +Placement.args = { + width: defaultWidth, + title: 'Use `placement="right"` to display this tooltip at the right.', + placement: 'right', +}; + +const TIMEOUT_S = 3; + +export const LiveUpdates = createStory({ + props: ['width', 'placement'], + data() { + return { + title: `(loading in ${TIMEOUT_S}s)`, + }; + }, + mounted() { + setTimeout(() => { + this.title = 'Content updated! The content is now overflowing so we use a tooltip!'; + }, TIMEOUT_S * 1000); + }, +}); +LiveUpdates.args = { + width: defaultWidth, +}; +LiveUpdates.argTypes = { + title: { + control: false, + }, +}; + +export const TruncateTarget = createStory({ + template: ` + <div class="gl-bg-black" :style="{ width }"> + <tooltip-on-truncate class="gl-display-flex" :truncate-target="truncateTarget" :title="title"> + <div class="gl-m-5 gl-bg-blue-50 gl-text-truncate"> + {{ title }} + </div> + </tooltip-on-truncate> + </div> + `, +}); +TruncateTarget.args = { + width: defaultWidth, + truncateTarget: 'child', + title: 'Wrap in container and use `truncate-target="child"` prop.', +}; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue new file mode 100644 index 00000000000..09414e679bb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue @@ -0,0 +1,85 @@ +<script> +import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; +import { isFunction, debounce } from 'lodash'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; + +const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + placement: { + type: String, + required: false, + default: 'top', + }, + truncateTarget: { + type: [String, Function], + required: false, + default: '', + }, + }, + data() { + return { + tooltipDisabled: true, + }; + }, + computed: { + classes() { + if (this.tooltipDisabled) { + return ''; + } + return 'js-show-tooltip'; + }, + tooltip() { + return { + title: this.title, + placement: this.placement, + disabled: this.tooltipDisabled, + }; + }, + }, + watch: { + title() { + // Wait on $nextTick in case the slot width changes + this.$nextTick(this.updateTooltip); + }, + }, + created() { + this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS); + }, + mounted() { + this.updateTooltip(); + }, + methods: { + selectTarget() { + if (isFunction(this.truncateTarget)) { + return this.truncateTarget(this.$el); + } else if (this.truncateTarget === 'child') { + return this.$el.childNodes[0]; + } + return this.$el; + }, + updateTooltip() { + this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget()); + }, + onResize() { + this.updateTooltipDebounced(); + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0"> + <slot></slot> + </span> +</template> diff --git a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue index f4cbaba9313..f4cbaba9313 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index c216a05bdb0..c216a05bdb0 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue index 5ca9e50d854..5ca9e50d854 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index ab04c6a38a5..0bb0e0d9fb0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -6,7 +6,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; -import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -166,6 +166,7 @@ export default { class="issue gl-display-flex! gl-px-5!" :class="{ closed: issuable.closedAt, today: createdInPastDay }" :data-labels="labelIdsString" + :data-qa-issue-id="issuableId" > <gl-form-checkbox v-if="showCheckbox" @@ -185,6 +186,13 @@ export default { :title="__('Confidential')" :aria-label="__('Confidential')" /> + <gl-icon + v-if="issuable.hidden" + v-gl-tooltip + name="spam" + :title="__('This issue is hidden because its author has been banned')" + :aria-label="__('Hidden')" + /> <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> {{ issuable.title }} <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> @@ -202,7 +210,7 @@ export default { <span v-else data-testid="issuable-reference" class="issuable-reference"> {{ reference }} </span> - <span class="gl-display-none gl-sm-display-inline-block"> + <span class="gl-display-none gl-sm-display-inline"> <span aria-hidden="true">·</span> <span class="issuable-authored gl-mr-3"> <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index c1082987146..2f8401b45f0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -1,5 +1,5 @@ <script> -import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; @@ -19,6 +19,7 @@ export default { tag: 'ul', }, components: { + GlAlert, GlKeysetPagination, GlSkeletonLoading, IssuableTabs, @@ -156,6 +157,11 @@ export default { required: false, default: false, }, + error: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -272,10 +278,12 @@ export default { :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 gl-border-t-none row-content-block" + data-qa-selector="issuable_search_container" @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" /> + <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert> <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar"> <template #bulk-edit-actions> <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot> @@ -302,6 +310,8 @@ export default { v-for="issuable in issuables" :key="issuableId(issuable)" :class="{ 'gl-cursor-grab': isManualOrdering }" + data-qa-selector="issuable_container" + :data-qa-issuable-title="issuable.title" :issuable-symbol="issuableSymbol" :issuable="issuable" :enable-label-permalinks="enableLabelPermalinks" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 96b07031a11..3ff87ba3c4f 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -46,7 +46,9 @@ export default { @click="$emit('click', tab.name)" > <template #title> - <span :title="tab.titleTooltip">{{ tab.title }}</span> + <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`"> + {{ tab.title }} + </span> <gl-badge v-if="tabCounts && isTabCountNumeric(tab)" variant="muted" diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index 773ad0f8e93..773ad0f8e93 100644 --- a/app/assets/javascripts/issuable_list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 05dc1650379..05dc1650379 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index f57b5b2deb4..f57b5b2deb4 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue index 5858af6cc51..5858af6cc51 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 33dca3e9332..33dca3e9332 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index d7da533d055..d7da533d055 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 011db52cbe3..8849af2a52e 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -1,5 +1,5 @@ <script> -import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; +import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; import IssuableBody from './issuable_body.vue'; import IssuableDiscussion from './issuable_discussion.vue'; @@ -100,7 +100,7 @@ export default { </script> <template> - <div class="issuable-show-container"> + <div class="issuable-show-container" data-qa-selector="issuable_show_container"> <issuable-header :status-badge-class="statusBadgeClass" :status-icon="statusIcon" diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index b96ce0c43f7..b96ce0c43f7 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js index 346f45c7d90..346f45c7d90 100644 --- a/app/assets/javascripts/issuable_show/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/pages/projects/labels/event_hub.js +++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue index 99dcccd12ed..99dcccd12ed 100644 --- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue diff --git a/app/assets/javascripts/issuable_sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js index 4f4b6341a1c..4f4b6341a1c 100644 --- a/app/assets/javascripts/issuable_sidebar/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js deleted file mode 100644 index fab0919d96e..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/issuable.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - props: { - issuableType: { - required: true, - type: String, - }, - }, - - computed: { - issuableDisplayName() { - return this.issuableType.replace(/_/g, ' '); - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 42272c222fc..d1630c9ac13 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -85,7 +85,7 @@ export default { ); }, i18n: { - buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'), + buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), noSuccessPathError: s__( 'SecurityConfiguration|%{featureName} merge request creation mutation failed', ), diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql index ae77a2ce5e4..829b9d9f9d8 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql @@ -1,6 +1,8 @@ fragment JobArtifacts on Pipeline { + id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..2e80db30e9a 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql @@ -4,11 +4,14 @@ query securityReportDownloadPaths( $reportTypes: [SecurityReportTypeEnum!] ) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id headPipeline { id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql index e1f3c55a886..e4f0c392b91 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -2,8 +2,8 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { project(fullPath: $projectPath) { + id pipeline(iid: $iid) { - id ...JobArtifacts } } diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue new file mode 100644 index 00000000000..5e9e50a94f0 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -0,0 +1,71 @@ +<script> +import { escape } from 'lodash'; +import { __ } from '~/locale'; + +export default { + props: { + initialTitle: { + type: String, + required: false, + default: '', + }, + placeholder: { + type: String, + required: false, + default: __('Add a title...'), + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + title: this.initialTitle, + }; + }, + methods: { + getSanitizedTitle(inputEl) { + const { innerText } = inputEl; + return escape(innerText); + }, + handleBlur({ target }) { + this.$emit('title-changed', this.getSanitizedTitle(target)); + }, + handleInput({ target }) { + this.$emit('title-input', this.getSanitizedTitle(target)); + }, + handleSubmit() { + this.$refs.titleEl.blur(); + }, + }, +}; +</script> + +<template> + <h2 + class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block" + :class="{ 'gl-cursor-not-allowed': disabled }" + data-testid="title" + aria-labelledby="item-title" + > + <span + id="item-title" + ref="titleEl" + role="textbox" + :aria-label="__('Title')" + :data-placeholder="placeholder" + :contenteditable="!disabled" + class="gl-pseudo-placeholder" + @blur="handleBlur" + @keyup="handleInput" + @keydown.enter.exact="handleSubmit" + @keydown.ctrl.u.prevent + @keydown.meta.u.prevent + @keydown.ctrl.b.prevent + @keydown.meta.b.prevent + >{{ title }}</span + > + </h2> +</template> diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql new file mode 100644 index 00000000000..2f302dae7d7 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation createWorkItem($input: LocalCreateWorkItemInput) { + localCreateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on LocalTitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json index c048ac34ac0..3b837e84ee9 100644 --- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json +++ b/app/assets/javascripts/work_items/graphql/fragmentTypes.json @@ -1 +1 @@ -{"__schema":{"types":[{"kind":"INTERFACE","name":"WorkItemWidget","possibleTypes":[{"name":"TitleWidget"}]}]}} +{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 083735336ce..fb536a425c0 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -4,6 +4,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import workItemQuery from './work_item.query.graphql'; import introspectionQueryResultData from './fragmentTypes.json'; +import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; const fragmentMatcher = new IntrospectionFragmentMatcher({ @@ -13,15 +14,12 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ export function createApolloProvider() { Vue.use(VueApollo); - const defaultClient = createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - typeDefs, + const defaultClient = createDefaultClient(resolvers, { + cacheConfig: { + fragmentMatcher, }, - ); + typeDefs, + }); defaultClient.cache.writeQuery({ query: workItemQuery, @@ -30,14 +28,14 @@ export function createApolloProvider() { }, data: { workItem: { - __typename: 'WorkItem', + __typename: 'LocalWorkItem', id: '1', type: 'FEATURE', widgets: { - __typename: 'WorkItemWidgetConnection', + __typename: 'LocalWorkItemWidgetConnection', nodes: [ { - __typename: 'TitleWidget', + __typename: 'LocalTitleWidget', type: 'TITLE', enabled: true, // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js index e69de29bb2d..63d5234d083 100644 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ b/app/assets/javascripts/work_items/graphql/resolvers.js @@ -0,0 +1,58 @@ +import { uuids } from '~/lib/utils/uuids'; +import workItemQuery from './work_item.query.graphql'; + +export const resolvers = { + Mutation: { + localCreateWorkItem(_, { input }, { cache }) { + const id = uuids()[0]; + const workItem = { + __typename: 'LocalWorkItem', + type: 'FEATURE', + id, + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [ + { + __typename: 'LocalTitleWidget', + type: 'TITLE', + enabled: true, + contentText: input.title, + }, + ], + }, + }; + + cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } }); + + return { + __typename: 'LocalCreateWorkItemPayload', + workItem, + }; + }, + + localUpdateWorkItem(_, { input }, { cache }) { + const workItemTitle = { + __typename: 'LocalTitleWidget', + type: 'TITLE', + enabled: true, + contentText: input.title, + }; + const workItem = { + __typename: 'LocalWorkItem', + type: 'FEATURE', + id: input.id, + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [workItemTitle], + }, + }; + + cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } }); + + return { + __typename: 'LocalUpdateWorkItemPayload', + workItem, + }; + }, + }, +}; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 4a6e4aeed60..177eea00322 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,38 +1,60 @@ -enum WorkItemType { +enum LocalWorkItemType { FEATURE } -enum WidgetType { +enum LocalWidgetType { TITLE } -interface WorkItemWidget { - type: WidgetType! +interface LocalWorkItemWidget { + type: LocalWidgetType! } # Replicating Relay connection type for client schema -type WorkItemWidgetEdge { +type LocalWorkItemWidgetEdge { cursor: String! - node: WorkItemWidget + node: LocalWorkItemWidget } -type WorkItemWidgetConnection { - edges: [WorkItemWidgetEdge] - nodes: [WorkItemWidget] +type LocalWorkItemWidgetConnection { + edges: [LocalWorkItemWidgetEdge] + nodes: [LocalWorkItemWidget] pageInfo: PageInfo! } -type TitleWidget implements WorkItemWidget { - type: WidgetType! +type LocalTitleWidget implements LocalWorkItemWidget { + type: LocalWidgetType! contentText: String! } -type WorkItem { +type LocalWorkItem { id: ID! - type: WorkItemType! - widgets: [WorkItemWidgetConnection] + type: LocalWorkItemType! + widgets: [LocalWorkItemWidgetConnection] +} + +input LocalCreateWorkItemInput { + title: String! +} + +input LocalUpdateWorkItemInput { + id: ID! + title: String +} + +type LocalCreateWorkItemPayload { + workItem: LocalWorkItem! +} + +type LocalUpdateWorkItemPayload { + workItem: LocalWorkItem! } extend type Query { - workItem(id: ID!): WorkItem! + workItem(id: ID!): LocalWorkItem! +} + +extend type Mutation { + localCreateWorkItem(input: LocalCreateWorkItemInput!): LocalCreateWorkItemPayload! + localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalUpdateWorkItemPayload! } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql new file mode 100644 index 00000000000..f0563f099b2 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation updateWorkItem($input: LocalUpdateWorkItemInput) { + localUpdateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on LocalTitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql index d7608c26052..154367dc0d8 100644 --- a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql @@ -1,3 +1,3 @@ -fragment WidgetBase on WorkItemWidget { +fragment WidgetBase on LocalWorkItemWidget { type } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 549e4f8c65a..9f173f7c302 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -7,7 +7,7 @@ query WorkItem($id: ID!) { widgets { nodes { ...WidgetBase - ... on TitleWidget { + ... on LocalTitleWidget { contentText } } diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue new file mode 100644 index 00000000000..12bad5606d4 --- /dev/null +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -0,0 +1,71 @@ +<script> +import { GlButton, GlAlert } from '@gitlab/ui'; +import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; + +import ItemTitle from '../components/item_title.vue'; + +export default { + components: { + GlButton, + GlAlert, + ItemTitle, + }, + data() { + return { + title: '', + error: false, + }; + }, + methods: { + async createWorkItem() { + try { + const response = await this.$apollo.mutate({ + mutation: createWorkItemMutation, + variables: { + input: { + title: this.title, + }, + }, + }); + + const { + data: { + localCreateWorkItem: { + workItem: { id }, + }, + }, + } = response; + this.$router.push({ name: 'workItem', params: { id } }); + } catch { + this.error = true; + } + }, + handleTitleInput(title) { + this.title = title; + }, + }, +}; +</script> + +<template> + <form @submit.prevent="createWorkItem"> + <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ + __('Something went wrong when creating a work item. Please try again') + }}</gl-alert> + <item-title data-testid="title-input" @title-input="handleTitleInput" /> + <div class="gl-bg-gray-10 gl-py-5 gl-px-6"> + <gl-button + variant="confirm" + :disabled="title.length === 0" + class="gl-mr-3" + data-testid="create-button" + type="submit" + > + {{ __('Create') }} + </gl-button> + <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)"> + {{ __('Cancel') }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 493ee0aba01..479274baf3a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,8 +1,16 @@ <script> +import { GlAlert } from '@gitlab/ui'; import workItemQuery from '../graphql/work_item.query.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { widgetTypes } from '../constants'; +import ItemTitle from '../components/item_title.vue'; + export default { + components: { + ItemTitle, + GlAlert, + }, props: { id: { type: String, @@ -12,6 +20,7 @@ export default { data() { return { workItem: null, + error: false, }; }, apollo: { @@ -29,20 +38,39 @@ export default { return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); }, }, + methods: { + async updateWorkItem(title) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.id, + title, + }, + }, + }); + } catch { + this.error = true; + } + }, + }, }; </script> <template> <section> + <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ + __('Something went wrong while updating work item. Please try again') + }}</gl-alert> <!-- Title widget placeholder --> <div> - <h2 + <item-title v-if="titleWidgetData" - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5" + :initial-title="titleWidgetData.contentText" data-testid="title" - > - {{ titleWidgetData.contentText }} - </h2> + @title-changed="updateWorkItem" + /> </div> </section> </template> diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js index a3cf44ad4ca..95772bbd026 100644 --- a/app/assets/javascripts/work_items/router/routes.js +++ b/app/assets/javascripts/work_items/router/routes.js @@ -1,7 +1,12 @@ export const routes = [ { + path: '/new', + name: 'createWorkItem', + component: () => import('../pages/create_work_item.vue'), + }, + { path: '/:id', - name: 'work_item', + name: 'workItem', component: () => import('../pages/work_item_root.vue'), props: true, }, diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index a3cbdb9ae86..377d5130571 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -12,7 +12,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); top: 35px; } - .badge.badge-pill { + .design-note-pin { display: flex; height: $design-pin-diameter; width: $design-pin-diameter; @@ -23,6 +23,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); border-radius: 50%; z-index: 1; padding: 0; + border: 0; &.resolved { background-color: $gray-500; @@ -34,7 +35,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } .comment-indicator, - .frame .badge.badge-pill { + .frame .design-note-pin { &:active { cursor: grabbing; } @@ -43,7 +44,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); /** * Design pin that overlays the design */ - .frame .badge.badge-pill { + .frame .design-note-pin { box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24; border: $white 2px solid; will-change: transform, box-shadow, opacity; @@ -114,7 +115,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); } } - .badge.badge-pill { + .design-note-pin { margin-left: $gl-padding; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index e458dfd5316..e0e9043ae24 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -451,3 +451,17 @@ fieldset[disabled] .btn, box-shadow: none; border-width: 1px; } + +copy-code { + @include gl-absolute; + @include gl-transition-medium; + @include gl-opacity-0; + + top: 7px; + right: $input-horizontal-padding; + + .markdown-code-block:hover &, + &:focus-within { + @include gl-opacity-10; + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 354d2737894..36a0d3ca3ca 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -479,6 +479,13 @@ img.emoji { border-top: 1px solid $border-color; } +.gl-pseudo-placeholder:empty::before { + content: attr(data-placeholder); + font-weight: $gl-font-weight-normal; + color: $gl-text-color-secondary; + cursor: text; +} + /** 🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index fa1892903a3..345c180d164 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -81,7 +81,6 @@ @include gl-px-0; @include gl-pb-2; @include gl-pt-0; - min-width: 150px; background-color: $gray-10; box-shadow: 0 $gl-spacing-scale-2 $gl-spacing-scale-5 $t-gray-a-24, 0 0 $gl-spacing-scale-1 $t-gray-a-24; border-style: none; @@ -309,6 +308,10 @@ } a.has-sub-items + .sidebar-sub-level-items { + @include media-breakpoint-up(sm) { + min-width: 150px; + } + .fly-out-top-item { @include fly-out-top-item($has-sub-items: true); } @@ -373,6 +376,18 @@ } } } + + li > a.gl-link { + // undo gl-link text items for things in the sidebar - including sub menus + // defined in https://gitlab.com/gitlab-org/gitlab-ui/-/blob/5431e0ca5149d4e02e3d5d617d194ac9609bb82d/src/components/base/link/link.scss + @include gl-text-body; + + &:active, + &:focus, + &:focus:active { + @include gl-text-decoration-none; + } + } } .sidebar-sub-level-items { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 8f65f349cf9..9209a0c2173 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -151,7 +151,17 @@ margin: 0; } + // + // IMPORTANT PERFORMANCE OPTIMIZATION + // + // When viewinng a blame with many commits a lot of content is rendered on the page. + // content-visibility rule below ensure that we only render what is visible to the user, + // thus reducing TBT in the browser. + // Grid is used instead of table layout because content-visibility performs better with it. tr { + content-visibility: auto; + display: grid; + grid-template-columns: 400px max-content auto; border-bottom: 1px solid $gray-darker; &:last-child { @@ -201,6 +211,10 @@ &.lines { padding: 0; } + + .code { + height: 100%; + } } @for $i from 0 through 5 { @@ -222,25 +236,6 @@ color: $gray-900; } } - - // - // IMPORTANT PERFORMANCE OPTIMIZATION - // - // When viewinng a blame with many commits a lot of content is rendered on the page. - // content-visibility rules below ensure that we only render what is visible to the user, thus reducing TBT in the browser. - .commit { - content-visibility: auto; - contain-intrinsic-size: 1px 3em; - } - - code .line { - content-visibility: auto; - contain-intrinsic-size: 1px 1.1875rem; - } - - .line-numbers { - content-visibility: auto; - } } &.logs { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index ae46ff33ec0..44b099fc873 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -523,9 +523,9 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important background-color: $orange-300; height: 12px; width: 12px; - margin-top: -15px; pointer-events: none; visibility: hidden; + top: 3px; } .with-notifications .notification-dot { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index e00bb83362a..f79dc38f2f7 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -174,3 +174,30 @@ body { min-height: 0; } } + +.gl-drawer-responsive { + // Both width & min-width + // are defined as per Pajamas + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44902#note_429056182 + width: 28%; + min-width: 400px; + padding-left: $gl-padding; + padding-right: $gl-padding; + box-shadow: none; + background-color: $gray-10; + border-left: 1px solid $gray-100; + + @include media-breakpoint-down(sm) { + min-width: unset; + width: 100%; + } + + // These overrides should not happen here, + // we should ideally have support for custom + // header and body classes in `GlDrawer`. + .gl-drawer-header, + .gl-drawer-body > * { + padding-left: 0; + padding-right: 0; + } +} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 9b04b9a2612..c6e52c13e83 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -139,6 +139,10 @@ font-family: $monospace-font !important; } +.suggestions.md > .markdown-code-block { + @include gl-static; +} + .md-suggestion-header { height: $suggestion-header-height; display: flex; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index c59e70c80df..39786aa0138 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -16,6 +16,11 @@ .snippet-file-content { border-radius: 3px; + .file-content { + max-height: 500px; + overflow-y: auto; + } + + .snippet-file-content { @include gl-mt-5; } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index cb36c4e5767..16ff4b81f95 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -625,6 +625,7 @@ body { /** CODE **/ pre { + @include gl-relative; font-family: $monospace-font; display: block; padding: $gl-padding-8 $input-horizontal-padding; @@ -636,6 +637,11 @@ pre { background-color: $gray-light; border: 1px solid $gray-100; border-radius: $border-radius-small; + + // Select only code elements that will have the copy code button + .markdown-code-block & { + padding: $input-horizontal-padding; + } } code { diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index fb4266a2f41..97dd7edef13 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -45,12 +45,12 @@ } } -@mixin line-number-hover($color) { - background-color: $color; - border-color: darken($color, 5%); +@mixin line-number-hover { + background-color: $purple-100; + border-color: $purple-200; a { - color: darken($color, 15%); + color: $gray-600; } } diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 2d180f49f97..0b696f1be60 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -22,7 +22,6 @@ $dark-highlight-bg: #ffe792; $dark-highlight-color: $black; $dark-pre-hll-bg: #373b41; $dark-hll-bg: #373b41; -$dark-over-bg: #9f9ab5; $dark-expanded-bg: #3e3e3e; $dark-coverage: #b3e841; $dark-no-coverage: #ff4f33; @@ -93,9 +92,10 @@ $dark-il: #de935f; .file-line-num { @include line-number-link($dark-line-num-color); } - + .line-numbers, - .diff-line-num { + .diff-line-num, + .code-search-line { background-color: $dark-main-bg; } @@ -169,16 +169,17 @@ $dark-il: #de935f; } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover($dark-over-bg); + @include line-number-hover; } } .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($dark-over-bg); + @include line-number-hover; } } diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index c0931188cc3..ae72c0b6bf4 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -15,7 +15,6 @@ $monokai-line-empty-bg: #49483e; $monokai-line-empty-border: darken($monokai-line-empty-bg, 15%); $monokai-diff-border: #808080; $monokai-highlight-bg: #ffe792; -$monokai-over-bg: #9f9ab5; $monokai-expanded-bg: #3e3e3e; $monokai-coverage: #a6e22e; $monokai-no-coverage: #fd971f; @@ -96,7 +95,8 @@ $monokai-gh: #75715e; } .line-numbers, - .diff-line-num { + .diff-line-num, + .code-search-line { background-color: $monokai-bg; } @@ -170,16 +170,17 @@ $monokai-gh: #75715e; } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover($monokai-over-bg); + @include line-number-hover; } } .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($monokai-over-bg); + @include line-number-hover; } } diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index ef7eb244b61..913b289d808 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -16,7 +16,8 @@ } .line-numbers, - .diff-line-num { + .diff-line-num, + .code-search-line { background-color: $gray-light; } @@ -43,7 +44,6 @@ } // Diff line - $none-over-bg: #ded7fc; $none-expanded-border: #e0e0e0; $none-expanded-bg: #e0e0e0; @@ -67,9 +67,10 @@ } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover($none-over-bg); + @include line-number-hover; } } @@ -88,7 +89,7 @@ &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($none-over-bg); + @include line-number-hover; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 8f09a178af1..eee699ca4c2 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -19,7 +19,6 @@ $solarized-dark-line-color-new: #5a766c; $solarized-dark-line-color-old: #7a6c71; $solarized-dark-highlight: #094554; $solarized-dark-hll-bg: #174652; -$solarized-dark-over-bg: #9f9ab5; $solarized-dark-expanded-bg: #010d10; $solarized-dark-coverage: #859900; $solarized-dark-no-coverage: #cb4b16; @@ -99,7 +98,8 @@ $solarized-dark-il: #2aa198; } .line-numbers, - .diff-line-num { + .diff-line-num, + .code-search-line { background-color: $solarized-dark-line-bg; } @@ -149,9 +149,10 @@ $solarized-dark-il: #2aa198; } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover($solarized-dark-over-bg); + @include line-number-hover; } } @@ -182,7 +183,7 @@ $solarized-dark-il: #2aa198; .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($solarized-dark-over-bg); + @include line-number-hover; } } diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 747cc639f91..8c5e1f7318b 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -20,7 +20,6 @@ $solarized-light-line-color-new: #a1a080; $solarized-light-line-color-old: #ad9186; $solarized-light-highlight: #eee8d5; $solarized-light-hll-bg: #ddd8c5; -$solarized-light-over-bg: #ded7fc; $solarized-light-expanded-border: #d2cdbd; $solarized-light-expanded-bg: #ece6d4; $solarized-light-coverage: #859900; @@ -106,7 +105,8 @@ $solarized-light-il: #2aa198; } .line-numbers, - .diff-line-num { + .diff-line-num, + .code-search-line { background-color: $solarized-light-line-bg; } @@ -169,9 +169,10 @@ $solarized-light-il: #2aa198; } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover($solarized-light-over-bg); + @include line-number-hover; } } @@ -190,7 +191,7 @@ $solarized-light-il: #2aa198; .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($solarized-light-over-bg); + @include line-number-hover; } } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 86b01926dd7..c0f8475323a 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -9,7 +9,6 @@ $white-code-color: $gl-text-color; $white-highlight: #fafe3d; $white-pre-hll-bg: #f8eec7; $white-hll-bg: #f8f8f8; -$white-over-bg: #ded7fc; $white-expanded-border: #e0e0e0; $white-expanded-bg: #f7f7f7; $white-c: #998; @@ -83,7 +82,8 @@ $white-gc-bg: #eaf2f5; } .line-numbers, -.diff-line-num { +.diff-line-num, +.code-search-line { background-color: $gray-light; &.conflict_marker, @@ -129,9 +129,10 @@ pre.code, } .diff-grid-left:hover, - .diff-grid-right:hover { + .diff-grid-right:hover, + &.code-search-line:hover { .diff-line-num:not(.empty-cell):not(.conflict_marker_their):not(.conflict_marker_our) { - @include line-number-hover($white-over-bg); + @include line-number-hover; } } @@ -156,7 +157,7 @@ pre.code, &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover($white-over-bg); + @include line-number-hover; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss index 3e5271f84d5..2c5ea8347ae 100644 --- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss +++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss @@ -326,10 +326,6 @@ } } -.transfer-project .select2-container { - min-width: 200px; -} - .right-sidebar { .block { .select2-container span { diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index 8794acd3c78..ee777820b81 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -113,8 +113,8 @@ - mini graph in Commit widget pipeline */ @mixin pipeline-graph-dropdown-menu() { - width: 240px; - max-width: 240px; + width: auto; + max-width: 400px; // override dropdown.scss &.dropdown-menu li button, @@ -185,8 +185,6 @@ } .ci-status-icon { - @include gl-mr-3; - position: relative; > svg { diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 47580e37eca..d37171bc75e 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -904,6 +904,7 @@ $ide-commit-header-height: 48px; .sidebar-context-title { white-space: nowrap; display: block; + color: var(--ide-text-color, $gl-text-color); &.text-secondary { font-weight: normal; @@ -964,6 +965,10 @@ $ide-commit-header-height: 48px; margin: 0; } } + + .gl-tab-content { + color: var(--ide-text-color, $gl-text-color); + } } .ide-pipeline-header { diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss index c74b5460e1a..79468ce62ce 100644 --- a/app/assets/stylesheets/page_bundles/import.scss +++ b/app/assets/stylesheets/page_bundles/import.scss @@ -1,12 +1,5 @@ @import 'mixins_and_variables_and_functions'; -// Fixing double scrollbar issue -// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1156 and -// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54837 -.import-entities-namespace-dropdown.show.dropdown .dropdown-menu { - max-height: initial; -} - .import-jobs-to-col { width: 39%; } @@ -38,3 +31,31 @@ box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200); } } + +$import-bar-height: $gl-spacing-scale-11; + +.import-table-bar { + @include gl-sticky; + height: $import-bar-height; + top: $header-height; + z-index: 3; + + html.with-performance-bar & { + top: $header-height + $performance-bar-height; + } +} + +.import-table { + border-collapse: separate; + + thead { + @include gl-sticky; + background-color: var(--gray-10, $gray-10); + top: calc(#{$header-height} + #{$import-bar-height}); + z-index: 3; + + html.with-performance-bar & { + top: calc(#{$header-height + $performance-bar-height} + #{$import-bar-height}); + } + } +} diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 7d1230b0225..02113fe8b58 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -45,7 +45,7 @@ top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px)); // stylelint-disable-next-line length-zero-no-unit max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); - z-index: 202; + z-index: 205; .drag-handle { bottom: 16px; diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index c8b1b6cf9aa..a9d353a0444 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -129,11 +129,17 @@ } .gl-pipeline-job-width { - width: 186px; + width: 100%; + max-width: 400px; } .gl-pipeline-job-width\! { - width: 186px !important; + width: 100% !important; + max-width: 400px !important; +} + +.gl-downstream-pipeline-job-width { + width: 240px; } .gl-linked-pipeline-padding { @@ -199,7 +205,6 @@ .big-pipeline-graph-dropdown-menu { @include pipeline-graph-dropdown-menu(); - width: 195px; min-width: 195px; left: 100%; top: -10px; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index b450bca4f41..5a091c14e53 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -7,20 +7,6 @@ } } - .agents-empty-state { - .text-content { - @include gl-max-w-full; - @include media-breakpoint-up(lg) { - max-width: 70%; - } - } - - .gl-alert-actions { - @include gl-mt-0; - @include gl-flex-wrap; - } - } - .gl-card-body { @include media-breakpoint-up(sm) { @include gl-pt-2; @@ -40,3 +26,15 @@ } } } + +.agent-activity-list { + .system-note .timeline-entry-inner { + .timeline-icon { + @include gl-mt-1; + } + } + + &.issuable-discussion .main-notes-list::before { + @include gl-top-3; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 7f35b8fab43..cc8ea1493fc 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -149,7 +149,6 @@ .commit-content { padding-right: 10px; white-space: normal; - overflow: hidden; .commit-title { display: flex; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index cf5e93e94a2..8600a4059d8 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -456,12 +456,6 @@ } .multiple-users { - position: relative; - height: 24px; - margin-bottom: 17px; - margin-top: 4px; - padding-bottom: 4px; - .btn-link { padding: 0; border: 0; @@ -875,10 +869,6 @@ } } -.issuable-suggestions svg { - vertical-align: sub; -} - .suggestion-footer { font-size: 12px; line-height: 15px; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 3b86750c6ca..a4b8e912614 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -947,19 +947,6 @@ $tabs-holder-z-index: 250; color: $gray-500; line-height: initial; } - - // GlDropdown mini pipeline (Vue) - // As the `mini-pipeline-item` mixin specificity is lower - // than the toggle of dropdown with 'variant="link"' we add - // classes ".gl-button.btn-link" to make it more specific - // and avoid having the size overriden - // - // See https://gitlab.com/gitlab-org/gitlab/-/issues/320737 - button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle, - .stage-cell button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle svg { - height: $ci-action-icon-size-lg; - width: $ci-action-icon-size-lg; - } } .merge-request-details .file-finder-overlay.diff-file-finder { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index af9f10c9a26..a7ed7172f5f 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -329,16 +329,6 @@ table.u2f-registrations { } } -.email-badge { - display: inline; - margin-right: $gl-padding / 2; - - .email-badge-email { - display: inline; - margin-right: $gl-padding / 4; - } -} - .edit-user { svg { fill: $gl-text-color-secondary; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 2e6c6a021f8..4c31cc6e111 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -321,6 +321,51 @@ input[type='checkbox']:hover { } } +// This overrides parts of the Project File View CSS +// We leverage most of the styling but broke off +// from how we were doing it in `shared/file_highlight` +#search-blob-content { + .line_holder { + pre { + padding: 0; // This overrides the existing style that will add space between each line. + } + + svg { + float: none; // We have more than one icon on this implementation and don't want to float them. + margin: 0; // We will manage the margin with GitLab UI utility classes + } + + .line-numbers { + padding: 0; // This overrides the existing style that will add space between each line. + min-width: 6.5rem; // Ensure our numbers fit + + .diff-line-num { + a { + transition: none; // There will be a hover transition from theme, blue, darkened + } + } + } + + &:hover { + svg { + visibility: visible; // We want to show the icons when the any part of the line is hovered + } + } + + // The icons only appear on hover + // So on mobile we can hide them and retake the space for the code blob + @include media-breakpoint-down(sm) { + svg { + display: none; + } + + .line-numbers { + min-width: 4rem; + } + } + } +} + // Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling /* stylelint-disable property-no-vendor-prefix */ input[type='search']::-webkit-search-decoration, diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index efa4b04ee62..c9ff8205142 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -407,6 +407,34 @@ h1 { .gl-form-input.form-control::placeholder { color: #868686; } +.gl-icon { + fill: currentColor; +} +.gl-icon.s12 { + width: 12px; + height: 12px; +} +.gl-icon.s16 { + width: 16px; + height: 16px; +} +.gl-icon.s32 { + width: 32px; + height: 32px; +} +.gl-link { + font-size: 0.875rem; + color: #428fdc; +} +.gl-link:active { + color: #9dc7f1; +} +.gl-link:active { + text-decoration: underline; + box-shadow: 0 0 0 1px rgba(51, 51, 51, 0.4), + 0 0 0 4px rgba(66, 143, 220, 0.48); + outline: none; +} .gl-button { display: inline-flex; } @@ -439,6 +467,29 @@ h1 { outline: none; background-color: #404040; } +.gl-button.gl-button.btn-default:active .gl-icon, +.gl-button.gl-button.btn-default.active .gl-icon { + color: #fafafa; +} +.gl-button.gl-button.btn-default .gl-icon { + color: #999; +} +.gl-search-box-by-type-search-icon { + margin: 0.5rem; + color: #999; + width: 1rem; + position: absolute; +} +.gl-search-box-by-type { + display: flex; + position: relative; +} +.gl-search-box-by-type-input, +.gl-search-box-by-type-input.gl-form-input { + height: 2rem; + padding-right: 2rem; + padding-left: 1.75rem; +} body, .form-control, .search form { @@ -912,9 +963,9 @@ input { background-color: #9e5400; height: 12px; width: 12px; - margin-top: -15px; pointer-events: none; visibility: hidden; + top: 3px; } .top-nav-toggle .dropdown-icon { margin-right: 0.5rem; @@ -1139,6 +1190,11 @@ input { border-right: 0.25rem solid #fff; border-right-color: var(--black, #fff); } +@media (min-width: 576px) { + .nav-sidebar a.has-sub-items + .sidebar-sub-level-items { + min-width: 150px; + } +} .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item { display: none; } @@ -1329,6 +1385,12 @@ input { font-weight: 400; color: #9dc7f1; } +.sidebar-top-level-items li > a.gl-link { + color: #fafafa; +} +.sidebar-top-level-items li > a.gl-link:active { + text-decoration: none; +} .sidebar-sub-level-items { padding-top: 0; padding-bottom: 0; @@ -1770,6 +1832,7 @@ body.gl-dark .header-search svg { body.gl-dark .header-search input { background-color: transparent; color: rgba(250, 250, 250, 0.8); + box-shadow: inset 0 0 0 1px rgba(250, 250, 250, 0.4); } body.gl-dark .header-search input::placeholder { color: rgba(250, 250, 250, 0.8); @@ -1826,6 +1889,9 @@ body.gl-dark .navbar-gitlab .search form:active { background-color: var(--gray-100); box-shadow: inset 0 0 0 1px var(--blue-200); } +body.gl-dark .navbar-gitlab .search form .search-input { + color: var(--gl-text-color); +} body.gl-dark { --gray-10: #1f1f1f; @@ -1948,6 +2014,9 @@ body.gl-dark { display: block; } } +.gl-relative { + position: relative; +} .gl-absolute { position: absolute; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 977f994dc78..a57202515ad 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -388,6 +388,34 @@ h1 { .gl-form-input.form-control::placeholder { color: #868686; } +.gl-icon { + fill: currentColor; +} +.gl-icon.s12 { + width: 12px; + height: 12px; +} +.gl-icon.s16 { + width: 16px; + height: 16px; +} +.gl-icon.s32 { + width: 32px; + height: 32px; +} +.gl-link { + font-size: 0.875rem; + color: #1f75cb; +} +.gl-link:active { + color: #0b5cad; +} +.gl-link:active { + text-decoration: underline; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.4), + 0 0 0 4px rgba(31, 117, 203, 0.48); + outline: none; +} .gl-button { display: inline-flex; } @@ -420,6 +448,29 @@ h1 { outline: none; background-color: #dbdbdb; } +.gl-button.gl-button.btn-default:active .gl-icon, +.gl-button.gl-button.btn-default.active .gl-icon { + color: #303030; +} +.gl-button.gl-button.btn-default .gl-icon { + color: #666; +} +.gl-search-box-by-type-search-icon { + margin: 0.5rem; + color: #666; + width: 1rem; + position: absolute; +} +.gl-search-box-by-type { + display: flex; + position: relative; +} +.gl-search-box-by-type-input, +.gl-search-box-by-type-input.gl-form-input { + height: 2rem; + padding-right: 2rem; + padding-left: 1.75rem; +} body, .form-control, .search form { @@ -893,9 +944,9 @@ input { background-color: #d99530; height: 12px; width: 12px; - margin-top: -15px; pointer-events: none; visibility: hidden; + top: 3px; } .top-nav-toggle .dropdown-icon { margin-right: 0.5rem; @@ -1120,6 +1171,11 @@ input { border-right: 0.25rem solid #000; border-right-color: var(--black, #000); } +@media (min-width: 576px) { + .nav-sidebar a.has-sub-items + .sidebar-sub-level-items { + min-width: 150px; + } +} .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item { display: none; } @@ -1310,6 +1366,12 @@ input { font-weight: 400; color: #0b5cad; } +.sidebar-top-level-items li > a.gl-link { + color: #303030; +} +.sidebar-top-level-items li > a.gl-link:active { + text-decoration: none; +} .sidebar-sub-level-items { padding-top: 0; padding-bottom: 0; @@ -1626,6 +1688,9 @@ svg.s16 { display: block; } } +.gl-relative { + position: relative; +} .gl-absolute { position: absolute; } diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 2b5751cab36..bb9a9cf0497 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -122,6 +122,10 @@ body.gl-dark { background-color: var(--gray-100); box-shadow: inset 0 0 0 1px var(--blue-200); } + + .search-input { + color: var(--gl-text-color); + } } } } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 1332686a906..e119af716a6 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -154,6 +154,7 @@ input { background-color: transparent; color: rgba($search-and-nav-links, 0.8); + box-shadow: inset 0 0 0 1px rgba($search-and-nav-links, 0.4); &::placeholder { color: rgba($search-and-nav-links, 0.8); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 7e46f16e1d0..2623de80fe9 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -226,6 +226,16 @@ $gl-line-height-42: px-to-rem(42px); max-height: none !important; } +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655 +.gl-max-w-62 { + max-width: $grid-size * 62; +} + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1655 +.gl-max-w-26 { + max-width: $grid-size * 26; +} + .gl-max-w-50p { max-width: 50%; } @@ -291,9 +301,9 @@ $gl-line-height-42: px-to-rem(42px); @include gl-focus($gl-border-size-1, $gray-900, true); } -// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2476 -.gl-md-max-w-50p { - @include gl-media-breakpoint-up(md) { - max-width: 50%; +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1637 +.gl-lg-w-25p { + @include gl-media-breakpoint-up(lg) { + width: 25%; } } diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb index 88bc5ea0198..420fd93fad5 100644 --- a/app/controllers/admin/plan_limits_controller.rb +++ b/app/controllers/admin/plan_limits_controller.rb @@ -31,6 +31,7 @@ class Admin::PlanLimitsController < Admin::ApplicationController params.require(:plan_limits).permit(%i[ plan_id conan_max_file_size + helm_max_file_size maven_max_file_size npm_max_file_size nuget_max_file_size diff --git a/app/controllers/admin/version_check_controller.rb b/app/controllers/admin/version_check_controller.rb new file mode 100644 index 00000000000..dde1a7abafa --- /dev/null +++ b/app/controllers/admin/version_check_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Admin::VersionCheckController < Admin::ApplicationController + feature_category :not_owned + + def version_check + response = VersionCheck.new.response + + expires_in 1.minute if response + render json: response + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3af1afab06e..d3ecbdcc1f6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -23,6 +23,7 @@ class ApplicationController < ActionController::Base include Gitlab::Utils::StrongMemoize include ::Gitlab::EndpointAttributes include FlocOptOut + include CheckRateLimit before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? @@ -66,10 +67,6 @@ class ApplicationController < ActionController::Base :manifest_import_enabled?, :phabricator_import_enabled?, :masked_page_url - # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security - # concerns due to caching private data. - DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store" - def self.endpoint_id_for_action(action_name) "#{self.name}##{action_name}" end @@ -283,10 +280,7 @@ class ApplicationController < ActionController::Base end def default_cache_headers - if current_user - headers['Cache-Control'] = default_cache_control - headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility - end + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility end def stream_csv_headers(csv_filename) @@ -297,14 +291,6 @@ class ApplicationController < ActionController::Base headers['Content-Disposition'] = "attachment; filename=\"#{csv_filename}\"" end - def default_cache_control - if request.xhr? - ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL - else - DEFAULT_GITLAB_CACHE_CONTROL - end - end - def validate_user_service_ticket! return unless signed_in? && session[:service_tickets] diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 5cb5690d72d..c32a7f10aa4 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -9,6 +9,8 @@ class AutocompleteController < ApplicationController feature_category :code_review, [:merge_request_target_branches] feature_category :continuous_delivery, [:deploy_keys_with_owners] + urgency :low, [:merge_request_target_branches] + def users group = Autocomplete::GroupFinder .new(current_user, project, params) diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 32de9e69c85..15a261f572a 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -280,7 +280,10 @@ class Clusters::ClustersController < Clusters::BaseController end def generate_gcp_authorize_url - state = generate_session_key_redirect(clusterable.new_path(provider: :gcp).to_s) + new_path = clusterable.new_path(provider: :gcp).to_s + error_path = @project ? project_clusters_path(@project) : new_path + + state = generate_session_key_redirect(new_path, error_path) @authorize_url = GoogleApi::CloudPlatform::Client.new( nil, callback_google_api_auth_url, @@ -339,9 +342,10 @@ class Clusters::ClustersController < Clusters::BaseController session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - def generate_session_key_redirect(uri) + def generate_session_key_redirect(uri, error_uri) GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| session[key] = uri + session[:error_uri] = error_uri end end diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index da5b7ccfbf0..14dcec33545 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -23,9 +23,9 @@ module AuthenticatesWithTwoFactor session[:otp_user_id] = user.id session[:user_password_hash] = Digest::SHA256.hexdigest(user.encrypted_password) - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) - if user.two_factor_webauthn_enabled? + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_authentication(user) else setup_u2f_authentication(user) diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb index 574fc6c0f37..05be04059fd 100644 --- a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb +++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb @@ -11,7 +11,7 @@ module AuthenticatesWithTwoFactorForAdminMode return handle_locked_user(user) unless user.can?(:log_in) session[:otp_user_id] = user.id - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) if user.two_factor_webauthn_enabled? setup_webauthn_authentication(user) diff --git a/app/controllers/concerns/check_rate_limit.rb b/app/controllers/concerns/check_rate_limit.rb index c4de3315e22..5ccdf843525 100644 --- a/app/controllers/concerns/check_rate_limit.rb +++ b/app/controllers/concerns/check_rate_limit.rb @@ -5,19 +5,27 @@ # Controller concern that checks if the rate limit for a given action is throttled by calling the # Gitlab::ApplicationRateLimiter class. If the action is throttled for the current user, the request # will be logged and an error message will be rendered with a Too Many Requests response status. +# See lib/api/helpers/rate_limiter.rb for API version module CheckRateLimit - def check_rate_limit(key) - return unless rate_limiter.throttled?(key, scope: current_user, users_allowlist: rate_limit_users_allowlist) + def check_rate_limit!(key, scope:, redirect_back: false, **options) + return unless rate_limiter.throttled?(key, scope: scope, **options) rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests + + return yield if block_given? + + message = _('This endpoint has been requested too many times. Try again later.') + + if redirect_back + redirect_back_or_default(options: { alert: message }) + else + render plain: message, status: :too_many_requests + end end + private + def rate_limiter ::Gitlab::ApplicationRateLimiter end - - def rate_limit_users_allowlist - Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist - end end diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index 626093b4588..70bcefe339c 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -23,6 +23,7 @@ module CycleAnalyticsParams opts[:from] = params[:from] || start_date(params) opts[:to] = params[:to] if params[:to] opts[:end_event_filter] = params[:end_event_filter] if params[:end_event_filter] + opts[:use_aggregated_data_collector] = params[:use_aggregated_data_collector] if params[:use_aggregated_data_collector] opts.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES)) opts.merge!(date_range(params)) end diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb index 07aca72b22f..44611641529 100644 --- a/app/controllers/concerns/dependency_proxy/group_access.rb +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -5,13 +5,13 @@ module DependencyProxy extend ActiveSupport::Concern included do - before_action :verify_dependency_proxy_enabled! + before_action :verify_dependency_proxy_available! before_action :authorize_read_dependency_proxy! end private - def verify_dependency_proxy_enabled! + def verify_dependency_proxy_available! render_404 unless group&.dependency_proxy_feature_available? end diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb index 6490742c0f8..1f788860c8f 100644 --- a/app/controllers/concerns/integrations/actions.rb +++ b/app/controllers/concerns/integrations/actions.rb @@ -23,7 +23,7 @@ module Integrations::Actions format.html do if saved PropagateIntegrationWorker.perform_async(integration.id) - redirect_to scoped_edit_integration_path(integration), notice: success_message + redirect_to scoped_edit_integration_path(integration, project: integration.project, group: integration.group), notice: success_message else render 'shared/integrations/edit' end diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb index af039057a9c..6a9d3d51f9b 100644 --- a/app/controllers/concerns/integrations/hooks_execution.rb +++ b/app/controllers/concerns/integrations/hooks_execution.rb @@ -32,16 +32,4 @@ module Integrations::HooksExecution flash[:alert] = "Hook execution failed: #{message}" end end - - def create_rate_limit(key, scope) - if rate_limiter.throttled?(key, scope: [scope, current_user]) - rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) - - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests - end - end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 2d7fbb78209..bac9732018c 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -17,6 +17,7 @@ module IssuableActions def show respond_to do |format| format.html do + @show_crm_contacts = issuable.is_a?(Issue) && can?(current_user, :read_crm_contact, issuable.project.group) # rubocop:disable Gitlab/ModuleWithInstanceVariables @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables render 'show' end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 8fd4e98d557..f716c1f6c2f 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -26,7 +26,6 @@ module MembershipActions member_data = if member.expires? { - expires_in: helpers.distance_of_time_in_words_to_now(member.expires_at), expires_soon: member.expires_soon?, expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium) } diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index c2ee735a2b5..8410a8779f6 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -3,7 +3,6 @@ module NotesActions include RendersNotes include Gitlab::Utils::StrongMemoize - include CheckRateLimit extend ActiveSupport::Concern # last_fetched_at is an integer number of microseconds, which is the same @@ -16,7 +15,11 @@ module NotesActions before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :note_project, only: [:create] - before_action -> { check_rate_limit(:notes_create) }, only: [:create] + before_action -> { + check_rate_limit!(:notes_create, + scope: current_user, + users_allowlist: Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist) + }, only: [:create] end def index @@ -341,3 +344,5 @@ module NotesActions noteable.discussions_rendered_on_frontend? end end + +NotesActions.prepend_mod_with('NotesActions') diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb index fbd44f52590..cd35eeb587c 100644 --- a/app/controllers/concerns/one_trust_csp.rb +++ b/app/controllers/concerns/one_trust_csp.rb @@ -8,11 +8,11 @@ module OneTrustCSP next unless helpers.one_trust_enabled? || policy.directives.present? default_script_src = policy.directives['script-src'] || policy.directives['default-src'] - script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com'] + script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org', 'https://*.onetrust.com'] policy.script_src(*script_src_values) default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] - connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org'] + connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org', 'https://*.onetrust.com'] policy.connect_src(*connect_src_values) end end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 2916762e31f..1d2f9e31c46 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -21,7 +21,7 @@ module PreviewMarkdown def projects_filter_params { - issuable_state_filter_enabled: true, + issuable_reference_expansion_enabled: true, suggestions_filter_enabled: params[:preview_suggestions].present? } end diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb index db56ce8f193..b510594ad63 100644 --- a/app/controllers/concerns/snippets/blobs_actions.rb +++ b/app/controllers/concerns/snippets/blobs_actions.rb @@ -51,3 +51,5 @@ module Snippets::BlobsActions params[:snippet_id] end end + +Snippets::BlobsActions.prepend_mod diff --git a/app/controllers/concerns/sourcegraph_decorator.rb b/app/controllers/concerns/sourcegraph_decorator.rb index 5ef09b9221f..061990a4361 100644 --- a/app/controllers/concerns/sourcegraph_decorator.rb +++ b/app/controllers/concerns/sourcegraph_decorator.rb @@ -11,7 +11,7 @@ module SourcegraphDecorator next unless Gitlab::CurrentSettings.sourcegraph_enabled default_connect_src = p.directives['connect-src'] || p.directives['default-src'] - connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.sourcegraph_url] + connect_src_values = Array.wrap(default_connect_src) | [Gitlab::Utils.append_path(Gitlab::CurrentSettings.sourcegraph_url, '.api/')] p.connect_src(*connect_src_values) end end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 848b7ee44c5..714a6f280f3 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -21,6 +21,10 @@ module WikiActions before_action :load_sidebar, except: [:pages] before_action :set_content_class + before_action do + push_frontend_feature_flag(:wiki_switch_between_content_editor_raw_markdown, @group, default_enabled: :yaml) + end + before_action only: [:show, :edit, :update] do @valid_encoding = valid_encoding? end @@ -79,7 +83,8 @@ module WikiActions render 'shared/wikis/show' elsif file_blob - send_blob(wiki.repository, file_blob) + # This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/247) + send_wiki_file_blob(wiki, file_blob) elsif show_create_form? # Assign a title to the WikiPage unless `id` is a randomly generated slug from #new title = params[:id] unless params[:random_title].present? @@ -301,4 +306,10 @@ module WikiActions view: diff_view } end + + def send_wiki_file_blob(wiki, file_blob) + send_blob(wiki.repository, file_blob) + end end + +WikiActions.prepend_mod diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 6725e19df25..dd30d688fa8 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -3,6 +3,7 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations include GitlabRecaptcha + include OneTrustCSP prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8d7686a95fb..2ecd17db487 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -18,6 +18,8 @@ class DashboardController < Dashboard::ApplicationController feature_category :team_planning, [:issues, :issues_calendar] feature_category :code_review, [:merge_requests] + urgency :low, [:merge_requests] + def activity respond_to do |format| format.html diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index 76a1c43dfa3..b9c5e87c69c 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -8,19 +8,36 @@ module GoogleApi feature_category :kubernetes_management + ## + # handle the response from google after the user + # goes through authentication and authorization process def callback - token, expires_at = GoogleApi::CloudPlatform::Client - .new(nil, callback_google_api_auth_url) - .get_token(params[:code]) - - session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token - session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = - expires_at.to_s - + redirect_uri = redirect_uri_from_session + ## + # when the user declines authorizations + # `error` param is returned + if params[:error] + flash[:alert] = _('Google Cloud authorizations required') + redirect_uri = session[:error_uri] + ## + # on success, the `code` param is returned + elsif params[:code] + token, expires_at = GoogleApi::CloudPlatform::Client + .new(nil, callback_google_api_auth_url) + .get_token(params[:code]) + + session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token + session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s + redirect_uri = redirect_uri_from_session + end + ## + # or google may just timeout rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed flash[:alert] = _('Timeout connecting to the Google API. Please try again.') + ## + # regardless, we redirect the user appropriately ensure - redirect_to redirect_uri_from_session + redirect_to redirect_uri end private diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 899fa614949..f48d03869a4 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -50,6 +50,8 @@ class GraphqlController < ApplicationController end rescue_from StandardError do |exception| + @exception_object = exception + log_exception(exception) if Rails.env.test? || Rails.env.development? @@ -197,7 +199,9 @@ class GraphqlController < ApplicationController # Merging to :metadata will ensure these are logged as top level keys payload[:metadata] ||= {} - payload[:metadata].merge!(graphql: logs) + payload[:metadata][:graphql] = logs + + payload[:exception_object] = @exception_object if @exception_object end def logs diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 82f8854bd2b..17cdcd9cb9b 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -5,6 +5,8 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController feature_category :team_planning, [:issues, :labels, :milestones, :commands] feature_category :code_review, [:merge_requests] + urgency :low, [:merge_requests] + def members render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 3152c4d733f..3fbcb2fd7aa 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -11,7 +11,6 @@ class Groups::BoardsController < Groups::ApplicationController push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) - push_frontend_feature_flag(:labels_widget, group, default_enabled: :yaml) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.use { } e.try { } diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb new file mode 100644 index 00000000000..f00f4d1df25 --- /dev/null +++ b/app/controllers/groups/crm/contacts_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Groups::Crm::ContactsController < Groups::ApplicationController + feature_category :team_planning + + before_action :authorize_read_crm_contact! + + def new + render action: "index" + end + + def edit + render action: "index" + end + + private + + def authorize_read_crm_contact! + render_404 unless can?(current_user, :read_crm_contact, group) + end +end diff --git a/app/controllers/groups/crm/organizations_controller.rb b/app/controllers/groups/crm/organizations_controller.rb new file mode 100644 index 00000000000..ab720f490be --- /dev/null +++ b/app/controllers/groups/crm/organizations_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Groups::Crm::OrganizationsController < Groups::ApplicationController + feature_category :team_planning + + before_action :authorize_read_crm_organization! + + def new + render action: "index" + end + + private + + def authorize_read_crm_organization! + render_404 unless can?(current_user, :read_crm_organization, group) + end +end diff --git a/app/controllers/groups/crm_controller.rb b/app/controllers/groups/crm_controller.rb deleted file mode 100644 index 40661b09be6..00000000000 --- a/app/controllers/groups/crm_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Groups::CrmController < Groups::ApplicationController - feature_category :team_planning - - before_action :authorize_read_crm_contact!, only: [:contacts] - before_action :authorize_read_crm_organization!, only: [:organizations] - - def contacts - respond_to do |format| - format.html - end - end - - def organizations - respond_to do |format| - format.html - end - end - - private - - def authorize_read_crm_contact! - render_404 unless can?(current_user, :read_crm_contact, group) - end - - def authorize_read_crm_organization! - render_404 unless can?(current_user, :read_crm_organization, group) - end -end diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index b037aa52939..2e120de435e 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -5,30 +5,19 @@ module Groups include ::DependencyProxy::GroupAccess before_action :authorize_admin_dependency_proxy!, only: :update - before_action :dependency_proxy + before_action :verify_dependency_proxy_enabled! feature_category :package_registry - def show - @blobs_count = group.dependency_proxy_blobs.count - @blobs_total_size = group.dependency_proxy_blobs.total_size - end - - def update - dependency_proxy.update(dependency_proxy_params) - - redirect_to group_dependency_proxy_path(group) - end - private def dependency_proxy @dependency_proxy ||= - group.dependency_proxy_setting || group.create_dependency_proxy_setting + group.dependency_proxy_setting || group.create_dependency_proxy_setting! end - def dependency_proxy_params - params.require(:dependency_proxy_group_setting).permit(:enabled) + def verify_dependency_proxy_enabled! + render_404 unless dependency_proxy.enabled? end end end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index fc930ffebbd..171314b5f26 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -19,7 +19,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy feature_category :dependency_proxy def manifest - result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute + result = DependencyProxy::FindCachedManifestService.new(group, image, tag, token).execute if result[:status] == :success if result[:manifest] diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 9dbbd385ea8..1e23db9f32b 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -8,6 +8,8 @@ module Groups feature_category :pipeline_authoring + urgency :low, [:show] + def show respond_to do |format| format.json do diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 6ae711a6e14..62336c7eede 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -37,7 +37,7 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml) end - before_action :export_rate_limit, only: [:export, :download_export] + before_action :check_export_rate_limit!, only: [:export, :download_export] helper_method :captcha_required? @@ -59,6 +59,9 @@ class GroupsController < Groups::ApplicationController feature_category :projects, [:projects] feature_category :importers, [:export, :download_export] + urgency :high, [:unfoldered_environment_names] + urgency :low, [:merge_requests] + def index redirect_to(current_user ? dashboard_groups_path : explore_groups_path) end @@ -92,7 +95,6 @@ class GroupsController < Groups::ApplicationController if @group.import_state&.in_progress? redirect_to group_import_path(@group) else - publish_invite_members_for_task_experiment render_show_html end end @@ -312,16 +314,12 @@ class GroupsController < Groups::ApplicationController url_for(safe_params) end - def export_rate_limit + def check_export_rate_limit! prefixed_action = "group_#{params[:action]}".to_sym scope = params[:action] == :download_export ? @group : nil - if Gitlab::ApplicationRateLimiter.throttled?(prefixed_action, scope: [current_user, scope].compact) - Gitlab::ApplicationRateLimiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user) - - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests - end + check_rate_limit!(prefixed_action, scope: [current_user, scope].compact) end def ensure_export_enabled @@ -380,13 +378,6 @@ class GroupsController < Groups::ApplicationController def captcha_required? captcha_enabled? && !params[:parent_id] end - - def publish_invite_members_for_task_experiment - return unless params[:open_modal] == 'invite_members_for_task' - return unless current_user&.can?(:admin_group_member, @group) - - experiment(:invite_members_for_task, namespace: @group).publish_to_client - end end GroupsController.prepend_mod_with('GroupsController') diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index e0020c22145..f267d383804 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -59,10 +59,6 @@ class HelpController < ApplicationController @instance_configuration = InstanceConfiguration.new end - def ui - @user = User.new(id: 0, name: 'John Doe', username: '@johndoe') - end - private def path_params diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 53856e4575b..7ad3a2ee358 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -3,7 +3,7 @@ class Import::BaseController < ApplicationController include ActionView::Helpers::SanitizeHelper - before_action :import_rate_limit, only: [:create] + before_action -> { check_rate_limit!(:project_import, scope: [current_user, :project_import], redirect_back: true) }, only: [:create] feature_category :importers def status @@ -98,18 +98,4 @@ class Import::BaseController < ApplicationController def project_save_error(project) project.errors.full_messages.join(', ') end - - def import_rate_limit - key = "project_import".to_sym - - if rate_limiter.throttled?(key, scope: [current_user, key]) - rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) - - redirect_back_or_default(options: { alert: _('This endpoint has been requested too many times. Try again later.') }) - end - end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end end diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index bec26cb547d..f26c06b7e37 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -40,13 +40,9 @@ class Import::BulkImportsController < ApplicationController end def create - response = ::BulkImports::CreateService.new(current_user, create_params, credentials).execute + responses = create_params.map { |entry| ::BulkImports::CreateService.new(current_user, entry, credentials).execute } - if response.success? - render json: response.payload.to_json(only: [:id]) - else - render json: { error: response.message }, status: response.http_status - end + render json: responses.map { |response| { success: response.success?, id: response.payload[:id], message: response.message } } end def realtime_changes diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb index 503b10f766b..aca71f6d57a 100644 --- a/app/controllers/import/gitlab_groups_controller.rb +++ b/app/controllers/import/gitlab_groups_controller.rb @@ -4,7 +4,7 @@ class Import::GitlabGroupsController < ApplicationController include WorkhorseAuthorization before_action :ensure_group_import_enabled - before_action :import_rate_limit, only: %i[create] + before_action :check_import_rate_limit!, only: %i[create] feature_category :importers @@ -55,12 +55,9 @@ class Import::GitlabGroupsController < ApplicationController render_404 unless Feature.enabled?(:group_import_export, @group, default_enabled: true) end - def import_rate_limit - if Gitlab::ApplicationRateLimiter.throttled?(:group_import, scope: current_user) - Gitlab::ApplicationRateLimiter.log_request(request, :group_import_request_limit, current_user) - - flash[:alert] = _('This endpoint has been requested too many times. Try again later.') - redirect_to new_group_path + def check_import_rate_limit! + check_rate_limit!(:group_import, scope: current_user) do + redirect_to new_group_path, alert: _('This endpoint has been requested too many times. Try again later.') end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index d4b1306cc5e..2a7f2d42e2a 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -77,12 +77,6 @@ class InvitesController < ApplicationController def track_invite_join_click return unless member && initial_invite_email? - if params[:experiment_name] == 'invite_email_preview_text' - experiment(:invite_email_preview_text, actor: member).track(:join_clicked) - elsif params[:experiment_name] == 'invite_email_from' - experiment(:invite_email_from, actor: member).track(:join_clicked) - end - Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s) end @@ -104,7 +98,6 @@ class InvitesController < ApplicationController session[:invite_email] = member.invite_email session[:originating_member_id] = member.id if initial_invite_email? - session[:invite_email_experiment_name] = params[:experiment_name] if initial_invite_email? && params[:experiment_name] end def initial_invite_email? diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 9d7a1712698..dc5b22e1606 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -9,7 +9,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController after_action :verify_known_sign_in - protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true + protect_from_forgery except: [:kerberos, :saml, :cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true feature_category :authentication_and_authorization @@ -162,6 +162,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController 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) log_audit_event(user, with: oauth['provider']) set_remember_me(user) @@ -287,10 +291,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def fail_admin_mode_invalid_credentials redirect_to new_admin_session_path, alert: _('Invalid login or password') end - - def context_user - current_user - end end OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController') diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb index 6e5b18cb885..be2cb270a19 100644 --- a/app/controllers/profiles/emails_controller.rb +++ b/app/controllers/profiles/emails_controller.rb @@ -2,8 +2,10 @@ class Profiles::EmailsController < Profiles::ApplicationController before_action :find_email, only: [:destroy, :resend_confirmation_instructions] - before_action -> { rate_limit!(:profile_add_new_email) }, only: [:create] - before_action -> { rate_limit!(:profile_resend_email_confirmation) }, only: [:resend_confirmation_instructions] + before_action -> { check_rate_limit!(:profile_add_new_email, scope: current_user, redirect_back: true) }, + only: [:create] + before_action -> { check_rate_limit!(:profile_resend_email_confirmation, scope: current_user, redirect_back: true) }, + only: [:resend_confirmation_instructions] feature_category :users @@ -42,16 +44,6 @@ class Profiles::EmailsController < Profiles::ApplicationController private - def rate_limit!(action) - rate_limiter = ::Gitlab::ApplicationRateLimiter - - if rate_limiter.throttled?(action, scope: current_user) - rate_limiter.log_request(request, action, current_user) - - redirect_back_or_default(options: { alert: _('This action has been performed too many times. Try again later.') }) - end - end - def email_params params.require(:email).permit(:email) end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e607346b40e..77fae34e2d2 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -8,7 +8,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController helper_method :current_password_required? before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end feature_category :authentication_and_authorization @@ -44,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController @qr_code = build_qr_code @account_string = account_string - if Feature.enabled?(:webauthn) + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_registration else setup_u2f_registration @@ -69,7 +69,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController @error = { message: _('Invalid pin code.') } @qr_code = build_qr_code - if Feature.enabled?(:webauthn) + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_registration else setup_u2f_registration diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 6330a6aa107..e6b80f90dca 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,7 +8,7 @@ class ProfilesController < Profiles::ApplicationController before_action :authorize_change_username!, only: :update_username skip_before_action :require_email, only: [:show, :update] before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end feature_category :users diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 0d5f64c739c..cf432cfb429 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -8,6 +8,8 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController feature_category :users, [:members] feature_category :snippets, [:snippets] + urgency :low, [:merge_requests] + def members 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 0f87690bba5..57a06f26f8c 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -27,3 +27,5 @@ class Projects::BlameController < Projects::ApplicationController @blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate! end end + +Projects::BlameController.prepend_mod diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index cd50c8cf5b1..b30ef7506aa 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -44,7 +44,7 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) - push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml) push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end @@ -99,7 +99,7 @@ class Projects::BlobController < Projects::ApplicationController @content = params[:content] @blob.load_all_data! diffy = Diffy::Diff.new(@blob.data, @content, diff: '-U 3', include_diff_info: true) - diff_lines = diffy.diff.scan(/.*\n/)[2..-1] + diff_lines = diffy.diff.scan(/.*\n/)[2..] diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines).to_a @diff_lines = Gitlab::Diff::Highlight.new(diff_lines, repository: @repository).highlight @@ -298,3 +298,5 @@ class Projects::BlobController < Projects::ApplicationController experiment(:code_quality_walkthrough, namespace: @project.root_ancestor).track(:commit_created) end end + +Projects::BlobController.prepend_mod diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 7354c2c71ac..81ad6243efe 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -11,7 +11,6 @@ class Projects::BoardsController < Projects::ApplicationController push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) - push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.use { } e.try { } diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index 9dc3194df85..7ef5016ac00 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -6,6 +6,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController feature_category :pipeline_authoring respond_to :json, only: [:create] + urgency :low, [:create] def show end diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 600516f95a2..6f12e3940dd 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -9,6 +9,8 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController feature_category :pipeline_authoring + urgency :low, [:show] + def show end @@ -21,7 +23,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController def setup_walkthrough_experiment experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e| e.candidate {} - e.record! + e.publish_to_database end end end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 5154f145b46..ba83f8dad35 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -22,6 +22,8 @@ class Projects::ForksController < Projects::ApplicationController end def index + @sort = params[:sort] + @total_forks_count = project.forks.size @public_forks_count = project.forks.public_only.size @private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb new file mode 100644 index 00000000000..aff305ab7d6 --- /dev/null +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Projects::GoogleCloud::BaseController < Projects::ApplicationController + feature_category :five_minute_production_app + + before_action :admin_project_google_cloud! + before_action :google_oauth2_enabled! + before_action :feature_flag_enabled! + + private + + def admin_project_google_cloud! + access_denied! unless can?(current_user, :admin_project_google_cloud, project) + end + + def google_oauth2_enabled! + config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') + if config.app_id.blank? || config.app_secret.blank? + access_denied! 'This GitLab instance not configured for Google Oauth2.' + end + end + + def feature_flag_enabled! + access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project) + end +end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb new file mode 100644 index 00000000000..a69a744154c --- /dev/null +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::BaseController + before_action :validate_gcp_token! + + def index + @google_cloud_path = project_google_cloud_index_path(project) + google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + gcp_projects = google_api_client.list_projects + + if gcp_projects.empty? + @js_data = { screen: 'no_gcp_projects' }.to_json + render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects' + else + @js_data = { + screen: 'service_accounts_form', + gcpProjects: gcp_projects, + environments: project.environments, + cancelPath: project_google_cloud_index_path(project) + }.to_json + end + rescue Google::Apis::ClientError => error + handle_gcp_error(error, project) + end + + def create + google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + service_accounts_service = GoogleCloud::ServiceAccountsService.new(project) + gcp_project = params[:gcp_project] + environment = params[:environment] + generated_name = "GitLab :: #{@project.name} :: #{environment}" + generated_desc = "GitLab generated service account for project '#{@project.name}' and environment '#{environment}'" + + service_account = google_api_client.create_service_account(gcp_project, generated_name, generated_desc) + service_account_key = google_api_client.create_service_account_key(gcp_project, service_account.unique_id) + + service_accounts_service.add_for_project( + environment, + service_account.project_id, + service_account.to_json, + service_account_key.to_json + ) + + redirect_to project_google_cloud_index_path(project), notice: _('Service account generated successfully') + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error + handle_gcp_error(error, project) + end + + private + + def validate_gcp_token! + is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + + return if is_token_valid + + return_url = project_google_cloud_index_path(project) + state = generate_session_key_redirect(request.url, return_url) + @authorize_url = GoogleApi::CloudPlatform::Client.new(nil, + callback_google_api_auth_url, + state: state).authorize_url + redirect_to @authorize_url + end + + def generate_session_key_redirect(uri, error_uri) + GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| + session[key] = uri + session[:error_uri] = error_uri + end + end + + def token_in_session + session[GoogleApi::CloudPlatform::Client.session_key_for_token] + end + + def expires_at_in_session + session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] + end + + def handle_gcp_error(error, project) + Gitlab::ErrorTracking.track_exception(error, project_id: project.id) + @js_data = { screen: 'gcp_error', error: error.to_s }.to_json + render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error' + end +end diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb index 7257ed1ef6f..1fa8ae60376 100644 --- a/app/controllers/projects/google_cloud_controller.rb +++ b/app/controllers/projects/google_cloud_controller.rb @@ -1,34 +1,12 @@ # frozen_string_literal: true -class Projects::GoogleCloudController < Projects::ApplicationController - feature_category :google_cloud - - before_action :admin_project_google_cloud? - before_action :google_oauth2_enabled? - before_action :feature_flag_enabled? - +class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController def index @js_data = { + screen: 'home', serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, - createServiceAccountUrl: '#mocked-url-create-service', + createServiceAccountUrl: project_google_cloud_service_accounts_path(project), emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg') }.to_json end - - private - - def admin_project_google_cloud? - access_denied! unless can?(current_user, :admin_project_google_cloud, project) - end - - def google_oauth2_enabled? - config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') - if config.app_id.blank? || config.app_secret.blank? - access_denied! 'This GitLab instance not configured for Google Oauth2.' - end - end - - def feature_flag_enabled? - access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud) - end end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index c79e5a8cc85..99eba32e00f 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -6,7 +6,7 @@ class Projects::HooksController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! before_action :hook_logs, only: :edit - before_action -> { create_rate_limit(:project_testing_hook, @project) }, only: :test + before_action -> { check_rate_limit!(:project_testing_hook, scope: [@project, current_user]) }, only: :test respond_to :html diff --git a/app/controllers/projects/integrations/shimos_controller.rb b/app/controllers/projects/integrations/shimos_controller.rb new file mode 100644 index 00000000000..827dbb8f3f9 --- /dev/null +++ b/app/controllers/projects/integrations/shimos_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + module Integrations + class ShimosController < Projects::ApplicationController + feature_category :integrations + + before_action :ensure_renderable + + def show; end + + private + + def ensure_renderable + render_404 unless Feature.enabled?(:shimo_integration, project) && project.has_shimo? && project.shimo_integration&.render? + end + end + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 853e9c7ccdd..fc67cd98d15 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -10,7 +10,7 @@ class Projects::IssuesController < Projects::ApplicationController include RecordUserLastActivity ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze - SET_ISSUEABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze + SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } @@ -22,7 +22,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } - before_action :set_issuables_index, if: ->(c) { SET_ISSUEABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) } + before_action :set_issuables_index, if: ->(c) { SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) } # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -37,7 +37,9 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_download_code!, only: [:related_branches] # Limit the amount of issues created per minute - before_action :create_rate_limit, only: [:create], if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) } + before_action -> { check_rate_limit!(:issues_create, scope: [@project, @current_user])}, + only: [:create], + if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) } before_action do push_frontend_feature_flag(:tribute_autocomplete, @project) @@ -49,19 +51,9 @@ class Projects::IssuesController < Projects::ApplicationController before_action only: :show do push_frontend_feature_flag(:real_time_issue_sidebar, @project, default_enabled: :yaml) - push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) + push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) - push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_issue_discussions, @project, default_enabled: :yaml) - - experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| - experiment_instance.exclude! unless helpers.can_admin_project_member?(@project) - - experiment_instance.use {} - experiment_instance.try(:invite_member_link) {} - - experiment_instance.track(:view, property: @project.root_ancestor.id.to_s) - end end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -373,20 +365,6 @@ class Projects::IssuesController < Projects::ApplicationController project_compare_path(project, from: project.default_branch, to: branch[:name]) end - def create_rate_limit - key = :issues_create - - if rate_limiter.throttled?(key, scope: [@project, @current_user]) - rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) - - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests - end - end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end - def service_desk? action_name == 'service_desk' end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 81b8da9cba3..fa7c62c34dd 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -4,8 +4,8 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload include ContinueParams - before_action :find_job_as_build, except: [:index, :play] - before_action :find_job_as_processable, only: [:play] + before_action :find_job_as_build, except: [:index, :play, :show] + before_action :find_job_as_processable, only: [:play, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, @@ -42,7 +42,7 @@ class Projects::JobsController < Projects::ApplicationController format.json do Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: BuildSerializer + render json: Ci::JobSerializer .new(project: @project, current_user: @current_user) .represent(@build.present(current_user: current_user), {}, BuildDetailsEntity) end @@ -118,7 +118,7 @@ class Projects::JobsController < Projects::ApplicationController end def status - render json: BuildSerializer + render json: Ci::JobSerializer .new(project: @project, current_user: @current_user) .represent_status(@build.present(current_user: current_user)) end diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb index 91a43c5f03f..177533b89c8 100644 --- a/app/controllers/projects/learn_gitlab_controller.rb +++ b/app/controllers/projects/learn_gitlab_controller.rb @@ -3,6 +3,7 @@ class Projects::LearnGitlabController < Projects::ApplicationController before_action :authenticate_user! before_action :check_experiment_enabled? + before_action :enable_invite_for_help_continuous_onboarding_experiment feature_category :users @@ -14,4 +15,13 @@ class Projects::LearnGitlabController < Projects::ApplicationController def check_experiment_enabled? return access_denied! unless helpers.learn_gitlab_enabled?(project) end + + def enable_invite_for_help_continuous_onboarding_experiment + return unless current_user.can?(:admin_group_member, project.namespace) + + experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e| + e.candidate {} + e.publish_to_database + end + end end diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index a8038878504..76a233afa13 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -5,6 +5,12 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap before_action :authorize_can_resolve_conflicts! + urgency :low, [ + :show, + :conflict_for_path, + :resolve_conflicts + ] + def show respond_to do |format| format.html do diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb index 399745151b1..588fc85ff77 100644 --- a/app/controllers/projects/merge_requests/content_controller.rb +++ b/app/controllers/projects/merge_requests/content_controller.rb @@ -13,6 +13,11 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl FAST_POLLING_INTERVAL = 10.seconds.in_milliseconds SLOW_POLLING_INTERVAL = 5.minutes.in_milliseconds + urgency :low, [ + :widget, + :cached_widget + ] + def widget respond_to do |format| format.json do diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index ecc5ad1f84e..beb179f584b 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -10,6 +10,15 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] + urgency :low, [ + :new, + :create, + :pipelines, + :diffs, + :branch_from, + :branch_to + ] + def new define_new_vars end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 1188aec24a8..32ca7d779d2 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -14,6 +14,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic after_action :track_viewed_diffs_events, only: [:diffs_batch] + urgency :low, [ + :show, + :diff_for_path, + :diffs_batch, + :diffs_metadata + ] + def show render_diffs end @@ -36,6 +43,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options = { environment: environment, merge_request: @merge_request, + commit: commit, diff_view: diff_view, merge_ref_head_diff: render_merge_ref_head_diff?, pagination_data: diffs.pagination_data, diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index ca3f36cafe1..645720a0889 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -9,6 +9,13 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli before_action :authorize_admin_draft!, only: [:update, :destroy] before_action :authorize_admin_draft!, if: -> { action_name == 'publish' && params[:id].present? } + urgency :low, [ + :create, + :update, + :destroy, + :publish + ] + def index drafts = prepare_notes_for_rendering(draft_notes) render json: DraftNoteSerializer.new(current_user: current_user).represent(drafts) @@ -110,7 +117,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli def render_draft_note(note) params = { target_id: merge_request.id, target_type: 'MergeRequest', text: note.note } result = PreviewMarkdownService.new(@project, current_user, params).execute - markdown_params = { markdown_engine: result[:markdown_engine], issuable_state_filter_enabled: true } + markdown_params = { markdown_engine: result[:markdown_engine], issuable_reference_expansion_enabled: true } note.rendered_note = view_context.markdown(result[:text], markdown_params) note.users_referenced = result[:users] diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6c5a8aa0610..7133233f083 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -42,21 +42,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml) push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml) push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml) - push_frontend_feature_flag(:labels_widget, project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) push_frontend_feature_flag(:diff_searching_usage_data, @project, default_enabled: :yaml) - - experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| - experiment_instance.exclude! unless helpers.can_admin_project_member?(@project) - - experiment_instance.use {} - experiment_instance.try(:invite_member_link) {} - - experiment_instance.track(:view, property: @project.root_ancestor.id.to_s) - end end before_action do @@ -74,15 +64,28 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :show, :toggle_award_emoji, :toggle_subscription, :update ] - feature_category :code_testing, [ - :test_reports, :coverage_reports, :codequality_reports, - :codequality_mr_diff_reports - ] - + feature_category :code_testing, [:test_reports, :coverage_reports] + feature_category :code_quality, [:codequality_reports, :codequality_mr_diff_reports] feature_category :accessibility_testing, [:accessibility_reports] feature_category :infrastructure_as_code, [:terraform_reports] feature_category :continuous_integration, [:pipeline_status, :pipelines, :exposed_artifacts] + urgency :high, [:export_csv] + urgency :low, [ + :index, + :show, + :commits, + :bulk_update, + :edit, + :update, + :cancel_auto_merge, + :merge, + :ci_environments_status, + :destroy, + :rebase, + :discussions + ] + def index @merge_requests = @issuables @@ -286,7 +289,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo if merge_request.errors.present? render json: @merge_request.errors, status: :bad_request else - render json: serializer.represent(@merge_request, serializer: 'basic') + render json: serializer.represent(@merge_request, serializer: params[:serializer] || 'basic') end end end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index e8057308386..7322e08e62e 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -57,7 +57,7 @@ class Projects::NotesController < Projects::ApplicationController def outdated_line_change diff_lines = Rails.cache.fetch(['note', note.id, 'oudated_line_change'], expires_in: 7.days) do - ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: @project, note: note).execute.to_json + ::MergeRequests::OutdatedDiscussionDiffLinesService.new(project: note.noteable.source_project, note: note).execute.to_json end render json: diff_lines diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 4af7508b935..ac94cc001dd 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -3,7 +3,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] - before_action :play_rate_limit, only: [:play] + before_action :check_play_rate_limit!, only: [:play] before_action :authorize_play_pipeline_schedule!, only: [:play] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] @@ -81,19 +81,15 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController private - def play_rate_limit + def check_play_rate_limit! return unless current_user - if rate_limiter.throttled?(:play_pipeline_schedule, scope: [current_user, schedule]) + check_rate_limit!(:play_pipeline_schedule, scope: [current_user, schedule]) do flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.') redirect_to pipeline_schedules_path(@project) end end - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end - def schedule @schedule ||= project.pipeline_schedules.find(params[:id]) end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index a2312484a9b..71dc67bb6dc 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -14,13 +14,22 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] + before_action do + push_frontend_feature_flag(:jobs_tab_vue, @project, default_enabled: :yaml) + end + # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/345074 track_redis_hll_event :charts, name: 'p_analytics_pipelines' + track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? } + track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? } + track_redis_hll_event :charts, name: 'p_analytics_ci_cd_lead_time', if: -> { should_track_ci_cd_lead_time? } + wrap_parameters Ci::Pipeline POLLING_INTERVAL = 10_000 @@ -307,7 +316,7 @@ class Projects::PipelinesController < Projects::ApplicationController e.control {} e.candidate {} - e.record! + e.publish_to_database end end @@ -320,9 +329,21 @@ class Projects::PipelinesController < Projects::ApplicationController e.control {} e.candidate {} - e.record! + e.publish_to_database end end + + def should_track_ci_cd_pipelines? + params[:chart].blank? || params[:chart] == 'pipelines' + end + + def should_track_ci_cd_deployment_frequency? + params[:chart] == 'deployment-frequency' + end + + def should_track_ci_cd_lead_time? + params[:chart] == 'lead-time' + end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 312919831d4..7aebff13278 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -82,17 +82,17 @@ module Projects def create_service Projects::Prometheus::Alerts::CreateService - .new(project, current_user, alerts_params) + .new(project: project, current_user: current_user, params: alerts_params) end def update_service Projects::Prometheus::Alerts::UpdateService - .new(project, current_user, alerts_params) + .new(project: project, current_user: current_user, params: alerts_params) end def destroy_service Projects::Prometheus::Alerts::DestroyService - .new(project, current_user, nil) + .new(project: project, current_user: current_user, params: nil) end def schedule_prometheus_update! diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index e86d2490282..9707b70f26f 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -13,7 +13,7 @@ class Projects::RawController < Projects::ApplicationController before_action :set_ref_and_path before_action :require_non_empty_project before_action :authorize_download_code! - before_action :show_rate_limit, only: [:show], unless: :external_storage_request? + before_action :check_show_rate_limit!, only: [:show], unless: :external_storage_request? before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? feature_category :source_code_management @@ -33,21 +33,11 @@ class Projects::RawController < Projects::ApplicationController @ref, @path = extract_ref(get_id) end - def show_rate_limit - if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @path], threshold: raw_blob_request_limit) - rate_limiter.log_request(request, :raw_blob_request_limit, current_user) - + def check_show_rate_limit! + check_rate_limit!(:raw_blob, scope: [@project, @path]) do render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests end end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter - end - - def raw_blob_request_limit - Gitlab::CurrentSettings - .current_application_settings - .raw_blob_request_limit - end end + +Projects::RawController.prepend_mod diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 8beebb52980..77826a2f789 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -3,16 +3,16 @@ class Projects::RepositoriesController < Projects::ApplicationController include ExtractsPath include StaticObjectExternalStorage - include Gitlab::RateLimitHelpers include HotlinkInterceptor + include Gitlab::RepositoryArchiveRateLimiter prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) } skip_before_action :default_cache_headers, only: :archive # Authorize + before_action :check_archive_rate_limiting!, only: :archive before_action :require_non_empty_project, except: :create - before_action :archive_rate_limit!, only: :archive before_action :intercept_hotlinking!, only: :archive before_action :assign_archive_vars, only: :archive before_action :assign_append_sha, only: :archive @@ -42,12 +42,6 @@ class Projects::RepositoriesController < Projects::ApplicationController private - def archive_rate_limit! - if archive_rate_limit_reached?(current_user, @project) - render plain: ::Gitlab::RateLimitHelpers::ARCHIVE_RATE_LIMIT_REACHED_MESSAGE, status: :too_many_requests - end - end - def repo_params @repo_params ||= { ref: @ref, path: params[:path], format: params[:format], append_sha: @append_sha } end @@ -125,6 +119,12 @@ class Projects::RepositoriesController < Projects::ApplicationController [path, nil] end end + + def check_archive_rate_limiting! + check_archive_rate_limit!(current_user, @project) do + render(plain: _('This archive has been requested too many times. Try again later.'), status: :too_many_requests) + end + end end Projects::RepositoriesController.prepend_mod_with('Projects::RepositoriesController') diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index e841c3e3d49..62a9f8a4625 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -4,8 +4,6 @@ class Projects::RunnersController < Projects::ApplicationController before_action :authorize_admin_build! before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] - layout 'project_settings' - feature_category :runner def index diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 4fe37352995..ef6c10d43cd 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -9,10 +9,10 @@ module Projects layout 'project_settings' before_action :authorize_admin_pipeline! + before_action :check_builds_available! before_action :define_variables before_action do push_frontend_feature_flag(:ajax_new_deploy_token, @project) - push_frontend_feature_flag(:ci_scoped_job_token, @project, default_enabled: :yaml) end helper_method :highlight_badge diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index f8f2c1f0836..660ebcc30d3 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -60,3 +60,5 @@ class Projects::TreeController < Projects::ApplicationController } end end + +Projects::TreeController.prepend_mod diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb index b319e427eaa..680874ffee4 100644 --- a/app/controllers/projects/usage_quotas_controller.rb +++ b/app/controllers/projects/usage_quotas_controller.rb @@ -9,14 +9,5 @@ class Projects::UsageQuotasController < Projects::ApplicationController def index @hide_search_settings = true - @storage_app_data = { - project_path: @project.full_path, - usage_quotas_help_page_path: help_page_path('user/usage_quotas'), - build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'), - packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'), - repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'), - snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'), - wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size') - } end end diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index f93c75a203e..e7bccf5a243 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -5,6 +5,8 @@ class Projects::VariablesController < Projects::ApplicationController feature_category :pipeline_authoring + urgency :low, [:show, :update] + def show respond_to do |format| format.json do diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 5b17b75a963..04dde5ef7b2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -30,14 +30,15 @@ class ProjectsController < Projects::ApplicationController before_action :event_filter, only: [:show, :activity] # Project Export Rate Limit - before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] + before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export] before_action do push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) - push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:highlight_js, @project, default_enabled: :yaml) push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml) push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml) + push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) end layout :determine_layout @@ -51,7 +52,9 @@ class ProjectsController < Projects::ApplicationController feature_category :team_planning, [:preview_markdown, :new_issuable_address] feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export] feature_category :code_review, [:unfoldered_environment_names] + urgency :low, [:refs] + urgency :high, [:unfoldered_environment_names] def index redirect_to(current_user ? root_path : explore_root_path) @@ -116,7 +119,10 @@ class ProjectsController < Projects::ApplicationController if @project.errors[:new_namespace].present? flash[:alert] = @project.errors[:new_namespace].first + return redirect_to edit_project_path(@project) end + + redirect_to edit_project_path(@project) end # rubocop: enable CodeReuse/ActiveRecord @@ -126,6 +132,8 @@ class ProjectsController < Projects::ApplicationController if ::Projects::UnlinkForkService.new(@project, current_user).execute flash[:notice] = _('The fork relationship has been removed.') end + + redirect_to edit_project_path(@project) end def activity @@ -452,6 +460,7 @@ class ProjectsController < Projects::ApplicationController :packages_enabled, :service_desk_enabled, :merge_commit_template, + :squash_commit_template, project_setting_attributes: project_setting_attributes ] + [project_feature_attributes: project_feature_attributes] end @@ -535,20 +544,12 @@ class ProjectsController < Projects::ApplicationController @project = @project.present(current_user: current_user) end - def export_rate_limit + def check_export_rate_limit! prefixed_action = "project_#{params[:action]}".to_sym project_scope = params[:action] == 'download_export' ? @project : nil - if rate_limiter.throttled?(prefixed_action, scope: [current_user, project_scope].compact) - rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user) - - render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests - end - end - - def rate_limiter - ::Gitlab::ApplicationRateLimiter + check_rate_limit!(prefixed_action, scope: [current_user, project_scope].compact) end def render_edit diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 39d3125a4a3..41fd1b7a1e6 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -72,16 +72,10 @@ module Registrations end def show_tasks_to_be_done? - return unless experiment(:invite_members_for_task).enabled? - MemberTask.for_members(current_user.members).exists? end # overridden in EE - def trial_params - end - - # overridden in EE def update_success_path end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 450c12a233b..ed3facd72c5 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -210,9 +210,6 @@ class RegistrationsController < Devise::RegistrationsController return unless member - experiment_name = session.delete(:invite_email_experiment_name) - experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text' - experiment(:invite_email_from, actor: member).track(:accepted) if experiment_name == 'invite_email_from' Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s) end diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index b3adda8c633..c002c9b83f9 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -8,12 +8,9 @@ module Repositories attr_reader :authentication_result, :redirected_path - delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true + delegate :authentication_abilities, to: :authentication_result, allow_nil: true delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result - alias_method :user, :actor - alias_method :authenticated_user, :actor - # Git clients will not know what authenticity token to send along skip_around_action :set_session_storage skip_before_action :verify_authenticity_token @@ -22,8 +19,16 @@ module Repositories feature_category :source_code_management + def authenticated_user + authentication_result&.user || authentication_result&.deploy_token + end + private + def user + authenticated_user + end + def download_request? raise NotImplementedError end diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 30cafb6747e..d93d88c9e64 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -76,7 +76,10 @@ module Repositories existing_oids = project.lfs_objects_oids(oids: objects_oids) objects.each do |object| - object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid]) + next if existing_oids.include?(object[:oid]) + next if should_auto_link? && oids_from_fork.include?(object[:oid]) && link_to_project!(object) + + object[:actions] = upload_actions(object) end objects @@ -150,6 +153,34 @@ module Repositories Gitlab::LfsToken.new(user).basic_encoding end + + def should_auto_link? + return false unless Feature.enabled?(:lfs_auto_link_fork_source, project) + return false unless project.forked? + + # Sanity check in case for some reason the user doesn't have access to the parent + can?(user, :download_code, project.fork_source) + end + + def oids_from_fork + @oids_from_fork ||= project.lfs_objects_oids_from_fork_source(oids: objects_oids) + end + + def link_to_project!(object) + lfs_object = LfsObject.for_oid_and_size(object[:oid], object[:size]) + + return unless lfs_object + + LfsObjectsProject.link_to_project!(lfs_object, project) + + Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project", + lfs_object_oid: lfs_object.oid, + lfs_object_size: lfs_object.size, + source_project_id: project.fork_source.id, + source_project_path: project.fork_source.full_path, + target_project_id: project.project_id, + target_project_path: project.full_path) + end end end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 0e285dae089..99a6dfa811e 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -5,14 +5,13 @@ class SearchController < ApplicationController include SearchHelper include RedisTracking - RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show].freeze + RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze track_redis_hll_event :show, name: 'i_search_total' around_action :allow_gitaly_ref_name_caching before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch - before_action :strip_surrounding_whitespace_from_search, except: :opensearch skip_before_action :authenticate_user! requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? @@ -74,11 +73,7 @@ class SearchController < ApplicationController def autocomplete term = params[:term] - if params[:project_id].present? - @project = Project.find_by(id: params[:project_id]) - @project = nil unless can?(current_user, :read_project, @project) - end - + @project = search_service.project @ref = params[:project_ref] if params[:project_ref].present? render json: search_autocomplete_opts(term).to_json @@ -97,12 +92,12 @@ class SearchController < ApplicationController def search_term_valid? unless search_service.valid_query_length? - flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT) + flash[:alert] = t('errors.messages.search_chars_too_long', count: Gitlab::Search::Params::SEARCH_CHAR_LIMIT) return false end unless search_service.valid_terms_count? - flash[:alert] = t('errors.messages.search_terms_too_long', count: SearchService::SEARCH_TERM_LIMIT) + flash[:alert] = t('errors.messages.search_terms_too_long', count: Gitlab::Search::Params::SEARCH_TERM_LIMIT) return false end @@ -147,10 +142,15 @@ class SearchController < ApplicationController payload[:metadata]['meta.search.filters.confidential'] = params[:confidential] payload[:metadata]['meta.search.filters.state'] = params[:state] payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results] + + if search_service.abuse_detected? + payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain) + payload[:metadata]['abuse.messages'] = search_service.abuse_messages + end end def block_anonymous_global_searches - return if params[:project_id].present? || params[:group_id].present? + return unless search_service.global_search? return if current_user return unless ::Feature.enabled?(:block_anonymous_global_searches, type: :ops) @@ -160,7 +160,7 @@ class SearchController < ApplicationController end def check_scope_global_search_enabled - return if params[:project_id].present? || params[:group_id].present? + return unless search_service.global_search? search_allowed = case params[:scope] when 'blobs' @@ -189,20 +189,15 @@ class SearchController < ApplicationController @timeout = true - if count_action_name? + case action_name.to_sym + when :count render json: {}, status: :request_timeout + when :autocomplete + render json: [], status: :request_timeout else render status: :request_timeout end end - - def count_action_name? - action_name.to_sym == :count - end - - def strip_surrounding_whitespace_from_search - %i(term search).each { |param| params[param]&.strip! } - end end SearchController.prepend_mod_with('SearchController') diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index bbd7e5d5725..7e8e3ea8789 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -32,7 +32,7 @@ class SessionsController < Devise::SessionsController before_action :load_recaptcha before_action :set_invite_params, only: [:new] before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end after_action :log_failed_login, if: :action_new_and_failed_login? @@ -84,6 +84,8 @@ class SessionsController < Devise::SessionsController end def destroy + headers['Clear-Site-Data'] = '"*"' + Gitlab::AppLogger.info("User Logout: username=#{current_user.username} ip=#{request.remote_ip}") super # hide the signed_out notice @@ -303,9 +305,9 @@ class SessionsController < Devise::SessionsController def authentication_method if user_params[:otp_attempt] AuthenticationEvent::TWO_FACTOR - elsif user_params[:device_response] && Feature.enabled?(:webauthn) + elsif user_params[:device_response] && Feature.enabled?(:webauthn, default_enabled: :yaml) AuthenticationEvent::TWO_FACTOR_WEBAUTHN - elsif user_params[:device_response] && !Feature.enabled?(:webauthn) + elsif user_params[:device_response] && !Feature.enabled?(:webauthn, default_enabled: :yaml) AuthenticationEvent::TWO_FACTOR_U2F else AuthenticationEvent::STANDARD diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb deleted file mode 100644 index f52a09adf5a..00000000000 --- a/app/controllers/user_callouts_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class UserCalloutsController < ApplicationController - feature_category :navigation - - def create - if callout.persisted? - respond_to do |format| - format.json { head :ok } - end - else - respond_to do |format| - format.json { head :bad_request } - end - end - end - - private - - def callout - Users::DismissUserCalloutService.new( - container: nil, current_user: current_user, params: { feature_name: feature_name } - ).execute - end - - def feature_name - params.require(:feature_name) - end -end diff --git a/app/controllers/users/callouts_controller.rb b/app/controllers/users/callouts_controller.rb new file mode 100644 index 00000000000..fe308d9dd1e --- /dev/null +++ b/app/controllers/users/callouts_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Users + class CalloutsController < ApplicationController + feature_category :navigation + + def create + if callout.persisted? + respond_to do |format| + format.json { head :ok } + end + else + respond_to do |format| + format.json { head :bad_request } + end + end + end + + private + + def callout + Users::DismissCalloutService.new( + container: nil, current_user: current_user, params: { feature_name: feature_name } + ).execute + end + + def feature_name + params.require(:feature_name) + end + end +end diff --git a/app/controllers/users/group_callouts_controller.rb b/app/controllers/users/group_callouts_controller.rb index cc27452e6a3..abca12ccea7 100644 --- a/app/controllers/users/group_callouts_controller.rb +++ b/app/controllers/users/group_callouts_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class GroupCalloutsController < UserCalloutsController + class GroupCalloutsController < Users::CalloutsController private def callout diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb index 7fbf0faa68b..f0d95b56d33 100644 --- a/app/controllers/users/terms_controller.rb +++ b/app/controllers/users/terms_controller.rb @@ -3,6 +3,7 @@ module Users class TermsController < ApplicationController include InternalRedirect + include OneTrustCSP skip_before_action :authenticate_user!, only: [:index] skip_before_action :enforce_terms! diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 37d87baf30b..859716b4739 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -13,7 +13,6 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp super publish_to_client - publish_to_database if @record end def publish_to_client @@ -25,6 +24,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp end def publish_to_database + ActiveSupport::Deprecation.warn('publish_to_database is deprecated and should not be used for reporting anymore') + return unless should_track? # if the context contains a namespace, group, project, user, or actor @@ -32,18 +33,18 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor] return unless ExperimentSubject.valid_subject?(subject) - variant = :experimental if @variant_name != :control - Experiment.add_subject(name, variant: variant || :control, subject: subject) - end - - def record! - @record = true + variant_name = :experimental if variant&.name != 'control' + Experiment.add_subject(name, variant: variant_name || :control, subject: subject) end def control_behavior # define a default nil control behavior so we can omit it when not needed end + def track(action, **event_args) + super(action, **tracking_context.merge(event_args)) + end + # TODO: remove # This is deprecated logic as of v0.6.0 and should eventually be removed, but # needs to stay intact for actively running experiments. The new strategy @@ -57,8 +58,25 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp Digest::MD5.hexdigest(ingredients.join('|')) end + def nest_experiment(other) + instance_exec(:nested, { label: other.name }, &Configuration.tracking_behavior) + end + private + def tracking_context + { + namespace: context.try(:namespace) || context.try(:group), + project: context.try(:project), + user: user_or_actor + }.compact || {} + end + + def user_or_actor + actor = context.try(:actor) + actor.respond_to?(:id) ? actor : context.try(:user) + end + def feature_flag_name name.tr('/', '_') end diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb index da699449d77..08c015838db 100644 --- a/app/experiments/combined_registration_experiment.rb +++ b/app/experiments/combined_registration_experiment.rb @@ -7,14 +7,12 @@ class CombinedRegistrationExperiment < ApplicationExperiment # rubocop:disable G super(source, 'force_company_trial') end - def redirect_path(trial_params) - @trial_params = trial_params - + def redirect_path run end def control_behavior - new_users_sign_up_group_path(@trial_params) + new_users_sign_up_group_path end def candidate_behavior diff --git a/app/experiments/new_project_readme_content_experiment.rb b/app/experiments/new_project_readme_content_experiment.rb index d9f0fb3b93e..1de7632268d 100644 --- a/app/experiments/new_project_readme_content_experiment.rb +++ b/app/experiments/new_project_readme_content_experiment.rb @@ -6,7 +6,7 @@ class NewProjectReadmeContentExperiment < ApplicationExperiment # rubocop:disabl def run_with(project, variant: nil) @project = project - record! + publish_to_database run(variant) end diff --git a/app/experiments/new_project_sast_enabled_experiment.rb b/app/experiments/new_project_sast_enabled_experiment.rb index 1ab86d70134..b7b4552f0cc 100644 --- a/app/experiments/new_project_sast_enabled_experiment.rb +++ b/app/experiments/new_project_sast_enabled_experiment.rb @@ -12,4 +12,7 @@ class NewProjectSastEnabledExperiment < ApplicationExperiment # rubocop:disable def free_indicator_behavior end + + def unchecked_candidate_behavior + end end diff --git a/app/finders/ci/auth_job_finder.rb b/app/finders/ci/auth_job_finder.rb index d207a522aa8..2dbdcb3c472 100644 --- a/app/finders/ci/auth_job_finder.rb +++ b/app/finders/ci/auth_job_finder.rb @@ -16,7 +16,7 @@ module Ci validate_job!(job) - if job.user && Feature.enabled?(:ci_scoped_job_token, job.project, default_enabled: :yaml) + if job.user job.user.set_ci_job_token_scope!(job) end end diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 8bc2a47a024..5d597f94f72 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -15,6 +15,7 @@ module Ci def execute search! + filter_by_active! filter_by_status! filter_by_runner_type! filter_by_tag_list! @@ -60,6 +61,10 @@ module Ci end end + def filter_by_active! + @runners = @runners.active(@params[:active]) if @params.include?(:active) + end + def filter_by_status! filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES) end diff --git a/app/finders/environments/environments_by_deployments_finder.rb b/app/finders/environments/environments_by_deployments_finder.rb index a41cfcb37e4..2716c80ea6e 100644 --- a/app/finders/environments/environments_by_deployments_finder.rb +++ b/app/finders/environments/environments_by_deployments_finder.rb @@ -12,29 +12,18 @@ module Environments # rubocop: disable CodeReuse/ActiveRecord def execute - deployments = project.deployments deployments = if ref deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref' - deployments.where(deployments_query, ref: ref.to_s) + Deployment.where(deployments_query, ref: ref.to_s) elsif commit - deployments.where(sha: commit.sha) + Deployment.where(sha: commit.sha) else - deployments.none + Deployment.none end - environments = - if Feature.enabled?(:environments_by_deployments_finder_exists_optimization, default_enabled: :yaml) - project.environments.available - .where('EXISTS (?)', deployments.where('environment_id = environments.id')) - else - environment_ids = deployments - .group(:environment_id) - .select(:environment_id) - - project.environments.available - .where(id: environment_ids) - end + environments = project.environments.available + .where('EXISTS (?)', deployments.where('environment_id = environments.id')) if params[:find_latest] find_one(environments.order_by_last_deployed_at_desc) diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 18ccea330af..7974710e67b 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -87,9 +87,13 @@ class GroupDescendantsFinder visible_to_user = visible_to_user.or(authorized_to_user) end - hierarchy_for_parent - .descendants - .where(visible_to_user) + group_to_query = if Feature.enabled?(:linear_group_descendants_finder, current_user, default_enabled: :yaml) + parent_group + else + hierarchy_for_parent + end + + group_to_query.descendants.where(visible_to_user) # rubocop: enable CodeReuse/Finder end # rubocop: enable CodeReuse/ActiveRecord @@ -155,7 +159,13 @@ class GroupDescendantsFinder # rubocop: disable CodeReuse/ActiveRecord def projects_matching_filter # rubocop: disable CodeReuse/Finder - projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id)) + objects_in_hierarchy = if Feature.enabled?(:linear_group_descendants_finder, current_user, default_enabled: :yaml) + parent_group.self_and_descendants.as_ids + else + hierarchy_for_parent.base_and_descendants.select(:id) + end + + projects_nested_in_group = Project.where(namespace_id: objects_in_hierarchy) params_with_search = params.merge(search: params[:filter]) ProjectsFinder.new(params: params_with_search, diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 7ea3362fba1..7e3cdd79a4c 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -52,7 +52,16 @@ class GroupsFinder < UnionFinder return [Group.all] if current_user&.can_read_all_resources? && all_available? groups = [] - groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user + + if current_user + if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml) + groups << current_user.authorized_groups.self_and_ancestors + groups << current_user.groups.self_and_descendants + else + groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects + end + end + groups << Group.unscoped.public_to_user(current_user) if include_public_groups? groups << Group.none if groups.empty? groups @@ -72,9 +81,13 @@ class GroupsFinder < UnionFinder .groups .where('members.access_level >= ?', params[:min_access_level]) - Gitlab::ObjectHierarchy - .new(groups) - .base_and_descendants + if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml) + groups.self_and_descendants + else + Gitlab::ObjectHierarchy + .new(groups) + .base_and_descendants + end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7b0cd17a761..3e436f30971 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -35,6 +35,8 @@ # updated_before: datetime # attempt_group_search_optimizations: boolean # attempt_project_search_optimizations: boolean +# crm_contact_id: integer +# crm_organization_id: integer # class IssuableFinder prepend FinderWithCrossProjectAccess @@ -59,6 +61,8 @@ class IssuableFinder assignee_username author_id author_username + crm_contact_id + crm_organization_id label_name milestone_title release_tag @@ -138,7 +142,9 @@ class IssuableFinder items = by_milestone(items) items = by_release(items) items = by_label(items) - by_my_reaction_emoji(items) + items = by_my_reaction_emoji(items) + items = by_crm_contact(items) + by_crm_organization(items) end def should_filter_negated_args? @@ -463,6 +469,14 @@ class IssuableFinder params[:non_archived].present? ? items.non_archived : items end + def by_crm_contact(items) + Issuables::CrmContactFilter.new(params: original_params).filter(items) + end + + def by_crm_organization(items) + Issuables::CrmOrganizationFilter.new(params: original_params).filter(items) + end + def or_filters_enabled? strong_memoize(:or_filters_enabled) do Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml) diff --git a/app/finders/issuables/crm_contact_filter.rb b/app/finders/issuables/crm_contact_filter.rb new file mode 100644 index 00000000000..bea5f7d2bfa --- /dev/null +++ b/app/finders/issuables/crm_contact_filter.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Issuables + class CrmContactFilter < BaseFilter + def filter(issuables) + by_crm_contact(issuables) + end + + # rubocop: disable CodeReuse/ActiveRecord + def by_crm_contact(issuables) + return issuables if params[:crm_contact_id].blank? + + condition = CustomerRelations::IssueContact + .where(contact_id: params[:crm_contact_id]) + .where(Arel.sql("issue_id = issues.id")) + issuables.where(condition.arel.exists) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/finders/issuables/crm_organization_filter.rb b/app/finders/issuables/crm_organization_filter.rb new file mode 100644 index 00000000000..f746049c405 --- /dev/null +++ b/app/finders/issuables/crm_organization_filter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Issuables + class CrmOrganizationFilter < BaseFilter + def filter(issuables) + by_crm_organization(issuables) + end + + # rubocop: disable CodeReuse/ActiveRecord + def by_crm_organization(issuables) + return issuables if params[:crm_organization_id].blank? + + condition = CustomerRelations::IssueContact + .joins(:contact) + .where(contact: { organization_id: params[:crm_organization_id] }) + .where(Arel.sql("issue_id = issues.id")) + issuables.where(condition.arel.exists) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 13696add965..ba709d3bdfc 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -174,8 +174,8 @@ class MergeRequestsFinder < IssuableFinder def by_deployments(items) env = params[:environment] - before = params[:deployed_before] - after = params[:deployed_after] + before = parse_datetime(params[:deployed_before]) + after = parse_datetime(params[:deployed_after]) id = params[:deployment_id] return items if !env && !before && !after && !id @@ -218,6 +218,13 @@ class MergeRequestsFinder < IssuableFinder items.none end end + + def parse_datetime(input) + # To work around http://www.ruby-lang.org/en/news/2021/11/15/date-parsing-method-regexp-dos-cve-2021-41817/ + DateTime.parse(input.byteslice(0, 128)) if input + rescue Date::Error + nil + end end MergeRequestsFinder.prepend_mod_with('MergeRequestsFinder') diff --git a/app/finders/packages/build_infos_finder.rb b/app/finders/packages/build_infos_finder.rb new file mode 100644 index 00000000000..92ad5888eb9 --- /dev/null +++ b/app/finders/packages/build_infos_finder.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Packages + class BuildInfosFinder + MAX_PAGE_SIZE = 100 + + def initialize(package, params) + @package = package + @params = params + end + + def execute + build_infos = @package.build_infos.without_empty_pipelines + build_infos = apply_order(build_infos) + build_infos = apply_limit(build_infos) + apply_cursor(build_infos) + end + + private + + def apply_order(build_infos) + order_direction = :desc + order_direction = :asc if last + + build_infos.order_by_pipeline_id(order_direction) + end + + def apply_limit(build_infos) + limit = [first, last, max_page_size, MAX_PAGE_SIZE].compact.min + limit += 1 if support_next_page + build_infos.limit(limit) + end + + def apply_cursor(build_infos) + if before + build_infos.with_pipeline_id_greater_than(before) + elsif after + build_infos.with_pipeline_id_less_than(after) + else + build_infos + end + end + + def first + @params[:first] + end + + def last + @params[:last] + end + + def max_page_size + @params[:max_page_size] + end + + def before + @params[:before] + end + + def after + @params[:after] + end + + def support_next_page + @params[:support_next_page] + end + end +end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index 2a62dd5c0e5..23b0e71d836 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -40,10 +40,14 @@ module Packages # access to packages is ruled by: # - project is public or the current user has access to it with at least the reporter level # - the repository feature is available to the current_user - ::Project - .in_namespace(groups) - .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER) - .with_feature_available_for_user(:repository, current_user) + if current_user.is_a?(DeployToken) + current_user.accessible_projects + else + ::Project + .in_namespace(groups) + .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER) + .with_feature_available_for_user(:repository, current_user) + end end def groups diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb index e7094d73905..a01465a64d2 100644 --- a/app/finders/personal_projects_finder.rb +++ b/app/finders/personal_projects_finder.rb @@ -28,6 +28,7 @@ class PersonalProjectsFinder < UnionFinder private def all_projects(current_user) + return [@user.personal_projects] if current_user && current_user.can_read_all_resources? return [projects_with_min_access_level(current_user)] if current_user && min_access_level? projects = [] diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb index 4ad9d1d7bf4..c2af581dd14 100644 --- a/app/finders/user_group_notification_settings_finder.rb +++ b/app/finders/user_group_notification_settings_finder.rb @@ -8,7 +8,12 @@ class UserGroupNotificationSettingsFinder def execute # rubocop: disable CodeReuse/ActiveRecord - groups_with_ancestors = Gitlab::ObjectHierarchy.new(Group.where(id: groups.select(:id))).base_and_ancestors + selected_groups = Group.where(id: groups.select(:id)) + groups_with_ancestors = if Feature.enabled?(:linear_user_group_notification_settings_finder_ancestors_scopes, user, default_enabled: :yaml) + selected_groups.self_and_ancestors + else + Gitlab::ObjectHierarchy.new(selected_groups).base_and_ancestors + end # rubocop: enable CodeReuse/ActiveRecord @loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id) diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 671c7c2cd25..290cd4d7146 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -4,4 +4,8 @@ module GraphqlTriggers def self.issuable_assignees_updated(issuable) GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) end + + def self.issue_crm_contacts_updated(issue) + GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue) + end end diff --git a/app/graphql/mutations/issues/set_crm_contacts.rb b/app/graphql/mutations/issues/set_crm_contacts.rb index 7a9e6237eaa..4e49a45d52a 100644 --- a/app/graphql/mutations/issues/set_crm_contacts.rb +++ b/app/graphql/mutations/issues/set_crm_contacts.rb @@ -5,7 +5,7 @@ module Mutations class SetCrmContacts < Base graphql_name 'IssueSetCrmContacts' - argument :crm_contact_ids, + argument :contact_ids, [::Types::GlobalIDType[::CustomerRelations::Contact]], required: true, description: 'Customer relations contact IDs to set. Replaces existing contacts by default.' @@ -15,27 +15,27 @@ module Mutations required: false, description: 'Changes the operation mode. Defaults to REPLACE.' - def resolve(project_path:, iid:, crm_contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + def resolve(project_path:, iid:, contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml) - crm_contact_ids = crm_contact_ids.compact.map do |crm_contact_id| - raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{crm_contact_id} is invalid." unless crm_contact_id.respond_to?(:model_id) + contact_ids = contact_ids.compact.map do |contact_id| + raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{contact_id} is invalid." unless contact_id.respond_to?(:model_id) - crm_contact_id.model_id.to_i + contact_id.model_id.to_i end attribute_name = case operation_mode when Types::MutationOperationModeEnum.enum[:append] - :add_crm_contact_ids + :add_ids when Types::MutationOperationModeEnum.enum[:remove] - :remove_crm_contact_ids + :remove_ids else - :crm_contact_ids + :replace_ids end - response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => crm_contact_ids }) + response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => contact_ids }) .execute(issue) { diff --git a/app/graphql/mutations/jira_import/start.rb b/app/graphql/mutations/jira_import/start.rb index 143a9558e38..4929d6f394a 100644 --- a/app/graphql/mutations/jira_import/start.rb +++ b/app/graphql/mutations/jira_import/start.rb @@ -14,15 +14,15 @@ module Mutations null: true, description: 'Jira import data after mutation.' - argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Project to import the Jira project into.' argument :jira_project_key, GraphQL::Types::String, required: true, description: 'Project key of the importer Jira project.' argument :jira_project_name, GraphQL::Types::String, required: false, description: 'Project name of the importer Jira project.' + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project to import the Jira project into.' argument :users_mapping, [Types::JiraUsersMappingInputType], required: false, diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb index d16b2327f2d..7ce850901af 100644 --- a/app/graphql/mutations/merge_requests/accept.rb +++ b/app/graphql/mutations/merge_requests/accept.rb @@ -26,12 +26,12 @@ module Mutations argument :commit_message, ::GraphQL::Types::String, required: false, description: 'Custom merge commit message.' - argument :squash_commit_message, ::GraphQL::Types::String, - required: false, - description: 'Custom squash commit message (if squash is true).' argument :sha, ::GraphQL::Types::String, required: true, description: 'HEAD SHA at the time when this merge was requested.' + argument :squash_commit_message, ::GraphQL::Types::String, + required: false, + description: 'Custom squash commit message (if squash is true).' argument :should_remove_source_branch, ::GraphQL::Types::Boolean, required: false, diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb index ff6e5cd28dd..1be99ea0ecd 100644 --- a/app/graphql/mutations/user_callouts/create.rb +++ b/app/graphql/mutations/user_callouts/create.rb @@ -15,7 +15,7 @@ module Mutations description: 'User callout dismissed.' def resolve(feature_name:) - callout = Users::DismissUserCalloutService.new( + callout = Users::DismissCalloutService.new( container: nil, current_user: current_user, params: { feature_name: feature_name } ).execute errors = errors_on_object(callout) diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql index df0b590acac..40e2934a038 100644 --- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql +++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql @@ -10,6 +10,7 @@ query getProjectContainerRepositories( ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { __typename + id containerRepositoriesCount containerRepositories( name: $name @@ -43,6 +44,7 @@ query getProjectContainerRepositories( } group(fullPath: $fullPath) @include(if: $isGroupPage) { __typename + id containerRepositoriesCount containerRepositories( name: $name diff --git a/app/graphql/queries/design_management/design_permissions.query.graphql b/app/graphql/queries/design_management/design_permissions.query.graphql index 55dfa35129c..a81afd47625 100644 --- a/app/graphql/queries/design_management/design_permissions.query.graphql +++ b/app/graphql/queries/design_management/design_permissions.query.graphql @@ -4,6 +4,7 @@ query permissions($fullPath: ID!, $iid: String!) { id issue(iid: $iid) { __typename + id userPermissions { __typename createDesign diff --git a/app/graphql/queries/design_management/get_design_list.query.graphql b/app/graphql/queries/design_management/get_design_list.query.graphql index 01503a9572f..f0caa7c5b4c 100644 --- a/app/graphql/queries/design_management/get_design_list.query.graphql +++ b/app/graphql/queries/design_management/get_design_list.query.graphql @@ -4,6 +4,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { id issue(iid: $iid) { __typename + id designCollection { __typename copyState diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql deleted file mode 100644 index be82813dddb..00000000000 --- a/app/graphql/queries/epic/epic_children.query.graphql +++ /dev/null @@ -1,132 +0,0 @@ -fragment PageInfo on PageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor -} - -fragment RelatedTreeBaseEpic on Epic { - id - iid - title - webPath - relativePosition - userPermissions { - __typename - adminEpic - createEpic - } - descendantWeightSum { - closedIssues - openedIssues - } - descendantCounts { - __typename - openedEpics - closedEpics - openedIssues - closedIssues - } - healthStatus { - __typename - issuesAtRisk - issuesOnTrack - issuesNeedingAttention - } -} - -fragment EpicNode on Epic { - ...RelatedTreeBaseEpic - state - reference(full: true) - relationPath - createdAt - closedAt - confidential - hasChildren - hasIssues - group { - __typename - fullPath - } -} - -query childItems( - $fullPath: ID! - $iid: ID - $pageSize: Int = 100 - $epicEndCursor: String = "" - $issueEndCursor: String = "" -) { - group(fullPath: $fullPath) { - __typename - id - path - fullPath - epic(iid: $iid) { - __typename - ...RelatedTreeBaseEpic - children(first: $pageSize, after: $epicEndCursor) { - __typename - edges { - __typename - node { - __typename - ...EpicNode - } - } - pageInfo { - __typename - ...PageInfo - } - } - issues(first: $pageSize, after: $issueEndCursor) { - __typename - edges { - __typename - node { - __typename - iid - epicIssueId - title - blocked - closedAt - state - createdAt - confidential - dueDate - weight - webPath - reference(full: true) - relationPath - relativePosition - assignees { - __typename - edges { - __typename - node { - __typename - webUrl - name - username - avatarUrl - } - } - } - milestone { - __typename - title - startDate - dueDate - } - healthStatus - } - } - pageInfo { - __typename - ...PageInfo - } - } - } - } -} diff --git a/app/graphql/queries/epic/epic_details.query.graphql b/app/graphql/queries/epic/epic_details.query.graphql index 406d630b180..eb4757a845a 100644 --- a/app/graphql/queries/epic/epic_details.query.graphql +++ b/app/graphql/queries/epic/epic_details.query.graphql @@ -1,14 +1,17 @@ query epicDetails($fullPath: ID!, $iid: ID!) { group(fullPath: $fullPath) { __typename + id epic(iid: $iid) { __typename + id participants { __typename edges { __typename node { __typename + id name avatarUrl webUrl diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql index 4e4caa1e27c..dd5c9e07488 100644 --- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql +++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql @@ -5,16 +5,19 @@ fragment LinkedPipelineData on Pipeline { path status: detailedStatus { __typename + id group label icon } sourceJob { __typename + id name } project { __typename + id name fullPath } @@ -23,6 +26,7 @@ fragment LinkedPipelineData on Pipeline { query getPipelineDetails($projectPath: ID!, $iid: ID!) { project(fullPath: $projectPath) { __typename + id pipeline(iid: $iid) { __typename id @@ -45,11 +49,14 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) { __typename nodes { __typename + id name status: detailedStatus { __typename + id action { __typename + id icon path title @@ -59,8 +66,10 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) { __typename nodes { __typename + id status: detailedStatus { __typename + id label group icon @@ -71,17 +80,20 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) { __typename nodes { __typename + id name scheduledAt needs { __typename nodes { __typename + id name } } status: detailedStatus { __typename + id icon tooltip hasDetails @@ -89,6 +101,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) { group action { __typename + id buttonTitle icon path diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql index ab8cbcb8aa3..150f59832f3 100644 --- a/app/graphql/queries/releases/all_releases.query.graphql +++ b/app/graphql/queries/releases/all_releases.query.graphql @@ -11,6 +11,7 @@ query allReleases( ) { project(fullPath: $fullPath) { __typename + id releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { __typename nodes { @@ -50,6 +51,7 @@ query allReleases( __typename nodes { __typename + id filepath collectedAt sha @@ -67,12 +69,14 @@ query allReleases( } commit { __typename + id sha webUrl title } author { __typename + id webUrl avatarUrl username diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql index b5c5f653429..bcb07ae3182 100644 --- a/app/graphql/queries/repository/path_last_commit.query.graphql +++ b/app/graphql/queries/repository/path_last_commit.query.graphql @@ -1,13 +1,14 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { project(fullPath: $projectPath) { - id __typename + id repository { __typename tree(path: $path, ref: $ref) { __typename lastCommit { __typename + id sha title titleHtml @@ -19,6 +20,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { authorGravatar author { __typename + id name avatarUrl webPath @@ -30,8 +32,10 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { __typename node { __typename + id detailedStatus { __typename + id detailsPath icon tooltip diff --git a/app/graphql/queries/snippet/project_permissions.query.graphql b/app/graphql/queries/snippet/project_permissions.query.graphql index 0c38e4f8a07..e0de79f4449 100644 --- a/app/graphql/queries/snippet/project_permissions.query.graphql +++ b/app/graphql/queries/snippet/project_permissions.query.graphql @@ -1,6 +1,7 @@ query CanCreateProjectSnippet($fullPath: ID!) { project(fullPath: $fullPath) { __typename + id userPermissions { __typename createSnippet diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql index ebfc135c51c..24b268ec853 100644 --- a/app/graphql/queries/snippet/snippet.query.graphql +++ b/app/graphql/queries/snippet/snippet.query.graphql @@ -49,6 +49,7 @@ query GetSnippetQuery($ids: [SnippetID!]) { } project { __typename + id fullPath webUrl } diff --git a/app/graphql/queries/snippet/user_permissions.query.graphql b/app/graphql/queries/snippet/user_permissions.query.graphql index a4914189807..4d131c48feb 100644 --- a/app/graphql/queries/snippet/user_permissions.query.graphql +++ b/app/graphql/queries/snippet/user_permissions.query.graphql @@ -1,6 +1,7 @@ query CanCreatePersonalSnippet { currentUser { __typename + id userPermissions { __typename createSnippet diff --git a/app/graphql/resolvers/base_issues_resolver.rb b/app/graphql/resolvers/base_issues_resolver.rb index 54ebb697cb2..dca93444907 100644 --- a/app/graphql/resolvers/base_issues_resolver.rb +++ b/app/graphql/resolvers/base_issues_resolver.rb @@ -4,13 +4,13 @@ module Resolvers class BaseIssuesResolver < BaseResolver prepend IssueResolverArguments - argument :state, Types::IssuableStateEnum, - required: false, - description: 'Current state of this issue.' argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria.', required: false, default_value: :created_desc + argument :state, Types::IssuableStateEnum, + required: false, + description: 'Current state of this issue.' type Types::IssueType.connection_type, null: true diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb index 5ae9e721cc8..df138a15538 100644 --- a/app/graphql/resolvers/ci/jobs_resolver.rb +++ b/app/graphql/resolvers/ci/jobs_resolver.rb @@ -29,7 +29,7 @@ module Resolvers job_types: security_report_types ).execute else - pipeline.statuses + pipeline.statuses_order_id_desc end end end diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb new file mode 100644 index 00000000000..d916a8a13f0 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_status_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + # NOTE: This class was introduced to allow modifying the meaning of certain values in RunnerStatusEnum + # while preserving backward compatibility. It can be removed in 15.0 once the API has stabilized. + class RunnerStatusResolver < BaseResolver + type Types::Ci::RunnerStatusEnum, null: false + + alias_method :runner, :object + + argument :legacy_mode, + type: GraphQL::Types::String, + default_value: '14.5', + required: false, + description: 'Compatibility mode. A null value turns off compatibility mode.', + deprecated: { reason: 'Will be removed in 15.0. From that release onward, the field will behave as if legacyMode is null', milestone: '14.6' } + + def resolve(legacy_mode:, **args) + runner.status(legacy_mode) + end + end + end +end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 07105701daa..9848b5a503f 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -7,6 +7,10 @@ module Resolvers type Types::Ci::RunnerType.connection_type, null: true + argument :active, ::GraphQL::Types::Boolean, + required: false, + description: 'Filter runners by active (true) or paused (false) status.' + argument :status, ::Types::Ci::RunnerStatusEnum, required: false, description: 'Filter runners by status.' @@ -38,6 +42,7 @@ module Resolvers def runners_finder_params(params) { + active: params[:active], status_status: params[:status]&.to_s, type_type: params[:type], tag_name: params[:tag_list], diff --git a/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb b/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb new file mode 100644 index 00000000000..b6fec3d3772 --- /dev/null +++ b/app/graphql/resolvers/clusters/agent_activity_events_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Clusters + class AgentActivityEventsResolver < BaseResolver + type Types::Clusters::AgentActivityEventType, null: true + + alias_method :agent, :object + + delegate :project, to: :agent + + def resolve(**args) + return ::Clusters::Agents::ActivityEvent.none unless can_view_activity_events? + + agent.activity_events + end + + private + + def can_view_activity_events? + current_user.can?(:admin_cluster, project) + end + end + end +end diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb index 9b8cea52e3b..5ad66ed7cdd 100644 --- a/app/graphql/resolvers/clusters/agents_resolver.rb +++ b/app/graphql/resolvers/clusters/agents_resolver.rb @@ -28,7 +28,10 @@ module Resolvers private def preloads - { tokens: :last_used_agent_tokens } + { + activity_events: { activity_events: [:user, agent_token: :agent] }, + tokens: :last_used_agent_tokens + } end end end diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb new file mode 100644 index 00000000000..55a83dd49da --- /dev/null +++ b/app/graphql/resolvers/container_repository_tags_resolver.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Resolvers + class ContainerRepositoryTagsResolver < BaseResolver + type Types::ContainerRepositoryTagType.connection_type, null: true + + argument :sort, Types::ContainerRepositoryTagsSortEnum, + description: 'Sort tags by these criteria.', + required: false, + default_value: nil + + argument :name, GraphQL::Types::String, + description: 'Search by tag name.', + required: false, + default_value: nil + + def resolve(sort:, **filters) + result = tags + + if filters[:name] + result = tags.filter do |tag| + tag.name.include?(filters[:name]) + end + end + + result = sort_tags(result, sort) if sort + result + end + + private + + def sort_tags(to_be_sorted, sort) + raise StandardError unless Types::ContainerRepositoryTagsSortEnum.enum.include?(sort) + + sort_value, _, direction = sort.to_s.rpartition('_') + + sorted = to_be_sorted.sort_by(&sort_value.to_sym) + return sorted.reverse if direction == 'desc' + + sorted + end + + def tags + object.tags + rescue Faraday::Error + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, "Can't connect to the Container Registry. If this error persists, please review the troubleshooting documentation." + end + end +end diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb index dec778fac80..a62ef6d76e5 100644 --- a/app/graphql/resolvers/design_management/designs_resolver.rb +++ b/app/graphql/resolvers/design_management/designs_resolver.rb @@ -8,16 +8,16 @@ module Resolvers type ::Types::DesignManagement::DesignType.connection_type, null: true - argument :ids, [DesignID], + argument :at_version, VersionID, required: false, - description: 'Filters designs by their ID.' + description: 'Filters designs to only those that existed at the version. ' \ + 'If argument is omitted or nil then all designs will reflect the latest version' argument :filenames, [GraphQL::Types::String], required: false, description: 'Filters designs by their filename.' - argument :at_version, VersionID, + argument :ids, [DesignID], required: false, - description: 'Filters designs to only those that existed at the version. ' \ - 'If argument is omitted or nil then all designs will reflect the latest version' + description: 'Filters designs by their ID.' def self.single ::Resolvers::DesignManagement::DesignResolver diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb index d879c1434dc..76e365c40b1 100644 --- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb +++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb @@ -16,16 +16,16 @@ module Resolvers authorize :read_design - argument :id, DesignAtVersionID, - required: false, - as: :design_at_version_id, - description: 'ID of the DesignAtVersion.' argument :design_id, DesignID, required: false, description: 'ID of a specific design.' argument :filename, GraphQL::Types::String, required: false, description: 'Filename of a specific design.' + argument :id, DesignAtVersionID, + required: false, + as: :design_at_version_id, + description: 'ID of the DesignAtVersion.' def self.single self diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb index 238dae0bf12..a1b1d3bfe4c 100644 --- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb +++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb @@ -14,7 +14,7 @@ module Resolvers return [] unless can_read_agent_configuration? kas_client.list_agent_config_files(project: project) - rescue GRPC::BadStatus => e + rescue GRPC::BadStatus, Gitlab::Kas::Client::ConfigurationError => e raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name end diff --git a/app/graphql/resolvers/package_pipelines_resolver.rb b/app/graphql/resolvers/package_pipelines_resolver.rb new file mode 100644 index 00000000000..59a1cd173a4 --- /dev/null +++ b/app/graphql/resolvers/package_pipelines_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + class PackagePipelinesResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Ci::PipelineType.connection_type, null: true + extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension + + authorizes_object! + authorize :read_pipeline + + alias_method :package, :object + + def resolve(first: nil, last: nil, after: nil, before: nil, lookahead:) + finder = ::Packages::BuildInfosFinder.new( + package, + first: first, + last: last, + after: decode_cursor(after), + before: decode_cursor(before), + max_page_size: context.schema.default_max_page_size, + support_next_page: lookahead.selects?(:page_info) + ) + + build_infos = finder.execute + + # this .pluck_pipeline_ids can load max 101 pipelines ids + ::Ci::Pipeline.id_in(build_infos.pluck_pipeline_ids) + 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 decode_cursor(encoded) + return unless encoded + + decoded = Gitlab::Json.parse(context.schema.cursor_encoder.decode(encoded, nonce: true)) + id_from_cursor(decoded) + rescue JSON::ParserError + raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor" + end + + def id_from_cursor(cursor) + cursor&.fetch('id') + rescue KeyError + raise Gitlab::Graphql::Errors::ArgumentError, "Please provide a valid cursor" + end + end +end diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb index 75068014242..8a2693ee46b 100644 --- a/app/graphql/resolvers/project_jobs_resolver.rb +++ b/app/graphql/resolvers/project_jobs_resolver.rb @@ -33,6 +33,7 @@ module Resolvers def preloads { + previous_stage_jobs_and_needs: [:needs, :pipeline], artifacts: [:job_artifacts], pipeline: [:user] } diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb index 5acd7f95606..ea733ab08ad 100644 --- a/app/graphql/resolvers/project_pipeline_resolver.rb +++ b/app/graphql/resolvers/project_pipeline_resolver.rb @@ -24,7 +24,6 @@ module Resolvers super end - # the preloads are defined on ee/app/graphql/ee/resolvers/project_pipeline_resolver.rb def resolve(iid: nil, sha: nil, **args) self.lookahead = args.delete(:lookahead) @@ -42,5 +41,11 @@ module Resolvers end end end + + def unconditional_includes + [ + { statuses: [:needs] } + ] + end end end diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb index 5a1e92efc96..47a8b028d4d 100644 --- a/app/graphql/resolvers/project_pipelines_resolver.rb +++ b/app/graphql/resolvers/project_pipelines_resolver.rb @@ -18,7 +18,7 @@ module Resolvers def preloads { - jobs: [:statuses], + jobs: { statuses_order_id_desc: [:needs] }, upstream: [:triggered_by_pipeline], downstream: [:triggered_pipelines] } diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb index 00f41517422..cbbc65d7263 100644 --- a/app/graphql/resolvers/snippets/blobs_resolver.rb +++ b/app/graphql/resolvers/snippets/blobs_resolver.rb @@ -35,3 +35,5 @@ module Resolvers end end end + +Resolvers::Snippets::BlobsResolver.prepend_mod diff --git a/app/graphql/resolvers/users/participants_resolver.rb b/app/graphql/resolvers/users/participants_resolver.rb new file mode 100644 index 00000000000..9e87b60fa34 --- /dev/null +++ b/app/graphql/resolvers/users/participants_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + module Users + class ParticipantsResolver < BaseResolver + type Types::UserType.connection_type, null: true + + def resolve(**args) + object.visible_participants(current_user) + end + end + end +end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb new file mode 100644 index 00000000000..f4409c983f8 --- /dev/null +++ b/app/graphql/types/base_edge.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseEdge < GraphQL::Types::Relay::BaseEdge + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 93e17ea6dfc..75909592c6c 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -78,6 +78,8 @@ module Types attr_reader :feature_flag def field_authorized?(object, ctx) + object = object.node if object.is_a?(GraphQL::Pagination::Connection::Edge) + authorization.ok?(object, ctx[:current_user]) end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index cd677e50d28..b5797f07aa6 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -7,6 +7,7 @@ module Types prepend Gitlab::Graphql::MarkdownField field_class Types::BaseField + edge_type_class Types::BaseEdge def self.accepts(*types) @accepts ||= [] diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb index b4dbe87e32d..afa66c1c510 100644 --- a/app/graphql/types/boards/board_issue_input_type.rb +++ b/app/graphql/types/boards/board_issue_input_type.rb @@ -17,6 +17,10 @@ module Types argument :assignee_wildcard_id, ::Types::Boards::AssigneeWildcardIdEnum, required: false, description: 'Filter by assignee wildcard. Incompatible with assigneeUsername.' + + argument :confidential, GraphQL::Types::Boolean, + required: false, + description: 'Filter by confidentiality.' end end end diff --git a/app/graphql/types/ci/build_need_type.rb b/app/graphql/types/ci/build_need_type.rb index 7bd12c99a08..b71d10c4c06 100644 --- a/app/graphql/types/ci/build_need_type.rb +++ b/app/graphql/types/ci/build_need_type.rb @@ -8,7 +8,7 @@ module Types graphql_name 'CiBuildNeed' field :id, GraphQL::Types::ID, null: false, - description: 'ID of the job we need to complete.' + description: 'ID of the BuildNeed.' field :name, GraphQL::Types::String, null: true, description: 'Name of the job we need to complete.' end diff --git a/app/graphql/types/ci/job_need_union.rb b/app/graphql/types/ci/job_need_union.rb new file mode 100644 index 00000000000..59608a6a312 --- /dev/null +++ b/app/graphql/types/ci/job_need_union.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + class JobNeedUnion < GraphQL::Schema::Union + TypeNotSupportedError = Class.new(StandardError) + + possible_types Types::Ci::JobType, Types::Ci::BuildNeedType + + def self.resolve_type(object, context) + if object.is_a?(::Ci::BuildNeed) + Types::Ci::BuildNeedType + elsif object.is_a?(CommitStatus) + Types::Ci::JobType + else + raise TypeNotSupportedError + end + end + end + end +end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 48bd91bfc5b..928ca2f597d 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -50,6 +50,8 @@ module Types null: true, description: 'How long the job was enqueued before starting.' + field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, + description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' field :detailed_status, Types::Ci::DetailedStatusType, null: true, description: 'Detailed status of the job.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, @@ -74,7 +76,7 @@ module Types description: 'Indicates the job is active.' field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?, description: 'Indicates the job is stuck.' - field :coverage, GraphQL::FLOAT_TYPE, null: true, + field :coverage, GraphQL::Types::Float, null: true, description: 'Coverage level of the job.' field :created_by_tag, GraphQL::Types::Boolean, null: false, description: 'Whether the job was created by a tag.' @@ -101,6 +103,30 @@ module Types end end + def previous_stage_jobs_or_needs + if object.scheduling_type == 'stage' + Gitlab::Graphql::Lazy.with_value(previous_stage_jobs) do |jobs| + jobs + end + else + object.needs + end + end + + def previous_stage_jobs + BatchLoader::GraphQL.for([object.pipeline, object.stage_idx - 1]).batch(default_value: []) do |tuples, loader| + tuples.group_by(&:first).each do |pipeline, keys| + positions = keys.map(&:second) + + stages = pipeline.stages.by_position(positions) + + stages.each do |stage| + loader.call([pipeline, stage.position], stage.latest_statuses) + end + end + end + end + def stage ::Gitlab::Graphql::Lazy.with_value(pipeline) do |pl| BatchLoader::GraphQL.for([pl, object.stage]).batch do |ids, loader| diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index da2f11be9e2..c8ac31bce4d 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -45,7 +45,7 @@ module Types field :queued_duration, Types::DurationType, null: true, description: 'How long the pipeline was queued before starting.' - field :coverage, GraphQL::FLOAT_TYPE, null: true, + field :coverage, GraphQL::Types::Float, null: true, description: 'Coverage percentage.' field :created_at, Types::TimeType, null: false, @@ -66,7 +66,7 @@ module Types field :stages, type: Types::Ci::StageType.connection_type, null: true, - authorize: :read_commit_status, + authorize: :read_build, description: 'Stages of the pipeline.', extras: [:lookahead], resolver: Resolvers::Ci::PipelineStagesResolver @@ -89,14 +89,14 @@ module Types field :jobs, ::Types::Ci::JobType.connection_type, null: true, - authorize: :read_commit_status, + authorize: :read_build, description: 'Jobs belonging to the pipeline.', resolver: ::Resolvers::Ci::JobsResolver field :job, type: ::Types::Ci::JobType, null: true, - authorize: :read_commit_status, + authorize: :read_build, description: 'Specific job in this pipeline, either by name or ID.' do argument :id, type: ::Types::GlobalIDType[::CommitStatus], @@ -116,7 +116,7 @@ module Types field :source_job, type: Types::Ci::JobType, null: true, - authorize: :read_commit_status, + authorize: :read_build, description: 'Job where pipeline was triggered from.' field :downstream, Types::Ci::PipelineType.connection_type, null: true, diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb index 8501ce20204..dd056191ceb 100644 --- a/app/graphql/types/ci/runner_status_enum.rb +++ b/app/graphql/types/ci/runner_status_enum.rb @@ -5,24 +5,37 @@ module Types class RunnerStatusEnum < BaseEnum graphql_name 'CiRunnerStatus' - ::Ci::Runner::AVAILABLE_STATUSES.each do |status| - description = case status - when 'active' - "A runner that is not paused." - when 'online' - "A runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." - when 'offline' - "A runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}." - when 'not_connected' - "A runner that has never contacted this instance." - else - "A runner that is #{status.to_s.tr('_', ' ')}." - end - - value status.to_s.upcase, - description: description, - value: status.to_sym - end + value 'ACTIVE', + description: 'Runner that is not paused.', + deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' }, + value: :active + + value 'PAUSED', + description: 'Runner that is paused.', + deprecated: { reason: 'Use CiRunnerType.active instead', milestone: '14.6' }, + value: :paused + + value 'ONLINE', + description: "Runner that contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.", + value: :online + + value 'OFFLINE', + description: "Runner that has not contacted this instance within the last #{::Ci::Runner::ONLINE_CONTACT_TIMEOUT.inspect}.", + deprecated: { reason: 'This field will have a slightly different scope starting in 15.0, with STALE being returned after a certain period offline', milestone: '14.6' }, + value: :offline + + value 'STALE', + description: "Runner that has not contacted this instance within the last #{::Ci::Runner::STALE_TIMEOUT.inspect}. Only available if legacyMode is null. Will be a possible return value starting in 15.0.", + value: :stale + + value 'NOT_CONNECTED', + description: 'Runner that has never contacted this instance.', + deprecated: { reason: "Use NEVER_CONTACTED instead. NEVER_CONTACTED will have a slightly different scope starting in 15.0, with STALE being returned instead after #{::Ci::Runner::STALE_TIMEOUT.inspect} of no contact", milestone: '14.6' }, + value: :not_connected + + value 'NEVER_CONTACTED', + description: 'Runner that has never contacted this instance. Set legacyMode to null to utilize this value. Will replace NOT_CONNECTED starting in 15.0.', + value: :never_contacted end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 9bf98aa7e86..d37cca0927f 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -27,8 +27,11 @@ module Types description: 'Access level of the runner.' field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is allowed to receive jobs.' - field :status, ::Types::Ci::RunnerStatusEnum, null: false, - description: 'Status of the runner.' + field :status, + Types::Ci::RunnerStatusEnum, + null: false, + description: 'Status of the runner.', + resolver: ::Resolvers::Ci::RunnerStatusResolver field :version, GraphQL::Types::String, null: true, description: 'Version of the runner.' field :short_sha, GraphQL::Types::String, null: true, @@ -50,7 +53,7 @@ module Types field :job_count, GraphQL::Types::Int, null: true, description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :admin_url, GraphQL::Types::String, null: true, - description: 'Admin URL of the runner. Only available for adminstrators.' + description: 'Admin URL of the runner. Only available for administrators.' def job_count # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT diff --git a/app/graphql/types/ci/runner_web_url_edge.rb b/app/graphql/types/ci/runner_web_url_edge.rb index 3b9fdfd1571..368e16f972c 100644 --- a/app/graphql/types/ci/runner_web_url_edge.rb +++ b/app/graphql/types/ci/runner_web_url_edge.rb @@ -3,7 +3,7 @@ module Types module Ci # rubocop: disable Graphql/AuthorizeTypes - class RunnerWebUrlEdge < GraphQL::Types::Relay::BaseEdge + class RunnerWebUrlEdge < ::Types::BaseEdge include FindClosest field :web_url, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index c0d931b3d31..70e78e391a7 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -4,7 +4,7 @@ module Types module Ci class StageType < BaseObject graphql_name 'CiStage' - authorize :read_commit_status + authorize :read_build field :id, GraphQL::Types::ID, null: false, description: 'ID of the stage.' @@ -31,7 +31,10 @@ module Types BatchLoader::GraphQL.for(key).batch(default_value: []) do |keys, loader| by_pipeline = keys.group_by(&:pipeline) - include_needs = keys.any? { |k| k.requires?(%i[nodes jobs nodes needs]) } + include_needs = keys.any? do |k| + k.requires?(%i[nodes jobs nodes needs]) || + k.requires?(%i[nodes jobs nodes previousStageJobsAndNeeds]) + end by_pipeline.each do |pl, key_group| project = pl.project diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb index 9ec5daa44ea..6e5f55aa3ed 100644 --- a/app/graphql/types/ci/test_case_type.rb +++ b/app/graphql/types/ci/test_case_type.rb @@ -18,7 +18,7 @@ module Types field :classname, GraphQL::Types::String, null: true, description: 'Classname of the test case.' - field :execution_time, GraphQL::FLOAT_TYPE, null: true, + field :execution_time, GraphQL::Types::Float, null: true, description: 'Test case execution time in seconds.' field :file, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/ci/test_report_total_type.rb b/app/graphql/types/ci/test_report_total_type.rb index aa07a391519..48aea1257c5 100644 --- a/app/graphql/types/ci/test_report_total_type.rb +++ b/app/graphql/types/ci/test_report_total_type.rb @@ -7,7 +7,7 @@ module Types graphql_name 'TestReportTotal' description 'Total test report statistics.' - field :time, GraphQL::FLOAT_TYPE, null: true, + field :time, GraphQL::Types::Float, null: true, description: 'Total duration of the tests.' field :count, GraphQL::Types::Int, null: true, diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb index 3db2d80d591..ec7b852213b 100644 --- a/app/graphql/types/ci/test_suite_summary_type.rb +++ b/app/graphql/types/ci/test_suite_summary_type.rb @@ -12,7 +12,7 @@ module Types field :name, GraphQL::Types::String, null: true, description: 'Name of the test suite.' - field :total_time, GraphQL::FLOAT_TYPE, null: true, + field :total_time, GraphQL::Types::Float, null: true, description: 'Total duration of the tests in the test suite.' field :total_count, GraphQL::Types::Int, null: true, diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb index f9f37d4045e..7ce479632cc 100644 --- a/app/graphql/types/ci/test_suite_type.rb +++ b/app/graphql/types/ci/test_suite_type.rb @@ -12,7 +12,7 @@ module Types field :name, GraphQL::Types::String, null: true, description: 'Name of the test suite.' - field :total_time, GraphQL::FLOAT_TYPE, null: true, + field :total_time, GraphQL::Types::Float, null: true, description: 'Total duration of the tests in the test suite.' field :total_count, GraphQL::Types::Int, null: true, diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb new file mode 100644 index 00000000000..79a9fd70505 --- /dev/null +++ b/app/graphql/types/clusters/agent_activity_event_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Clusters + class AgentActivityEventType < BaseObject + graphql_name 'ClusterAgentActivityEvent' + + authorize :admin_cluster + + connection_type_class(Types::CountableConnectionType) + + field :recorded_at, + Types::TimeType, + null: true, + description: 'Timestamp the event was recorded.' + + field :kind, + GraphQL::Types::String, + null: true, + description: 'Type of event.' + + field :level, + GraphQL::Types::String, + null: true, + description: 'Severity of the event.' + + field :user, + Types::UserType, + null: true, + description: 'User associated with the event.' + + field :agent_token, + Types::Clusters::AgentTokenType, + null: true, + description: 'Agent token associated with the event.' + end + end +end diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb index ce748f6e8ae..89316ed4728 100644 --- a/app/graphql/types/clusters/agent_type.rb +++ b/app/graphql/types/clusters/agent_type.rb @@ -55,6 +55,12 @@ module Types complexity: 5, resolver: ::Resolvers::Kas::AgentConnectionsResolver + field :activity_events, + Types::Clusters::AgentActivityEventType.connection_type, + null: true, + description: 'Recent activity for the cluster agent.', + resolver: Resolvers::Clusters::AgentActivityEventsResolver + def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find end diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb index 8190cc9bc25..e713aaebe36 100644 --- a/app/graphql/types/container_repository_details_type.rb +++ b/app/graphql/types/container_repository_details_type.rb @@ -12,16 +12,11 @@ module Types Types::ContainerRepositoryTagType.connection_type, null: true, description: 'Tags of the container repository.', - max_page_size: 20 + max_page_size: 20, + resolver: Resolvers::ContainerRepositoryTagsResolver def can_delete Ability.allowed?(current_user, :destroy_container_image, object) end - - def tags - object.tags - rescue Faraday::Error - raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'We are having trouble connecting to the Container Registry. If this error persists, please review the troubleshooting documentation.' - end end end diff --git a/app/graphql/types/container_repository_tags_sort_enum.rb b/app/graphql/types/container_repository_tags_sort_enum.rb new file mode 100644 index 00000000000..253cffd9a8c --- /dev/null +++ b/app/graphql/types/container_repository_tags_sort_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class ContainerRepositoryTagsSortEnum < BaseEnum + graphql_name 'ContainerRepositoryTagSort' + description 'Values for sorting tags' + + value 'NAME_ASC', 'Ordered by name in ascending order.', value: :name_asc + value 'NAME_DESC', 'Ordered by name in descending order.', value: :name_desc + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 3b0f93d8dc1..498569f11ca 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -80,7 +80,8 @@ module Types description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' field :participants, Types::UserType.connection_type, null: true, complexity: 5, - description: 'List of participants in the issue.' + description: 'List of participants in the issue.', + resolver: Resolvers::Users::ParticipantsResolver field :emails_disabled, GraphQL::Types::Boolean, null: false, method: :project_emails_disabled?, description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled.' diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb index 6999ea270a2..0cfba6bbbd0 100644 --- a/app/graphql/types/issue_type_enum.rb +++ b/app/graphql/types/issue_type_enum.rb @@ -5,7 +5,7 @@ module Types graphql_name 'IssueType' description 'Issue type' - ::WorkItem::Type.base_types.keys.each do |issue_type| + ::WorkItem::Type.allowed_types_for_issues.each do |issue_type| value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type" end end diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb index d009b67bc0f..9596c812c69 100644 --- a/app/graphql/types/merge_request_connection_type.rb +++ b/app/graphql/types/merge_request_connection_type.rb @@ -3,7 +3,7 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class MergeRequestConnectionType < Types::CountableConnectionType - field :total_time_to_merge, GraphQL::FLOAT_TYPE, null: true, + field :total_time_to_merge, GraphQL::Types::Float, null: true, description: 'Total sum of time to merge, in seconds, for the collection of merge requests.' # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index a0f00ddc3c6..0672ec6f0f8 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -21,10 +21,8 @@ module Types description: 'Internal ID of the merge request.' field :title, GraphQL::Types::String, null: false, description: 'Title of the merge request.' - markdown_field :title_html, null: true field :description, GraphQL::Types::String, null: true, description: 'Description of the merge request (Markdown rendered as HTML for caching).' - markdown_field :description_html, null: true field :state, MergeRequestStateEnum, null: false, description: 'State of the merge request.' field :created_at, Types::TimeType, null: false, @@ -96,7 +94,7 @@ module Types description: 'Rebase commit SHA of the merge request.' field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true, description: 'Indicates if there is a rebase currently in progress for the merge request.' - field :default_merge_commit_message, GraphQL::Types::String, null: true, + field :default_merge_commit_message, GraphQL::Types::String, null: true, calls_gitaly: true, description: 'Default merge commit message of the merge request.' field :default_merge_commit_message_with_description, GraphQL::Types::String, null: true, description: 'Default merge commit message of the merge request with description. Will have the same value as `defaultMergeCommitMessage` when project has `mergeCommitTemplate` set.', @@ -148,7 +146,8 @@ module Types field :author, Types::UserType, null: true, description: 'User who created this merge request.' field :participants, Types::UserType.connection_type, null: true, complexity: 15, - description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.' + description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.', + resolver: Resolvers::Users::ParticipantsResolver field :subscribed, GraphQL::Types::Boolean, method: :subscribed?, null: false, complexity: 5, description: 'Indicates if the currently logged in user is subscribed to this merge request.' field :labels, Types::LabelType.connection_type, null: true, complexity: 5, @@ -201,6 +200,9 @@ module Types field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the merge request.' + markdown_field :title_html, null: true + markdown_field :description_html, null: true + def approved_by object.approved_by_users end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 3c5994ac559..ba90fb06cb2 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -20,7 +20,6 @@ module Types field :description, GraphQL::Types::String, null: true, description: 'Description of the namespace.' - markdown_field :description_html, null: true field :visibility, GraphQL::Types::String, null: true, description: 'Visibility of the namespace.' @@ -47,6 +46,8 @@ module Types null: true, description: "Shared runners availability for the namespace and its descendants." + markdown_field :description_html, null: true + def root_storage_statistics Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index da6ea83401d..7314c137010 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -33,8 +33,6 @@ module Types method: :note, description: 'Content of the note.' - markdown_field :body_html, null: true, method: :note - field :created_at, Types::TimeType, null: false, description: 'Timestamp of the note creation.' field :updated_at, Types::TimeType, null: false, @@ -50,6 +48,8 @@ module Types null: true, description: 'URL to view this Note in the Web UI.' + markdown_field :body_html, null: true, method: :note + def url ::Gitlab::UrlBuilder.build(object) end diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb index 59a4885e87e..5ac80860fe2 100644 --- a/app/graphql/types/packages/package_details_type.rb +++ b/app/graphql/types/packages/package_details_type.rb @@ -14,6 +14,13 @@ module Types field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true, description: 'Dependency link.' + # this is an override of Types::Packages::PackageType.pipelines + # in order to use a custom resolver: Resolvers::PackagePipelinesResolver + field :pipelines, + resolver: Resolvers::PackagePipelinesResolver, + description: 'Pipelines that built the package.', + deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' } + def versions object.versions end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index 9851c6aec7e..d1312cb963d 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -21,7 +21,8 @@ module Types field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.' field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' field :pipelines, Types::Ci::PipelineType.connection_type, null: true, - description: 'Pipelines that built the package.' + description: 'Pipelines that built the package.', + deprecated: { reason: 'Due to scalability concerns, this field is going to be removed', milestone: '14.6' } field :metadata, Types::Packages::MetadataType, null: true, description: 'Package metadata.' field :versions, ::Types::Packages::PackageType.connection_type, null: true, diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index 60a3d5ce06b..ab2b9c2a3af 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -6,26 +6,26 @@ module Types authorize :read_statistics - field :commit_count, GraphQL::FLOAT_TYPE, null: false, + field :commit_count, GraphQL::Types::Float, null: false, description: 'Commit count of the project.' - field :storage_size, GraphQL::FLOAT_TYPE, null: false, + field :storage_size, GraphQL::Types::Float, null: false, description: 'Storage size of the project in bytes.' - field :repository_size, GraphQL::FLOAT_TYPE, null: false, + field :repository_size, GraphQL::Types::Float, null: false, description: 'Repository size of the project in bytes.' - field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false, + field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'Large File Storage (LFS) object size of the project in bytes.' - field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, + field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'Build artifacts size of the project in bytes.' - field :packages_size, GraphQL::FLOAT_TYPE, null: false, + field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size of the project in bytes.' - field :wiki_size, GraphQL::FLOAT_TYPE, null: true, + field :wiki_size, GraphQL::Types::Float, null: true, description: 'Wiki size of the project in bytes.' - field :snippets_size, GraphQL::FLOAT_TYPE, null: true, + field :snippets_size, GraphQL::Types::Float, null: true, description: 'Snippets size of the project in bytes.' - field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: true, + field :pipeline_artifacts_size, GraphQL::Types::Float, null: true, description: 'CI Pipeline artifacts size in bytes.' - field :uploads_size, GraphQL::FLOAT_TYPE, null: true, + field :uploads_size, GraphQL::Types::Float, null: true, description: 'Uploads size of the project in bytes.' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index b6cb9cd3302..3d2ee47a499 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -194,7 +194,7 @@ module Types field :jobs, type: Types::Ci::JobType.connection_type, null: true, - authorize: :read_commit_status, + authorize: :read_build, description: 'Jobs of a project. This field can only be resolved for one project in any single request.', resolver: Resolvers::ProjectJobsResolver @@ -386,6 +386,11 @@ module Types null: true, description: 'Template used to create merge commit message in merge requests.' + field :squash_commit_template, + GraphQL::Types::String, + null: true, + description: 'Template used to create squash commit message in merge requests.' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index 104171e6772..3265c14bdca 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -71,6 +71,10 @@ module Types field :pipeline_editor_path, GraphQL::Types::String, null: true, description: 'Web path to edit .gitlab-ci.yml file.' + field :code_owners, [Types::UserType], null: true, + description: 'List of code owners for the blob.', + calls_gitaly: true + field :file_type, GraphQL::Types::String, null: true, description: 'Expected format of the blob based on the extension.' @@ -91,6 +95,9 @@ module Types calls_gitaly: true, description: 'Whether the current user can modify the blob.' + field :can_current_user_push_to_branch, GraphQL::Types::Boolean, null: true, method: :can_current_user_push_to_branch?, + description: 'Whether the current user can push to the branch.' + def raw_text_blob object.data unless object.binary? end @@ -101,3 +108,5 @@ module Types end end end + +Types::Repository::BlobType.prepend_mod_with('Types::Repository::BlobType') diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index 47ca195cc4b..88dc6036bfd 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -6,14 +6,14 @@ module Types authorize :read_statistics - field :storage_size, GraphQL::FLOAT_TYPE, null: false, description: 'Total storage in bytes.' - field :repository_size, GraphQL::FLOAT_TYPE, null: false, description: 'Git repository size in bytes.' - field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false, description: 'LFS objects size in bytes.' - field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI artifacts size in bytes.' - field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'Packages size in bytes.' - field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'Wiki size in bytes.' - field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'Snippets size in bytes.' - field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI pipeline artifacts size in bytes.' - field :uploads_size, GraphQL::FLOAT_TYPE, null: false, description: 'Uploads size in bytes.' + field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.' + field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.' + field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.' + field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.' + field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.' + field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.' + field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.' + field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.' + field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.' end end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 5356a998f0d..3629edb5b33 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -6,5 +6,8 @@ module Types field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the assignees of an issuable are updated.' + + field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the crm contacts of an issuable are updated.' end end diff --git a/app/graphql/types/user_callout_feature_name_enum.rb b/app/graphql/types/user_callout_feature_name_enum.rb index 410ca5e1c95..bcb49a709ed 100644 --- a/app/graphql/types/user_callout_feature_name_enum.rb +++ b/app/graphql/types/user_callout_feature_name_enum.rb @@ -5,7 +5,7 @@ module Types graphql_name 'UserCalloutFeatureNameEnum' description 'Name of the feature that the callout is for.' - ::UserCallout.feature_names.keys.each do |feature_name| + ::Users::Callout.feature_names.keys.each do |feature_name| value feature_name.upcase, value: feature_name, description: "Callout feature name for #{feature_name}." end end diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb index 877ad6db576..1d38262159f 100644 --- a/app/helpers/access_tokens_helper.rb +++ b/app/helpers/access_tokens_helper.rb @@ -1,7 +1,30 @@ # frozen_string_literal: true module AccessTokensHelper + include AccountsHelper + include ApplicationHelper + def scope_description(prefix) prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc] end + + def tokens_app_data + { + feed_token: { + enabled: !Gitlab::CurrentSettings.disable_feed_token, + token: current_user.feed_token, + reset_path: reset_feed_token_profile_path + }, + incoming_email_token: { + enabled: incoming_email_token_enabled?, + token: current_user.enabled_incoming_email_token, + reset_path: reset_incoming_email_token_profile_path + }, + static_object_token: { + enabled: static_objects_external_storage_enabled?, + token: current_user.enabled_static_object_token, + reset_path: reset_static_object_token_profile_path + } + }.to_json + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 58f933a7fe0..02a87979f40 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -206,10 +206,6 @@ module ApplicationHelper 'https://' + promo_host end - def contact_sales_url - promo_url + '/sales' - end - def support_url Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 6fe92a5a978..c1a74382d46 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -86,6 +86,17 @@ module AuthHelper auth_providers.select { |provider| form_based_provider?(provider) } end + def saml_providers + auth_providers.select { |provider| auth_strategy_class(provider) == 'OmniAuth::Strategies::SAML' } + end + + def auth_strategy_class(provider) + config = Gitlab::Auth::OAuth::Provider.config_for(provider) + return if config.nil? || config['args'].blank? + + config.args['strategy_class'] + end + def any_form_based_providers_enabled? form_based_providers.any? { |provider| form_enabled_for_sign_in?(provider) } end @@ -164,10 +175,25 @@ module AuthHelper end def google_tag_manager_enabled? - Gitlab.com? && - extra_config.has_key?('google_tag_manager_id') && - extra_config.google_tag_manager_id.present? && - !current_user + return false unless Gitlab.dev_env_or_com? + + has_config_key = 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 + + has_config_key && !current_user + 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) diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb new file mode 100644 index 00000000000..a03f7f4097a --- /dev/null +++ b/app/helpers/badges_helper.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module BadgesHelper + VARIANT_CLASSES = { + muted: "badge-muted", + neutral: "badge-neutral", + info: "badge-info", + success: "badge-success", + warning: "badge-warning", + danger: "badge-danger" + }.tap { |hash| hash.default = hash.fetch(:muted) } .freeze + + SIZE_CLASSES = { + sm: "sm", + md: "md", + lg: "lg" + }.tap { |hash| hash.default = hash.fetch(:md) } .freeze + + GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze + + GL_ICON_CLASSES = %w[gl-icon gl-badge-icon].freeze + + # Creates a GitLab UI badge. + # + # Examples: + # # Plain text badge + # gl_badge_tag("foo") + # + # # Danger variant + # gl_badge_tag("foo", variant: :danger) + # + # # Small size + # gl_badge_tag("foo", size: :sm) + # + # # With icon + # gl_badge_tag("foo", icon: "question-o") + # + # # Icon-only + # gl_badge_tag("foo", icon: "question-o", icon_only: true) + # + # # Badge link + # gl_badge_tag("foo", nil, href: some_path) + # + # # Custom classes + # gl_badge_tag("foo", nil, class: "foo-bar") + # + # # Block content + # gl_badge_tag({ variant: :danger }, { class: "foo-bar" }) do + # "foo" + # end + # + # For accessibility, ensure that the given text or block is non-empty. + # + # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default. + def gl_badge_tag(*args, &block) + if block_given? + build_gl_badge_tag(capture(&block), *args) + else + build_gl_badge_tag(*args) + end + end + + private + + def build_gl_badge_tag(content, options = nil, html_options = nil) + options ||= {} + html_options ||= {} + + icon_only = options[:icon_only] + variant_class = VARIANT_CLASSES[options.fetch(:variant, :muted)] + size_class = SIZE_CLASSES[options.fetch(:size, :md)] + + html_options = html_options.merge( + class: [ + *GL_BADGE_CLASSES, + variant_class, + size_class, + *html_options[:class] + ] + ) + + if icon_only + html_options['aria-label'] = content + html_options['role'] = 'img' + end + + if options[:icon] + icon_classes = GL_ICON_CLASSES.dup + icon_classes << "gl-mr-2" unless icon_only + icon = sprite_icon(options[:icon], css_class: icon_classes.join(' ')) + + content = icon_only ? icon : icon + content + end + + tag = html_options[:href].nil? ? :span : :a + + content_tag(tag, content, html_options) + end +end diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb index 82c74e2416d..5117f7c6d9c 100644 --- a/app/helpers/blame_helper.rb +++ b/app/helpers/blame_helper.rb @@ -1,6 +1,13 @@ # 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 } @@ -24,4 +31,12 @@ module BlameHelper "blame-commit-age-#{age_group}" 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 end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index c26a73028b9..57da04b38cc 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -23,6 +23,7 @@ module BoardsHelper labels_filter_base_path: build_issue_link_base, labels_fetch_path: labels_fetch_path, labels_manage_path: labels_manage_path, + releases_fetch_path: releases_fetch_path, board_type: board.to_type } end @@ -65,6 +66,14 @@ module BoardsHelper end end + def releases_fetch_path + if board.group_board? + group_releases_path(@group) + else + project_releases_path(@project) + end + end + def board_base_url if board.group_board? group_boards_url(@group) diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index d02fe3f20b0..c7f40decae8 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -19,6 +19,13 @@ module Ci } end + def bridge_data(build) + { + "build_name" => build.name, + "empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg') + } + end + def job_counts { "all" => limited_counter_with_delimiter(@all_builds), diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 17057505173..8f219656b71 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -23,7 +23,7 @@ module Ci icon = 'status-paused' span_class = 'gl-text-gray-600' end - when :not_connected + when :not_connected, :never_contacted title = s_("Runners|New runner, has not connected yet") icon = 'warning-solid' when :offline diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index ca5fe38576e..2b5f726dad1 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -283,7 +283,7 @@ module DiffHelper return path unless path.size > max && max > 3 - "...#{path[-(max - 3)..-1]}" + "...#{path[-(max - 3)..]}" end def code_navigation_path(diffs) diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 92d06471384..2699681fed7 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -18,7 +18,7 @@ module ExportHelper [ _('Milestones'), _('Labels'), - _('Boards and Board Lists'), + _('Boards and board lists'), _('Badges'), _('Subgroups') ] diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 9b4d0c0b9b3..3a5dcb4e664 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -35,7 +35,7 @@ module FormHelper def assignees_dropdown_options(issuable_type) dropdown_data = { toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', - title: 'Select assignee', + title: _('Select assignee'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', placeholder: _('Search users'), @@ -45,9 +45,9 @@ module FormHelper current_user: true, project_id: (@target_project || @project)&.id, field_name: "#{issuable_type}[assignee_ids][]", - default_label: 'Unassigned', + default_label: _('Unassigned'), 'max-select': 1, - 'dropdown-header': 'Assignee', + 'dropdown-header': _('Assignee'), multi_select: true, 'input-meta': 'name', 'always-show-selectbox': true, @@ -123,7 +123,7 @@ module FormHelper def multiple_assignees_dropdown_options(options) new_options = options.dup - new_options[:title] = 'Select assignee(s)' + new_options[:title] = _('Select assignee(s)') new_options[:data][:'dropdown-header'] = 'Assignee(s)' new_options[:data].delete(:'max-select') diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 09ff57e2baf..4d81aeca37a 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -29,7 +29,7 @@ module IdeHelper def convert_to_project_entity_json(project) return unless project - API::Entities::Project.represent(project).to_json + API::Entities::Project.represent(project, current_user: current_user).to_json end def enable_environments_guidance? diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index bb4a7fef6be..c5e767c6f64 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -17,31 +17,31 @@ module IntegrationsHelper "#{event}_events" end - def scoped_integrations_path - if @project.present? - project_settings_integrations_path(@project) - elsif @group.present? - group_settings_integrations_path(@group) + def scoped_integrations_path(project: nil, group: nil) + if project.present? + project_settings_integrations_path(project) + elsif group.present? + group_settings_integrations_path(group) else integrations_admin_application_settings_path end end - def scoped_integration_path(integration) - if @project.present? - project_service_path(@project, integration) - elsif @group.present? - group_settings_integration_path(@group, integration) + def scoped_integration_path(integration, project: nil, group: nil) + if project.present? + project_service_path(project, integration) + elsif group.present? + group_settings_integration_path(group, integration) else admin_application_settings_integration_path(integration) end end - def scoped_edit_integration_path(integration) - if @project.present? - edit_project_service_path(@project, integration) - elsif @group.present? - edit_group_settings_integration_path(@group, integration) + def scoped_edit_integration_path(integration, project: nil, group: nil) + if project.present? + edit_project_service_path(project, integration) + elsif group.present? + edit_group_settings_integration_path(group, integration) else edit_admin_application_settings_integration_path(integration) end @@ -51,11 +51,11 @@ module IntegrationsHelper overrides_admin_application_settings_integration_path(integration, options) end - def scoped_test_integration_path(integration) - if @project.present? - test_project_service_path(@project, integration) - elsif @group.present? - test_group_settings_integration_path(@group, integration) + def scoped_test_integration_path(integration, project: nil, group: nil) + if project.present? + test_project_service_path(project, integration) + elsif group.present? + test_group_settings_integration_path(group, integration) else test_admin_application_settings_integration_path(integration) end @@ -71,7 +71,7 @@ module IntegrationsHelper end end - def integration_form_data(integration, group: nil) + def integration_form_data(integration, project: nil, group: nil) form_data = { id: integration.id, show_active: integration.show_active_box?.to_s, @@ -87,9 +87,9 @@ module IntegrationsHelper inherit_from_id: integration.inherit_from_id, integration_level: integration_level(integration), editable: integration.editable?.to_s, - cancel_path: scoped_integrations_path, + cancel_path: scoped_integrations_path(project: project, group: group), can_test: integration.testable?.to_s, - test_path: scoped_test_integration_path(integration), + test_path: scoped_test_integration_path(integration, project: project, group: group), reset_path: scoped_reset_integration_path(integration, group: group) } @@ -107,9 +107,9 @@ module IntegrationsHelper } end - def integration_list_data(integrations) + def integration_list_data(integrations, group: nil, project: nil) { - integrations: integrations.map { |i| serialize_integration(i) }.to_json + integrations: integrations.map { |i| serialize_integration(i, group: group, project: project) }.to_json } end @@ -215,13 +215,13 @@ module IntegrationsHelper end end - def serialize_integration(integration) + def serialize_integration(integration, group: nil, project: nil) { active: integration.operating?, title: integration.title, description: integration.description, updated_at: integration.updated_at, - edit_path: scoped_edit_integration_path(integration), + edit_path: scoped_edit_integration_path(integration, group: group, project: project), name: integration.to_param } end diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 01ae0ce4f31..8b26b646fdd 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -35,14 +35,7 @@ module InviteMembersHelper default_access_level: Gitlab::Access::GUEST } - experiment(:member_areas_of_focus, user: current_user) do |e| - e.publish_to_database - - e.control { dataset.merge!(areas_of_focus_options: [], no_selection_areas_of_focus: []) } - e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) } - end - - if show_invite_members_for_task? + if show_invite_members_for_task?(source) dataset.merge!( tasks_to_be_done_options: tasks_to_be_done_options.to_json, projects: projects_for_source(source).to_json, @@ -55,35 +48,16 @@ module InviteMembersHelper private - def member_areas_of_focus_options - [ - { - value: 'Contribute to the codebase', text: s_('InviteMembersModal|Contribute to the codebase') - }, - { - value: 'Collaborate on open issues and merge requests', text: s_('InviteMembersModal|Collaborate on open issues and merge requests') - }, - { - value: 'Configure CI/CD', text: s_('InviteMembersModal|Configure CI/CD') - }, - { - value: 'Configure security features', text: s_('InviteMembersModal|Configure security features') - }, - { - value: 'Other', text: s_('InviteMembersModal|Other') - } - ] - end - # Overridden in EE def users_filter_data(group) {} end - def show_invite_members_for_task? - return unless current_user && experiment(:invite_members_for_task).enabled? + def show_invite_members_for_task?(source) + return unless current_user - params[:open_modal] == 'invite_members_for_task' + invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).variant.name == 'candidate' + params[:open_modal] == 'invite_members_for_task' || invite_for_help_continuous_onboarding end def tasks_to_be_done_options diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb index 6b546d5c6fc..6c23f888823 100644 --- a/app/helpers/issuables_description_templates_helper.rb +++ b/app/helpers/issuables_description_templates_helper.rb @@ -6,7 +6,7 @@ module IssuablesDescriptionTemplatesHelper def template_dropdown_tag(issuable, &block) selected_template = selected_template(issuable) - title = selected_template || "Choose a template" + title = selected_template || _('Choose a template') options = { toggle_class: 'js-issuable-selector', title: title, diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 07f5adae272..53a7487741e 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -80,7 +80,7 @@ module IssuablesHelper def users_dropdown_label(selected_users) case selected_users.length when 0 - "Unassigned" + _('Unassigned') when 1 selected_users[0].name else @@ -133,7 +133,7 @@ module IssuablesHelper end # rubocop: enable CodeReuse/ActiveRecord - def milestone_dropdown_label(milestone_title, default_label = "Milestone") + def milestone_dropdown_label(milestone_title, default_label = _('Milestone')) title = case milestone_title when Milestone::Upcoming.name then Milestone::Upcoming.title @@ -188,7 +188,12 @@ module IssuablesHelper end def issuables_state_counter_text(issuable_type, state, display_count) - titles = { opened: "Open" } + titles = { + opened: _("Open"), + closed: _("Closed"), + merged: _("Merged"), + all: _("All") + } state_title = titles[state] || state.to_s.humanize html = content_tag(:span, state_title) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index a88ca6f6b11..cddf740a0e6 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -193,11 +193,13 @@ module IssuesHelper { can_create_issue: show_new_issue_link?(project).to_s, can_create_incident: create_issue_type_allowed?(project, :incident).to_s, + can_destroy_issue: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable).to_s, can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s, can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s, can_update_issue: can?(current_user, :update_issue, issuable).to_s, iid: issuable.iid, is_issue_author: (issuable.author == current_user).to_s, + issue_path: issuable_path(issuable), issue_type: issuable_display_type(issuable), new_issue_path: new_project_issue_path(project, new_issuable_params), project_path: project.full_path, @@ -212,6 +214,8 @@ module IssuesHelper calendar_path: url_for(safe_params.merge(calendar_url_options)), empty_state_svg_path: image_path('illustrations/issues.svg'), full_path: namespace.full_path, + is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s, + is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s, is_signed_in: current_user.present?.to_s, jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: url_for(safe_params.merge(rss_url_options)), diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index 475469a6df9..9a0f0944fd1 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -8,7 +8,8 @@ module JiraConnectHelper groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }), subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json, subscriptions_path: jira_connect_subscriptions_path, - users_path: current_user ? nil : jira_connect_users_path + users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in + gitlab_user_path: current_user ? user_path(current_user) : nil } end diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 08a30c4d53b..7f8f6d77ff4 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -10,7 +10,8 @@ module LearnGitlabHelper def learn_gitlab_data(project) { actions: onboarding_actions_data(project).to_json, - sections: onboarding_sections_data.to_json + sections: onboarding_sections_data.to_json, + project: onboarding_project_data(project).to_json } end @@ -56,6 +57,10 @@ module LearnGitlabHelper } end + def onboarding_project_data(project) + { name: project.name } + end + def action_urls LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS) diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index f185d6cd002..f16d9f6325b 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -181,7 +181,7 @@ module MarkupHelper wiki: wiki, repository: wiki.repository, page_slug: wiki_page.slug, - issuable_state_filter_enabled: true + issuable_reference_expansion_enabled: true ).merge(render_wiki_content_context_container(wiki)) end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index d5d692f2d6e..abb7128470f 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -182,7 +182,7 @@ module MergeRequestsHelper project_path: project_path(merge_request.project), changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'), is_fluid_layout: fluid_layout.to_s, - dismiss_endpoint: user_callouts_path, + dismiss_endpoint: callouts_path, show_suggest_popover: show_suggest_popover?.to_s, show_whitespace_default: @show_whitespace_default.to_s, file_by_file_default: @file_by_file_default.to_s, diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 106df168080..6acec417a75 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -88,6 +88,13 @@ module NamespacesHelper group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend end + def namespaces_as_json(selected = :current_user) + { + group: formatted_namespaces(current_user.manageable_groups_with_routes), + user: formatted_namespaces([current_user.namespace]) + }.to_json + end + private # Many importers create a temporary Group, so use the real @@ -119,6 +126,17 @@ module NamespacesHelper [group_label.camelize, elements] end + + def formatted_namespaces(namespaces) + namespaces.sort_by(&:human_name).map! do |n| + { + id: n.id, + display_path: n.full_path, + human_name: n.human_name, + name: n.name + } + end + end end NamespacesHelper.prepend_mod_with('NamespacesHelper') diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index e7d69c38a54..715a5a02b50 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -50,7 +50,7 @@ module Nav menu_items.push(create_epic_menu_item(group)) - if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can?(current_user, :admin_group_member, group) + if can?(current_user, :admin_group_member, group) menu_items.push( invite_members_menu_item( href: group_group_members_path(group) @@ -101,7 +101,7 @@ module Nav ) end - if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can_admin_project_member?(project) + if can_admin_project_member?(project) menu_items.push( invite_members_menu_item( href: project_project_members_path(project) @@ -161,12 +161,11 @@ module Nav ::Gitlab::Nav::TopNavMenuItem.build( id: 'invite', title: s_('InviteMember|Invite members'), - emoji: ('shaking_hands' if experiment_enabled?(:invite_members_new_dropdown)), + emoji: 'shaking_hands', href: href, data: { - track_action: 'click_link', - track_label: tracking_label, - track_property: experiment_tracking_category_and_group(:invite_members_new_dropdown) + track_action: 'click_link_invite_members', + track_label: 'plus_menu_dropdown' } ) end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 9db28b54fe9..ddaef4652b4 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -67,7 +67,6 @@ module NotificationsHelper when :custom _('You will only receive notifications for the events you choose') when :owner_disabled - # Any change must be reflected in board_sidebar_subscription.vue _('Notifications have been disabled by the project or group owner') end end diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index ed96f3cef4f..c0ba93f4a30 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -20,21 +20,4 @@ module NotifyHelper (source.description || default_description).truncate(200, separator: ' ') end - - def invited_join_url(token, member) - additional_params = { invite_type: Emails::Members::INITIAL_INVITE } - - # order important below to our scheduled testing of these - # `from` experiment will be after the `text` on, but we may not cleanup - # from the `text` one by the time we run the `from` experiment, - # therefore we want to support `text` being fully enabled - # but if `from` is also enabled, then we only care about `from` - if experiment(:invite_email_from, actor: member).enabled? - additional_params[:experiment_name] = 'invite_email_from' - elsif experiment(:invite_email_preview_text, actor: member).enabled? - additional_params[:experiment_name] = 'invite_email_preview_text' - end - - invite_url(token, additional_params) - end end diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 5d2f225edcf..baeb9a477c3 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -16,7 +16,7 @@ module OperationsHelper { 'prometheus_activated' => prometheus_integration.manual_configuration?.to_s, - 'prometheus_form_path' => scoped_integration_path(prometheus_integration), + 'prometheus_form_path' => scoped_integration_path(prometheus_integration, project: prometheus_integration.project, group: prometheus_integration.group), 'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project), 'prometheus_authorization_key' => @project.alerting_setting&.token, 'prometheus_api_url' => prometheus_integration.api_url, diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index c69d9eb1326..66f80e7eeb8 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -38,17 +38,6 @@ module PackagesHelper "#{Gitlab.config.gitlab.host}/#{group_id}" end - def packages_list_data(type, resource) - { - resource_id: resource.id, - full_path: resource.full_path, - page_type: type, - empty_list_help_url: help_page_path('user/packages/package_registry/index'), - empty_list_illustration: image_path('illustrations/no-packages.svg'), - package_help_url: help_page_path('user/packages/index') - } - end - def track_package_event(event_name, scope, **args) ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute category = args.delete(:category) || self.class.name diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 09fc1ab9d50..0d514773891 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -61,6 +61,11 @@ module ProfilesHelper def ssh_key_expires_field_description s_('Profiles|Key can still be used after expiration.') end + + # Overridden in EE::ProfilesHelper#ssh_key_expiration_policy_enabled? + def ssh_key_expiration_policy_enabled? + false + end end ProfilesHelper.prepend_mod diff --git a/app/helpers/projects/cluster_agents_helper.rb b/app/helpers/projects/cluster_agents_helper.rb index 20fa721cc3b..aeeab250c7a 100644 --- a/app/helpers/projects/cluster_agents_helper.rb +++ b/app/helpers/projects/cluster_agents_helper.rb @@ -4,7 +4,8 @@ module Projects::ClusterAgentsHelper def js_cluster_agent_details_data(agent_name, project) { agent_name: agent_name, - project_path: project.full_path + project_path: project.full_path, + activity_empty_state_image: image_path('illustrations/empty-state/empty-state-agents.svg') } end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 8366b25d2bc..827d2cb7164 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -120,6 +120,15 @@ module ProjectsHelper { project_full_name: project.full_name } end + def remove_fork_project_confirm_json(project, remove_form_id) + { + remove_form_id: remove_form_id, + button_text: _('Remove fork relationship'), + confirm_danger_message: remove_fork_project_warning_message(project), + phrase: @project.path + } + end + def visible_fork_source(project) project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source) end @@ -405,6 +414,16 @@ module ProjectsHelper project.path_with_namespace end + def fork_button_disabled_tooltip(project) + return unless current_user + + if !current_user.can?(:fork_project, project) + s_("ProjectOverview|You don't have permission to fork this project") + elsif !current_user.can?(:create_fork) + s_('ProjectOverview|You have reached your project limit') + end + end + private def tab_ability_map diff --git a/app/helpers/routing/graphql_helper.rb b/app/helpers/routing/graphql_helper.rb index beefbb9b387..2e1d084e3cc 100644 --- a/app/helpers/routing/graphql_helper.rb +++ b/app/helpers/routing/graphql_helper.rb @@ -9,5 +9,9 @@ module Routing def graphql_etag_pipeline_sha_path(sha) [api_graphql_path, "pipelines/sha/#{sha}"].join(':') end + + def graphql_etag_project_on_demand_scan_counts_path(project) + [api_graphql_path, "on_demand_scan/counts/#{project.full_path}"].join(':') + end end end diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index ac30669dc83..fd9907edc37 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -3,7 +3,10 @@ module Routing module PseudonymizationHelper class MaskHelper - QUERY_PARAMS_TO_NOT_MASK = %w[].freeze + QUERY_PARAMS_TO_NOT_MASK = %w[ + scope + state + ].freeze def initialize(request_object, group, project) @request = request_object @@ -69,12 +72,10 @@ module Routing end end - def masked_page_url + def masked_page_url(group:, project:) return unless Feature.enabled?(:mask_page_urls, type: :ops) - current_group = group if defined?(group) - current_project = project if defined?(project) - mask_helper = MaskHelper.new(request, current_group, current_project) + mask_helper = MaskHelper.new(request, group, project) mask_helper.mask_params # We rescue all exception for time being till we test this helper extensively. diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index b28e5ff39b2..fb30e8ca059 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -70,6 +70,15 @@ 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 projects_sort_option_titles # Only used for the project filter search bar projects_sort_options_hash.merge({ @@ -93,6 +102,15 @@ module SortingHelper } end + def forks_reverse_sort_options_hash + { + sort_value_recently_created => sort_value_oldest_created, + sort_value_oldest_created => sort_value_recently_created, + sort_value_latest_activity => sort_value_oldest_activity, + sort_value_oldest_activity => sort_value_latest_activity + } + end + def groups_sort_options_hash { sort_value_name => sort_title_name, @@ -303,6 +321,13 @@ module SortingHelper 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) + + sort_direction_button(url, reverse_sort, sort_value) + end end SortingHelper.prepend_mod_with('SortingHelper') diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 1d8b657025c..f2e1d158c2d 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -40,7 +40,9 @@ module SystemNoteHelper 'new_alert_added' => 'warning', 'severity' => 'information-o', 'cloned' => 'documents', - 'issue_type' => 'pencil-square' + 'issue_type' => 'pencil-square', + 'attention_requested' => 'user', + 'attention_request_removed' => 'user' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index e53e35baac3..2efc3f27dc7 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -14,8 +14,7 @@ module TabHelper gl_tabs_classes = %w[nav gl-tabs-nav] html_options = html_options.merge( - class: [*html_options[:class], gl_tabs_classes].join(' '), - role: 'tablist' + class: [*html_options[:class], gl_tabs_classes].join(' ') ) content = capture(&block) if block_given? @@ -54,7 +53,7 @@ module TabHelper extra_tab_classes = html_options.delete(:tab_class) tab_class = %w[nav-item].push(*extra_tab_classes) - content_tag(:li, class: tab_class, role: 'presentation') do + content_tag(:li, class: tab_class) do if block_given? link_to(options, html_options, &block) else @@ -63,6 +62,19 @@ module TabHelper end end + # Creates a <gl-badge> for use inside tabs. + # + # html_options - The html_options hash (default: {}) + def gl_tab_counter_badge(count, html_options = {}) + gl_badge_tag( + count, + { size: :sm }, + html_options.merge( + class: ['gl-tab-counter-badge', *html_options[:class]] + ) + ) + end + # Navigation link helper # # Returns an `li` element with an 'active' class if the supplied @@ -150,7 +162,7 @@ module TabHelper action = options.delete(:action) route_matches_paths?(options.delete(:path)) || - route_matches_pages?(options.delete(:page)) || + route_matches_page_without_exclusion?(options.delete(:page), options.delete(:exclude_page)) || route_matches_controllers_and_or_actions?(controller, action) end @@ -175,6 +187,13 @@ module TabHelper end end + def route_matches_page_without_exclusion?(pages, exclude_page) + return false unless route_matches_pages?(pages) + return true unless exclude_page.present? + + !route_matches_pages?(exclude_page) + end + def route_matches_pages?(pages) Array(pages).compact.any? do |single_page| # We need to distinguish between Hash argument and other types of @@ -211,12 +230,3 @@ module TabHelper current_page?(options) end end - -def gl_tab_counter_badge(count, html_options = {}) - badge_classes = %w[badge badge-muted badge-pill gl-badge sm gl-tab-counter-badge] - content_tag(:span, - count, - class: [*html_options[:class], badge_classes].join(' '), - data: html_options[:data] - ) -end diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index db355f5ff65..d16f13304e5 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -3,6 +3,7 @@ module TimeZoneHelper TIME_ZONE_FORMAT_ATTRS = { short: %i[identifier name offset], + abbr: %i[identifier abbr], full: %i[identifier name abbr offset formatted_offset] }.freeze private_constant :TIME_ZONE_FORMAT_ATTRS @@ -32,7 +33,7 @@ module TimeZoneHelper end end - def local_time_instance(timezone) + def local_timezone_instance(timezone) return Time.zone if timezone.blank? ActiveSupport::TimeZone.new(timezone) || Time.zone @@ -41,7 +42,7 @@ module TimeZoneHelper def local_time(timezone) return if timezone.blank? - time_zone_instance = local_time_instance(timezone) + time_zone_instance = local_timezone_instance(timezone) time_zone_instance.now.strftime("%-l:%M %p") end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index d1f33f99ad0..d089b540282 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -81,7 +81,7 @@ module TreeHelper end def commit_in_fork_help - _("A new branch will be created in your fork and a new merge request will be started.") + _("GitLab will create a branch in your fork and start a merge request.") end def commit_in_single_accessible_branch diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb deleted file mode 100644 index d8e69145c40..00000000000 --- a/app/helpers/user_callouts_helper.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module UserCalloutsHelper - GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' - GCP_SIGNUP_OFFER = 'gcp_signup_offer' - SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' - TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' - CUSTOMIZE_HOMEPAGE = 'customize_homepage' - FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' - REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' - UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' - INVITE_MEMBERS_BANNER = 'invite_members_banner' - SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' - - def show_gke_cluster_integration_callout?(project) - active_nav_link?(controller: sidebar_operations_paths) && - can?(current_user, :create_cluster, project) && - !user_dismissed?(GKE_CLUSTER_INTEGRATION) - end - - def show_gcp_signup_offer? - !user_dismissed?(GCP_SIGNUP_OFFER) - end - - def render_flash_user_callout(flash_type, message, feature_name) - render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name - end - - def render_dashboard_ultimate_trial(user) - end - - def render_two_factor_auth_recovery_settings_check - end - - def show_suggest_popover? - !user_dismissed?(SUGGEST_POPOVER_DISMISSED) - end - - def show_customize_homepage_banner? - current_user.default_dashboard? && !user_dismissed?(CUSTOMIZE_HOMEPAGE) - end - - def show_feature_flags_new_version? - !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) - end - - def show_unfinished_tag_cleanup_callout? - !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT) - end - - def show_registration_enabled_user_callout? - !Gitlab.com? && - current_user&.admin? && - signup_enabled? && - !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) - end - - def dismiss_two_factor_auth_recovery_settings_check - end - - def show_invite_banner?(group) - Ability.allowed?(current_user, :admin_group, group) && - !just_created? && - !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) && - !multiple_members?(group) - end - - def show_security_newsletter_user_callout? - current_user&.admin? && - !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) - end - - private - - def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) - return false unless current_user - - current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) - end - - def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) - return false unless current_user - - current_user.dismissed_callout_for_group?(feature_name: feature_name, - group: group, - ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) - end - - def just_created? - flash[:notice]&.include?('successfully created') - end - - def multiple_members?(group) - group.member_count > 1 || group.members_with_parents.count > 1 - end -end - -UserCalloutsHelper.prepend_mod diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb new file mode 100644 index 00000000000..32b0d7b3fe3 --- /dev/null +++ b/app/helpers/users/callouts_helper.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Users + module CalloutsHelper + GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' + GCP_SIGNUP_OFFER = 'gcp_signup_offer' + SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' + TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' + FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' + REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' + UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' + SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' + + def show_gke_cluster_integration_callout?(project) + active_nav_link?(controller: sidebar_operations_paths) && + can?(current_user, :create_cluster, project) && + !user_dismissed?(GKE_CLUSTER_INTEGRATION) + end + + def show_gcp_signup_offer? + !user_dismissed?(GCP_SIGNUP_OFFER) + end + + def render_flash_user_callout(flash_type, message, feature_name) + render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name + end + + def render_dashboard_ultimate_trial(user) + end + + def render_two_factor_auth_recovery_settings_check + end + + def show_suggest_popover? + !user_dismissed?(SUGGEST_POPOVER_DISMISSED) + end + + def show_feature_flags_new_version? + !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) + end + + def show_unfinished_tag_cleanup_callout? + !user_dismissed?(UNFINISHED_TAG_CLEANUP_CALLOUT) + end + + def show_registration_enabled_user_callout? + !Gitlab.com? && + current_user&.admin? && + signup_enabled? && + !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) + end + + def dismiss_two_factor_auth_recovery_settings_check + end + + def show_security_newsletter_user_callout? + current_user&.admin? && + !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) + end + + private + + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) + return false unless current_user + + current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + end + end +end + +Users::CalloutsHelper.prepend_mod diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb new file mode 100644 index 00000000000..b66c7f9f821 --- /dev/null +++ b/app/helpers/users/group_callouts_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Users + module GroupCalloutsHelper + INVITE_MEMBERS_BANNER = 'invite_members_banner' + + def show_invite_banner?(group) + Ability.allowed?(current_user, :admin_group, group) && + !just_created? && + !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) && + !multiple_members?(group) + end + + private + + def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil) + return false unless current_user + + current_user.dismissed_callout_for_group?(feature_name: feature_name, + group: group, + ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + end + + def just_created? + flash[:notice]&.include?('successfully created') + end + + def multiple_members?(group) + group.member_count > 1 || group.members_with_parents.count > 1 + end + end +end diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index f8d7264d4cc..7875b9e4a28 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -6,7 +6,7 @@ module VersionCheckHelper return unless Gitlab::CurrentSettings.version_check_enabled return if User.single_user&.requires_usage_stats_consent? - image_tag VersionCheck.url, class: 'js-version-status-badge' + image_tag VersionCheck.image_url, class: 'js-version-status-badge' end def link_to_version diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb index 4afc5643af4..1a9dbefceef 100644 --- a/app/helpers/x509_helper.rb +++ b/app/helpers/x509_helper.rb @@ -18,6 +18,6 @@ module X509Helper end def x509_signature?(sig) - sig.is_a?(X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature) + sig.is_a?(CommitSignatures::X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature) end end diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb index e745cd51a55..317e1545350 100644 --- a/app/mailers/emails/in_product_marketing.rb +++ b/app/mailers/emails/in_product_marketing.rb @@ -29,7 +29,7 @@ module Emails format.html do @message.format = :html - render layout: nil + render layout: 'in_product_marketing_mailer' end format.text do @@ -41,3 +41,5 @@ module Emails end end end + +Emails::InProductMarketing.prepend_mod diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 51c4779d8cf..bbc4be3b324 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -5,18 +5,18 @@ module Emails def new_issue_email(recipient_id, issue_id, reason = nil) setup_issue_mail(issue_id, recipient_id) - mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason)) + mail_new_thread(@issue, issue_thread_options(@issue.author_id, reason)) end def issue_due_email(recipient_id, issue_id, reason = nil) setup_issue_mail(issue_id, recipient_id) - mail_answer_thread(@issue, issue_thread_options(@issue.author_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(@issue.author_id, reason)) end def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil) setup_issue_mail(issue_id, recipient_id) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end # rubocop: disable CodeReuse/ActiveRecord @@ -26,7 +26,7 @@ module Emails @previous_assignees = [] @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end # rubocop: enable CodeReuse/ActiveRecord @@ -34,9 +34,8 @@ module Emails setup_issue_mail(issue_id, recipient_id, closed_via: closed_via) @updated_by = User.find(updated_by_user_id) - @recipient = User.find(recipient_id) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id, reason = nil) @@ -44,13 +43,13 @@ module Emails @label_names = label_names @labels_url = project_labels_url(@project) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end def removed_milestone_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil) setup_issue_mail(issue_id, recipient_id) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end def changed_milestone_issue_email(recipient_id, issue_id, milestone, updated_by_user_id, reason = nil) @@ -58,7 +57,7 @@ module Emails @milestone = milestone @milestone_url = milestone_url(@milestone) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason).merge({ + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason).merge({ template_name: 'changed_milestone_email' })) end @@ -68,7 +67,7 @@ module Emails @issue_status = status @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason)) end def issue_moved_email(recipient, issue, new_issue, updated_by_user, reason = nil) @@ -77,7 +76,7 @@ module Emails @new_issue = new_issue @new_project = new_issue.project @can_access_project = recipient.can?(:read_project, @new_project) - mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) + mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason)) end def issue_cloned_email(recipient, issue, new_issue, updated_by_user, reason = nil) @@ -87,7 +86,7 @@ module Emails @issue = issue @new_issue = new_issue @can_access_project = recipient.can?(:read_project, @new_issue.project) - mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) + mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason)) end def import_issues_csv_email(user_id, project_id, results) @@ -124,14 +123,15 @@ module Emails @project = @issue.project @target_url = project_issue_url(@project, @issue) @closed_via = closed_via + @recipient = User.find(recipient_id) @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end - def issue_thread_options(sender_id, recipient_id, reason) + def issue_thread_options(sender_id, reason) { from: sender(sender_id), - to: User.find(recipient_id).notification_email_for(@project.group), + to: @recipient.notification_email_for(@project.group), subject: subject("#{@issue.title} (##{@issue.iid})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 8a9ed557cc6..ef2220751bf 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -61,7 +61,7 @@ module Emails Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s) - mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers.merge(additional_invite_settings)) do |format| + mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format| format.html { render layout: 'unknown_user_mailer' } format.text { render layout: 'unknown_user_mailer' } end @@ -151,17 +151,7 @@ module Emails def invite_email_subject if member.created_by - experiment(:invite_email_from, actor: member) do |experiment_instance| - experiment_instance.use do - subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) - end - - experiment_instance.candidate do - subject(s_("MemberInviteEmail|I've invited you to join me in GitLab")) - end - - experiment_instance.run - end + subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) else subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular }) end @@ -178,21 +168,6 @@ module Emails end end - def additional_invite_settings - return {} unless member.created_by - - experiment(:invite_email_from, actor: member) do |experiment_instance| - experiment_instance.use { {} } - experiment_instance.candidate do - { - from: "#{member.created_by.name} <#{member.created_by.email}>" - } - end - - experiment_instance.run - end - end - def member_exists? Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank? member.present? diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 2746b8b7188..d2e710cc329 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -5,13 +5,13 @@ module Emails def new_merge_request_email(recipient_id, merge_request_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id, present: true) - mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason)) + mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, reason)) end def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id, present: true) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], existing_commits: []) @@ -20,7 +20,7 @@ module Emails @existing_commits = existing_commits @updated_by_user = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def change_in_merge_request_draft_status_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) @@ -28,7 +28,7 @@ module Emails @updated_by_user = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end # rubocop: disable CodeReuse/ActiveRecord @@ -38,7 +38,7 @@ module Emails @previous_assignees = [] @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end # rubocop: enable CodeReuse/ActiveRecord @@ -49,7 +49,7 @@ module Emails @previous_reviewers = [] @previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any? - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end # rubocop: enable CodeReuse/ActiveRecord @@ -58,13 +58,13 @@ module Emails @label_names = label_names @labels_url = project_labels_url(@project) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def removed_milestone_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def changed_milestone_merge_request_email(recipient_id, merge_request_id, milestone, updated_by_user_id, reason = nil) @@ -72,7 +72,7 @@ module Emails @milestone = milestone @milestone_url = milestone_url(@milestone) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason).merge({ + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason).merge({ template_name: 'changed_milestone_email' })) end @@ -81,20 +81,27 @@ module Emails setup_merge_request_mail(merge_request_id, recipient_id) @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def request_review_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) + end + + def attention_requested_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) + setup_merge_request_mail(merge_request_id, recipient_id) + + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id, reason = nil) @@ -102,27 +109,27 @@ module Emails @mr_status = status @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end def merge_request_unmergeable_email(recipient_id, merge_request_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, reason)) end def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) @resolved_by = User.find(resolved_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, reason)) end def merge_when_pipeline_succeeds_email(recipient_id, merge_request_id, mwps_set_by_user_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) @mwps_set_by = ::User.find(mwps_set_by_user_id) - mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, reason)) end def merge_requests_csv_email(user, project, csv_data, export_status) @@ -147,19 +154,19 @@ module Emails @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project @target_url = project_merge_request_url(@project, @merge_request) + @recipient = User.find(recipient_id) if present - recipient = User.find(recipient_id) - @mr_presenter = @merge_request.present(current_user: recipient) + @mr_presenter = @merge_request.present(current_user: @recipient) end @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end - def merge_request_thread_options(sender_id, recipient_id, reason = nil) + def merge_request_thread_options(sender_id, reason = nil) { from: sender(sender_id), - to: User.find(recipient_id).notification_email_for(@project.group), + to: @recipient.notification_email_for(@project.group), subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"), 'X-GitLab-NotificationReason' => reason } diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 587c1479286..1e254a32885 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -7,7 +7,7 @@ module Emails @commit = @note.noteable @target_url = project_commit_url(*note_target_url_options) - mail_answer_note_thread(@commit, @note, note_thread_options(recipient_id, reason)) + mail_answer_note_thread(@commit, @note, note_thread_options(reason)) end def note_issue_email(recipient_id, note_id, reason = nil) @@ -15,7 +15,7 @@ module Emails @issue = @note.noteable @target_url = project_issue_url(*note_target_url_options) - mail_answer_note_thread(@issue, @note, note_thread_options(recipient_id, reason)) + mail_answer_note_thread(@issue, @note, note_thread_options(reason)) end def note_merge_request_email(recipient_id, note_id, reason = nil) @@ -23,7 +23,7 @@ module Emails @merge_request = @note.noteable @target_url = project_merge_request_url(*note_target_url_options) - mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id, reason)) + mail_answer_note_thread(@merge_request, @note, note_thread_options(reason)) end def note_snippet_email(recipient_id, note_id, reason = nil) @@ -37,7 +37,7 @@ module Emails @target_url = gitlab_snippet_url(@note.noteable) end - mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason)) + mail_answer_note_thread(@snippet, @note, note_thread_options(reason)) end def note_design_email(recipient_id, note_id, reason = nil) @@ -49,7 +49,7 @@ module Emails design.issue, note_target_url_query_params.merge(vueroute: design.filename) ) - mail_answer_note_thread(design, @note, note_thread_options(recipient_id, reason)) + mail_answer_note_thread(design, @note, note_thread_options(reason)) end private @@ -62,10 +62,10 @@ module Emails { anchor: "note_#{@note.id}" } end - def note_thread_options(recipient_id, reason) + def note_thread_options(reason) { from: sender(@note.author_id), - to: User.find(recipient_id).notification_email_for(@project&.group || @group), + to: @recipient.notification_email_for(@project&.group || @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"), 'X-GitLab-NotificationReason' => reason } @@ -76,6 +76,7 @@ module Emails @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project @group = @note.noteable.try(:group) + @recipient = User.find(recipient_id) if (@project || @group) && @note.persisted? @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 06ba16f9724..14c724b5b91 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -59,6 +59,7 @@ module Emails def prometheus_alert_fired_email(project, user, alert) @project = project @alert = alert.present + @incident = alert.issue add_project_headers add_alert_headers @@ -80,11 +81,10 @@ module Emails end def add_incident_headers - incident = @alert.issue - return unless incident + return unless @incident - headers['X-GitLab-Incident-ID'] = incident.id - headers['X-GitLab-Incident-IID'] = incident.iid + headers['X-GitLab-Incident-ID'] = @incident.id + headers['X-GitLab-Incident-IID'] = @incident.iid end end end diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb index c9c77ab9333..4875abafe8d 100644 --- a/app/mailers/emails/releases.rb +++ b/app/mailers/emails/releases.rb @@ -9,11 +9,10 @@ module Emails namespace_id: @project.namespace, project_id: @project ) - - user = User.find(user_id) + @recipient = User.find(user_id) mail( - to: user.notification_email_for(@project.group), + to: @recipient.notification_email_for(@project.group), subject: subject(release_email_subject) ) end diff --git a/app/models/active_session.rb b/app/models/active_session.rb index a0e74c7f48e..0094d98fb73 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -4,7 +4,7 @@ # # The raw session information is stored by the Rails session store # (config/initializers/session_store.rb). These entries are accessible by the -# rack_key_name class method and consistute the base of the session data +# rack_key_name class method and constitute the base of the session data # entries. All other entries in the session store can be traced back to these # entries. # @@ -21,14 +21,24 @@ # class ActiveSession include ActiveModel::Model + include ::Gitlab::Redis::SessionsStoreHelper SESSION_BATCH_SIZE = 200 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 - attr_accessor :created_at, :updated_at, - :ip_address, :browser, :os, - :device_name, :device_type, - :is_impersonated, :session_id, :session_private_id + attr_accessor :ip_address, :browser, :os, + :device_name, :device_type, + :is_impersonated, :session_id, :session_private_id + + attr_reader :created_at, :updated_at + + def created_at=(time) + @created_at = time.is_a?(String) ? Time.zone.parse(time) : time + end + + def updated_at=(time) + @updated_at = time.is_a?(String) ? Time.zone.parse(time) : time + end def current?(rack_session) return false if session_private_id.nil? || rack_session.id.nil? @@ -38,15 +48,29 @@ class ActiveSession session_private_id == rack_session.id.private_id end + def eql?(other) + other.is_a?(self.class) && id == other.id + end + alias_method :==, :eql? + + def id + session_private_id.presence || session_id + end + + def ids + [session_private_id, session_id].compact + end + def human_device_type device_type&.titleize end def self.set(user, request) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| session_private_id = request.session.id.private_id client = DeviceDetector.new(request.user_agent) timestamp = Time.current + expiry = Settings.gitlab['session_expire_delay'] * 60 active_user_session = new( ip_address: request.remote_ip, @@ -63,7 +87,14 @@ class ActiveSession redis.pipelined do redis.setex( key_name(user.id, session_private_id), - Settings.gitlab['session_expire_delay'] * 60, + expiry, + active_user_session.dump + ) + + # Deprecated legacy format - temporary to support mixed deployments + redis.setex( + key_name_v1(user.id, session_private_id), + expiry, Marshal.dump(active_user_session) ) @@ -76,7 +107,7 @@ class ActiveSession end def self.list(user) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| cleaned_up_lookup_entries(redis, user).map do |raw_session| load_raw_session(raw_session) end @@ -84,14 +115,17 @@ class ActiveSession end def self.cleanup(user) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| clean_up_old_sessions(redis, user) cleaned_up_lookup_entries(redis, user) end end def self.destroy_sessions(redis, user, session_ids) + return if session_ids.empty? + key_names = session_ids.map { |session_id| key_name(user.id, session_id) } + key_names += session_ids.map { |session_id| key_name_v1(user.id, session_id) } redis.srem(lookup_key_name(user.id), session_ids) @@ -104,7 +138,7 @@ class ActiveSession def self.destroy_session(user, session_id) return unless session_id - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| destroy_sessions(redis, user, [session_id].compact) end end @@ -113,26 +147,31 @@ class ActiveSession sessions = not_impersonated(user) sessions.reject! { |session| session.current?(current_rack_session) } if current_rack_session - Gitlab::Redis::SharedState.with do |redis| - session_ids = (sessions.map(&:session_id) | sessions.map(&:session_private_id)).compact + redis_store_class.with do |redis| + session_ids = sessions.flat_map(&:ids) destroy_sessions(redis, user, session_ids) if session_ids.any? end end - def self.not_impersonated(user) + private_class_method def self.not_impersonated(user) list(user).reject(&:is_impersonated) end - def self.rack_key_name(session_id) - "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" + private_class_method def self.rack_key_name(session_id) + "#{Gitlab::Redis::Sessions::SESSION_NAMESPACE}:#{session_id}" end def self.key_name(user_id, session_id = '*') - "#{Gitlab::Redis::SharedState::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" + "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}::v2:#{user_id}:#{session_id}" + end + + # Deprecated + def self.key_name_v1(user_id, session_id = '*') + "#{Gitlab::Redis::Sessions::USER_SESSIONS_NAMESPACE}:#{user_id}:#{session_id}" end def self.lookup_key_name(user_id) - "#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}" + "#{Gitlab::Redis::Sessions::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}" end def self.list_sessions(user) @@ -143,7 +182,7 @@ class ActiveSession # # Returns an array of strings def self.session_ids_for_user(user_id) - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| redis.smembers(lookup_key_name(user_id)) end end @@ -156,7 +195,7 @@ class ActiveSession def self.sessions_from_ids(session_ids) return [] if session_ids.empty? - Gitlab::Redis::SharedState.with do |redis| + redis_store_class.with do |redis| session_keys = rack_session_keys(session_ids) session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch| @@ -169,71 +208,102 @@ class ActiveSession end end - # Deserializes a session Hash object from Redis. - # + def dump + "v2:#{Gitlab::Json.dump(self)}" + end + + # Private: + # raw_session - Raw bytes from Redis # - # Returns an ActiveSession object - def self.load_raw_session(raw_session) - # rubocop:disable Security/MarshalLoad - Marshal.load(raw_session) - # rubocop:enable Security/MarshalLoad + # Returns an instance of this class + private_class_method def self.load_raw_session(raw_session) + return unless raw_session + + if raw_session.start_with?('v2:') + session_data = Gitlab::Json.parse(raw_session[3..]).symbolize_keys + new(**session_data) + else + # Deprecated legacy format. To be removed in 15.0 + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/30516 + # Explanation of why this Marshal.load call is OK: + # https://gitlab.com/gitlab-com/gl-security/appsec/appsec-reviews/-/issues/124#note_744576714 + # rubocop:disable Security/MarshalLoad + Marshal.load(raw_session) + # rubocop:enable Security/MarshalLoad + end end - def self.rack_session_keys(rack_session_ids) - rack_session_ids.map { |session_id| rack_key_name(session_id)} + private_class_method def self.rack_session_keys(rack_session_ids) + rack_session_ids.map { |session_id| rack_key_name(session_id) } end - def self.raw_active_session_entries(redis, session_ids, user_id) - return [] if session_ids.empty? + private_class_method def self.raw_active_session_entries(redis, session_ids, user_id) + return {} if session_ids.empty? + + found = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + session_ids.zip(redis.mget(entry_keys)).to_h + end - entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } + found.compact! + missing = session_ids - found.keys + return found if missing.empty? - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.mget(entry_keys) + fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + entry_keys = missing.map { |session_id| key_name_v1(user_id, session_id) } + missing.zip(redis.mget(entry_keys)).to_h end + + fallbacks.merge(found.compact) end - def self.active_session_entries(session_ids, user_id, redis) + private_class_method def self.active_session_entries(session_ids, user_id, redis) return [] if session_ids.empty? - entry_keys = raw_active_session_entries(redis, session_ids, user_id) - - entry_keys.compact.map do |raw_session| - load_raw_session(raw_session) - end + raw_active_session_entries(redis, session_ids, user_id) + .values + .compact + .map { load_raw_session(_1) } end - def self.clean_up_old_sessions(redis, user) + private_class_method def self.clean_up_old_sessions(redis, user) session_ids = session_ids_for_user(user.id) return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS - # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. sessions = active_session_entries(session_ids, user.id, redis) - sessions.sort_by! {|session| session.updated_at }.reverse! - destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) - destroyable_session_ids = destroyable_sessions.flat_map { |session| [session.session_id, session.session_private_id] }.compact - destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? + sessions.sort_by!(&:updated_at).reverse! + + # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. + destroyable_session_ids = sessions + .drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + .flat_map(&:ids) + + destroy_sessions(redis, user, destroyable_session_ids) end # Cleans up the lookup set by removing any session IDs that are no longer present. # # Returns an array of marshalled ActiveModel objects that are still active. - def self.cleaned_up_lookup_entries(redis, user) + # Records removed keys in the optional `removed` argument array. + def self.cleaned_up_lookup_entries(redis, user, removed = []) + lookup_key = lookup_key_name(user.id) session_ids = session_ids_for_user(user.id) - entries = raw_active_session_entries(redis, session_ids, user.id) + session_ids_and_entries = raw_active_session_entries(redis, session_ids, user.id) # remove expired keys. # only the single key entries are automatically expired by redis, the # lookup entries in the set need to be removed manually. - session_ids_and_entries = session_ids.zip(entries) - redis.pipelined do - session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry| - redis.srem(lookup_key_name(user.id), session_id) + redis.pipelined do |pipeline| + session_ids_and_entries.each do |session_id, entry| + next if entry + + pipeline.srem(lookup_key, session_id) + removed << session_id end end - entries.compact + session_ids_and_entries.values.compact end end diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index e8b03fa066a..8d3a032812e 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -26,6 +26,12 @@ module Analytics :project_id end + def self.distinct_stages_within_hierarchy(group) + with_preloaded_labels + .where(project_id: group.all_projects.select(:id)) + .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*") + end + private # Project should belong to a group when the stage has Label based events since only GroupLabels are allowed. diff --git a/app/models/application_record.rb b/app/models/application_record.rb index bcd8bdd6638..b64e6c59817 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -7,6 +7,10 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + # We should avoid using pluck https://docs.gitlab.com/ee/development/sql.html#plucking-ids + # but, if we are going to use it, let's try and limit the number of records + MAX_PLUCK = 1_000 + alias_method :reset, :reload def self.without_order diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index af5796d682f..65472615f42 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -21,7 +21,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token - add_authentication_token_field :static_objects_external_storage_auth_token + add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :optional belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id' belongs_to :push_rule @@ -144,10 +144,6 @@ class ApplicationSetting < ApplicationRecord length: { maximum: 2000, message: _('is too long (maximum is %{count} characters)') }, allow_blank: true - validates :spam_check_api_key, - presence: true, - if: :spam_check_endpoint_enabled - validates :unique_ips_limit_per_user, numericality: { greater_than_or_equal_to: 1 }, presence: true, @@ -410,7 +406,7 @@ class ApplicationSetting < ApplicationRecord if: :external_authorization_service_enabled validates :spam_check_endpoint_url, - addressable_url: { schemes: %w(grpc) }, allow_blank: true + addressable_url: { schemes: %w(tls grpc) }, allow_blank: true validates :spam_check_endpoint_url, presence: true, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 54ec8b2c3e4..5e20aac3b92 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -363,6 +363,14 @@ module ApplicationSettingImplementation super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end + def static_objects_external_storage_auth_token=(token) + if token.present? + set_static_objects_external_storage_auth_token(token) + else + self.static_objects_external_storage_auth_token_encrypted = nil + end + end + def performance_bar_allowed_group Group.find_by_id(performance_bar_allowed_group_id) end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 2368be6196c..38b7da76306 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -20,8 +20,6 @@ class BulkImports::Entity < ApplicationRecord self.table_name = 'bulk_import_entities' - EXPORT_RELATIONS_URL = '/%{resource}/%{full_path}/export_relations' - belongs_to :bulk_import, optional: false belongs_to :parent, class_name: 'BulkImports::Entity', optional: true @@ -104,18 +102,42 @@ class BulkImports::Entity < ApplicationRecord end end + def entity_type + source_type.gsub('_entity', '') + end + def pluralized_name - source_type.gsub('_entity', '').pluralize + entity_type.pluralize + end + + def base_resource_url_path + "/#{pluralized_name}/#{encoded_source_full_path}" end def export_relations_url_path - @export_relations_url_path ||= EXPORT_RELATIONS_URL % { resource: pluralized_name, full_path: encoded_source_full_path } + "#{base_resource_url_path}/export_relations" end def relation_download_url_path(relation) "#{export_relations_url_path}/download?relation=#{relation}" end + def wikis_url_path + "#{base_resource_url_path}/wikis" + end + + def project? + source_type == 'project_entity' + end + + def group? + source_type == 'group_entity' + end + + def update_service + "::#{pluralized_name.capitalize}::UpdateService".constantize + end + private def validate_parent_is_a_group diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb index 4d370315ad5..036d511bc59 100644 --- a/app/models/bulk_imports/file_transfer/base_config.rb +++ b/app/models/bulk_imports/file_transfer/base_config.rb @@ -5,6 +5,9 @@ module BulkImports class BaseConfig include Gitlab::Utils::StrongMemoize + UPLOADS_RELATION = 'uploads' + SELF_RELATION = 'self' + def initialize(portable) @portable = portable end @@ -26,7 +29,11 @@ module BulkImports end def portable_relations - tree_relations + file_relations - skipped_relations + tree_relations + file_relations + self_relation - skipped_relations + end + + def self_relation?(relation) + relation == SELF_RELATION end def tree_relation?(relation) @@ -43,6 +50,10 @@ module BulkImports portable_tree[:include].find { |include| include[relation.to_sym] } end + def portable_relations_tree + @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys + end + private attr_reader :portable @@ -65,10 +76,6 @@ module BulkImports @portable_class_sym ||= portable_class.to_s.demodulize.underscore.to_sym end - def portable_relations_tree - @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys - end - def import_export_yaml raise NotImplementedError end @@ -78,12 +85,16 @@ module BulkImports end def file_relations - [] + [UPLOADS_RELATION] end def skipped_relations [] end + + def self_relation + [SELF_RELATION] + end end end end diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb index 9a0434da08a..fdfb0dd0186 100644 --- a/app/models/bulk_imports/file_transfer/project_config.rb +++ b/app/models/bulk_imports/file_transfer/project_config.rb @@ -3,8 +3,6 @@ module BulkImports module FileTransfer class ProjectConfig < BaseConfig - UPLOADS_RELATION = 'uploads' - SKIPPED_RELATIONS = %w( project_members group_members @@ -14,10 +12,6 @@ module BulkImports ::Gitlab::ImportExport.config_file end - def file_relations - [UPLOADS_RELATION] - end - def skipped_relations SKIPPED_RELATIONS end diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index 9de3239ee0f..cfe33c013ba 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -29,7 +29,7 @@ class BulkImports::Tracker < ApplicationRecord def self.stage_running?(entity_id, stage) where(stage: stage, bulk_import_entity_id: entity_id) - .with_status(:created, :started) + .with_status(:created, :enqueued, :started) .exists? end @@ -45,15 +45,24 @@ class BulkImports::Tracker < ApplicationRecord state :created, value: 0 state :started, value: 1 state :finished, value: 2 + state :enqueued, value: 3 state :failed, value: -1 state :skipped, value: -2 event :start do - transition created: :started + transition enqueued: :started # To avoid errors when re-starting a pipeline in case of network errors transition started: :started end + event :retry do + transition started: :enqueued + end + + event :enqueue do + transition created: :enqueued + end + event :finish do transition started: :finished transition failed: :failed diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index da7312df18b..ff3f2663b73 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class ChatName < ApplicationRecord - include LooseForeignKey - LAST_USED_AT_INTERVAL = 1.hour belongs_to :integration, foreign_key: :service_id @@ -16,8 +14,6 @@ class ChatName < ApplicationRecord validates :user_id, uniqueness: { scope: [:service_id] } validates :chat_id, uniqueness: { scope: [:service_id, :team_id] } - loose_foreign_key :ci_pipeline_chat_data, :chat_name_id, on_delete: :async_delete - # Updates the "last_used_timestamp" but only if it wasn't already updated # recently. # diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3fdc44bccf3..428e440afba 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -10,6 +10,7 @@ module Ci include Presentable include Importable include Ci::HasRef + extend ::Gitlab::Utils::Override BuildArchivedError = Class.new(StandardError) @@ -58,7 +59,7 @@ module Ci has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build - has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', dependent: :nullify, inverse_of: :build, foreign_key: :ci_build_id # rubocop:disable Cop/ActiveRecordDependent + has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', inverse_of: :build, foreign_key: :ci_build_id accepts_nested_attributes_for :runner_session, update_only: true accepts_nested_attributes_for :job_variables @@ -164,6 +165,7 @@ module Ci scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) } scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) } + scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) } scope :last_month, -> { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } scope :scheduled_actions, -> { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) } @@ -188,8 +190,6 @@ module Ci scope :without_coverage, -> { where(coverage: nil) } scope :with_coverage_regex, -> { where.not(coverage_regex: nil) } - scope :for_project, -> (project_id) { where(project_id: project_id) } - acts_as_taggable add_authentication_token_field :token, encrypted: :required @@ -286,6 +286,7 @@ module Ci build.run_after_commit do BuildQueueWorker.perform_async(id) + BuildHooksWorker.perform_async(id) end end @@ -451,7 +452,7 @@ module Ci end def retryable? - return false if retried? || archived? + return false if retried? || archived? || deployment_rejected? success? || failed? || canceled? end @@ -722,6 +723,14 @@ module Ci self.token && ActiveSupport::SecurityUtils.secure_compare(token, self.token) end + # acts_as_taggable uses this method create/remove tags with contexts + # defined by taggings and to get those contexts it executes a query. + # We don't use any other contexts except `tags`, so we don't need it. + override :custom_contexts + def custom_contexts + [] + end + def tag_list if tags.loaded? tags.map(&:name) @@ -1074,6 +1083,16 @@ module Ci runner&.instance_type? end + def job_variables_attributes + strong_memoize(:job_variables_attributes) do + job_variables.internal_source.map do |variable| + variable.attributes.except('id', 'job_id', 'encrypted_value', 'encrypted_value_iv').tap do |attrs| + attrs[:value] = variable.value + end + end + end + end + protected def run_status_commit_hooks! diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ec1137920ef..e6dd62fab34 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -2,6 +2,7 @@ module Ci class JobArtifact < Ci::ApplicationRecord + include IgnorableColumns include AfterCommitQueue include ObjectStorage::BackgroundMove include UpdateProjectStatistics @@ -120,6 +121,9 @@ module Ci belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id + # We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597 + ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22' + mount_file_store_uploader JobArtifactUploader skip_callback :save, :after, :store_file!, if: :store_after_commit? @@ -133,6 +137,7 @@ module Ci scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) } scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) } + scope :for_job_ids, ->(job_ids) { where(job_id: job_ids) } scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) } scope :with_job, -> { joins(:job).includes(:job) } @@ -266,6 +271,10 @@ module Ci self.where(project: project).sum(:size) end + def self.distinct_job_ids + distinct.pluck(:job_id) + end + ## # FastDestroyAll concerns # rubocop: disable CodeReuse/ServiceClass diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb new file mode 100644 index 00000000000..8a4be3139e8 --- /dev/null +++ b/app/models/ci/namespace_mirror.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + # This model represents a record in a shadow table of the main database's namespaces table. + # It allows us to navigate the namespace hierarchy on the ci database without resorting to a JOIN. + class NamespaceMirror < ApplicationRecord + belongs_to :namespace + + scope :contains_namespace, -> (id) do + where('traversal_ids @> ARRAY[?]::int[]', id) + end + + class << self + def sync!(event) + namespace = event.namespace + traversal_ids = namespace.self_and_ancestor_ids(hierarchy_order: :desc) + + upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids }, + unique_by: :namespace_id) + + # It won't be necessary once we remove `sync_traversal_ids`. + # More info: https://gitlab.com/gitlab-org/gitlab/-/issues/347541 + sync_children_namespaces!(event.namespace_id, traversal_ids) + end + + private + + def sync_children_namespaces!(namespace_id, traversal_ids) + contains_namespace(namespace_id) + .where.not(namespace_id: namespace_id) + .update_all( + "traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]" + ) + end + end + end +end diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index ccad6290fac..41dc74ef050 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -30,6 +30,10 @@ module Ci self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id) end + def maintain_denormalized_data? + ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data, default_enabled: :yaml) + end + private def args_from_build(build) @@ -42,15 +46,9 @@ module Ci namespace: project.namespace } - if Feature.enabled?(:ci_pending_builds_maintain_tags_data, type: :development, default_enabled: :yaml) + if maintain_denormalized_data? args.store(:tag_ids, build.tags_ids) - end - - if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml) args.store(:instance_runners_enabled, shared_runners_enabled?(project)) - end - - if Feature.enabled?(:ci_pending_builds_maintain_namespace_traversal_ids, type: :development, default_enabled: :yaml) args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a29aa756e38..a90bd739741 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -63,6 +63,7 @@ module Ci has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :statuses_order_id_desc, -> { order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline @@ -82,8 +83,6 @@ module Ci # Merge requests for which the current pipeline is running against # the merge request's latest commit. has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' - has_many :package_build_infos, class_name: 'Packages::BuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent - has_many :package_file_build_infos, class_name: 'Packages::PackageFileBuildInfo', dependent: :nullify, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline @@ -236,7 +235,18 @@ module Ci pipeline.run_after_commit do PipelineHooksWorker.perform_async(pipeline.id) - ExpirePipelineCacheWorker.perform_async(pipeline.id) + + if pipeline.project.jira_subscription_exists? + # Passing the seq-id ensures this is idempotent + seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id + ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id) + end + + if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, pipeline.project, default_enabled: :yaml) + Ci::ExpirePipelineCacheService.new.execute(pipeline) # rubocop: disable CodeReuse/ServiceClass + else + ExpirePipelineCacheWorker.perform_async(pipeline.id) + end end end @@ -271,14 +281,6 @@ module Ci end end - after_transition any => any do |pipeline| - pipeline.run_after_commit do - # Passing the seq-id ensures this is idempotent - seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id - ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id) - end - end - after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| pipeline.run_after_commit do ::Ci::TestFailureHistoryService.new(pipeline).async.perform_if_needed # rubocop: disable CodeReuse/ServiceClass @@ -643,7 +645,7 @@ module Ci def coverage coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 - '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) + coverage_array.reduce(:+) / coverage_array.size end end @@ -947,22 +949,16 @@ module Ci end def environments_in_self_and_descendants - if ::Feature.enabled?(:avoid_cross_joins_environments_in_self_and_descendants, default_enabled: :yaml) - # We limit to 100 unique environments for application safety. - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 - expanded_environment_names = - builds_in_self_and_descendants.joins(:metadata) - .where.not('ci_builds_metadata.expanded_environment_name' => nil) - .distinct('ci_builds_metadata.expanded_environment_name') - .limit(100) - .pluck(:expanded_environment_name) - - Environment.where(project: project, name: expanded_environment_names) - else - environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id') + # We limit to 100 unique environments for application safety. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 + expanded_environment_names = + builds_in_self_and_descendants.joins(:metadata) + .where.not('ci_builds_metadata.expanded_environment_name' => nil) + .distinct('ci_builds_metadata.expanded_environment_name') + .limit(100) + .pluck(:expanded_environment_name) - Environment.where(id: environment_ids) - end + Environment.where(project: project, name: expanded_environment_names).with_deployment(sha) end # With multi-project and parent-child pipelines @@ -1276,18 +1272,18 @@ module Ci self.builds.latest.build_matchers(project) end - def predefined_vars_in_builder_enabled? - strong_memoize(:predefined_vars_in_builder_enabled) do - Feature.enabled?(:ci_predefined_vars_in_builder, project, default_enabled: :yaml) - end - end - def authorized_cluster_agents strong_memoize(:authorized_cluster_agents) do ::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent) end end + def create_deployment_in_separate_transaction? + strong_memoize(:create_deployment_in_separate_transaction) do + ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml) + end + end + private def add_message(severity, content) diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb new file mode 100644 index 00000000000..d6aaa3f50c1 --- /dev/null +++ b/app/models/ci/project_mirror.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ci + # This model represents a shadow table of the main database's projects table. + # It allows us to navigate the project and namespace hierarchy on the ci database. + class ProjectMirror < ApplicationRecord + belongs_to :project + + class << self + def sync!(event) + upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id }, + unique_by: :project_id) + end + end + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8a3025e5608..a80fd02080f 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -12,7 +12,6 @@ module Ci include Gitlab::Utils::StrongMemoize include TaggableQueries include Presentable - include LooseForeignKey add_authentication_token_field :token, encrypted: :optional @@ -27,6 +26,21 @@ module Ci project_type: 3 } + enum executor_type: { + unknown: 0, + custom: 1, + shell: 2, + docker: 3, + docker_windows: 4, + docker_ssh: 5, + ssh: 6, + parallels: 7, + virtualbox: 8, + docker_machine: 9, + docker_ssh_machine: 10, + kubernetes: 11 + }, _suffix: true + # This `ONLINE_CONTACT_TIMEOUT` needs to be larger than # `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY` # @@ -40,9 +54,12 @@ module Ci # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner DB entry can be updated UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze + # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale + STALE_TIMEOUT = 3.months + AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze + AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648 AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze @@ -58,12 +75,14 @@ module Ci before_save :ensure_token - scope :active, -> { where(active: true) } - scope :paused, -> { where(active: false) } + scope :active, -> (value = true) { where(active: value) } + scope :paused, -> { active(false) } scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) } - scope :recent, -> { where('ci_runners.created_at > :date OR ci_runners.contacted_at > :date', date: 3.months.ago) } + scope :recent, -> { where('ci_runners.created_at >= :date OR ci_runners.contacted_at >= :date', date: stale_deadline) } + scope :stale, -> { where('ci_runners.created_at < :date AND (ci_runners.contacted_at IS NULL OR ci_runners.contacted_at < :date)', date: stale_deadline) } scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } - scope :not_connected, -> { where(contacted_at: nil) } + scope :not_connected, -> { where(contacted_at: nil) } # TODO: Remove in 15.0 + scope :never_contacted, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } @@ -78,10 +97,7 @@ module Ci scope :belonging_to_group, -> (group_id, include_ancestors: false) { groups = ::Group.where(id: group_id) - - if include_ancestors - groups = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors - end + groups = groups.self_and_ancestors if include_ancestors joins(:runner_namespaces) .where(ci_runner_namespaces: { namespace_id: groups }) @@ -102,10 +118,9 @@ module Ci scope :belonging_to_parent_group_of_project, -> (project_id) { project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) - hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors joins(:groups) - .where(namespaces: { id: hierarchy_groups }) + .where(namespaces: { id: project_groups.self_and_ancestors.as_ids }) .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433') } @@ -152,7 +167,7 @@ module Ci after_destroy :cleanup_runner_queue - cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at + cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout, error_message: 'Maximum job timeout has a value which could not be accepted' @@ -168,8 +183,6 @@ module Ci validates :config, json_schema: { filename: 'ci_runner_config' } - loose_foreign_key :clusters_applications_runners, :runner_id, on_delete: :async_nullify - # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens. @@ -185,6 +198,10 @@ module Ci ONLINE_CONTACT_TIMEOUT.ago end + def self.stale_deadline + STALE_TIMEOUT.ago + end + def self.recent_queue_deadline # we add queue expiry + online # - contacted_at can be updated at any time within this interval @@ -273,8 +290,17 @@ module Ci contacted_at && contacted_at > self.class.online_contact_time_deadline end - def status - return :not_connected unless contacted_at + def stale? + return false unless created_at + + [created_at, contacted_at].compact.max < self.class.stale_deadline + end + + def status(legacy_mode = nil) + return deprecated_rest_status if legacy_mode == '14.5' + + return :stale if stale? + return :never_contacted unless contacted_at online? ? :online : :offline end @@ -387,8 +413,9 @@ module Ci # database after heartbeat write happens. # ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do - values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config) || {} + values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {} values[:contacted_at] = Time.current + values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown) cache_attributes(values) @@ -413,6 +440,20 @@ module Ci private + EXECUTOR_NAME_TO_TYPES = { + 'custom' => :custom, + 'shell' => :shell, + 'docker' => :docker, + 'docker-windows' => :docker_windows, + 'docker-ssh' => :docker_ssh, + 'ssh' => :ssh, + 'parallels' => :parallels, + 'virtualbox' => :virtualbox, + 'docker+machine' => :docker_machine, + 'docker-ssh+machine' => :docker_ssh_machine, + 'kubernetes' => :kubernetes + }.freeze + def cleanup_runner_queue Gitlab::Redis::SharedState.with do |redis| redis.del(runner_queue_key) diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index 52a31863fb2..82390ccc538 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -7,7 +7,6 @@ module Ci self.limit_name = 'ci_registered_group_runners' self.limit_scope = :group self.limit_relation = :recent_runners - self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_namespaces belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace' diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index 148a29a0f8b..42c24c8c8d1 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -7,7 +7,6 @@ module Ci self.limit_name = 'ci_registered_project_runners' self.limit_scope = :project self.limit_relation = :recent_runners - self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_projects belongs_to :project, inverse_of: :runner_projects diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index e2b15497638..8c4e97ac840 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -22,6 +22,7 @@ module Ci scope :ordered, -> { order(position: :asc) } scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :by_name, ->(names) { where(name: names) } + scope :by_position, ->(positions) { where(position: positions) } with_options unless: :importing? do validates :project, presence: true diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index cf6d95fc6df..98490a13351 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -4,6 +4,8 @@ module Clusters class Agent < ApplicationRecord self.table_name = 'cluster_agents' + INACTIVE_AFTER = 1.hour.freeze + belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project @@ -16,6 +18,8 @@ module Clusters has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization' has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project + has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent + scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } @@ -31,5 +35,9 @@ module Clusters def has_access_to?(requested_project) requested_project == project end + + def active? + agent_tokens.where("last_used_at > ?", INACTIVE_AFTER.ago).exists? + end end end diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index 27a3cd8d13d..87dba50cd69 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -28,8 +28,12 @@ module Clusters cache_attributes(track_values) - # Use update_column so updated_at is skipped - update_columns(track_values) if can_update_track_values? + if can_update_track_values? + log_activity_event!(track_values[:last_used_at]) unless agent.active? + + # Use update_column so updated_at is skipped + update_columns(track_values) + end end private @@ -44,5 +48,14 @@ module Clusters real_last_used_at.nil? || (Time.current - real_last_used_at) >= last_used_at_max_age end + + def log_activity_event!(recorded_at) + agent.activity_events.create!( + kind: :agent_connected, + level: :info, + recorded_at: recorded_at, + agent_token: self + ) + end end end diff --git a/app/models/clusters/agents/activity_event.rb b/app/models/clusters/agents/activity_event.rb new file mode 100644 index 00000000000..5d9c885c923 --- /dev/null +++ b/app/models/clusters/agents/activity_event.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class ActivityEvent < ApplicationRecord + include NullifyIfBlank + + self.table_name = 'agent_activity_events' + + belongs_to :agent, class_name: 'Clusters::Agent', optional: false + belongs_to :user + belongs_to :agent_token, class_name: 'Clusters::AgentToken' + + scope :in_timeline_order, -> { order(recorded_at: :desc, id: :desc) } + + validates :recorded_at, :kind, :level, presence: true + + nullify_if_blank :detail + + enum kind: { + token_created: 0, + token_revoked: 1, + agent_connected: 2, + agent_disconnected: 3 + }, _prefix: true + + enum level: { + debug: 0, + info: 1, + warn: 2, + error: 3, + fatal: 4, + unknown: 5 + }, _prefix: true + end + end +end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 59a9251d6b7..b57a24dead0 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.34.0' + VERSION = '0.35.0' self.table_name = 'clusters_applications_runners' @@ -50,34 +50,6 @@ module Clusters private - def ensure_runner - runner || create_and_assign_runner - end - - def create_and_assign_runner - transaction do - Ci::Runner.create!(runner_create_params).tap do |runner| - update!(runner_id: runner.id) - end - end - end - - def runner_create_params - attributes = { - name: 'kubernetes-cluster', - runner_type: cluster.cluster_type, - tag_list: %w[kubernetes cluster] - } - - if cluster.group_type? - attributes[:runner_namespaces] = [::Ci::RunnerNamespace.new(namespace: group)] - elsif cluster.project_type? - attributes[:runner_projects] = [::Ci::RunnerProject.new(project: project)] - end - - attributes - end - def gitlab_url Gitlab::Routing.url_helpers.root_url(only_path: false) end @@ -85,7 +57,6 @@ module Clusters def specification { "gitlabUrl" => gitlab_url, - "runnerToken" => ensure_runner.token, "runners" => { "privileged" => privileged } } end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 7ec614b048c..1bd8e8b44cb 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -50,12 +50,6 @@ module Clusters alias_attribute :ca_pem, :ca_cert - delegate :enabled?, to: :cluster, allow_nil: true - delegate :provided_by_user?, to: :cluster, allow_nil: true - delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true - - alias_method :active?, :enabled? - enum_with_nil authorization_type: { unknown_authorization: nil, rbac: 1, @@ -66,6 +60,19 @@ module Clusters nullify_if_blank :namespace + def enabled? + !!cluster&.enabled? + end + alias_method :active?, :enabled? + + def provided_by_user? + !!cluster&.provided_by_user? + end + + def allow_user_defined_namespace? + !!cluster&.allow_user_defined_namespace? + end + def predefined_variables(project:, environment_name:, kubernetes_namespace: nil) Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'KUBE_URL', value: api_url) diff --git a/app/models/commit.rb b/app/models/commit.rb index 553681ee960..f0c5f3c2d12 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -84,43 +84,27 @@ class Commit sha[0..MIN_SHA_LENGTH] end - def diff_safe_lines(project: nil) - diff_safe_max_lines(project: project) + def diff_max_files + Gitlab::CurrentSettings.diff_max_files end - def diff_max_files(project: nil) - if Feature.enabled?(:increased_diff_limits, project) - 3000 - elsif Feature.enabled?(:configurable_diff_limits, project) - Gitlab::CurrentSettings.diff_max_files - else - 1000 - end - end - - def diff_max_lines(project: nil) - if Feature.enabled?(:increased_diff_limits, project) - 100000 - elsif Feature.enabled?(:configurable_diff_limits, project) - Gitlab::CurrentSettings.diff_max_lines - else - 50000 - end + def diff_max_lines + Gitlab::CurrentSettings.diff_max_lines end - def max_diff_options(project: nil) + def max_diff_options { - max_files: diff_max_files(project: project), - max_lines: diff_max_lines(project: project) + max_files: diff_max_files, + max_lines: diff_max_lines } end - def diff_safe_max_files(project: nil) - diff_max_files(project: project) / DIFF_SAFE_LIMIT_FACTOR + def diff_safe_max_files + diff_max_files / DIFF_SAFE_LIMIT_FACTOR end - def diff_safe_max_lines(project: nil) - diff_max_lines(project: project) / DIFF_SAFE_LIMIT_FACTOR + def diff_safe_max_lines + diff_max_lines / DIFF_SAFE_LIMIT_FACTOR end def from_hash(hash, container) diff --git a/app/models/commit_signatures/gpg_signature.rb b/app/models/commit_signatures/gpg_signature.rb new file mode 100644 index 00000000000..1ce76b53da4 --- /dev/null +++ b/app/models/commit_signatures/gpg_signature.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +module CommitSignatures + class GpgSignature < ApplicationRecord + include CommitSignature + + sha_attribute :gpg_key_primary_keyid + + belongs_to :gpg_key + belongs_to :gpg_key_subkey + + validates :gpg_key_primary_keyid, presence: true + + def self.with_key_and_subkeys(gpg_key) + subkey_ids = gpg_key.subkeys.pluck(:id) + + where( + arel_table[:gpg_key_id].eq(gpg_key.id).or( + arel_table[:gpg_key_subkey_id].in(subkey_ids) + ) + ) + end + + def gpg_key=(model) + case model + when GpgKey + super + when GpgKeySubkey + self.gpg_key_subkey = model + when NilClass + super + self.gpg_key_subkey = nil + end + end + + def gpg_key + if gpg_key_id + super + elsif gpg_key_subkey_id + gpg_key_subkey + end + end + + def gpg_key_primary_keyid + super&.upcase + end + + def gpg_commit + return unless commit + + Gitlab::Gpg::Commit.new(commit) + end + end +end diff --git a/app/models/commit_signatures/x509_commit_signature.rb b/app/models/commit_signatures/x509_commit_signature.rb new file mode 100644 index 00000000000..2cbb331dd7e --- /dev/null +++ b/app/models/commit_signatures/x509_commit_signature.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +module CommitSignatures + class X509CommitSignature < ApplicationRecord + include CommitSignature + + belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false + + validates :x509_certificate_id, presence: true + + def x509_commit + return unless commit + + Gitlab::X509::Commit.new(commit) + end + end +end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index d75f7984e2c..d6a2f62ca9b 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -53,15 +53,13 @@ class CommitStatus < Ci::ApplicationRecord scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } + scope :for_project, -> (project_id) { where(project_id: project_id) } scope :for_ref, -> (ref) { where(ref: ref) } scope :by_name, -> (name) { where(name: name) } scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :with_pipeline, -> { joins(:pipeline) } scope :updated_at_before, ->(date) { where('ci_builds.updated_at < ?', date) } scope :created_at_before, ->(date) { where('ci_builds.created_at < ?', date) } - scope :updated_before, ->(lookback:, timeout:) { - where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout) - } scope :scheduled_at_before, ->(date) { where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date) } @@ -71,7 +69,8 @@ class CommitStatus < Ci::ApplicationRecord # Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables. # https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding project_ids = Project.where_full_path_in(Array(paths)).pluck(:id) - where(project: project_ids) + + for_project(project_ids) end scope :with_preloads, -> do @@ -147,7 +146,7 @@ class CommitStatus < Ci::ApplicationRecord end event :drop do - transition [:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled] => :failed + transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :failed end event :success do @@ -191,7 +190,12 @@ class CommitStatus < Ci::ApplicationRecord commit_status.run_after_commit do PipelineProcessWorker.perform_async(pipeline_id) unless transition_options[:skip_pipeline_processing] - ExpireJobCacheWorker.perform_async(id) + + if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, project, default_enabled: :yaml) + expire_etag_cache! + else + ExpireJobCacheWorker.perform_async(id) + end end end @@ -217,6 +221,10 @@ class CommitStatus < Ci::ApplicationRecord false end + def self.bulk_insert_tags!(statuses, tag_list_by_build) + Gitlab::Ci::Tags::BulkInsert.new(statuses, tag_list_by_build).insert! + end + def locking_enabled? will_save_change_to_status? end @@ -300,6 +308,12 @@ class CommitStatus < Ci::ApplicationRecord .update_all(retried: true, processed: true) end + def expire_etag_cache! + job_path = Gitlab::Routing.url_helpers.project_build_path(project, id, format: :json) + + Gitlab::EtagCaching::Store.new.touch(job_path) + end + private def unrecoverable_failure? diff --git a/app/models/concerns/after_commit_queue.rb b/app/models/concerns/after_commit_queue.rb new file mode 100644 index 00000000000..7f525bec9e9 --- /dev/null +++ b/app/models/concerns/after_commit_queue.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module AfterCommitQueue + extend ActiveSupport::Concern + + included do + after_commit :_run_after_commit_queue + after_rollback :_clear_after_commit_queue + end + + def run_after_commit(&block) + _after_commit_queue << block if block + + true + end + + def run_after_commit_or_now(&block) + if self.class.inside_transaction? + if connection.current_transaction.records&.include?(self) + run_after_commit(&block) + else + # If the current transaction does not include this record, we can run + # the block now, even if it queues a Sidekiq job. + Sidekiq::Worker.skipping_transaction_check do + instance_eval(&block) + end + end + else + instance_eval(&block) + end + + true + end + + protected + + def _run_after_commit_queue + while action = _after_commit_queue.pop + self.instance_eval(&action) + end + end + + def _after_commit_queue + @after_commit_queue ||= [] + end + + def _clear_after_commit_queue + _after_commit_queue.clear + end +end diff --git a/app/models/concerns/calloutable.rb b/app/models/concerns/calloutable.rb deleted file mode 100644 index 8b9cfae6a32..00000000000 --- a/app/models/concerns/calloutable.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Calloutable - extend ActiveSupport::Concern - - included do - belongs_to :user - - validates :user, presence: true - end - - def dismissed_after?(dismissed_after) - dismissed_at > dismissed_after - end -end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index a9589cea5e9..12ddbc2cc40 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -13,7 +13,6 @@ module Ci track_duration do variables = pipeline.variables_builder.scoped_variables(self, environment: environment, dependencies: dependencies) - variables.concat(predefined_variables) unless pipeline.predefined_vars_in_builder_enabled? variables.concat(project.predefined_variables) variables.concat(pipeline.predefined_variables) variables.concat(runner.predefined_variables) if runnable? && runner @@ -71,24 +70,6 @@ module Ci end end - def predefined_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_JOB_NAME', value: name) - variables.append(key: 'CI_JOB_STAGE', value: stage) - variables.append(key: 'CI_JOB_MANUAL', value: 'true') if action? - variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request - - variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance) - variables.append(key: 'CI_NODE_TOTAL', value: ci_node_total_value.to_s) - - # legacy variables - variables.append(key: 'CI_BUILD_NAME', value: name) - variables.append(key: 'CI_BUILD_STAGE', value: stage) - variables.append(key: 'CI_BUILD_TRIGGERED', value: 'true') if trigger_request - variables.append(key: 'CI_BUILD_MANUAL', value: 'true') if action? - end - end - def kubernetes_variables ::Gitlab::Ci::Variables::Collection.new.tap do |collection| # Should get merged with the cluster kubeconfig in deployment_variables, see @@ -123,13 +104,5 @@ module Ci def secret_project_variables(environment: expanded_environment_name) project.ci_variables_for(ref: git_ref, environment: environment) end - - private - - def ci_node_total_value - parallel = self.options&.dig(:parallel) - parallel = parallel.dig(:total) if parallel.is_a?(Hash) - parallel || 1 - end end end diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb new file mode 100644 index 00000000000..5bdfa9a2966 --- /dev/null +++ b/app/models/concerns/commit_signature.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +module CommitSignature + extend ActiveSupport::Concern + + included do + include ShaAttribute + + sha_attribute :commit_sha + + enum verification_status: { + unverified: 0, + verified: 1, + same_user_different_email: 2, + other_user: 3, + unverified_key: 4, + unknown_key: 5, + multiple_signatures: 6 + } + + belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false + + validates :commit_sha, presence: true + validates :project_id, presence: true + + scope :by_commit_sha, ->(shas) { where(commit_sha: shas) } + end + + class_methods do + def safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) + end + + # Find commits that are lacking a signature in the database at present + def unsigned_commit_shas(commit_shas) + return [] if commit_shas.empty? + + signed = by_commit_sha(commit_shas).pluck(:commit_sha) + commit_shas - signed + end + end + + def commit + project.commit(commit_sha) + end + + def user + commit.committer + end +end diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index b13ca4bf06e..051158e5de5 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -3,7 +3,6 @@ module DiffPositionableNote extend ActiveSupport::Concern included do - delegate :on_text?, :on_image?, to: :position, allow_nil: true before_validation :set_original_position, on: :create before_validation :update_position, on: :create, if: :on_text?, unless: :importing? @@ -34,6 +33,14 @@ module DiffPositionableNote end end + def on_text? + !!position&.on_text? + end + + def on_image? + !!position&.on_image? + end + def supported? for_commit? || self.noteable.has_complete_diff_refs? end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 1b4cc14f4a2..312b88a4d6d 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -28,6 +28,7 @@ module Enums trace_size_exceeded: 19, builds_disabled: 20, environment_creation_failure: 21, + deployment_rejected: 22, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, diff --git a/app/models/concerns/import_state/sidekiq_job_tracker.rb b/app/models/concerns/import_state/sidekiq_job_tracker.rb index b7d0ed0f51b..340bf4279bc 100644 --- a/app/models/concerns/import_state/sidekiq_job_tracker.rb +++ b/app/models/concerns/import_state/sidekiq_job_tracker.rb @@ -15,7 +15,7 @@ module ImportState def refresh_jid_expiration return unless jid - Gitlab::SidekiqStatus.set(jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION) + Gitlab::SidekiqStatus.set(jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION, value: 2) end def self.jid_by(project_id:, status:) diff --git a/app/models/concerns/incident_management/escalatable.rb b/app/models/concerns/incident_management/escalatable.rb index 78dce63f59e..81eef50603a 100644 --- a/app/models/concerns/incident_management/escalatable.rb +++ b/app/models/concerns/incident_management/escalatable.rb @@ -102,3 +102,5 @@ module IncidentManagement end end end + +::IncidentManagement::Escalatable.prepend_mod diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 4273eb331a1..dcd80201d3f 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -43,7 +43,7 @@ module Issuable included do cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :description, issuable_state_filter_enabled: true + cache_markdown_field :description, issuable_reference_expansion_enabled: true redact_field :description @@ -61,6 +61,16 @@ module Issuable # We check first if we're loaded to not load unnecessarily. loaded? && to_a.all? { |note| note.association(:award_emoji).loaded? } end + + def projects_loaded? + # We check first if we're loaded to not load unnecessarily. + loaded? && to_a.all? { |note| note.association(:project).loaded? } + end + + def system_note_metadata_loaded? + # We check first if we're loaded to not load unnecessarily. + loaded? && to_a.all? { |note| note.association(:system_note_metadata).loaded? } + end end has_many :note_authors, -> { distinct }, through: :notes, source: :author @@ -183,6 +193,10 @@ module Issuable incident? end + def supports_escalation? + incident? + end + def incident? is_a?(Issue) && super end @@ -524,6 +538,8 @@ module Issuable includes = [] includes << :author unless notes.authors_loaded? includes << :award_emoji unless notes.award_emojis_loaded? + includes << :project unless notes.projects_loaded? + includes << :system_note_metadata unless notes.system_note_metadata_loaded? if includes.any? notes.includes(includes) diff --git a/app/models/concerns/loose_foreign_key.rb b/app/models/concerns/loose_foreign_key.rb deleted file mode 100644 index 102292672b3..00000000000 --- a/app/models/concerns/loose_foreign_key.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -module LooseForeignKey - extend ActiveSupport::Concern - - # This concern adds loose foreign key support to ActiveRecord models. - # Loose foreign keys allow delayed processing of associated database records - # with similar guarantees than a database foreign key. - # - # Prerequisites: - # - # To start using the concern, you'll need to install a database trigger to the parent - # table in a standard DB migration (not post-migration). - # - # > track_record_deletions(:projects) - # - # Usage: - # - # > class Ci::Build < ApplicationRecord - # > - # > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete - # > - # > # associations can be still defined, the dependent options is no longer necessary: - # > has_many :security_scans, class_name: 'Security::Scan' - # > - # > end - # - # Options for on_delete: - # - # - :async_delete - deletes the children rows via an asynchronous process. - # - :async_nullify - sets the foreign key column to null via an asynchronous process. - # - # How it works: - # - # When adding loose foreign key support to the table, a DELETE trigger is installed - # which tracks the record deletions (stores primary key value of the deleted row) in - # a database table. - # - # These deletion records are processed asynchronously and records are cleaned up - # according to the loose foreign key definitions described in the model. - # - # The cleanup happens in batches, which reduces the likelyhood of statement timeouts. - # - # When all associations related to the deleted record are cleaned up, the record itself - # is deleted. - included do - class_attribute :loose_foreign_key_definitions, default: [] - end - - class_methods do - def loose_foreign_key(to_table, column, options) - symbolized_options = options.symbolize_keys - - unless base_class? - raise <<~MSG - loose_foreign_key can be only used on base classes, inherited classes are not supported. - Please define the loose_foreign_key on the #{base_class.name} class. - MSG - end - - on_delete_options = %i[async_delete async_nullify] - - unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym) - raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}" - end - - definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new( - table_name.to_s, - to_table.to_s, - { - column: column.to_s, - on_delete: symbolized_options[:on_delete].to_sym - } - ) - - self.loose_foreign_key_definitions += [definition] - end - end -end diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb index 216a3a0bd64..5859f43a70c 100644 --- a/app/models/concerns/merge_request_reviewer_state.rb +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -15,11 +15,5 @@ module MergeRequestReviewerState inclusion: { in: self.states.keys } after_initialize :set_state, unless: :persisted? - - def set_state - if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml) - self.state = :attention_requested - end - end end end diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb index 9cf66c756a0..77409549e85 100644 --- a/app/models/concerns/packages/debian/component_file.rb +++ b/app/models/concerns/packages/debian/component_file.rb @@ -20,13 +20,13 @@ module Packages belongs_to :component, class_name: "Packages::Debian::#{container_type.capitalize}Component", inverse_of: :files belongs_to :architecture, class_name: "Packages::Debian::#{container_type.capitalize}Architecture", inverse_of: :files, optional: true - enum file_type: { packages: 1, source: 2, di_packages: 3 } + enum file_type: { packages: 1, sources: 2, di_packages: 3 } enum compression_type: { gz: 1, bz2: 2, xz: 3 } validates :component, presence: true validates :file_type, presence: true - validates :architecture, presence: true, unless: :source? - validates :architecture, absence: true, if: :source? + validates :architecture, presence: true, unless: :sources? + validates :architecture, absence: true, if: :sources? validates :file, length: { minimum: 0, allow_nil: false } validates :size, presence: true validates :file_store, presence: true @@ -81,7 +81,7 @@ module Packages case file_type when 'packages' "#{component.name}/binary-#{architecture.name}/#{file_name}#{extension}" - when 'source' + when 'sources' "#{component.name}/source/#{file_name}#{extension}" when 'di_packages' "#{component.name}/debian-installer/binary-#{architecture.name}/#{file_name}#{extension}" diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 25410a859e9..1663aa6c886 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -60,6 +60,15 @@ module Participable filtered_participants_hash[user] end + # Returns only participants visible for the user + # + # Returns an Array of User instances. + def visible_participants(user) + return participants(user) unless Feature.enabled?(:verify_participants_access, project, default_enabled: :yaml) + + filter_by_ability(raw_participants(user, verify_access: true)) + end + # Checks if the user is a participant in a discussion. # # This method processes attributes of objects in breadth-first order. @@ -84,8 +93,7 @@ module Participable end end - def raw_participants(current_user = nil) - current_user ||= author + def raw_participants(current_user = nil, verify_access: false) ext = Gitlab::ReferenceExtractor.new(project, current_user) participants = Set.new process = [self] @@ -97,6 +105,8 @@ module Participable when User participants << source when Participable + next unless !verify_access || source_visible_to_user?(source, current_user) + source.class.participant_attrs.each do |attr| if attr.respond_to?(:call) source.instance_exec(current_user, ext, &attr) @@ -116,6 +126,10 @@ module Participable participants.merge(ext.users) end + def source_visible_to_user?(source, user) + Ability.allowed?(user, "read_#{source.model_name.element}".to_sym, source) + end + def filter_by_ability(participants) case self when PersonalSnippet diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb index 23d2d00b346..f95f9dd8ad7 100644 --- a/app/models/concerns/partitioned_table.rb +++ b/app/models/concerns/partitioned_table.rb @@ -7,7 +7,8 @@ module PartitionedTable attr_reader :partitioning_strategy PARTITIONING_STRATEGIES = { - monthly: Gitlab::Database::Partitioning::MonthlyStrategy + monthly: Gitlab::Database::Partitioning::MonthlyStrategy, + sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy }.freeze def partitioned_by(partitioning_key, strategy:, **kwargs) diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index c32e499c329..9069d3088cd 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -168,6 +168,24 @@ module RelativePositioning self.relative_position = MIN_POSITION end + def next_object_by_relative_position(ignoring: nil, order: :asc) + relation = relative_positioning_scoped_items(ignoring: ignoring).reorder(relative_position: order) + + relation = if order == :asc + relation.where(self.class.arel_table[:relative_position].gt(relative_position)) + else + relation.where(self.class.arel_table[:relative_position].lt(relative_position)) + end + + relation.first + end + + def relative_positioning_scoped_items(ignoring: nil) + relation = self.class.relative_positioning_query_base(self) + relation = exclude_self(relation, excluded: ignoring) if ignoring.present? + relation + end + # This method is used during rebalancing - override it to customise the update # logic: def update_relative_siblings(relation, range, delta) diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index 60e1dde17b9..aae338e9759 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -30,11 +30,14 @@ module ResolvableDiscussion delegate :resolved_at, :resolved_by, - :resolved_by_push?, to: :last_resolved_note, allow_nil: true end + def resolved_by_push? + !!last_resolved_note&.resolved_by_push? + end + def resolvable? strong_memoize(:resolvable) do potentially_resolvable? && notes.any?(&:resolvable?) diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index ba7c6c0cd8b..e49f4d03bda 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -3,11 +3,14 @@ module ShaAttribute extend ActiveSupport::Concern + # Needed for the database method + include DatabaseReflection + class_methods do def sha_attribute(name) return if ENV['STATIC_VERIFICATION'] - validate_binary_column_exists!(name) if Rails.env.development? + validate_binary_column_exists!(name) if Rails.env.development? || Rails.env.test? attribute(name, Gitlab::Database::ShaAttribute.new) end diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb index 3be82ed72d3..447521ad8c1 100644 --- a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb +++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb @@ -11,7 +11,7 @@ module TokenAuthenticatableStrategies # The pattern of the token is "#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv_of_12_characters}" if token.start_with?(DYNAMIC_NONCE_IDENTIFIER) && token.size > NONCE_SIZE + DYNAMIC_NONCE_IDENTIFIER.size token_to_decrypt = token[1...-NONCE_SIZE] - iv = token[-NONCE_SIZE..-1] + iv = token[-NONCE_SIZE..] Gitlab::CryptoHelper.aes256_gcm_decrypt(token_to_decrypt, nonce: iv) else diff --git a/app/models/concerns/transactions.rb b/app/models/concerns/transactions.rb index a186ebc8475..1c9bd8274f5 100644 --- a/app/models/concerns/transactions.rb +++ b/app/models/concerns/transactions.rb @@ -8,7 +8,7 @@ module Transactions # transaction. Handles special cases when running inside a test environment, # where tests may be wrapped in transactions def inside_transaction? - base = Rails.env.test? ? @open_transactions_baseline.to_i : 0 + base = Rails.env.test? ? open_transactions_baseline.to_i : 0 connection.open_transactions > base end @@ -24,5 +24,15 @@ module Transactions def reset_open_transactions_baseline @open_transactions_baseline = 0 end + + def open_transactions_baseline + return unless Rails.env.test? + + if @open_transactions_baseline.nil? + return self == ApplicationRecord ? nil : superclass.open_transactions_baseline + end + + @open_transactions_baseline + end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 8e130998f11..c914819f79d 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -145,9 +145,14 @@ class ContainerRepository < ApplicationRecord name: path.repository_name) end - def self.create_from_path!(path) - safe_find_or_create_by!(project: path.repository_project, - name: path.repository_name) + def self.find_or_create_from_path(path) + repository = safe_find_or_create_by( + project: path.repository_project, + name: path.repository_name + ) + return repository if repository.persisted? + + find_by_path!(path) end def self.build_root_repository(project) diff --git a/app/models/context_commits_diff.rb b/app/models/context_commits_diff.rb index fe1a72b79f2..3d25b60678a 100644 --- a/app/models/context_commits_diff.rb +++ b/app/models/context_commits_diff.rb @@ -3,6 +3,7 @@ class ContextCommitsDiff include ActsAsPaginatedDiff + delegate :head, :base, to: :compare attr_reader :merge_request def initialize(merge_request) diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index 5898bc3412f..d8669f1f4c2 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -25,6 +25,13 @@ class CustomerRelations::Contact < ApplicationRecord validates :description, length: { maximum: 1024 } validate :validate_email_format + def self.find_ids_by_emails(group_id, emails) + raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK + + where(group_id: group_id, email: emails) + .pluck(:id) + end + private def validate_email_format diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb index 98faf8d6644..78f662b6a58 100644 --- a/app/models/customer_relations/issue_contact.rb +++ b/app/models/customer_relations/issue_contact.rb @@ -8,6 +8,14 @@ class CustomerRelations::IssueContact < ApplicationRecord validate :contact_belongs_to_issue_group + def self.find_contact_ids_by_emails(issue_id, emails) + raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK + + joins(:contact) + .where(issue_id: issue_id, customer_relations_contacts: { email: emails }) + .pluck(:contact_id) + end + private def contact_belongs_to_issue_group diff --git a/app/models/deployment.rb b/app/models/deployment.rb index ade19ce02a8..4c60ce57f49 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -46,9 +46,10 @@ class Deployment < ApplicationRecord scope :for_project, -> (project_id) { where(project_id: project_id) } scope :for_projects, -> (projects) { where(project: projects) } - scope :visible, -> { where(status: %i[running success failed canceled]) } + scope :visible, -> { where(status: %i[running success failed canceled blocked]) } scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success } scope :active, -> { where(status: %i[created running]) } + scope :upcoming, -> { where(status: %i[blocked running]) } scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) } scope :with_api_entity_associations, -> { preload({ deployable: { runner: [], tags: [], user: [], job_artifacts_archive: [] } }) } @@ -64,6 +65,10 @@ class Deployment < ApplicationRecord transition created: :running end + event :block do + transition created: :blocked + end + event :succeed do transition any - [:success] => :success end @@ -119,6 +124,8 @@ class Deployment < ApplicationRecord next if transition.loopback? deployment.run_after_commit do + next unless deployment.project.jira_subscription_exists? + ::JiraConnect::SyncDeploymentsWorker.perform_async(id) end end @@ -126,6 +133,8 @@ class Deployment < ApplicationRecord after_create unless: :importing? do |deployment| run_after_commit do + next unless deployment.project.jira_subscription_exists? + ::JiraConnect::SyncDeploymentsWorker.perform_async(deployment.id) end end @@ -136,7 +145,8 @@ class Deployment < ApplicationRecord success: 2, failed: 3, canceled: 4, - skipped: 5 + skipped: 5, + blocked: 6 } def self.archivables_in(project, limit:) @@ -387,6 +397,8 @@ class Deployment < ApplicationRecord cancel! when 'skipped' skip! + when 'blocked' + block! else raise ArgumentError, "The status #{status.inspect} is invalid" end diff --git a/app/models/dev_ops_report/metric.rb b/app/models/dev_ops_report/metric.rb index 14eff725433..d30e869b155 100644 --- a/app/models/dev_ops_report/metric.rb +++ b/app/models/dev_ops_report/metric.rb @@ -6,6 +6,20 @@ module DevOpsReport self.table_name = 'conversational_development_index_metrics' + METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes + percentage_notes leader_milestones instance_milestones percentage_milestones + leader_boards instance_boards percentage_boards leader_merge_requests + instance_merge_requests percentage_merge_requests leader_ci_pipelines + instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments + percentage_environments leader_deployments instance_deployments percentage_deployments + leader_projects_prometheus_active instance_projects_prometheus_active + percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues + percentage_service_desk_issues].freeze + + METRICS.each do |metric_name| + validates metric_name, presence: true, numericality: { greater_than_or_equal_to: 0 } + end + def instance_score(feature) self["instance_#{feature}"] end diff --git a/app/models/environment.rb b/app/models/environment.rb index 2618991c9e5..a830c04f291 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -31,7 +31,7 @@ class Environment < ApplicationRecord has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true - has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment + has_one :upcoming_deployment, -> { upcoming.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment before_validation :generate_slug, if: ->(env) { env.slug.blank? } diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index 0b638f65768..18c1467e6f6 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -3,6 +3,9 @@ class ErrorTracking::ErrorEvent < ApplicationRecord belongs_to :error, counter_cache: :events_count + # Scrub null bytes + attribute :payload, Gitlab::Database::Type::JsonPgSafe.new + validates :payload, json_schema: { filename: 'error_tracking_event_payload' } validates :error, presence: true diff --git a/app/models/event.rb b/app/models/event.rb index f6174589a84..409bc66c66c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -130,10 +130,11 @@ class Event < ApplicationRecord # Update Gitlab::ContributionsCalendar#activity_dates if this changes def contributions - where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)", - actions[:pushed], - %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]], - "Note", actions[:commented]) + where( + 'action IN (?) OR (target_type IN (?) AND action IN (?))', + [actions[:pushed], actions[:commented]], + %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]] + ) end def limit_recent(limit = 20, offset = nil) diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 0cb3662368c..a56e28859c9 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -92,13 +92,13 @@ class GpgKey < ApplicationRecord end def revoke - GpgSignature + CommitSignatures::GpgSignature .with_key_and_subkeys(self) - .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key]) + .where.not(verification_status: CommitSignatures::GpgSignature.verification_statuses[:unknown_key]) .update_all( gpg_key_id: nil, gpg_key_subkey_id: nil, - verification_status: GpgSignature.verification_statuses[:unknown_key], + verification_status: CommitSignatures::GpgSignature.verification_statuses[:unknown_key], updated_at: Time.zone.now ) diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb deleted file mode 100644 index 2775b520b2f..00000000000 --- a/app/models/gpg_signature.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -class GpgSignature < ApplicationRecord - include ShaAttribute - - sha_attribute :commit_sha - sha_attribute :gpg_key_primary_keyid - - enum verification_status: { - unverified: 0, - verified: 1, - same_user_different_email: 2, - other_user: 3, - unverified_key: 4, - unknown_key: 5, - multiple_signatures: 6 - } - - belongs_to :project - belongs_to :gpg_key - belongs_to :gpg_key_subkey - - validates :commit_sha, presence: true - validates :project_id, presence: true - validates :gpg_key_primary_keyid, presence: true - - scope :by_commit_sha, ->(shas) { where(commit_sha: shas) } - - def self.with_key_and_subkeys(gpg_key) - subkey_ids = gpg_key.subkeys.pluck(:id) - - where( - arel_table[:gpg_key_id].eq(gpg_key.id).or( - arel_table[:gpg_key_subkey_id].in(subkey_ids) - ) - ) - end - - def self.safe_create!(attributes) - create_with(attributes) - .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) - end - - # Find commits that are lacking a signature in the database at present - def self.unsigned_commit_shas(commit_shas) - return [] if commit_shas.empty? - - signed = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha) - - commit_shas - signed - end - - def gpg_key=(model) - case model - when GpgKey - super - when GpgKeySubkey - self.gpg_key_subkey = model - when NilClass - super - self.gpg_key_subkey = nil - end - end - - def gpg_key - if gpg_key_id - super - elsif gpg_key_subkey_id - gpg_key_subkey - end - end - - def gpg_key_primary_keyid - super&.upcase - end - - def commit - project.commit(commit_sha) - end - - def gpg_commit - return unless commit - - Gitlab::Gpg::Commit.new(commit) - end -end diff --git a/app/models/group.rb b/app/models/group.rb index 2dd20300ad2..f51782785f9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -852,15 +852,7 @@ class Group < Namespace end def self.groups_including_descendants_by(group_ids) - groups = Group.where(id: group_ids) - - if Feature.enabled?(:linear_group_including_descendants_by, default_enabled: :yaml) - groups.self_and_descendants - else - Gitlab::ObjectHierarchy - .new(groups) - .base_and_descendants - end + Group.where(id: group_ids).self_and_descendants end def disable_shared_runners! diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index d1584a62bfb..16b95d2a2b9 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -31,10 +31,6 @@ class ProjectHook < WebHook _('Webhooks') end - def web_hooks_disable_failed? - Feature.enabled?(:web_hooks_disable_failed, project) - end - override :rate_limit def rate_limit project.actual_limits.limit_for(:web_hook_calls) @@ -44,6 +40,13 @@ class ProjectHook < WebHook def application_context super.merge(project: project) end + + private + + override :web_hooks_disable_failed? + def web_hooks_disable_failed? + Feature.enabled?(:web_hooks_disable_failed, project) + end end ProjectHook.prepend_mod_with('ProjectHook') diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index cb5c1ac48cd..e8a55abfc8f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -34,9 +34,19 @@ class WebHook < ApplicationRecord end def executable? - return true unless web_hooks_disable_failed? + !temporarily_disabled? && !permanently_disabled? + end + + def temporarily_disabled? + return false unless web_hooks_disable_failed? + + disabled_until.present? && disabled_until >= Time.current + end + + def permanently_disabled? + return false unless web_hooks_disable_failed? - recent_failures <= FAILURE_THRESHOLD && (disabled_until.nil? || disabled_until < Time.current) + recent_failures > FAILURE_THRESHOLD end # rubocop: disable CodeReuse/ServiceClass @@ -69,6 +79,8 @@ class WebHook < ApplicationRecord end def disable! + return if permanently_disabled? + update_attribute(:recent_failures, FAILURE_THRESHOLD + 1) end @@ -80,7 +92,7 @@ class WebHook < ApplicationRecord end def backoff! - return if backoff_count >= MAX_FAILURES && disabled_until && disabled_until > Time.current + return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?) assign_attributes(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES)) save(validate: false) @@ -93,7 +105,19 @@ class WebHook < ApplicationRecord save(validate: false) end - # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited. + # @return [Boolean] Whether or not the WebHook is currently throttled. + def rate_limited? + return false unless rate_limit + + Gitlab::ApplicationRateLimiter.peek( + :web_hook_calls, + scope: [self], + threshold: rate_limit + ) + end + + # Threshold for the rate-limit. + # Overridden in ProjectHook and GroupHook, other WebHooks are not rate-limited. def rate_limit nil end diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb index 88aef104d88..fc881e62efd 100644 --- a/app/models/incident_management/issuable_escalation_status.rb +++ b/app/models/incident_management/issuable_escalation_status.rb @@ -7,8 +7,11 @@ module IncidentManagement self.table_name = 'incident_management_issuable_escalation_statuses' belongs_to :issue + has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status validates :issue, presence: true, uniqueness: true + + delegate :project, to: :issue end end diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index 0bf9e805aa8..bbddc18103a 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -62,6 +62,7 @@ class InstanceConfiguration def plan_file_size_limits(plan) { conan: plan.actual_limits[:conan_max_file_size], + helm: plan.actual_limits[:helm_max_file_size], maven: plan.actual_limits[:maven_max_file_size], npm: plan.actual_limits[:npm_max_file_size], nuget: plan.actual_limits[:nuget_max_file_size], diff --git a/app/models/integration.rb b/app/models/integration.rb index d3059fa6d4a..29d96650a81 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -14,11 +14,13 @@ class Integration < ApplicationRecord asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email - pivotaltracker prometheus pushover redmine shimo slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao + pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao ].freeze + # TODO Shimo is temporary disabled on group and instance-levels. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677 PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ - jenkins + jenkins shimo ].freeze # Fake integrations to help with local development. diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index 3fd67205e92..42a6a3a19c8 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -128,7 +128,7 @@ module Integrations false end - def create_cross_reference_note(mentioned, noteable, author) + def create_cross_reference_note(external_issue, mentioned_in, author) # implement inside child end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 42c291abf55..d46299de1be 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -234,19 +234,19 @@ module Integrations end override :create_cross_reference_note - def create_cross_reference_note(mentioned, noteable, author) - unless can_cross_reference?(noteable) - return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) } + 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) } end - jira_issue = find_issue(mentioned.id) + jira_issue = find_issue(external_issue.id) return unless jira_issue.present? - noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id - noteable_type = noteable_name(noteable) - entity_url = build_entity_url(noteable_type, noteable_id) - entity_meta = build_entity_meta(noteable) + mentioned_in_id = mentioned_in.respond_to?(:iid) ? mentioned_in.iid : mentioned_in.id + mentioned_in_type = mentionable_name(mentioned_in) + entity_url = build_entity_url(mentioned_in_type, mentioned_in_id) + entity_meta = build_entity_meta(mentioned_in) data = { user: { @@ -259,9 +259,9 @@ module Integrations }, entity: { id: entity_meta[:id], - name: noteable_type.humanize.downcase, + name: mentioned_in_type.humanize.downcase, url: entity_url, - title: noteable.title, + title: mentioned_in.title, description: entity_meta[:description], branch: entity_meta[:branch] } @@ -302,11 +302,11 @@ module Integrations private - def branch_name(noteable) + def branch_name(commit) if Feature.enabled?(:jira_use_first_ref_by_oid, project, default_enabled: :yaml) - noteable.first_ref_by_oid(project.repository) + commit.first_ref_by_oid(project.repository) else - noteable.ref_names(project.repository).first + commit.ref_names(project.repository).first end end @@ -316,8 +316,8 @@ module Integrations end end - def can_cross_reference?(noteable) - case noteable + def can_cross_reference?(mentioned_in) + case mentioned_in when Commit then commit_events when MergeRequest then merge_requests_events else true @@ -487,36 +487,36 @@ module Integrations "#{Settings.gitlab.base_url.chomp("/")}#{resource}" end - def build_entity_url(noteable_type, entity_id) + def build_entity_url(entity_type, entity_id) polymorphic_url( [ self.project, - noteable_type.to_sym + entity_type.to_sym ], id: entity_id, host: Settings.gitlab.base_url ) end - def build_entity_meta(noteable) - if noteable.is_a?(Commit) + def build_entity_meta(entity) + if entity.is_a?(Commit) { - id: noteable.short_id, - description: noteable.safe_message, - branch: branch_name(noteable) + id: entity.short_id, + description: entity.safe_message, + branch: branch_name(entity) } - elsif noteable.is_a?(MergeRequest) + elsif entity.is_a?(MergeRequest) { - id: noteable.to_reference, - branch: noteable.source_branch + id: entity.to_reference, + branch: entity.source_branch } else {} end end - def noteable_name(noteable) - name = noteable.model_name.singular + def mentionable_name(mentionable) + name = mentionable.model_name.singular # ProjectSnippet inherits from Snippet class so it causes # routing error building the URL. diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index 4f42fda2577..0e1023bb7a7 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -5,7 +5,11 @@ module Integrations prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? + after_commit :cache_project_has_shimo + def render? + return false unless Feature.enabled?(:shimo_integration, project) + valid? && activated? end @@ -43,5 +47,14 @@ module Integrations } ] end + + private + + def cache_project_has_shimo + return unless project && !project.destroyed? + + project.project_setting.save! unless project.project_setting.persisted? + project.project_setting.update_column(:has_shimo, activated?) + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 47dc084d69c..537e16e5cc3 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -63,6 +63,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :issue_email_participants + has_one :email has_many :assignees, class_name: "User", through: :issue_assignees has_many :zoom_meetings has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -228,9 +229,37 @@ class Issue < ApplicationRecord end end + def next_object_by_relative_position(ignoring: nil, order: :asc) + return super unless Feature.enabled?(:optimized_issue_neighbor_queries, project, default_enabled: :yaml) + + array_mapping_scope = -> (id_expression) do + relation = Issue.where(Issue.arel_table[:project_id].eq(id_expression)) + + if order == :asc + relation.where(Issue.arel_table[:relative_position].gt(relative_position)) + else + relation.where(Issue.arel_table[:relative_position].lt(relative_position)) + end + end + + relation = Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( + scope: Issue.order(relative_position: order, id: order), + array_scope: relative_positioning_parent_projects, + array_mapping_scope: array_mapping_scope, + finder_query: -> (_, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + ).execute + + relation = exclude_self(relation, excluded: ignoring) if ignoring.present? + + relation.take + end + + def relative_positioning_parent_projects + project.group&.root_ancestor&.all_projects&.select(:id) || Project.id_in(project).select(:id) + end + def self.relative_positioning_query_base(issue) - projects = issue.project.group&.root_ancestor&.all_projects || issue.project - in_projects(projects) + in_projects(issue.relative_positioning_parent_projects) end def self.relative_positioning_parent_column @@ -433,8 +462,6 @@ class Issue < ApplicationRecord # Returns `true` if the current issue can be viewed by either a logged in User # or an anonymous user. def visible_to_user?(user = nil) - return false unless project && project.feature_available?(:issues, user) - return publicly_visible? unless user return false unless readable_by?(user) @@ -562,10 +589,10 @@ class Issue < ApplicationRecord project.team.member?(user, Gitlab::Access::REPORTER) elsif hidden? false + elsif project.public? || (project.internal? && !user.external?) + project.feature_available?(:issues, user) else - project.public? || - project.internal? && !user.external? || - project.team.member?(user) + project.team.member?(user) end end @@ -604,7 +631,7 @@ class Issue < ApplicationRecord def could_not_move(exception) # Symptom of running out of space - schedule rebalancing - IssueRebalancingWorker.perform_async(nil, *project.self_or_root_group_ids) + Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids) end end diff --git a/app/models/issue/email.rb b/app/models/issue/email.rb new file mode 100644 index 00000000000..730fda5cdb4 --- /dev/null +++ b/app/models/issue/email.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Issue::Email < ApplicationRecord + self.table_name = 'issue_emails' + + belongs_to :issue + + validates :email_message_id, uniqueness: true, presence: true, length: { maximum: 1000 } + validates :issue, presence: true, uniqueness: true +end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 9765ac6f2e9..caeffae7bda 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -13,6 +13,7 @@ class LfsObject < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: LfsObjectUploader::Store::REMOTE) } scope :for_oids, -> (oids) { where(oid: oids) } + scope :for_oid_and_size, -> (oid, size) { find_by(oid: oid, size: size) } validates :oid, presence: true, uniqueness: true diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index e5632ff2842..bf6d1394569 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -21,9 +21,19 @@ class LfsObjectsProject < ApplicationRecord scope :project_id_in, ->(ids) { where(project_id: ids) } scope :lfs_object_in, -> (lfs_objects) { where(lfs_object: lfs_objects) } + def self.link_to_project!(lfs_object, project) + # We can't use an upsert here because there is no uniqueness constraint: + # https://gitlab.com/gitlab-org/gitlab/-/issues/347466 + self.safe_find_or_create_by!(lfs_object_id: lfs_object.id, project_id: project.id) # rubocop:disable Performance/ActiveRecordSubtransactionMethods + end + + def self.update_statistics_for_project_id(project_id) + ProjectCacheWorker.perform_async(project_id, [], [:lfs_objects_size]) # rubocop:disable CodeReuse/Worker + end + private def update_project_statistics - ProjectCacheWorker.perform_async(project_id, [], [:lfs_objects_size]) + self.class.update_statistics_for_project_id(project_id) end end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index c3b3e76f67b..0fbdd2d8a5b 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -1,15 +1,45 @@ # frozen_string_literal: true class LooseForeignKeys::DeletedRecord < ApplicationRecord + PARTITION_DURATION = 1.day + + include PartitionedTable + self.primary_key = :id + self.ignored_columns = %i[partition] + + partitioned_by :partition, strategy: :sliding_list, + next_partition_if: -> (active_partition) do + return false if Feature.disabled?(:lfk_automatic_partition_creation, default_enabled: :yaml) + + oldest_record_in_partition = LooseForeignKeys::DeletedRecord + .select(:id, :created_at) + .for_partition(active_partition) + .order(:id) + .limit(1) + .take + + oldest_record_in_partition.present? && oldest_record_in_partition.created_at < PARTITION_DURATION.ago + end, + detach_partition_if: -> (partition) do + return false if Feature.disabled?(:lfk_automatic_partition_dropping, default_enabled: :yaml) + + !LooseForeignKeys::DeletedRecord + .for_partition(partition) + .status_pending + .exists? + end scope :for_table, -> (table) { where(fully_qualified_table_name: table) } + scope :for_partition, -> (partition) { where(partition: partition) } scope :consume_order, -> { order(:partition, :consume_after, :id) } enum status: { pending: 1, processed: 2 }, _prefix: :status def self.load_batch_for_table(table, batch_size) - for_table(table) + # selecting partition as partition_number to workaround the sliding partitioning column ignore + select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) + .for_table(table) .status_pending .consume_order .limit(batch_size) @@ -20,9 +50,9 @@ class LooseForeignKeys::DeletedRecord < ApplicationRecord # Run a query for each partition to optimize the row lookup by primary key (partition, id) update_count = 0 - all_records.group_by(&:partition).each do |partition, records_within_partition| + all_records.group_by(&:partition_number).each do |partition, records_within_partition| update_count += status_pending - .where(partition: partition) + .for_partition(partition) .where(id: records_within_partition.pluck(:id)) .update_all(status: :processed) end diff --git a/app/models/member.rb b/app/models/member.rb index 11f67a77ee2..90fb281abf4 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -25,7 +25,7 @@ class Member < ApplicationRecord belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations has_one :member_task - delegate :name, :username, :email, to: :user, prefix: true + 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 @@ -52,6 +52,7 @@ class Member < ApplicationRecord message: _('project bots cannot be added to other groups / projects') }, if: :project_bot? + validate :access_level_inclusion scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -382,6 +383,12 @@ class Member < ApplicationRecord private + def access_level_inclusion + return if access_level.in?(Gitlab::Access.all_values) + + errors.add(:access_level, "is not included in the list") + end + def send_invite # override in subclass end @@ -417,11 +424,9 @@ class Member < ApplicationRecord def after_accept_invite post_create_hook - if experiment(:invite_members_for_task).enabled? - run_after_commit_or_now do - if member_task - TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i]) - end + 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 diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 9062a405218..1ad4cb6d368 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -6,6 +6,7 @@ class GroupMember < Member include CreatedAtFilterable SOURCE_TYPE = 'Namespace' + SOURCE_TYPE_FORMAT = /\ANamespace\z/.freeze belongs_to :group, foreign_key: 'source_id' alias_attribute :namespace_id, :source_id @@ -13,9 +14,7 @@ class GroupMember < Member # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE - validates :source_type, format: { with: /\ANamespace\z/ } - validates :access_level, presence: true - validate :access_level_inclusion + validates :source_type, format: { with: SOURCE_TYPE_FORMAT } default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope @@ -65,12 +64,6 @@ class GroupMember < Member super end - def access_level_inclusion - return if access_level.in?(Gitlab::Access.all_values) - - errors.add(:access_level, "is not included in the list") - end - def send_invite run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) } diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 89b72508e84..6fc665cb87a 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -3,6 +3,7 @@ class ProjectMember < Member extend ::Gitlab::Utils::Override SOURCE_TYPE = 'Project' + SOURCE_TYPE_FORMAT = /\AProject\z/.freeze belongs_to :project, foreign_key: 'source_id' @@ -10,8 +11,7 @@ class ProjectMember < Member # Make sure project member points only to project as it source default_value_for :source_type, SOURCE_TYPE - validates :source_type, format: { with: /\AProject\z/ } - validates :access_level, inclusion: { in: Gitlab::Access.values } + validates :source_type, format: { with: SOURCE_TYPE_FORMAT } default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope scope :in_project, ->(project) { where(source_id: project.id) } @@ -92,6 +92,13 @@ class ProjectMember < Member private + override :access_level_inclusion + def access_level_inclusion + return if access_level.in?(Gitlab::Access.values) + + errors.add(:access_level, "is not included in the list") + end + override :refresh_member_authorized_projects def refresh_member_authorized_projects(blocking:) return unless user diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index ba7e4b39989..8b8eca54550 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -13,7 +13,7 @@ class MembersPreloader ActiveRecord::Associations::Preloader.new.preload(members, :created_by) ActiveRecord::Associations::Preloader.new.preload(members, user: :status) ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations) - ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn) + ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn, default_enabled: :yaml) end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 0cd8f12088c..f88aee38d67 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -506,12 +506,12 @@ class MergeRequest < ApplicationRecord def self.reference_pattern @reference_pattern ||= %r{ (#{Project.reference_pattern})? - #{Regexp.escape(reference_prefix)}(?<merge_request>\d+) + #{Regexp.escape(reference_prefix)}#{Gitlab::Regex.merge_request} }x end def self.link_reference_pattern - @link_reference_pattern ||= super("merge_requests", /(?<merge_request>\d+)/) + @link_reference_pattern ||= super("merge_requests", Gitlab::Regex.merge_request) end def self.reference_valid?(reference) @@ -768,7 +768,7 @@ class MergeRequest < ApplicationRecord def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. - merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size + merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size end def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) @@ -1317,7 +1317,7 @@ class MergeRequest < ApplicationRecord def default_merge_commit_message(include_description: false) if self.target_project.merge_commit_template.present? && !include_description - return ::Gitlab::MergeRequests::MergeCommitMessage.new(merge_request: self).message + return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self).merge_message end closes_issues_references = visible_closing_issues_for.map do |issue| @@ -1340,6 +1340,10 @@ class MergeRequest < ApplicationRecord end def default_squash_commit_message + if self.target_project.squash_commit_template.present? + return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self).squash_message + end + title end @@ -1798,7 +1802,7 @@ class MergeRequest < ApplicationRecord def pipeline_coverage_delta if base_pipeline&.coverage && head_pipeline&.coverage - '%.2f' % (head_pipeline.coverage.to_f - base_pipeline.coverage.to_f) + head_pipeline.coverage - base_pipeline.coverage end end @@ -1880,30 +1884,7 @@ class MergeRequest < ApplicationRecord override :ensure_metrics def ensure_metrics - if Feature.enabled?(:use_upsert_query_for_mr_metrics, default_enabled: :yaml) - MergeRequest::Metrics.record!(self) - else - # Backward compatibility: some merge request metrics records will not have target_project_id filled in. - # In that case the first `safe_find_or_create_by` will return false. - # The second finder call will be eliminated in https://gitlab.com/gitlab-org/gitlab/-/issues/233507 - metrics_record = MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id, target_project_id: target_project_id) || MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id) - - metrics_record.tap do |metrics_record| - # Make sure we refresh the loaded association object with the newly created/loaded item. - # This is needed in order to have the exact functionality than before. - # - # Example: - # - # merge_request.metrics.destroy - # merge_request.ensure_metrics - # merge_request.metrics # should return the metrics record and not nil - # merge_request.metrics.merge_request # should return the same MR record - - metrics_record.target_project_id = target_project_id - metrics_record.association(:merge_request).target = self - association(:metrics).target = metrics_record - end - end + MergeRequest::Metrics.record!(self) end def allows_reviewers? diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index fd8e5860040..77b46fa50f4 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -10,6 +10,12 @@ class MergeRequestAssignee < ApplicationRecord scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) } + def set_state + if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml) + self.state = MergeRequestReviewer.find_by(user_id: self.user_id, merge_request_id: self.merge_request_id)&.state || :attention_requested + end + end + def cache_key [model_name.cache_key, id, state, assignee.cache_key] end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 2516ff05bda..87afb7a489a 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -719,7 +719,7 @@ class MergeRequestDiff < ApplicationRecord if compare.commits.empty? new_attributes[:state] = :empty else - diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project)) + diff_collection = compare.diffs(Commit.max_diff_options) new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index 4abf0fa09f0..8c75fb2e4e6 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -6,6 +6,12 @@ class MergeRequestReviewer < ApplicationRecord belongs_to :merge_request belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers + def set_state + if Feature.enabled?(:mr_attention_requests, self.merge_request&.project, default_enabled: :yaml) + self.state = MergeRequestAssignee.find_by(user_id: self.user_id, merge_request_id: self.merge_request_id)&.state || :attention_requested + end + end + def cache_key [model_name.cache_key, id, state, reviewer.cache_key] end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 353a896b3fe..4b1cf2fa217 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -51,9 +51,7 @@ class Namespace < ApplicationRecord # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. - # TODO: can this be moved into the UserNamespace class? - # evaluate in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - belongs_to :owner, class_name: "User" + belongs_to :owner, class_name: 'User' belongs_to :parent, class_name: "Namespace" has_many :children, -> { where(type: Group.sti_name) }, class_name: "Namespace", foreign_key: :parent_id @@ -66,6 +64,9 @@ class Namespace < ApplicationRecord has_one :admin_note, inverse_of: :namespace accepts_nested_attributes_for :admin_note, update_only: true + has_one :ci_namespace_mirror, class_name: 'Ci::NamespaceMirror' + has_many :sync_events, class_name: 'Namespaces::SyncEvent' + validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, presence: true, @@ -96,7 +97,7 @@ class Namespace < ApplicationRecord validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } - validate :validate_parent_type, if: -> { Feature.enabled?(:validate_namespace_parent_type, default_enabled: :yaml) } + validate :validate_parent_type # ProjectNamespaces excluded as they are not meant to appear in the group hierarchy at the moment. validate :nesting_level_allowed, unless: -> { project_namespace? } @@ -106,6 +107,8 @@ class Namespace < ApplicationRecord delegate :name, to: :owner, allow_nil: true, prefix: true delegate :avatar_url, to: :owner, allow_nil: true + after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? } + after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } before_create :sync_share_with_group_lock_with_parent @@ -122,12 +125,8 @@ class Namespace < ApplicationRecord saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? } - # TODO: change to `type: Namespaces::UserNamespace.sti_name` when - # working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - scope :user_namespaces, -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) } - # TODO: this can be simplified with `type != 'Project'` when working on issue - # https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].is_distinct_from(Namespaces::ProjectNamespace.sti_name)) } + scope :user_namespaces, -> { where(type: Namespaces::UserNamespace.sti_name) } + scope :without_project_namespaces, -> { where(Namespace.arel_table[:type].not_eq(Namespaces::ProjectNamespace.sti_name)) } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } @@ -615,6 +614,13 @@ class Namespace < ApplicationRecord def enforce_minimum_path_length? path_changed? && !project_namespace? end + + # SyncEvents are created by PG triggers (with the function `insert_namespaces_sync_event`) + def schedule_sync_event_worker + run_after_commit do + Namespaces::SyncEvent.enqueue_worker + end + end end Namespace.prepend_mod_with('Namespace') diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index 22ec550dee2..fbd87e3232d 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -7,5 +7,9 @@ module Namespaces def self.sti_name 'Project' end + + def self.polymorphic_name + 'Namespaces::ProjectNamespace' + end end end diff --git a/app/models/namespaces/sync_event.rb b/app/models/namespaces/sync_event.rb new file mode 100644 index 00000000000..8534d8afb8c --- /dev/null +++ b/app/models/namespaces/sync_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This model serves to keep track of changes to the namespaces table in the main database, and allowing to safely +# replicate these changes to other databases. +class Namespaces::SyncEvent < ApplicationRecord + self.table_name = 'namespaces_sync_events' + + belongs_to :namespace + + scope :preload_synced_relation, -> { preload(:namespace) } + scope :order_by_id_asc, -> { order(id: :asc) } + + def self.enqueue_worker + ::Namespaces::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker + end +end diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 1736fe82ca5..5a5f2a5d063 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -64,6 +64,13 @@ module Namespaces traversal_ids.present? end + def use_traversal_ids_for_ancestors_upto? + return false unless use_traversal_ids? + return false unless Feature.enabled?(:use_traversal_ids_for_ancestors_upto, root_ancestor, default_enabled: :yaml) + + traversal_ids.present? + end + def use_traversal_ids_for_root_ancestor? return false unless Feature.enabled?(:use_traversal_ids_for_root_ancestor, default_enabled: :yaml) @@ -114,6 +121,35 @@ module Namespaces hierarchy_order == :desc ? traversal_ids[0..-2] : traversal_ids[0..-2].reverse end + # Returns all ancestors upto but excluding the top. + # When no top is given, all ancestors are returned. + # When top is not found, returns all ancestors. + # + # This copies the behavior of the recursive method. We will deprecate + # this behavior soon. + def ancestors_upto(top = nil, hierarchy_order: nil) + return super unless use_traversal_ids_for_ancestors_upto? + + # We can't use a default value in the method definition above because + # we need to preserve those specific parameters for super. + hierarchy_order ||= :desc + + # Get all ancestor IDs inclusively between top and our parent. + top_index = top ? traversal_ids.find_index(top.id) : 0 + ids = traversal_ids[top_index...-1] + ids_string = ids.map { |id| Integer(id) }.join(',') + + # WITH ORDINALITY lets us order the result to match traversal_ids order. + from_sql = <<~SQL + unnest(ARRAY[#{ids_string}]::bigint[]) WITH ORDINALITY AS ancestors(id, ord) + INNER JOIN namespaces ON namespaces.id = ancestors.id + SQL + + self.class + .from(Arel.sql(from_sql)) + .order('ancestors.ord': hierarchy_order) + end + def self_and_ancestors(hierarchy_order: nil) return super unless use_traversal_ids_for_ancestors? @@ -168,7 +204,7 @@ module Namespaces end if bottom - skope = skope.where(id: bottom.traversal_ids[0..-1]) + skope = skope.where(id: bottom.traversal_ids) end # The original `with_depth` attribute in ObjectHierarchy increments as you diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index f5c44171c42..0dfb7320461 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -105,27 +105,32 @@ module Namespaces :traversal_ids, 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids' ) - cte = Gitlab::SQL::CTE.new(:base_cte, base) + base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base) namespaces = Arel::Table.new(:namespaces) - records = unscoped - .with(cte.to_arel) - .from([cte.table, namespaces]) # Bound the search space to ourselves (optional) and descendants. # # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids) # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids - records = records - .where(cte.table[:next_traversal_ids].eq(nil).or(cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) - .where(next_sibling_func(cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) + records = unscoped + .from([base_cte.table, namespaces]) + .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) + .where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) # AND base_cte.traversal_ids <= namespaces.traversal_ids - if include_self - records.where(cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) - else - records.where(cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) - end + records = if include_self + records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) + else + records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) + end + + records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records) + + unscoped + .unscope(where: [:type]) + .with(base_cte.to_arel, records_cte.to_arel) + .from(records_cte.alias_to(namespaces)) end def next_sibling_func(*args) diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb index 8d2c5d3be5a..53eac27aa54 100644 --- a/app/models/namespaces/traversal/recursive.rb +++ b/app/models/namespaces/traversal/recursive.rb @@ -46,6 +46,7 @@ module Namespaces object_hierarchy(self.class.where(id: id)) .ancestors(upto: top, hierarchy_order: hierarchy_order) end + alias_method :recursive_ancestors_upto, :ancestors_upto def self_and_ancestors(hierarchy_order: nil) return self.class.where(id: id) unless parent_id diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb index d4d7d352e71..14b867b2607 100644 --- a/app/models/namespaces/user_namespace.rb +++ b/app/models/namespaces/user_namespace.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# TODO: currently not created/mapped in the database, will be done in another issue -# https://gitlab.com/gitlab-org/gitlab/-/issues/341070 module Namespaces #################################################################### # PLEASE DO NOT OVERRIDE METHODS IN THIS CLASS! diff --git a/app/models/note.rb b/app/models/note.rb index cb285028203..a143c21c0f9 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -23,7 +23,7 @@ class Note < ApplicationRecord include FromUnion include Sortable - cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true redact_field :note @@ -603,6 +603,15 @@ class Note < ApplicationRecord }) end + def show_outdated_changes? + return false unless for_merge_request? + return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml) + return false unless system? + return false unless change_position&.line_range + + change_position.line_range["end"] || change_position.line_range["start"] + end + private def system_note_viewable_by?(user) diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index c227626af9e..3713be6cb91 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -6,6 +6,7 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' REVIEW_REQUESTED = 'review_requested' + ATTENTION_REQUESTED = 'attention_requested' MENTIONED = 'mentioned' SUBSCRIBED = 'subscribed' @@ -14,6 +15,7 @@ class NotificationReason OWN_ACTIVITY, ASSIGNED, REVIEW_REQUESTED, + ATTENTION_REQUESTED, MENTIONED, SUBSCRIBED ].freeze diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb index 1b0f0ed8ffd..38245bef7a5 100644 --- a/app/models/packages/build_info.rb +++ b/app/models/packages/build_info.rb @@ -3,4 +3,10 @@ class Packages::BuildInfo < ApplicationRecord belongs_to :package, inverse_of: :build_infos belongs_to :pipeline, class_name: 'Ci::Pipeline' + + scope :pluck_pipeline_ids, -> { pluck(:pipeline_id) } + scope :without_empty_pipelines, -> { where.not(pipeline_id: nil) } + scope :order_by_pipeline_id, -> (direction) { order(pipeline_id: direction) } + scope :with_pipeline_id_less_than, -> (pipeline_id) { where("pipeline_id < ?", pipeline_id) } + scope :with_pipeline_id_greater_than, -> (pipeline_id) { where("pipeline_id > ?", pipeline_id) } end diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb index 7ec2641177a..58af34879af 100644 --- a/app/models/packages/conan/metadatum.rb +++ b/app/models/packages/conan/metadatum.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true class Packages::Conan::Metadatum < ApplicationRecord + NONE_VALUE = '_' + belongs_to :package, -> { where(package_type: :conan) }, inverse_of: :conan_metadatum validates :package, presence: true validates :package_username, - presence: true, - format: { with: Gitlab::Regex.conan_recipe_component_regex } - - validates :package_channel, - presence: true, - format: { with: Gitlab::Regex.conan_recipe_component_regex } + :package_channel, + presence: true, + format: { with: Gitlab::Regex.conan_recipe_user_channel_regex } validate :conan_package_type + validate :username_channel_none_values def recipe "#{package.name}/#{package.version}@#{package_username}/#{package_channel}" @@ -31,6 +31,15 @@ class Packages::Conan::Metadatum < ApplicationRecord package_username.tr('+', '/') end + def self.validate_username_and_channel(username, channel) + return if (username != NONE_VALUE && channel != NONE_VALUE) || + (username == NONE_VALUE && channel == NONE_VALUE) + + none_field = username == NONE_VALUE ? :username : :channel + + yield(none_field) + end + private def conan_package_type @@ -38,4 +47,10 @@ class Packages::Conan::Metadatum < ApplicationRecord errors.add(:base, _('Package type must be Conan')) end end + + def username_channel_none_values + self.class.validate_username_and_channel(package_username, package_channel) do |none_field| + errors.add("package_#{none_field}".to_sym, _("can't be solely blank")) + end + end end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index 1a4d3bd5794..1c38edcca61 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Postgresql - class ReplicationSlot < ApplicationRecord + class ReplicationSlot < Gitlab::Database::SharedModel self.table_name = 'pg_replication_slots' # Returns true if there are any replication slots in use. diff --git a/app/models/preloaders/group_policy_preloader.rb b/app/models/preloaders/group_policy_preloader.rb index 95d6e0b5c1f..44030140ce3 100644 --- a/app/models/preloaders/group_policy_preloader.rb +++ b/app/models/preloaders/group_policy_preloader.rb @@ -8,15 +8,12 @@ module Preloaders end def execute - Preloaders::UserMaxAccessLevelInGroupsPreloader.new(@groups, @current_user).execute - Preloaders::GroupRootAncestorPreloader.new(@groups, root_ancestor_preloads).execute + Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute end private - def root_ancestor_preloads - [] - end + attr_reader :groups, :current_user end end diff --git a/app/models/preloaders/group_root_ancestor_preloader.rb b/app/models/preloaders/group_root_ancestor_preloader.rb deleted file mode 100644 index 3ca713d9635..00000000000 --- a/app/models/preloaders/group_root_ancestor_preloader.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Preloaders - class GroupRootAncestorPreloader - def initialize(groups, root_ancestor_preloads = []) - @groups = groups - @root_ancestor_preloads = root_ancestor_preloads - end - - def execute - return unless ::Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) - - # 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') - - root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any? - - root_ancestors_by_id = root_query.group_by(&:source_id) - - @groups.each do |group| - group.root_ancestor = root_ancestors_by_id[group.id].first - end - end - - private - - def join_sql - Group.select('id, traversal_ids[1] as root_id').where(id: @groups.map(&:id)).to_sql - end - end -end diff --git a/app/models/project.rb b/app/models/project.rb index 45999da7839..a751e8adeb0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -102,6 +102,8 @@ class Project < ApplicationRecord after_save :update_project_statistics, if: :saved_change_to_namespace_id? + after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? } + after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } after_save :save_topics @@ -394,6 +396,9 @@ class Project < ApplicationRecord has_many :timelogs + has_one :ci_project_mirror, class_name: 'Ci::ProjectMirror' + has_many :sync_events, class_name: 'Projects::SyncEvent' + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_setting, update_only: true @@ -449,10 +454,11 @@ class Project < ApplicationRecord delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, - :allow_merge_on_skipped_pipeline=, :has_confluence?, + :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, to: :project_setting delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true + delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage @@ -477,7 +483,8 @@ class Project < ApplicationRecord validates :project_feature, presence: true validates :namespace, presence: true - validates :project_namespace, presence: true, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? } + validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? } + validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) } validates :name, uniqueness: { scope: :namespace_id } validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, @@ -575,18 +582,12 @@ class Project < ApplicationRecord .where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%") end - # "enabled" here means "not disabled". It includes private features! scope :with_feature_enabled, ->(feature) { - access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)] - enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil)) - - with_project_feature.where(enabled_feature) + with_project_feature.merge(ProjectFeature.with_feature_enabled(feature)) } - # Picks a feature where the level is exactly that given. scope :with_feature_access_level, ->(feature, level) { - access_level_attribute = ProjectFeature.access_level_attribute(feature) - with_project_feature.where(project_features: { access_level_attribute => level }) + with_project_feature.merge(ProjectFeature.with_feature_access_level(feature, level)) } # Picks projects which use the given programming language @@ -687,37 +688,8 @@ class Project < ApplicationRecord end end - # project features may be "disabled", "internal", "enabled" or "public". If "internal", - # they are only available to team members. This scope returns projects where - # the feature is either public, enabled, or internal with permission for the user. - # Note: this scope doesn't enforce that the user has access to the projects, it just checks - # that the user has access to the feature. It's important to use this scope with others - # that checks project authorizations first (e.g. `filter_by_feature_visibility`). - # - # This method uses an optimised version of `with_feature_access_level` for - # logged in users to more efficiently get private projects with the given - # feature. def self.with_feature_available_for_user(feature, user) - visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] - - if user&.can_read_all_resources? - with_feature_enabled(feature) - elsif user - min_access_level = ProjectFeature.required_minimum_access_level(feature) - column = ProjectFeature.quoted_access_level_column(feature) - - with_project_feature - .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))", - { - public_visible: visible, - private_visible: ProjectFeature::PRIVATE, - authorizations: user.authorizations_for_projects(min_access_level: min_access_level) - }) - else - # This has to be added to include features whose value is nil in the db - visible << nil - with_feature_access_level(feature, visible) - end + with_project_feature.merge(ProjectFeature.with_feature_available_for_user(feature, user)) end def self.projects_user_can(projects, user, action) @@ -1469,7 +1441,9 @@ class Project < ApplicationRecord end def disabled_integrations - [:shimo] + disabled_integrations = [] + disabled_integrations << 'shimo' unless Feature.enabled?(:shimo_integration, self) + disabled_integrations end def find_or_initialize_integration(name) @@ -1600,6 +1574,12 @@ class Project < ApplicationRecord oids(lfs_objects, oids: oids) end + def lfs_objects_oids_from_fork_source(oids: []) + return [] unless forked? + + oids(fork_source.lfs_objects, oids: oids) + end + def personal? !group end @@ -2747,6 +2727,12 @@ class Project < ApplicationRecord end end + def remove_project_authorizations(user_ids, per_batch = 1000) + user_ids.each_slice(per_batch) do |user_ids_batch| + project_authorizations.where(user_id: user_ids_batch).delete_all + end + end + private # overridden in EE @@ -2957,6 +2943,13 @@ class Project < ApplicationRecord project_namespace.shared_runners_enabled = shared_runners_enabled project_namespace.visibility_level = visibility_level end + + # SyncEvents are created by PG triggers (with the function `insert_projects_sync_event`) + def schedule_sync_event_worker + run_after_commit do + Projects::SyncEvent.enqueue_worker + end + end end Project.prepend_mod_with('Project') diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index fed19a37a16..c76332b21cd 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -17,20 +17,6 @@ class ProjectAuthorization < ApplicationRecord .group(:project_id) end - def self.insert_authorizations(rows, per_batch = 1000) - rows.each_slice(per_batch) do |slice| - tuples = slice.map do |tuple| - tuple.map { |value| connection.quote(value) } - end - - connection.execute <<-EOF.strip_heredoc - INSERT INTO project_authorizations (user_id, project_id, access_level) - VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} - ON CONFLICT DO NOTHING - EOF - end - end - # This method overrides its ActiveRecord's version in order to work correctly # with composite primary keys and fix the tests for Rails 6.1 # @@ -39,6 +25,12 @@ class ProjectAuthorization < ApplicationRecord def self.insert_all(attributes) super(attributes, unique_by: connection.schema_cache.primary_keys(table_name)) end + + def self.insert_all_in_batches(attributes, per_batch = 1000) + attributes.each_slice(per_batch) do |attributes_batch| + insert_all(attributes_batch) + end + end end ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 676c28d5e1b..0d3e50837ab 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -83,6 +83,52 @@ class ProjectFeature < ApplicationRecord end end + # "enabled" here means "not disabled". It includes private features! + scope :with_feature_enabled, ->(feature) { + feature_access_level_attribute = arel_table[access_level_attribute(feature)] + enabled_feature = feature_access_level_attribute.gt(DISABLED).or(feature_access_level_attribute.eq(nil)) + + where(enabled_feature) + } + + # Picks a feature where the level is exactly that given. + scope :with_feature_access_level, ->(feature, level) { + feature_access_level_attribute = access_level_attribute(feature) + where(project_features: { feature_access_level_attribute => level }) + } + + # project features may be "disabled", "internal", "enabled" or "public". If "internal", + # they are only available to team members. This scope returns features where + # the feature is either public, enabled, or internal with permission for the user. + # Note: this scope doesn't enforce that the user has access to the projects, it just checks + # that the user has access to the feature. It's important to use this scope with others + # that checks project authorizations first (e.g. `filter_by_feature_visibility`). + # + # This method uses an optimised version of `with_feature_access_level` for + # logged in users to more efficiently get private projects with the given + # feature. + def self.with_feature_available_for_user(feature, user) + visible = [ENABLED, PUBLIC] + + if user&.can_read_all_resources? + with_feature_enabled(feature) + elsif user + min_access_level = required_minimum_access_level(feature) + column = quoted_access_level_column(feature) + + where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))", + { + public_visible: visible, + private_visible: PRIVATE, + authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id') + }) + else + # This has to be added to include features whose value is nil in the db + visible << nil + with_feature_access_level(feature, visible) + end + end + def public_pages? return true unless Gitlab.config.pages.access_control diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 6c8d2226bc9..fc834286876 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -13,6 +13,7 @@ class ProjectSetting < ApplicationRecord self.primary_key = :project_id validates :merge_commit_template, length: { maximum: 500 } + validates :squash_commit_template, length: { maximum: 500 } def squash_enabled_by_default? %w[always default_on].include?(squash_option) diff --git a/app/models/projects/sync_event.rb b/app/models/projects/sync_event.rb new file mode 100644 index 00000000000..5221b00c55f --- /dev/null +++ b/app/models/projects/sync_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This model serves to keep track of changes to the namespaces table in the main database as they relate to projects, +# allowing to safely replicate changes to other databases. +class Projects::SyncEvent < ApplicationRecord + self.table_name = 'projects_sync_events' + + belongs_to :project + + scope :preload_synced_relation, -> { preload(:project) } + scope :order_by_id_asc, -> { order(id: :asc) } + + def self.enqueue_worker + ::Projects::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 47482f04bca..645cc9773bd 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -519,6 +519,8 @@ class Repository raw_repository.batch_blobs(items, blob_size_limit: blob_size_limit).map do |blob| Blob.decorate(blob, container) end + rescue Gitlab::Git::Repository::NoRepository + [] end def root_ref diff --git a/app/models/serverless/domain.rb b/app/models/serverless/domain.rb index 2fef3b66b08..164f93afa9a 100644 --- a/app/models/serverless/domain.rb +++ b/app/models/serverless/domain.rb @@ -37,7 +37,7 @@ module Serverless 'a1', serverless_domain_cluster.uuid[2..-3], 'f2', - serverless_domain_cluster.uuid[-2..-1] + serverless_domain_cluster.uuid[-2..] ].join end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dd76f2c3c84..6a8123b3c08 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -98,87 +98,115 @@ class Snippet < ApplicationRecord mode: :per_attribute_iv, algorithm: 'aes-256-cbc' - def self.with_optional_visibility(value = nil) - if value - where(visibility_level: value) - else - all + class << self + # Searches for snippets with a matching title, description or file name. + # + # This method uses ILIKE on PostgreSQL. + # + # query - The search query as a String. + # + # Returns an ActiveRecord::Relation. + def search(query) + fuzzy_search(query, [:title, :description, :file_name]) end - end - def self.only_personal_snippets - where(project_id: nil) - end + def parent_class + ::Project + end - def self.only_project_snippets - where.not(project_id: nil) - end + def sanitized_file_name(file_name) + file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '') + end - def self.only_include_projects_visible_to(current_user = nil) - levels = Gitlab::VisibilityLevel.levels_for_user(current_user) + def with_optional_visibility(value = nil) + if value + where(visibility_level: value) + else + all + end + end - joins(:project).where(projects: { visibility_level: levels }) - end + def only_personal_snippets + where(project_id: nil) + end - def self.only_include_projects_with_snippets_enabled(include_private: false) - column = ProjectFeature.access_level_attribute(:snippets) - levels = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] + def only_project_snippets + where.not(project_id: nil) + end - levels << ProjectFeature::PRIVATE if include_private + def only_include_projects_visible_to(current_user = nil) + levels = Gitlab::VisibilityLevel.levels_for_user(current_user) - joins(project: :project_feature) - .where(project_features: { column => levels }) - end + joins(:project).where(projects: { visibility_level: levels }) + end - def self.only_include_authorized_projects(current_user) - where( - 'EXISTS (?)', - ProjectAuthorization - .select(1) - .where('project_id = snippets.project_id') - .where(user_id: current_user.id) - ) - end + def only_include_projects_with_snippets_enabled(include_private: false) + column = ProjectFeature.access_level_attribute(:snippets) + levels = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] - def self.for_project_with_user(project, user = nil) - return none unless project.snippets_visible?(user) + levels << ProjectFeature::PRIVATE if include_private - if user && project.team.member?(user) - project.snippets - else - project.snippets.public_to_user(user) + joins(project: :project_feature) + .where(project_features: { column => levels }) end - end - def self.visible_to_or_authored_by(user) - query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user)) - query.or(where(author_id: user.id)) - end + def only_include_authorized_projects(current_user) + where( + 'EXISTS (?)', + ProjectAuthorization + .select(1) + .where('project_id = snippets.project_id') + .where(user_id: current_user.id) + ) + end - def self.reference_prefix - '$' - end + def for_project_with_user(project, user = nil) + return none unless project.snippets_visible?(user) + + if user && project.team.member?(user) + project.snippets + else + project.snippets.public_to_user(user) + end + end - # Pattern used to extract `$123` snippet references from text - # - # This pattern supports cross-project references. - def self.reference_pattern - @reference_pattern ||= %r{ + def visible_to_or_authored_by(user) + query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user)) + query.or(where(author_id: user.id)) + end + + def reference_prefix + '$' + end + + # Pattern used to extract `$123` snippet references from text + # + # This pattern supports cross-project references. + def reference_pattern + @reference_pattern ||= %r{ (#{Project.reference_pattern})? #{Regexp.escape(reference_prefix)}(?<snippet>\d+) }x - end + end - def self.link_reference_pattern - @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/) - end + def link_reference_pattern + @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/) + end - def self.find_by_id_and_project(id:, project:) - Snippet.find_by(id: id, project: project) - end + def find_by_id_and_project(id:, project:) + Snippet.find_by(id: id, project: project) + end + + def find_by_project_title_trunc_created_at(project, title, created_at) + where(project: project, title: title) + .find_by( + "date_trunc('second', created_at at time zone :tz) at time zone :tz = :created_at", + tz: created_at.zone, created_at: created_at) + end - def self.max_file_limit - MAX_FILE_COUNT + def max_file_limit + MAX_FILE_COUNT + end end def initialize(attributes = {}) @@ -230,10 +258,6 @@ class Snippet < ApplicationRecord super.to_s end - def self.sanitized_file_name(file_name) - file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '') - end - def visibility_level_field :visibility_level end @@ -371,23 +395,6 @@ class Snippet < ApplicationRecord def multiple_files? list_files.size > 1 end - - class << self - # Searches for snippets with a matching title, description or file name. - # - # This method uses ILIKE on PostgreSQL. - # - # query - The search query as a String. - # - # Returns an ActiveRecord::Relation. - def search(query) - fuzzy_search(query, [:title, :description, :file_name]) - end - - def parent_class - ::Project - end - end end Snippet.prepend_mod_with('Snippet') diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 749b9dce97c..7b13109dbc4 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -24,6 +24,7 @@ class SystemNoteMetadata < ApplicationRecord opened closed merged duplicate locked unlocked outdated reviewer tag due_date pinned_embed cherry_pick health_status approved unapproved status alert_issue_added relate unrelate new_alert_added severity + attention_requested attention_request_removed ].freeze validates :note, presence: true, unless: :importing? diff --git a/app/models/todo.rb b/app/models/todo.rb index cfcb2201b80..dc436570f52 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -3,6 +3,7 @@ class Todo < ApplicationRecord include Sortable include FromUnion + include EachBatch # 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 diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb index 65dc7a47533..7c01aa7a420 100644 --- a/app/models/u2f_registration.rb +++ b/app/models/u2f_registration.rb @@ -12,11 +12,7 @@ class U2fRegistration < ApplicationRecord converter = Gitlab::Auth::U2fWebauthnConverter.new(self) WebauthnRegistration.create!(converter.convert) rescue StandardError => ex - Gitlab::AppJsonLogger.error( - event: 'u2f_migration', - error: ex.class.name, - backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(ex.backtrace), - message: "U2F to WebAuthn conversion failed") + Gitlab::ErrorTracking.track_exception(ex, u2f_registration_id: self.id) end def update_webauthn_registration diff --git a/app/models/user.rb b/app/models/user.rb index 3ab5b7ee364..a39da30220a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,6 +27,7 @@ class User < ApplicationRecord include HasUserType include Gitlab::Auth::Otp::Fortinet include RestrictedSignup + include StripAttribute DEFAULT_NOTIFICATION_LEVEL = :participating @@ -112,10 +113,8 @@ class User < ApplicationRecord # # Namespace for personal projects - # TODO: change to `:namespace, -> { where(type: Namespaces::UserNamespace.sti_name}, class_name: 'Namespaces::UserNamespace'...` - # when working on issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 has_one :namespace, - -> { where(type: [nil, Namespaces::UserNamespace.sti_name]) }, + -> { where(type: Namespaces::UserNamespace.sti_name) }, dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent foreign_key: :owner_id, inverse_of: :owner, @@ -189,8 +188,8 @@ class User < ApplicationRecord has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent - has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent + has_many :builds, class_name: 'Ci::Build' + has_many :pipelines, class_name: 'Ci::Pipeline' has_many :todos has_many :notification_settings has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -206,7 +205,7 @@ class User < ApplicationRecord has_many :bulk_imports has_many :custom_attributes, class_name: 'UserCustomAttribute' - has_many :callouts, class_name: 'UserCallout' + has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -391,8 +390,10 @@ class User < ApplicationRecord # this state transition object in order to do a rollback. # For this reason the tradeoff is to disable this cop. after_transition any => :blocked do |user| - Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user) - Ci::DisableUserPipelineSchedulesService.new.execute(user) + user.run_after_commit do + Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user) + Ci::DisableUserPipelineSchedulesService.new.execute(user) + end end after_transition any => :deactivated do |user| @@ -466,6 +467,8 @@ class User < ApplicationRecord scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } scope :get_ids_by_username, -> (username) { where(username: username).pluck(:id) } + strip_attributes! :name + def preferred_language read_attribute('preferred_language') || I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) || @@ -844,10 +847,6 @@ class User < ApplicationRecord # Instance methods # - def default_dashboard? - dashboard == self.class.column_defaults['dashboard'] - end - def full_path username end @@ -915,6 +914,8 @@ class User < ApplicationRecord end def two_factor_u2f_enabled? + return false if Feature.enabled?(:webauthn, default_enabled: :yaml) + if u2f_registrations.loaded? u2f_registrations.any? else @@ -927,7 +928,7 @@ class User < ApplicationRecord end def two_factor_webauthn_enabled? - return false unless Feature.enabled?(:webauthn) + return false unless Feature.enabled?(:webauthn, default_enabled: :yaml) (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) end @@ -989,11 +990,7 @@ class User < ApplicationRecord # Returns the groups a user is a member of, either directly or through a parent group def membership_groups - if Feature.enabled?(:linear_user_membership_groups, self, default_enabled: :yaml) - groups.self_and_descendants - else - Gitlab::ObjectHierarchy.new(groups).base_and_descendants - end + groups.self_and_descendants end # Returns a relation of groups the user has access to, including their parent @@ -1615,7 +1612,7 @@ class User < ApplicationRecord .select('ci_runners.*') group_runners = Ci::RunnerNamespace - .where(namespace_id: Gitlab::ObjectHierarchy.new(owned_groups).base_and_descendants.select(:id)) + .where(namespace_id: owned_groups.self_and_descendant_ids) .joins(:runner) .select('ci_runners.*') @@ -1796,7 +1793,7 @@ class User < ApplicationRecord # we do this on read since migrating all existing users is not a feasible # solution. def feed_token - Gitlab::CurrentSettings.disable_feed_token ? nil : ensure_feed_token! + ensure_feed_token! unless Gitlab::CurrentSettings.disable_feed_token end # Each existing user needs to have a `static_object_token`. @@ -1806,6 +1803,14 @@ class User < ApplicationRecord ensure_static_object_token! end + def enabled_static_object_token + static_object_token if Gitlab::CurrentSettings.static_objects_external_storage_enabled? + end + + def enabled_incoming_email_token + incoming_email_token if Gitlab::IncomingEmail.supports_issue_creation? + end + def sync_attribute?(attribute) return true if ldap_user? && attribute == :email @@ -1949,7 +1954,7 @@ class User < ApplicationRecord end def find_or_initialize_callout(feature_name) - callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name]) + callouts.find_or_initialize_by(feature_name: ::Users::Callout.feature_names[feature_name]) end def find_or_initialize_group_callout(feature_name, group_id) @@ -2160,12 +2165,7 @@ class User < ApplicationRecord project_creation_levels << nil end - if Feature.enabled?(:linear_user_groups_with_developer_maintainer_project_access, self, default_enabled: :yaml) - developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels) - else - developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants - ::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels) - end + developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels) end def no_recent_activity? diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb deleted file mode 100644 index b990aedd4f8..00000000000 --- a/app/models/user_callout.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class UserCallout < ApplicationRecord - include Calloutable - - enum feature_name: { - gke_cluster_integration: 1, - gcp_signup_offer: 2, - cluster_security_warning: 3, - ultimate_trial: 4, # EE-only - geo_enable_hashed_storage: 5, # EE-only - geo_migrate_hashed_storage: 6, # EE-only - canary_deployment: 7, # EE-only - gold_trial_billings: 8, # EE-only - suggest_popover_dismissed: 9, - tabs_position_highlight: 10, - threat_monitoring_info: 11, # EE-only - two_factor_auth_recovery_settings_check: 12, # EE-only - web_ide_alert_dismissed: 16, # no longer in use - active_user_count_threshold: 18, # EE-only - buy_pipeline_minutes_notification_dot: 19, # EE-only - personal_access_token_expiry: 21, # EE-only - suggest_pipeline: 22, - customize_homepage: 23, - feature_flags_new_version: 24, - registration_enabled_callout: 25, - new_user_signups_cap_reached: 26, # EE-only - unfinished_tag_cleanup_callout: 27, - eoa_bronze_plan_banner: 28, # EE-only - pipeline_needs_banner: 29, - pipeline_needs_hover_tip: 30, - web_ide_ci_environments_guidance: 31, - security_configuration_upgrade_banner: 32, - cloud_licensing_subscription_activation_banner: 33, # EE-only - trial_status_reminder_d14: 34, # EE-only - trial_status_reminder_d3: 35, # EE-only - security_configuration_devops_alert: 36, # EE-only - profile_personal_access_token_expiry: 37, # EE-only - terraform_notification_dismissed: 38, - security_newsletter_callout: 39 - } - - validates :feature_name, - presence: true, - uniqueness: { scope: :user_id }, - inclusion: { in: UserCallout.feature_names.keys } -end diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 6b0ed89c683..3787ad1c380 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -2,9 +2,6 @@ class UserDetail < ApplicationRecord extend ::Gitlab::Utils::Override - include IgnorableColumns - - ignore_columns %i[bio_html cached_markdown_version], remove_with: '14.5', remove_after: '2021-10-22' REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb new file mode 100644 index 00000000000..9ce0beed3b3 --- /dev/null +++ b/app/models/users/callout.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Users + class Callout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_callouts' + + enum feature_name: { + gke_cluster_integration: 1, + gcp_signup_offer: 2, + cluster_security_warning: 3, + ultimate_trial: 4, # EE-only + geo_enable_hashed_storage: 5, # EE-only + geo_migrate_hashed_storage: 6, # EE-only + canary_deployment: 7, # EE-only + gold_trial_billings: 8, # EE-only + suggest_popover_dismissed: 9, + tabs_position_highlight: 10, + threat_monitoring_info: 11, # EE-only + two_factor_auth_recovery_settings_check: 12, # EE-only + web_ide_alert_dismissed: 16, # no longer in use + active_user_count_threshold: 18, # EE-only + buy_pipeline_minutes_notification_dot: 19, # EE-only + personal_access_token_expiry: 21, # EE-only + suggest_pipeline: 22, + feature_flags_new_version: 24, + registration_enabled_callout: 25, + new_user_signups_cap_reached: 26, # EE-only + unfinished_tag_cleanup_callout: 27, + eoa_bronze_plan_banner: 28, # EE-only + pipeline_needs_banner: 29, + pipeline_needs_hover_tip: 30, + web_ide_ci_environments_guidance: 31, + security_configuration_upgrade_banner: 32, + cloud_licensing_subscription_activation_banner: 33, # EE-only + trial_status_reminder_d14: 34, # EE-only + trial_status_reminder_d3: 35, # EE-only + security_configuration_devops_alert: 36, # EE-only + profile_personal_access_token_expiry: 37, # EE-only + terraform_notification_dismissed: 38, + security_newsletter_callout: 39, + verification_reminder: 40 # EE-only + } + + validates :feature_name, + presence: true, + uniqueness: { scope: :user_id }, + inclusion: { in: Users::Callout.feature_names.keys } + end +end diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb new file mode 100644 index 00000000000..280a819e4d5 --- /dev/null +++ b/app/models/users/calloutable.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Users + module Calloutable + extend ActiveSupport::Concern + + included do + belongs_to :user + + validates :user, presence: true + end + + def dismissed_after?(dismissed_after) + dismissed_at > dismissed_after + end + end +end diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 540d1a1d242..da9b95fd718 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -2,7 +2,7 @@ module Users class GroupCallout < ApplicationRecord - include Calloutable + include Users::Calloutable self.table_name = 'user_group_callouts' diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 25438581f2f..3dbbbcdfe23 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -338,7 +338,7 @@ class WikiPage current_dirname = File.dirname(title) if persisted? - return title[1..-1] if current_dirname == '/' + return title[1..] if current_dirname == '/' return File.join([directory.presence, title].compact) if current_dirname == '.' end diff --git a/app/models/work_item/type.rb b/app/models/work_item/type.rb index 7038beadd62..3acb9c0011c 100644 --- a/app/models/work_item/type.rb +++ b/app/models/work_item/type.rb @@ -15,7 +15,8 @@ class WorkItem::Type < ApplicationRecord issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 }, incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 }, test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only - requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 } ## EE-only + requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only + task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 } }.freeze cache_markdown_field :description, pipeline: :single_line @@ -42,6 +43,10 @@ class WorkItem::Type < ApplicationRecord default_by_type(:issue) end + def self.allowed_types_for_issues + base_types.keys.excluding('task') + end + private def strip_whitespace diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 428fd336a32..2c1d0110b7c 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -13,7 +13,7 @@ class X509Certificate < ApplicationRecord belongs_to :x509_issuer, class_name: 'X509Issuer', foreign_key: 'x509_issuer_id', optional: false - has_many :x509_commit_signatures, inverse_of: 'x509_certificate' + has_many :x509_commit_signatures, class_name: 'CommitSignatures::X509CommitSignature', inverse_of: 'x509_certificate' # rfc 5280 - 4.2.1.2 Subject Key Identifier validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ } diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb deleted file mode 100644 index 57d809f7cfb..00000000000 --- a/app/models/x509_commit_signature.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -class X509CommitSignature < ApplicationRecord - include ShaAttribute - - sha_attribute :commit_sha - - enum verification_status: { - unverified: 0, - verified: 1 - } - - belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false - belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false - - validates :commit_sha, presence: true - validates :project_id, presence: true - validates :x509_certificate_id, presence: true - - scope :by_commit_sha, ->(shas) { where(commit_sha: shas) } - - def self.safe_create!(attributes) - create_with(attributes) - .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) - end - - # Find commits that are lacking a signature in the database at present - def self.unsigned_commit_shas(commit_shas) - return [] if commit_shas.empty? - - signed = by_commit_sha(commit_shas).pluck(:commit_sha) - commit_shas - signed - end - - def commit - project.commit(commit_sha) - end - - def x509_commit - return unless commit - - Gitlab::X509::Commit.new(commit) - end - - def user - commit.committer - end -end diff --git a/app/policies/clusters/agents/activity_event_policy.rb b/app/policies/clusters/agents/activity_event_policy.rb new file mode 100644 index 00000000000..25fe1570b4b --- /dev/null +++ b/app/policies/clusters/agents/activity_event_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class ActivityEventPolicy < BasePolicy + alias_method :event, :subject + + delegate { event.agent } + end + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 833d5b9bd34..5c4990ffd9b 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GroupPolicy < BasePolicy +class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy include FindGroupProjects desc "Group is public" @@ -77,6 +77,11 @@ class GroupPolicy < BasePolicy condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) } + with_scope :subject + condition(:group_runner_registration_allowed, score: 0, scope: :subject) do + Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') + end + rule { can?(:read_group) & design_management_enabled }.policy do enable :read_design_activity end @@ -157,6 +162,7 @@ class GroupPolicy < BasePolicy enable :destroy_package enable :create_projects enable :admin_pipeline + enable :admin_group_runners enable :admin_build enable :read_cluster enable :add_cluster @@ -199,6 +205,10 @@ class GroupPolicy < BasePolicy enable :read_nested_project_resources end + rule { can?(:admin_group_runners) }.policy do + enable :register_group_runners + end + rule { owner }.enable :create_subgroup rule { maintainer & maintainer_can_create_group }.enable :create_subgroup @@ -261,6 +271,10 @@ class GroupPolicy < BasePolicy prevent :admin_crm_organization end + rule { ~group_runner_registration_allowed }.policy do + prevent :register_group_runners + end + def access_level(for_any_session: false) return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 0cf1bcb9737..33c90d49f68 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -class NamespacePolicy < ::Namespaces::UserNamespacePolicy +class NamespacePolicy < BasePolicy # NamespacePolicy has been traditionally for user namespaces. # So these policies have been moved into Namespaces::UserNamespacePolicy. # Once the user namespace conversion is complete, we can look at # either removing this file or locating common namespace policy items # here. + # See https://gitlab.com/groups/gitlab-org/-/epics/6689 for details end diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb new file mode 100644 index 00000000000..1ed9f05306f --- /dev/null +++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Namespaces + class GroupProjectNamespaceSharedPolicy < ::NamespacePolicy + # Nothing here at the moment, but as we move policies from ProjectPolicy to ProjectNamespacePolicy, + # anything common with GroupPolicy but not with UserNamespacePolicy can go in here. + # See https://gitlab.com/groups/gitlab-org/-/epics/6689 + end +end diff --git a/app/policies/namespaces/project_namespace_policy.rb b/app/policies/namespaces/project_namespace_policy.rb index bc08a7a45ed..33aadc7c411 100644 --- a/app/policies/namespaces/project_namespace_policy.rb +++ b/app/policies/namespaces/project_namespace_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Namespaces - class ProjectNamespacePolicy < BasePolicy + class ProjectNamespacePolicy < Namespaces::GroupProjectNamespaceSharedPolicy # For now users are not granted any permissions on project namespace # as it's completely hidden to them. When we start using project # namespaces in queries, we will have to extend this policy. diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index f8b285e5312..09b0f5d608d 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Namespaces - class UserNamespacePolicy < BasePolicy + class UserNamespacePolicy < ::NamespacePolicy rule { anonymous }.prevent_all - condition(:personal_project, scope: :subject) { @subject.kind == 'user' } condition(:can_create_personal_project, scope: :user) { @user.can_create_project? } condition(:owner) { @subject.owner == @user } @@ -19,7 +18,7 @@ module Namespaces enable :read_package_settings end - rule { personal_project & ~can_create_personal_project }.prevent :create_projects + rule { ~can_create_personal_project }.prevent :create_projects rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 5835a77d0b9..3bd92ebc942 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -15,19 +15,8 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated Gitlab::Highlight.highlight( blob.path, - limited_blob_data(to: to), - language: language, - plain: plain - ) - end - - def highlight_transformed(plain: nil) - load_all_blob_data - - Gitlab::Highlight.highlight( - blob.path, - transformed_blob_data, - language: transformed_blob_language, + blob_data(to), + language: blob_language, plain: plain ) end @@ -38,6 +27,14 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated highlight(plain: false) end + def blob_data(to) + @_blob_data ||= Gitlab::Diff::CustomDiff.transformed_blob_data(blob) || limited_blob_data(to: to) + end + + def blob_language + @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || language + end + def raw_plain_data blob.data unless blob.binary? end @@ -66,6 +63,11 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default end + # Will be overridden in EE + def code_owners + [] + end + def fork_and_edit_path fork_path_for_current_user(project, edit_blob_path) end @@ -78,6 +80,12 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated super(blob, project, blob.commit_id) end + def can_current_user_push_to_branch? + return false unless current_user && project.repository.branch_exists?(blob.commit_id) + + user_access(project).can_push_to_branch?(blob.commit_id) + end + def ide_edit_path super(project, blob.commit_id, blob.path) end @@ -123,21 +131,6 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated def language blob.language_from_gitattributes end - - def transformed_blob_language - @transformed_blob_language ||= blob.path.ends_with?('.ipynb') ? 'md' : language - end - - def transformed_blob_data - @transformed_blob ||= if blob.path.ends_with?('.ipynb') && blob.transformed_for_diff - IpynbDiff.transform(blob.data, - raise_errors: true, - options: { include_metadata: false, cell_decorator: :percent }) - end - - @transformed_blob ||= blob.data - rescue IpynbDiff::InvalidNotebookError => e - Gitlab::ErrorTracking.log_exception(e) - blob.data - end end + +BlobPresenter.prepend_mod_with('BlobPresenter') diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index e0cb899c9d3..7f5dffadcfb 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -3,7 +3,6 @@ module Ci class PipelinePresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize - include ActionView::Helpers::UrlHelper delegator_override_with Gitlab::Utils::StrongMemoize # TODO: Remove `Gitlab::Utils::StrongMemoize` inclusion as it's duplicate delegator_override_with ActionView::Helpers::TagHelper # TODO: Remove `ActionView::Helpers::UrlHelper` inclusion as it overrides `Ci::Pipeline#tag` @@ -62,6 +61,13 @@ module Ci localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline')) end + delegator_override :coverage + def coverage + return unless pipeline.coverage.present? + + '%.2f' % pipeline.coverage + end + def ref_text if pipeline.detached_merge_request_pipeline? _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}") @@ -101,7 +107,7 @@ module Ci end def link_to_pipeline_ref - link_to(pipeline.ref, + ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), class: "ref-name") end @@ -109,7 +115,7 @@ module Ci def link_to_merge_request return unless merge_request_presenter - link_to(merge_request_presenter.to_reference, + ApplicationController.helpers.link_to(merge_request_presenter.to_reference, project_merge_request_path(merge_request_presenter.project, merge_request_presenter), class: 'mr-iid') end @@ -136,7 +142,7 @@ module Ci private def plain_ref_name - content_tag(:span, pipeline.ref, class: 'ref-name') + ApplicationController.helpers.content_tag(:span, pipeline.ref, class: 'ref-name') end def merge_request_presenter @@ -153,7 +159,7 @@ module Ci all_related_merge_requests.first(limit).map do |merge_request| mr_path = project_merge_request_path(merge_request.project, merge_request) - link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' + ApplicationController.helpers.link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' end end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 7919e501bf0..250715d7c9c 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -29,7 +29,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated no_matching_runner: 'No matching runner available', trace_size_exceeded: 'The job log size limit was reached', builds_disabled: 'The CI/CD is disabled for this project', - environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.' + environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.', + deployment_rejected: 'This deployment job was rejected.' }.freeze TROUBLESHOOTING_DOC = { diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index d19d4964524..eeb94a8e657 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -136,7 +136,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated pipeline: :gfm, author: author, project: project, - issuable_state_filter_enabled: true + issuable_reference_expansion_enabled: true ) end @@ -146,7 +146,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated pipeline: :gfm, author: author, project: project, - issuable_state_filter_enabled: true + issuable_reference_expansion_enabled: true ) end @@ -254,6 +254,13 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + delegator_override :pipeline_coverage_delta + def pipeline_coverage_delta + return unless merge_request.pipeline_coverage_delta.present? + + '%.2f' % merge_request.pipeline_coverage_delta + end + private def cached_can_be_reverted? diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb index 9e3308c2573..c30dfa6196b 100644 --- a/app/presenters/packages/npm/package_presenter.rb +++ b/app/presenters/packages/npm/package_presenter.rb @@ -12,10 +12,9 @@ module Packages attr_reader :name, :packages - def initialize(name, packages, include_metadata: false) + def initialize(name, packages) @name = name @packages = packages - @include_metadata = include_metadata end def versions @@ -24,10 +23,7 @@ module Packages packages.each_batch do |relation| batched_packages = relation.including_dependency_links .preload_files - - if @include_metadata - batched_packages = batched_packages.preload_npm_metadatum - end + .preload_npm_metadatum batched_packages.each do |package| package_file = package.package_files.last @@ -92,8 +88,6 @@ module Packages end def abbreviated_package_json(package) - return {} unless @include_metadata - json = package.npm_metadatum&.package_json || {} json.slice(*PACKAGE_JSON_ALLOWED_FIELDS) end diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb new file mode 100644 index 00000000000..89fca1a451a --- /dev/null +++ b/app/presenters/projects/security/configuration_presenter.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Projects + module Security + class ConfigurationPresenter < Gitlab::View::Presenter::Delegated + include AutoDevopsHelper + include ::Security::LatestPipelineInformation + + presents ::Project, as: :project + + def to_h + { + auto_devops_enabled: auto_devops_source?, + auto_devops_help_page_path: help_page_path('topics/autodevops/index'), + auto_devops_path: auto_devops_settings_path(project), + can_enable_auto_devops: can_enable_auto_devops?, + features: features, + help_page_path: help_page_path('user/application_security/index'), + latest_pipeline_path: latest_pipeline_path, + # TODO: gitlab_ci_present will incorrectly report `false` if the CI/CD configuration file name + # has been customized and a file with the given custom name exists in the repo. This edge case + # will be addressed in https://gitlab.com/gitlab-org/gitlab/-/issues/342465 + gitlab_ci_present: project.repository.gitlab_ci_yml.present?, + gitlab_ci_history_path: gitlab_ci_history_path, + auto_fix_enabled: autofix_enabled, + can_toggle_auto_fix_settings: can_toggle_autofix, + auto_fix_user_path: auto_fix_user_path + } + end + + def to_html_data_attribute + data = to_h + data[:features] = data[:features].to_json + data[:auto_fix_enabled] = data[:auto_fix_enabled].to_json + + data + end + + private + + def autofix_enabled; end + + def auto_fix_user_path; end + + def can_enable_auto_devops? + feature_available?(:builds, current_user) && + can?(current_user, :admin_project, self) && + !archived? + end + + def can_toggle_autofix; end + + def gitlab_ci_history_path + return '' if project.empty_repo? + + gitlab_ci = ::Gitlab::FileDetector::PATTERNS[:gitlab_ci] + ::Gitlab::Routing.url_helpers.project_blame_path(project, File.join(project.default_branch_or_main, gitlab_ci)) + end + + def features + scans = scan_types.map do |scan_type| + scan(scan_type, configured: scanner_enabled?(scan_type)) + end + + # These scans are "fake" (non job) entries. Add them manually. + scans << scan(:corpus_management, configured: true) + scans << scan(:dast_profiles, configured: true) + end + + def latest_pipeline_path + return help_page_path('ci/pipelines') unless latest_default_branch_pipeline + + project_pipeline_path(self, latest_default_branch_pipeline) + end + + def scan(type, configured: false) + scan = ::Gitlab::Security::ScanConfiguration.new(project: project, type: type, configured: configured) + + { + type: scan.type, + configured: scan.configured?, + configuration_path: scan.configuration_path, + available: scan.available? + } + end + + def scan_types + ::Security::SecurityJobsFinder.allowed_job_types + ::Security::LicenseComplianceJobsFinder.allowed_job_types + end + + def project_settings + project.security_setting + end + end + end +end + +Projects::Security::ConfigurationPresenter.prepend_mod_with('Projects::Security::ConfigurationPresenter') diff --git a/app/presenters/prometheus_alert_presenter.rb b/app/presenters/prometheus_alert_presenter.rb index 714329ede71..776e2baebdd 100644 --- a/app/presenters/prometheus_alert_presenter.rb +++ b/app/presenters/prometheus_alert_presenter.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated - include ActionView::Helpers::UrlHelper - presents ::PrometheusAlert, as: :prometheus_alert def humanized_text diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb index 4072696eb89..026d442291c 100644 --- a/app/presenters/snippet_blob_presenter.rb +++ b/app/presenters/snippet_blob_presenter.rb @@ -57,3 +57,5 @@ class SnippetBlobPresenter < BlobPresenter gitlab_raw_snippet_blob_url(snippet, blob.path, only_path: only_path) end end + +SnippetBlobPresenter.prepend_mod diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb index c5cc8c89fb7..cfbf6f60e38 100644 --- a/app/serializers/analytics/cycle_analytics/stage_entity.rb +++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb @@ -3,6 +3,10 @@ module Analytics module CycleAnalytics class StageEntity < Grape::Entity + include ActionView::Context + include LabelsHelper + include ActionView::Helpers::TagHelper + expose :title expose :hidden expose :legend @@ -43,10 +47,20 @@ module Analytics html_description(object.end_event) end + # Avoid including ActionView::Helpers::UrlHelper + def link_to(*args) + ActionController::Base.helpers.link_to(*args) + end + private def html_description(event) - Banzai::Renderer.render(event.markdown_description, { group: object.group, project: nil }) + options = {} + if event.label_based? + options[:label_html] = render_label(event.label, link: '', small: true, tooltip: true) + end + + content_tag(:p) { event.html_description(options).html_safe } end end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 4615f471639..9fd35faf0b7 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class BuildDetailsEntity < JobEntity - expose :coverage, :erased_at, :duration +class BuildDetailsEntity < Ci::JobEntity + expose :coverage, :erased_at, :finished_at, :duration expose :tag_list, as: :tags expose :has_trace?, as: :has_trace expose :stage @@ -109,6 +109,8 @@ class BuildDetailsEntity < JobEntity private + alias_method :build, :object + def build_failed_issue_options { title: "Job Failed ##{build.id}", description: "Job [##{build.id}](#{project_job_url(project, build)}) failed for #{build.sha}:\n" } diff --git a/app/serializers/build_serializer.rb b/app/serializers/build_serializer.rb deleted file mode 100644 index 0649fdad6a8..00000000000 --- a/app/serializers/build_serializer.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class BuildSerializer < BaseSerializer - entity JobEntity - - def represent_status(resource) - data = represent(resource, { only: [:status] }) - data.fetch(:status, {}) - end -end diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb new file mode 100644 index 00000000000..fca3dec74d4 --- /dev/null +++ b/app/serializers/ci/job_entity.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Ci + class JobEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + + expose :started?, as: :started + expose :complete?, as: :complete + expose :archived?, as: :archived + + # bridge jobs don't have build details pages + expose :build_path, if: ->(job) { !job.is_a?(Ci::Bridge) } do |job| + job_path(job) + end + + expose :retry_path, if: -> (*) { retryable? } do |job| + path_to(:retry_namespace_project_job, job) + end + + expose :cancel_path, if: -> (*) { cancelable? } do |job| + path_to( + :cancel_namespace_project_job, + job, + { continue: { to: job_path(job) } } + ) + end + + expose :play_path, if: -> (*) { playable? } do |job| + path_to(:play_namespace_project_job, job) + end + + expose :unschedule_path, if: -> (*) { scheduled? } do |job| + path_to(:unschedule_namespace_project_job, job) + end + + expose :playable?, as: :playable + expose :scheduled?, as: :scheduled + expose :scheduled_at, if: -> (*) { scheduled? } + expose :created_at + expose :updated_at + expose :detailed_status, as: :status, with: DetailedStatusEntity + expose :callout_message, if: -> (*) { failed? && !job.script_failure? } + expose :recoverable, if: -> (*) { failed? } + + private + + alias_method :job, :object + + def cancelable? + job.cancelable? && can?(request.current_user, :update_build, job) + end + + def retryable? + job.retryable? && can?(request.current_user, :update_build, job) + end + + def playable? + job.playable? && can?(request.current_user, :update_build, job) + end + + def scheduled? + job.scheduled? + end + + def detailed_status + job.detailed_status(request.current_user) + end + + def path_to(route, job, params = {}) + send("#{route}_path", job.project.namespace, job.project, job, params) # rubocop:disable GitlabSecurity/PublicSend + end + + def job_path(job) + job.target_url || path_to(:namespace_project_job, job) + end + + def failed? + job.failed? + end + + def callout_message + job_presenter.callout_failure_message + end + + def recoverable + job_presenter.recoverable? + end + + def job_presenter + @job_presenter ||= job.present + end + end +end diff --git a/app/serializers/ci/job_serializer.rb b/app/serializers/ci/job_serializer.rb new file mode 100644 index 00000000000..01f9e223943 --- /dev/null +++ b/app/serializers/ci/job_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ci + class JobSerializer < BaseSerializer + entity Ci::JobEntity + + def represent_status(resource) + data = represent(resource, { only: [:status] }) + data.fetch(:status, {}) + end + end +end diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index a5af543f49f..20aeb978520 100644 --- a/app/serializers/ci/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -4,7 +4,7 @@ class Ci::PipelineEntity < Grape::Entity include RequestAwareEntity include Gitlab::Utils::StrongMemoize - delegate :name, :failure_reason, to: :presented_pipeline + delegate :name, :failure_reason, :coverage, to: :presented_pipeline expose :id expose :iid @@ -82,7 +82,7 @@ class Ci::PipelineEntity < Grape::Entity project_pipeline_path(pipeline.project, pipeline) end - expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline| + expose :failed_builds, if: -> (*) { can_retry? }, using: Ci::JobEntity do |pipeline| pipeline.failed_builds.each do |build| build.project = pipeline.project end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 08a939e86c5..7a2fba73f3a 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -22,12 +22,14 @@ class DeploymentEntity < Grape::Entity expose :deployed_at expose :tag expose :last? + expose :last?, as: :is_last + expose :deployed_by, as: :user, using: UserEntity expose :deployable, if: -> (deployment) { deployment.deployable.present? } do |deployment, opts| deployment.deployable.yield_self do |deployable| if include_details? - JobEntity.represent(deployable, opts) + Ci::JobEntity.represent(deployable, opts) elsif can_read_deployables? { name: deployable.name, build_path: project_job_path(deployable.project, deployable) } @@ -36,10 +38,10 @@ class DeploymentEntity < Grape::Entity end expose :commit, using: CommitEntity, if: -> (*) { include_details? } - expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } - expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } + expose :manual_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? } + expose :scheduled_actions, using: Ci::JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_build } do |deployment, options| - JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) + Ci::JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) end expose :cluster do |deployment, options| diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb index b48037dd53f..7710efed3ab 100644 --- a/app/serializers/deployment_serializer.rb +++ b/app/serializers/deployment_serializer.rb @@ -4,7 +4,7 @@ class DeploymentSerializer < BaseSerializer entity DeploymentEntity def represent_concise(resource, opts = {}) - opts[:only] = [:iid, :id, :sha, :created_at, :deployed_at, :tag, :last?, :id, ref: [:name]] + opts[:only] = [:iid, :id, :sha, :created_at, :deployed_at, :tag, :last?, :is_last, :id, ref: [:name]] represent(resource, opts) end end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 7eca56b2f48..ef856ee0116 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -90,3 +90,5 @@ class DiffFileEntity < DiffFileBaseEntity options.fetch(:diff_view, :inline).to_sym end end + +DiffFileEntity.prepend_mod diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb deleted file mode 100644 index eb8622edb38..00000000000 --- a/app/serializers/job_entity.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -class JobEntity < Grape::Entity - include RequestAwareEntity - - expose :id - expose :name - - expose :started?, as: :started - expose :complete?, as: :complete - expose :archived?, as: :archived - - # bridge jobs don't have build detail pages - expose :build_path, if: ->(build) { !build.is_a?(Ci::Bridge) } do |build| - build_path(build) - end - - expose :retry_path, if: -> (*) { retryable? } do |build| - path_to(:retry_namespace_project_job, build) - end - - expose :cancel_path, if: -> (*) { cancelable? } do |build| - path_to( - :cancel_namespace_project_job, - build, - { continue: { to: build_path(build) } } - ) - end - - expose :play_path, if: -> (*) { playable? } do |build| - path_to(:play_namespace_project_job, build) - end - - expose :unschedule_path, if: -> (*) { scheduled? } do |build| - path_to(:unschedule_namespace_project_job, build) - end - - expose :playable?, as: :playable - expose :scheduled?, as: :scheduled - expose :scheduled_at, if: -> (*) { scheduled? } - expose :created_at - expose :updated_at - expose :detailed_status, as: :status, with: DetailedStatusEntity - expose :callout_message, if: -> (*) { failed? && !build.script_failure? } - expose :recoverable, if: -> (*) { failed? } - - private - - alias_method :build, :object - - def cancelable? - build.cancelable? && can?(request.current_user, :update_build, build) - end - - def retryable? - build.retryable? && can?(request.current_user, :update_build, build) - end - - def playable? - build.playable? && can?(request.current_user, :update_build, build) - end - - def scheduled? - build.scheduled? - end - - def detailed_status - build.detailed_status(request.current_user) - end - - def path_to(route, build, params = {}) - send("#{route}_path", build.project.namespace, build.project, build, params) # rubocop:disable GitlabSecurity/PublicSend - end - - def build_path(build) - build.target_url || path_to(:namespace_project_job, build) - end - - def failed? - build.failed? - end - - def callout_message - build_presenter.callout_failure_message - end - - def recoverable - build_presenter.recoverable? - end - - def build_presenter - @build_presenter ||= build.present - end -end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb index 0db7624b3f7..3597d5531fa 100644 --- a/app/serializers/job_group_entity.rb +++ b/app/serializers/job_group_entity.rb @@ -6,7 +6,7 @@ class JobGroupEntity < Grape::Entity expose :name expose :size expose :detailed_status, as: :status, with: DetailedStatusEntity - expose :jobs, with: JobEntity + expose :jobs, with: Ci::JobEntity private diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index d7221109ecb..f2f97f560e0 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -63,6 +63,12 @@ class MemberEntity < Grape::Entity member.respond_to?(:invited_user_state) ? member.invited_user_state : "" end end + + private + + def current_user + options[:current_user] + end end MemberEntity.prepend_mod_with('MemberEntity') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index bd60d60c8db..b9c71e6d97b 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -73,7 +73,7 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :user_callouts_path do |_merge_request| - user_callouts_path + callouts_path end expose :suggest_pipeline_feature_id do |_merge_request| diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb index bdf5cb160b2..f4fb01604d0 100644 --- a/app/serializers/merge_requests/pipeline_entity.rb +++ b/app/serializers/merge_requests/pipeline_entity.rb @@ -43,7 +43,9 @@ class MergeRequests::PipelineEntity < Grape::Entity # Coverage isn't always necessary (e.g. when displaying project pipelines in # the UI). Instead of creating an entirely different entity we just allow the # disabling of this specific field whenever necessary. - expose :coverage, unless: proc { options[:disable_coverage] } + expose :coverage, unless: proc { options[:disable_coverage] } do |pipeline| + pipeline.present.coverage + end expose :ref do expose :branch?, as: :branch diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 8f189f14dea..58ad5812801 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -51,7 +51,7 @@ class NoteEntity < API::Entities::Note SystemNoteHelper.system_note_icon_name(note) end - expose :outdated_line_change_path, if: -> (note, _) { note.system? && note.change_position&.line_range && Feature.enabled?(:display_outdated_line_diff, note.project, default_enabled: :yaml) } do |note| + expose :outdated_line_change_path, if: -> (note, _) { note.show_outdated_changes? } do |note| outdated_line_change_namespace_project_note_path(namespace_id: note.project.namespace, project_id: note.project, id: note) end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 0aadcd01a43..548ff577863 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -15,13 +15,13 @@ class StageEntity < Grape::Entity expose :latest_statuses, if: -> (_, opts) { opts[:details] }, - with: JobEntity do |stage| + with: Ci::JobEntity do |stage| latest_statuses end expose :retried, if: -> (_, opts) { opts[:retried] }, - with: JobEntity do |stage| + with: Ci::JobEntity do |stage| retried_statuses end diff --git a/app/services/admin/propagate_service_template.rb b/app/services/admin/propagate_service_template.rb deleted file mode 100644 index c251537c479..00000000000 --- a/app/services/admin/propagate_service_template.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Admin - class PropagateServiceTemplate - include PropagateService - - def propagate - # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178 - end - end -end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 563d4a924fc..1426bf25a00 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class AuditEventService + include AuditEventSaveType + # Instantiates a new service # # @param [User] author the user who authors the change @@ -10,13 +12,16 @@ class AuditEventService # - Group: events are visible at Group and Instance level # - User: events are visible at Instance level # @param [Hash] details extra data of audit event + # @param [Symbol] save_type the type to save the event + # Can be selected from the following, :database, :stream, :database_and_stream . # # @return [AuditEventService] - def initialize(author, entity, details = {}) + def initialize(author, entity, details = {}, save_type = :database_and_stream) @author = build_author(author) @entity = entity @details = details @ip_address = resolve_ip_address(@author) + @save_type = save_type end # Builds the @details attribute for authentication @@ -133,8 +138,8 @@ class AuditEventService end def save_or_track(event) - event.save! - stream_event_to_external_destinations(event) + event.save! if should_save_database?(@save_type) + stream_event_to_external_destinations(event) if should_save_stream?(@save_type) rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s) end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index bc734465750..ea4723c9e28 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -156,7 +156,7 @@ module Auth return if path.has_repository? return unless actions.include?('push') - ContainerRepository.create_from_path!(path) + ContainerRepository.find_or_create_from_path(path) end # Overridden in EE diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb index c4b18a26d0e..3a2251f15cc 100644 --- a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb +++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb @@ -47,7 +47,11 @@ module AuthorizedProjectUpdate missing_auth_found_callback.call(project_id, level) end - array << [user.id, project_id, level] + array << { + user_id: user.id, + project_id: project_id, + access_level: level + } end end diff --git a/app/services/authorized_project_update/project_group_link_create_service.rb b/app/services/authorized_project_update/project_group_link_create_service.rb index e9e7c56d7c7..10cf4c50569 100644 --- a/app/services/authorized_project_update/project_group_link_create_service.rb +++ b/app/services/authorized_project_update/project_group_link_create_service.rb @@ -65,16 +65,8 @@ module AuthorizedProjectUpdate end def update_authorizations(user_ids_to_delete, authorizations_to_create) - ProjectAuthorization.transaction do - if user_ids_to_delete.any? - ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_delete) # rubocop: disable CodeReuse/ActiveRecord - .delete_all - end - - if authorizations_to_create.any? - ProjectAuthorization.insert_all(authorizations_to_create) - end - end + project.remove_project_authorizations(user_ids_to_delete) if user_ids_to_delete.any? + ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any? end end end diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb index d70d0efc2af..17ba48cffcd 100644 --- a/app/services/authorized_project_update/project_recalculate_service.rb +++ b/app/services/authorized_project_update/project_recalculate_service.rb @@ -64,16 +64,8 @@ module AuthorizedProjectUpdate end def refresh_authorizations - ProjectAuthorization.transaction do - if user_ids_to_remove.any? - ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_remove) # rubocop: disable CodeReuse/ActiveRecord - .delete_all - end - - if authorizations_to_create.any? - ProjectAuthorization.insert_all(authorizations_to_create) - end - end + project.remove_project_authorizations(user_ids_to_remove) if user_ids_to_remove.any? + ProjectAuthorization.insert_all_in_batches(authorizations_to_create) if authorizations_to_create.any? end def apply_scopes(project_authorizations) diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index e756e8c14d8..da80211f9bb 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -64,7 +64,7 @@ module AutoMerge # NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project` # feature flag is removed. def self.can_add_to_merge_train?(merge_request) - if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) + if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project) merge_request.for_same_project? else true diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index c1becbb5609..cbf2b34b33c 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -59,7 +59,7 @@ module BulkImports ) bulk_import.create_configuration!(credentials.slice(:url, :access_token)) - params.each do |entity| + Array.wrap(params).each do |entity| BulkImports::Entity.create!( bulk_import: bulk_import, source_type: entity[:source_type], diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index 4718b3914b2..14f073120c5 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -59,7 +59,7 @@ module BulkImports end def export_service - @export_service ||= if config.tree_relation?(relation) + @export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation) TreeExportService.new(portable, config.export_path, relation) elsif config.file_relation?(relation) FileExportService.new(portable, config.export_path, relation) diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb index b8e7ac4574b..8e885e590d1 100644 --- a/app/services/bulk_imports/tree_export_service.rb +++ b/app/services/bulk_imports/tree_export_service.rb @@ -10,6 +10,8 @@ module BulkImports end def execute + return serializer.serialize_root(config.class::SELF_RELATION) if self_relation? + relation_definition = config.tree_relation_definition_for(relation) raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition @@ -18,6 +20,8 @@ module BulkImports end def exported_filename + return "#{relation}.json" if self_relation? + "#{relation}.ndjson" end @@ -39,5 +43,9 @@ module BulkImports def json_writer ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path) end + + def self_relation? + relation == config.class::SELF_RELATION + end end end diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb index 32cc48c152c..7f5ee7b8624 100644 --- a/app/services/bulk_imports/uploads_export_service.rb +++ b/app/services/bulk_imports/uploads_export_service.rb @@ -5,6 +5,7 @@ module BulkImports include Gitlab::ImportExport::CommandLineUtil BATCH_SIZE = 100 + AVATAR_PATH = 'avatar' def initialize(portable, export_path) @portable = portable @@ -34,7 +35,7 @@ module BulkImports def export_subdir_path(upload) subdir = if upload.path == avatar_path - 'avatar' + AVATAR_PATH else upload.try(:secret).to_s end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 540e8f7b970..c1f35afba40 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -2,10 +2,14 @@ module Ci class CreatePipelineService < BaseService - attr_reader :pipeline + attr_reader :pipeline, :logger CreateError = Class.new(StandardError) + LOG_MAX_DURATION_THRESHOLD = 3.seconds + LOG_MAX_PIPELINE_SIZE = 2_000 + LOG_MAX_CREATION_THRESHOLD = 20.seconds + SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, Gitlab::Ci::Pipeline::Chain::Build::Associations, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, @@ -24,7 +28,10 @@ module Ci Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::StopDryRun, + Gitlab::Ci::Pipeline::Chain::EnsureEnvironments, + Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups, Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::CreateDeployments, Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, Gitlab::Ci::Pipeline::Chain::Limit::Activity, Gitlab::Ci::Pipeline::Chain::Limit::JobActivity, @@ -53,6 +60,7 @@ module Ci # @return [Ci::Pipeline] The created Ci::Pipeline object. # rubocop: disable Metrics/ParameterLists def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block) + @logger = build_logger @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -76,6 +84,7 @@ module Ci push_options: params[:push_options] || {}, chat_data: params[:chat_data], bridge: bridge, + logger: @logger, **extra_options(**options)) # Ensure we never persist the pipeline when dry_run: true @@ -98,6 +107,9 @@ module Ci else ServiceResponse.success(payload: pipeline) end + + ensure + @logger.commit(pipeline: pipeline, caller: self.class.name) end # rubocop: enable Metrics/ParameterLists @@ -135,6 +147,32 @@ module Ci def extra_options(content: nil, dry_run: false) { content: content, dry_run: dry_run } end + + def build_logger + Gitlab::Ci::Pipeline::Logger.new(project: project) do |l| + l.log_when do |observations| + observations.any? do |name, values| + values.any? && + name.to_s.end_with?('duration_s') && + values.max >= LOG_MAX_DURATION_THRESHOLD + end + end + + l.log_when do |observations| + values = observations['pipeline_size_count'] + next false if values.empty? + + values.max >= LOG_MAX_PIPELINE_SIZE + end + + l.log_when do |observations| + values = observations['pipeline_creation_duration_s'] + next false if values.empty? + + values.max >= LOG_MAX_CREATION_THRESHOLD + end + end + end end end diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb index 48a6344f576..8622b1a5863 100644 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -60,6 +60,10 @@ module Ci url_helpers.graphql_etag_pipeline_sha_path(sha) end + def graphql_project_on_demand_scan_counts_path(project) + url_helpers.graphql_etag_project_on_demand_scan_counts_path(project) + end + # Updates ETag caches of a pipeline. # # This logic resides in a separate method so that EE can more easily extend @@ -70,18 +74,25 @@ module Ci def update_etag_cache(pipeline, store) project = pipeline.project - store.touch(project_pipelines_path(project)) - store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? - store.touch(new_merge_request_pipelines_path(project)) + etag_paths = [ + project_pipelines_path(project), + new_merge_request_pipelines_path(project), + graphql_project_on_demand_scan_counts_path(project) + ] + + etag_paths << commit_pipelines_path(project, pipeline.commit) unless pipeline.commit.nil? + each_pipelines_merge_request_path(pipeline) do |path| - store.touch(path) + etag_paths << path end - pipeline.self_with_upstreams_and_downstreams.each do |relative_pipeline| - store.touch(project_pipeline_path(relative_pipeline.project, relative_pipeline)) - store.touch(graphql_pipeline_path(relative_pipeline)) - store.touch(graphql_pipeline_sha_path(relative_pipeline.sha)) + pipeline.self_with_upstreams_and_downstreams.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord + etag_paths << project_pipeline_path(relative_pipeline.project, relative_pipeline) + etag_paths << graphql_pipeline_path(relative_pipeline) + etag_paths << graphql_pipeline_sha_path(relative_pipeline.sha) end + + store.touch(*etag_paths) end def url_helpers 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 e4f65736a58..7fa56677a0c 100644 --- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb +++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb @@ -14,6 +14,7 @@ module Ci def initialize @removed_artifacts_count = 0 + @start_at = Time.current end ## @@ -25,9 +26,9 @@ module Ci def execute in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do if ::Feature.enabled?(:ci_destroy_unlocked_job_artifacts) - destroy_unlocked_job_artifacts(Time.current) + destroy_unlocked_job_artifacts else - destroy_job_artifacts_with_slow_iteration(Time.current) + destroy_job_artifacts_with_slow_iteration end end @@ -36,16 +37,37 @@ module Ci private - def destroy_unlocked_job_artifacts(start_at) + def destroy_unlocked_job_artifacts loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do - artifacts = Ci::JobArtifact.expired_before(start_at).artifact_unlocked.limit(BATCH_SIZE) + artifacts = Ci::JobArtifact.expired_before(@start_at).artifact_unlocked.limit(BATCH_SIZE) service_response = destroy_batch(artifacts) @removed_artifacts_count += service_response[:destroyed_artifacts_count] + + update_locked_status_on_unknown_artifacts if service_response[:destroyed_artifacts_count] == 0 + + # Return a truthy value here to prevent exiting #loop_until + @removed_artifacts_count end end - def destroy_job_artifacts_with_slow_iteration(start_at) - Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index| + def update_locked_status_on_unknown_artifacts + build_ids = Ci::JobArtifact.expired_before(@start_at).artifact_unknown.limit(BATCH_SIZE).distinct_job_ids + + return unless build_ids.present? + + locked_pipeline_build_ids = ::Ci::Build.with_pipeline_locked_artifacts.id_in(build_ids).pluck_primary_key + unlocked_pipeline_build_ids = build_ids - locked_pipeline_build_ids + + update_unknown_artifacts(locked_pipeline_build_ids, Ci::JobArtifact.lockeds[:artifacts_locked]) + update_unknown_artifacts(unlocked_pipeline_build_ids, Ci::JobArtifact.lockeds[:unlocked]) + end + + def update_unknown_artifacts(build_ids, locked_value) + Ci::JobArtifact.for_job_ids(build_ids).update_all(locked: locked_value) if build_ids.any? + end + + def destroy_job_artifacts_with_slow_iteration + Ci::JobArtifact.expired_before(@start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index| # For performance reasons, join with ci_pipelines after the batch is queried. # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496 artifacts = relation.unlocked @@ -53,7 +75,7 @@ module Ci service_response = destroy_batch(artifacts) @removed_artifacts_count += service_response[:destroyed_artifacts_count] - break if loop_timeout?(start_at) + break if loop_timeout? break if index >= LOOP_LIMIT end end @@ -62,8 +84,8 @@ module Ci Ci::JobArtifacts::DestroyBatchService.new(artifacts).execute end - def loop_timeout?(start_at) - Time.current > start_at + LOOP_TIMEOUT + def loop_timeout? + Time.current > @start_at + LOOP_TIMEOUT end end end diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index 725ecbcce5d..40e2cd82b4f 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -14,7 +14,7 @@ module Ci Ci::JobVariable.bulk_insert!(variables) success - rescue SizeLimitError, ParserError, ActiveRecord::RecordInvalid => error + rescue SizeLimitError, ParserError, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => error Gitlab::ErrorTracking.track_exception(error, job_id: artifact.job_id) error(error.message, :bad_request) end @@ -33,13 +33,13 @@ module Ci end def parse!(artifact) - variables = [] + variables = {} artifact.each_blob do |blob| blob.each_line do |line| key, value = scan_line!(line) - variables << Ci::JobVariable.new(job_id: artifact.job_id, + variables[key] = Ci::JobVariable.new(job_id: artifact.job_id, source: :dotenv, key: key, value: value) end end @@ -49,7 +49,7 @@ module Ci "Dotenv files cannot have more than #{dotenv_variable_limit} variables" end - variables + variables.values end def scan_line!(line) diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 236d660d829..d8ce063ffb4 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -36,6 +36,10 @@ module Ci update_pipeline! update_statuses_processed! + if Feature.enabled?(:expire_job_and_pipeline_cache_synchronously, pipeline.project, default_enabled: :yaml) + Ci::ExpirePipelineCacheService.new.execute(pipeline) + end + true end diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index c1cf06a4631..e2673c763f3 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -9,7 +9,7 @@ module Ci # if build.enqueue build.tap do |build| - build.update(user: current_user, job_variables_attributes: job_variables_attributes || []) + build.update!(user: current_user, job_variables_attributes: job_variables_attributes || []) AfterRequeueJobService.new(project, current_user).execute(build) end diff --git a/app/services/ci/process_sync_events_service.rb b/app/services/ci/process_sync_events_service.rb new file mode 100644 index 00000000000..6be8c41dc6a --- /dev/null +++ b/app/services/ci/process_sync_events_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Ci + class ProcessSyncEventsService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + BATCH_SIZE = 1000 + + def initialize(sync_event_class, sync_class) + @sync_event_class = sync_event_class + @sync_class = sync_class + end + + def execute + return unless ::Feature.enabled?(:ci_namespace_project_mirrors, default_enabled: :yaml) + + # preventing parallel processing over the same event table + try_obtain_lease { process_events } + + enqueue_worker_if_there_still_event + end + + private + + def process_events + events = @sync_event_class.preload_synced_relation.first(BATCH_SIZE) + + return if events.empty? + + first = events.first + last_processed = nil + + begin + events.each do |event| + @sync_class.sync!(event) + + last_processed = event + end + ensure + # remove events till the one that was last succesfully processed + @sync_event_class.id_in(first.id..last_processed.id).delete_all if last_processed + end + end + + def enqueue_worker_if_there_still_event + @sync_event_class.enqueue_worker if @sync_event_class.exists? + end + + def lease_key + "#{super}::#{@sync_event_class}" + end + + def lease_timeout + 1.minute + end + end +end diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb index 3c886cb023f..9f476c8a785 100644 --- a/app/services/ci/queue/build_queue_service.rb +++ b/app/services/ci/queue/build_queue_service.rb @@ -24,7 +24,7 @@ module Ci # rubocop:disable CodeReuse/ActiveRecord def builds_for_group_runner - if strategy.use_denormalized_namespace_traversal_ids? + if strategy.use_denormalized_data_strategy? strategy.builds_for_group_runner else # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL` @@ -89,11 +89,9 @@ module Ci end def runner_projects_relation - if ::Feature.enabled?(:ci_pending_builds_project_runners_decoupling, runner, default_enabled: :yaml) - runner.runner_projects.select('"ci_runner_projects"."project_id"::bigint') - else - runner.projects.without_deleted.with_builds_enabled - end + runner + .runner_projects + .select('"ci_runner_projects"."project_id"::bigint') end end end diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb index ac449a5289e..237dd510d50 100644 --- a/app/services/ci/queue/builds_table_strategy.rb +++ b/app/services/ci/queue/builds_table_strategy.rb @@ -57,15 +57,7 @@ module Ci relation.pluck(:id) end - def use_denormalized_shared_runners_data? - false - end - - def use_denormalized_minutes_data? - false - end - - def use_denormalized_namespace_traversal_ids? + def use_denormalized_data_strategy? false end diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb index 7a913e47df4..47158b8ea1d 100644 --- a/app/services/ci/queue/pending_builds_strategy.rb +++ b/app/services/ci/queue/pending_builds_strategy.rb @@ -23,7 +23,7 @@ module Ci end def builds_matching_tag_ids(relation, ids) - if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml) + if use_denormalized_data_strategy? relation.for_tags(runner.tags_ids) else relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id')) @@ -31,7 +31,7 @@ module Ci end def builds_with_any_tags(relation) - if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml) + if use_denormalized_data_strategy? relation.where('cardinality(tag_ids) > 0') else relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id')) @@ -50,22 +50,14 @@ module Ci relation.pluck(:build_id) end - def use_denormalized_shared_runners_data? - ::Feature.enabled?(:ci_queueing_denormalize_shared_runners_information, runner, type: :development, default_enabled: :yaml) - end - - def use_denormalized_minutes_data? - ::Feature.enabled?(:ci_queueing_denormalize_ci_minutes_information, runner, type: :development, default_enabled: :yaml) - end - - def use_denormalized_namespace_traversal_ids? - ::Feature.enabled?(:ci_queueing_denormalize_namespace_traversal_ids, runner, type: :development, default_enabled: :yaml) + def use_denormalized_data_strategy? + ::Feature.enabled?(:ci_queuing_use_denormalized_data_strategy, default_enabled: :yaml) end private def builds_available_for_shared_runners - if use_denormalized_shared_runners_data? + if use_denormalized_data_strategy? new_builds.with_instance_runners else new_builds diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 67ef4f10709..e0f0f8f58b8 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -269,14 +269,7 @@ module Ci { missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? }, runner_unsupported: -> (build, params) { !build.supported_runner?(params.dig(:info, :features)) }, - archived_failure: -> (build, _) { build.archived? } - }.merge(builds_enabled_checks) - end - - def builds_enabled_checks - return {} unless ::Feature.enabled?(:ci_queueing_builds_enabled_checks, runner, default_enabled: :yaml) - - { + archived_failure: -> (build, _) { build.archived? }, project_deleted: -> (build, _) { build.project.pending_delete? }, builds_disabled: -> (build, _) { !build.project.builds_enabled? } } diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index ebb07de9d29..89fe4ff9f60 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -2,12 +2,14 @@ module Ci class RetryBuildService < ::BaseService + include Gitlab::Utils::StrongMemoize + def self.clone_accessors %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list protected needs_attributes - resource_group scheduling_type].freeze + job_variables_attributes resource_group scheduling_type].freeze end def self.extra_accessors @@ -45,6 +47,11 @@ module Ci job.save! end end + + if create_deployment_in_separate_transaction? + clone_deployment!(new_build, build) + end + build.reset # refresh the data to get new values of `retried` and `processed`. new_build @@ -63,7 +70,9 @@ module Ci def clone_build(build) project.builds.new(build_attributes(build)).tap do |new_build| - new_build.assign_attributes(deployment_attributes_for(new_build, build)) + unless create_deployment_in_separate_transaction? + new_build.assign_attributes(deployment_attributes_for(new_build, build)) + end end end @@ -72,6 +81,11 @@ module Ci [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend end + if create_deployment_in_separate_transaction? && build.persisted_environment.present? + attributes[:metadata_attributes] ||= {} + attributes[:metadata_attributes][:expanded_environment_name] = build.expanded_environment_name + end + attributes[:user] = current_user attributes end @@ -80,6 +94,26 @@ module Ci ::Gitlab::Ci::Pipeline::Seed::Build .deployment_attributes_for(new_build, old_build.persisted_environment) end + + def clone_deployment!(new_build, old_build) + return unless old_build.deployment.present? + + # We should clone the previous deployment attributes instead of initializing + # new object with `Seed::Deployment`. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/347206 + deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment + .new(new_build, old_build.persisted_environment).to_resource + + return unless deployment + + new_build.create_deployment!(deployment.attributes) + end + + def create_deployment_in_separate_transaction? + strong_memoize(:create_deployment_in_separate_transaction) do + ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml) + end + end end end diff --git a/app/services/ci/stuck_builds/drop_pending_service.rb b/app/services/ci/stuck_builds/drop_pending_service.rb index 4653e701973..dddd1cfb781 100644 --- a/app/services/ci/stuck_builds/drop_pending_service.rb +++ b/app/services/ci/stuck_builds/drop_pending_service.rb @@ -7,7 +7,6 @@ module Ci BUILD_PENDING_OUTDATED_TIMEOUT = 1.day BUILD_PENDING_STUCK_TIMEOUT = 1.hour - BUILD_LOOKBACK = 5.days def execute Gitlab::AppLogger.info "#{self.class}: Cleaning pending timed-out builds" @@ -30,11 +29,11 @@ module Ci # because we want to force the query planner to use the # `ci_builds_gitlab_monitor_metrics` index all the time. def pending_builds(timeout) - if Feature.enabled?(:ci_new_query_for_pending_stuck_jobs) - Ci::Build.pending.created_at_before(timeout).updated_at_before(timeout).order(created_at: :asc, project_id: :asc) - else - Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: timeout) - end + Ci::Build + .pending + .created_at_before(timeout) + .updated_at_before(timeout) + .order(created_at: :asc, project_id: :asc) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index c1cbf031ca1..146239bb7e5 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -14,7 +14,7 @@ module Ci # Add a build to the pending builds queue # def push(build, transition) - return unless maintain_pending_builds_queue?(build) + return unless maintain_pending_builds_queue? raise InvalidQueueTransition unless transition.to == 'pending' @@ -33,7 +33,7 @@ module Ci # Remove a build from the pending builds queue # def pop(build, transition) - return unless maintain_pending_builds_queue?(build) + return unless maintain_pending_builds_queue? raise InvalidQueueTransition unless transition.from == 'pending' @@ -52,7 +52,7 @@ module Ci # Add shared runner build tracking entry (used for queuing). # def track(build, transition) - return unless Feature.enabled?(:ci_track_shared_runner_builds, build.project, default_enabled: :yaml) + return unless maintain_pending_builds_queue? return unless build.shared_runner_build? raise InvalidQueueTransition unless transition.to == 'running' @@ -73,7 +73,7 @@ module Ci # queuing). # def untrack(build, transition) - return unless Feature.enabled?(:ci_untrack_shared_runner_builds, build.project, default_enabled: :yaml) + return unless maintain_pending_builds_queue? return unless build.shared_runner_build? raise InvalidQueueTransition unless transition.from == 'running' @@ -113,8 +113,8 @@ module Ci end end - def maintain_pending_builds_queue?(build) - Feature.enabled?(:ci_pending_builds_queue_maintain, build.project, default_enabled: :yaml) + def maintain_pending_builds_queue? + ::Ci::PendingBuild.maintain_denormalized_data? end end end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index 826d9a2eda3..9df36b86404 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -216,11 +216,12 @@ module Ci end def chunks_migration_enabled? - ::Gitlab::Ci::Features.accept_trace?(build.project) + ::Feature.enabled?(:ci_enable_live_trace, build.project) && + ::Feature.enabled?(:ci_accept_trace, build.project, type: :ops, default_enabled: true) end def log_invalid_chunks? - ::Gitlab::Ci::Features.log_invalid_trace_chunks?(build.project) + ::Feature.enabled?(:ci_trace_log_invalid_chunks, build.project, type: :ops, default_enabled: false) end end end diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb index d546dbcfe3d..733b684bcc6 100644 --- a/app/services/ci/update_pending_build_service.rb +++ b/app/services/ci/update_pending_build_service.rb @@ -9,13 +9,13 @@ module Ci def initialize(model, update_params) @model = model - @update_params = update_params + @update_params = update_params.symbolize_keys validations! end def execute - return unless ::Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, @model, default_enabled: :yaml) + return unless ::Ci::PendingBuild.maintain_denormalized_data? @model.pending_builds.each_batch do |relation| relation.update_all(@update_params) diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb index ae2617f510b..5b8a0e46a6c 100644 --- a/app/services/clusters/agent_tokens/create_service.rb +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -11,6 +11,8 @@ module Clusters token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) if token.save + log_activity_event!(token) + ServiceResponse.success(payload: { secret: token.token, token: token }) else ServiceResponse.error(message: token.errors.full_messages) @@ -26,6 +28,16 @@ module Clusters def filtered_params params.slice(*ALLOWED_PARAMS) end + + def log_activity_event!(token) + token.agent.activity_events.create!( + kind: :token_created, + level: :info, + recorded_at: token.created_at, + user: current_user, + agent_token: token + ) + end end end end diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb index 0173f93f625..80192aa14ab 100644 --- a/app/services/clusters/cleanup/project_namespace_service.rb +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -26,8 +26,10 @@ module Clusters begin kubeclient_delete_namespace(kubernetes_namespace) - rescue Kubeclient::HttpError - next + rescue Kubeclient::HttpError => e + # unauthorized, forbidden: GitLab's access has been revoked + # certificate verify failed: Cluster is probably gone forever + raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i end kubernetes_namespace.destroy! diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb index 53f968cd409..dce41d2a39c 100644 --- a/app/services/clusters/cleanup/service_account_service.rb +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -24,6 +24,10 @@ module Clusters # The resources have already been deleted, possibly on a previous attempt that timed out rescue Gitlab::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 + # certificate verify failed: Cluster is probably gone forever + raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i end end end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index 7bc3b267a12..1b1598b301c 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -38,7 +38,7 @@ class CohortsService { registration_month: registration_month, - activity_months: activity_months[1..-1], + activity_months: activity_months[1..], total: activity_months.first[:total], inactive: inactive } diff --git a/app/services/concerns/admin/propagate_service.rb b/app/services/concerns/admin/propagate_service.rb deleted file mode 100644 index 03e422aec54..00000000000 --- a/app/services/concerns/admin/propagate_service.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Admin - module PropagateService - extend ActiveSupport::Concern - - BATCH_SIZE = 10_000 - - class_methods do - def propagate(integration) - new(integration).propagate - end - end - - def initialize(integration) - @integration = integration - end - - private - - attr_reader :integration - - def create_integration_for_projects_without_integration - propagate_integrations( - Project.without_integration(integration), - PropagateIntegrationProjectWorker - ) - end - - def propagate_integrations(relation, worker_class) - relation.each_batch(of: BATCH_SIZE) do |records| - min_id, max_id = records.pick("MIN(#{relation.table_name}.id), MAX(#{relation.table_name}.id)") - worker_class.perform_async(integration.id, min_id, max_id) - end - end - end -end diff --git a/app/services/concerns/audit_event_save_type.rb b/app/services/concerns/audit_event_save_type.rb new file mode 100644 index 00000000000..6696e4adae7 --- /dev/null +++ b/app/services/concerns/audit_event_save_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module AuditEventSaveType + SAVE_TYPES = { + database: 0b01, + stream: 0b10, + database_and_stream: 0b11 + }.freeze + + # def should_save_stream?(type) + # def should_save_database?(type) + [:database, :stream].each do |type| + define_method("should_save_#{type}?") do |param_type| + return false unless save_type_valid?(param_type) + + # If the current type does not support query, the result of the `&` operation is 0 . + SAVE_TYPES[param_type] & SAVE_TYPES[type] != 0 + end + end + + private + + def save_type_valid?(type) + SAVE_TYPES.key?(type) + end +end diff --git a/app/services/concerns/protected_ref_name_sanitizer.rb b/app/services/concerns/protected_ref_name_sanitizer.rb new file mode 100644 index 00000000000..3966c410fec --- /dev/null +++ b/app/services/concerns/protected_ref_name_sanitizer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ProtectedRefNameSanitizer + def sanitize_name(name) + name = CGI.unescapeHTML(name) + name = Sanitize.fragment(name) + + # Sanitize.fragment escapes HTML chars, so unescape again to allow names + # like `feature->master` + CGI.unescapeHTML(name) + end +end diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_cached_manifest_service.rb index aeb62be9f3a..faf0402edaa 100644 --- a/app/services/dependency_proxy/find_or_create_manifest_service.rb +++ b/app/services/dependency_proxy/find_cached_manifest_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module DependencyProxy - class FindOrCreateManifestService < DependencyProxy::BaseService + class FindCachedManifestService < DependencyProxy::BaseService def initialize(group, image, tag, token) @group = group @image = image @@ -20,36 +20,13 @@ module DependencyProxy return respond if cached_manifest_matches?(head_result) - if Feature.enabled?(:dependency_proxy_manifest_workhorse, @group, default_enabled: :yaml) - success(manifest: nil, from_cache: false) - else - pull_new_manifest - respond(from_cache: false) - end + success(manifest: nil, from_cache: false) rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS respond end private - def pull_new_manifest - DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest| - params = { - file_name: @file_name, - content_type: new_manifest[:content_type], - digest: new_manifest[:digest], - file: new_manifest[:file], - size: new_manifest[:file].size - } - - if @manifest - @manifest.update!(params) - else - @manifest = @group.dependency_proxy_manifests.create!(params) - end - end - end - def cached_manifest_matches?(head_result) return false if head_result[:status] == :error diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb deleted file mode 100644 index e8f0ad6374a..00000000000 --- a/app/services/dependency_proxy/pull_manifest_service.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module DependencyProxy - class PullManifestService < DependencyProxy::BaseService - def initialize(image, tag, token) - @image = image - @tag = tag - @token = token - end - - def execute_with_manifest - raise ArgumentError, 'Block must be provided' unless block_given? - - response = Gitlab::HTTP.get(manifest_url, headers: auth_headers.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES.join(','))) - - if response.success? - file = Tempfile.new - - begin - file.write(response.body) - file.flush - - yield( - success( - file: file, - digest: response.headers[DependencyProxy::Manifest::DIGEST_HEADER], - content_type: response.headers['content-type'] - ) - ) - ensure - file.close - file.unlink - end - else - yield(error(response.body, response.code)) - end - rescue Timeout::Error => exception - error(exception.message, 599) - end - - private - - def manifest_url - registry.manifest_url(@image, @tag) - end - end -end diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb index 504b55b99ac..15384fb0db1 100644 --- a/app/services/deployments/older_deployments_drop_service.rb +++ b/app/services/deployments/older_deployments_drop_service.rb @@ -12,6 +12,8 @@ module Deployments return unless @deployment&.running? older_deployments_builds.each do |build| + next if build.manual? + Gitlab::OptimisticLocking.retry_lock(build, name: 'older_deployments_drop') do |build| build.drop(:forward_deployment_failure) end diff --git a/app/services/events/destroy_service.rb b/app/services/events/destroy_service.rb new file mode 100644 index 00000000000..fdb718f0fcb --- /dev/null +++ b/app/services/events/destroy_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Events + class DestroyService + def initialize(project) + @project = project + end + + def execute + project.events.all.delete_all + + ServiceResponse.success(message: 'Events were deleted.') + rescue StandardError + ServiceResponse.error(message: 'Failed to remove events.') + end + + private + + attr_reader :project + end +end diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb index ca0b6b89199..86dc6188f0a 100644 --- a/app/services/feature_flags/base_service.rb +++ b/app/services/feature_flags/base_service.rb @@ -43,6 +43,7 @@ module FeatureFlags def sync_to_jira(feature_flag) return unless feature_flag.present? + return unless project.jira_subscription_exists? seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id feature_flag.run_after_commit do diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 9b113be5465..aa471d3a69f 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -157,11 +157,11 @@ module Git end def unsigned_x509_shas(commits) - X509CommitSignature.unsigned_commit_shas(commits.map(&:sha)) + CommitSignatures::X509CommitSignature.unsigned_commit_shas(commits.map(&:sha)) end def unsigned_gpg_shas(commits) - GpgSignature.unsigned_commit_shas(commits.map(&:sha)) + CommitSignatures::GpgSignature.unsigned_commit_shas(commits.map(&:sha)) end def enqueue_update_signatures diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index 5bf39d98fa3..13223872e4f 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -26,7 +26,6 @@ module Git enqueue_detect_repository_languages execute_related_hooks - perform_housekeeping stop_environments unlock_artifacts @@ -71,13 +70,6 @@ module Git BranchHooksService.new(project, current_user, params).execute end - def perform_housekeeping - housekeeping = Repositories::HousekeepingService.new(project) - housekeeping.increment! - housekeeping.execute if housekeeping.needed? - rescue Repositories::HousekeepingService::LeaseTaken - end - def removing_branch? Gitlab::Git.blank_ref?(newrev) end diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index da05f18b5ac..d4081fc149b 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -9,6 +9,8 @@ module Git process_changes_by_action(:branch, changes.branch_changes) process_changes_by_action(:tag, changes.tag_changes) + + perform_housekeeping end private @@ -83,5 +85,12 @@ module Git MergeRequests::PushedBranchesService.new(project: project, current_user: current_user, params: { changes: changes }).execute end + + def perform_housekeeping + housekeeping = Repositories::HousekeepingService.new(project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Repositories::HousekeepingService::LeaseTaken + end end end diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb index 29ed69693b0..a512b27493d 100644 --- a/app/services/google_cloud/service_accounts_service.rb +++ b/app/services/google_cloud/service_accounts_service.rb @@ -27,6 +27,24 @@ module GoogleCloud end end + def add_for_project(environment, gcp_project_id, service_account, service_account_key) + project_var_create_or_replace( + environment, + 'GCP_PROJECT_ID', + gcp_project_id + ) + project_var_create_or_replace( + environment, + 'GCP_SERVICE_ACCOUNT', + service_account + ) + project_var_create_or_replace( + environment, + 'GCP_SERVICE_ACCOUNT_KEY', + service_account_key + ) + end + private def group_vars_by_environment @@ -36,5 +54,12 @@ module GoogleCloud grouped[variable.environment_scope][variable.key] = variable.value end end + + def project_var_create_or_replace(environment_scope, key, value) + params = { key: key, filter: { environment_scope: environment_scope } } + existing_variable = ::Ci::VariablesFinder.new(@project, params).execute.first + existing_variable.destroy if existing_variable + @project.variables.create!(key: key, value: value, environment_scope: environment_scope, protected: true) + end end end diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index 2a7a5dae291..a689b088854 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -8,7 +8,7 @@ class GravatarService return unless identifier hash = Digest::MD5.hexdigest(identifier.strip.downcase) - size = 40 unless size && size > 0 + size = Groups::GroupMembersHelper::AVATAR_SIZE unless size && size > 0 sprintf gravatar_url, hash: hash, diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index cd89eb799dc..10ff4961faf 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -29,11 +29,11 @@ module Groups update_group_attributes ensure_ownership update_integrations - update_pending_builds! end post_update_hooks(@updated_project_ids) propagate_integrations + update_pending_builds true end @@ -228,13 +228,15 @@ module Groups end end - def update_pending_builds! - update_params = { + def update_pending_builds + ::Ci::PendingBuilds::UpdateGroupWorker.perform_async(group.id, pending_builds_params) + end + + def pending_builds_params + { namespace_traversal_ids: group.traversal_ids, namespace_id: group.id } - - ::Ci::UpdatePendingBuildService.new(group, update_params).execute end end end diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb index eb6b46a5613..c09dce0761f 100644 --- a/app/services/groups/update_shared_runners_service.rb +++ b/app/services/groups/update_shared_runners_service.rb @@ -8,7 +8,7 @@ module Groups validate_params update_shared_runners - update_pending_builds! + update_pending_builds_async success @@ -28,12 +28,18 @@ module Groups group.update_shared_runners_setting!(params[:shared_runners_setting]) end - def update_pending_builds! - return unless group.previous_changes.include?('shared_runners_enabled') + def update_pending_builds? + group.previous_changes.include?('shared_runners_enabled') + end + + def update_pending_builds_async + return unless update_pending_builds? - update_params = { instance_runners_enabled: group.shared_runners_enabled } + group.run_after_commit_or_now do |group| + pending_builds_params = { instance_runners_enabled: group.shared_runners_enabled } - ::Ci::UpdatePendingBuildService.new(group, update_params).execute + ::Ci::UpdatePendingBuildService.new(group, pending_builds_params).execute + end end end end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 2aaab88e778..061543b5885 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -10,7 +10,7 @@ module Import def execute(access_params, provider) if blocked_url? - return log_and_return_error("Invalid URL: #{url}", :bad_request) + return log_and_return_error("Invalid URL: #{url}", _("Invalid URL: %{url}") % { url: url }, :bad_request) end unless authorized? @@ -119,6 +119,15 @@ module Import error(_('Import failed due to a GitHub error: %{original}') % { original: exception.response_body }, :unprocessable_entity) end + + def log_and_return_error(message, translated_message, http_status) + Gitlab::GithubImport::Logger.error( + message: 'Error while attempting to import from GitHub', + error: message + ) + + error(translated_message, http_status) + end end end diff --git a/app/services/incident_management/issuable_escalation_statuses/create_service.rb b/app/services/incident_management/issuable_escalation_statuses/create_service.rb new file mode 100644 index 00000000000..e28debf0fa3 --- /dev/null +++ b/app/services/incident_management/issuable_escalation_statuses/create_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IncidentManagement + module IssuableEscalationStatuses + class CreateService < BaseService + def initialize(issue) + @issue = issue + @alert = issue.alert_management_alert + end + + def execute + escalation_status = ::IncidentManagement::IssuableEscalationStatus.new(issue: issue, **alert_params) + + if escalation_status.save + ServiceResponse.success(payload: { escalation_status: escalation_status }) + else + ServiceResponse.error(message: escalation_status.errors&.full_messages) + end + end + + private + + attr_reader :issue, :alert + + def alert_params + return {} unless alert + + { + status_event: alert.status_event_for(alert.status_name) + } + end + end + end +end + +IncidentManagement::IssuableEscalationStatuses::CreateService.prepend_mod diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/integrations/propagate_service.rb index f7a4bf1a9f9..6d27929d2d0 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/integrations/propagate_service.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -module Admin - class PropagateIntegrationService - include PropagateService +module Integrations + class PropagateService + BATCH_SIZE = 10_000 + + def initialize(integration) + @integration = integration + end def propagate if integration.instance_level? @@ -16,8 +20,21 @@ module Admin end end + def self.propagate(integration) + new(integration).propagate + end + private + attr_reader :integration + + def create_integration_for_projects_without_integration + propagate_integrations( + Project.without_integration(integration), + PropagateIntegrationProjectWorker + ) + end + def update_inherited_integrations propagate_integrations( Integration.by_type(integration.type).inherit_from_id(integration.id), @@ -52,5 +69,12 @@ module Admin PropagateIntegrationProjectWorker ) end + + def propagate_integrations(relation, worker_class) + relation.each_batch(of: BATCH_SIZE) do |records| + min_id, max_id = records.pick("MIN(#{relation.table_name}.id), MAX(#{relation.table_name}.id)") + worker_class.perform_async(integration.id, min_id, max_id) + end + end end end diff --git a/app/services/integrations/propagate_template_service.rb b/app/services/integrations/propagate_template_service.rb new file mode 100644 index 00000000000..85a82ba4c8e --- /dev/null +++ b/app/services/integrations/propagate_template_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Integrations + # TODO: Remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/335178 + class PropagateTemplateService + def self.propagate(_integration) + # no-op + end + end +end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2daf098b94a..1d1d9b6bec7 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -56,6 +56,8 @@ class IssuableBaseService < ::BaseProjectService # confidential attribute is a special type of metadata and needs to be allowed to be set # by non-members on issues in public projects so that security issues can be reported as confidential. params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable) + params.delete(:add_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable) + params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable) filter_assignees(issuable) filter_milestone @@ -206,6 +208,9 @@ class IssuableBaseService < ::BaseProjectService params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a) end + params.delete(:remove_contacts) + add_crm_contact_emails = params.delete(:add_contacts) + issuable.assign_attributes(allowed_create_params(params)) before_create(issuable) @@ -219,6 +224,7 @@ class IssuableBaseService < ::BaseProjectService handle_changes(issuable, { params: params }) after_create(issuable) + set_crm_contacts(issuable, add_crm_contact_emails) execute_hooks(issuable) users_to_invalidate = issuable.allows_reviewers? ? issuable.assignees | issuable.reviewers : issuable.assignees @@ -229,6 +235,12 @@ class IssuableBaseService < ::BaseProjectService issuable end + def set_crm_contacts(issuable, add_crm_contact_emails, remove_crm_contact_emails = []) + return unless add_crm_contact_emails.present? || remove_crm_contact_emails.present? + + ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { add_emails: add_crm_contact_emails, remove_emails: remove_crm_contact_emails }).execute(issuable) + end + def before_create(issuable) # To be overridden by subclasses end @@ -254,6 +266,7 @@ class IssuableBaseService < ::BaseProjectService assign_requested_labels(issuable) assign_requested_assignees(issuable) + assign_requested_crm_contacts(issuable) if issuable.changed? || params.present? issuable.assign_attributes(allowed_update_params(params)) @@ -414,6 +427,12 @@ class IssuableBaseService < ::BaseProjectService issuable.touch end + def assign_requested_crm_contacts(issuable) + add_crm_contact_emails = params.delete(:add_contacts) + remove_crm_contact_emails = params.delete(:remove_contacts) + set_crm_contacts(issuable, add_crm_contact_emails, remove_crm_contact_emails) + end + def assign_requested_assignees(issuable) return if issuable.is_a?(Epic) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index efb5de5b17c..577f7dd1e3a 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -30,7 +30,7 @@ module Issues gates = [issue.project, issue.project.group].compact return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) } - IssueRebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids) + Issues::RebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids) end private diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index fa8d380404b..79b59eee5e1 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -41,7 +41,7 @@ module Issues user = current_user issue.run_after_commit do NewIssueWorker.perform_async(issue.id, user.id) - IssuePlacementWorker.perform_async(nil, issue.project_id) + Issues::PlacementWorker.perform_async(nil, issue.project_id) Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.namespace.id) end end @@ -50,6 +50,7 @@ module Issues def after_create(issue) user_agent_detail_service.create resolve_discussions_with_issue(issue) + create_escalation_status(issue) super end @@ -80,6 +81,10 @@ module Issues attr_reader :spam_params + def create_escalation_status(issue) + ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation? + end + def user_agent_detail_service UserAgentDetailService.new(spammable: @issue, spam_params: spam_params) end diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb index 13fe30b5ac8..c435ab81b4d 100644 --- a/app/services/issues/set_crm_contacts_service.rb +++ b/app/services/issues/set_crm_contacts_service.rb @@ -2,10 +2,9 @@ module Issues class SetCrmContactsService < ::BaseProjectService - attr_accessor :issue, :errors - MAX_ADDITIONAL_CONTACTS = 6 + # Replacing contacts by email is not currently supported def execute(issue) @issue = issue @errors = [] @@ -13,33 +12,49 @@ module Issues return error_no_permissions unless allowed? return error_invalid_params unless valid_params? - determine_changes if params[:crm_contact_ids] - + @existing_ids = issue.customer_relations_contact_ids + determine_changes if params[:replace_ids].present? return error_too_many if too_many? - add_contacts if params[:add_crm_contact_ids] - remove_contacts if params[:remove_crm_contact_ids] + add if params[:add_ids].present? + remove if params[:remove_ids].present? + + add_by_email if params[:add_emails].present? + remove_by_email if params[:remove_emails].present? if issue.valid? + GraphqlTriggers.issue_crm_contacts_updated(issue) + issue.touch ServiceResponse.success(payload: issue) else # The default error isn't very helpful: "Issue customer relations contacts is invalid" issue.errors.delete(:issue_customer_relations_contacts) issue.errors.add(:issue_customer_relations_contacts, errors.to_sentence) - ServiceResponse.error(payload: issue, message: issue.errors.full_messages) + ServiceResponse.error(payload: issue, message: issue.errors.full_messages.to_sentence) end end private + attr_accessor :issue, :errors, :existing_ids + def determine_changes - existing_contact_ids = issue.issue_customer_relations_contacts.map(&:contact_id) - params[:add_crm_contact_ids] = params[:crm_contact_ids] - existing_contact_ids - params[:remove_crm_contact_ids] = existing_contact_ids - params[:crm_contact_ids] + params[:add_ids] = params[:replace_ids] - existing_ids + params[:remove_ids] = existing_ids - params[:replace_ids] + end + + def add + add_by_id(params[:add_ids]) + end + + def add_by_email + contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group.id, params[:add_emails]) + add_by_id(contact_ids) end - def add_contacts - params[:add_crm_contact_ids].uniq.each do |contact_id| + def add_by_id(contact_ids) + contact_ids -= existing_ids + contact_ids.uniq.each do |contact_id| issue_contact = issue.issue_customer_relations_contacts.create(contact_id: contact_id) unless issue_contact.persisted? @@ -49,9 +64,19 @@ module Issues end end - def remove_contacts + def remove + remove_by_id(params[:remove_ids]) + end + + def remove_by_email + contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, params[:remove_emails]) + remove_by_id(contact_ids) + end + + def remove_by_id(contact_ids) + contact_ids &= existing_ids issue.issue_customer_relations_contacts - .where(contact_id: params[:remove_crm_contact_ids]) # rubocop: disable CodeReuse/ActiveRecord + .where(contact_id: contact_ids) # rubocop: disable CodeReuse/ActiveRecord .delete_all end @@ -64,27 +89,43 @@ module Issues end def set_present? - params[:crm_contact_ids].present? + params[:replace_ids].present? end def add_or_remove_present? - params[:add_crm_contact_ids].present? || params[:remove_crm_contact_ids].present? + add_present? || remove_present? + end + + def add_present? + params[:add_ids].present? || params[:add_emails].present? + end + + def remove_present? + params[:remove_ids].present? || params[:remove_emails].present? end def too_many? - params[:add_crm_contact_ids] && params[:add_crm_contact_ids].length > MAX_ADDITIONAL_CONTACTS + too_many_ids? || too_many_emails? + end + + def too_many_ids? + params[:add_ids] && params[:add_ids].length > MAX_ADDITIONAL_CONTACTS + end + + def too_many_emails? + params[:add_emails] && params[:add_emails].length > MAX_ADDITIONAL_CONTACTS end def error_no_permissions - ServiceResponse.error(message: ['You have insufficient permissions to set customer relations contacts for this issue']) + ServiceResponse.error(message: _('You have insufficient permissions to set customer relations contacts for this issue')) end def error_invalid_params - ServiceResponse.error(message: ['You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids']) + ServiceResponse.error(message: _('You cannot combine replace_ids with add_ids or remove_ids')) end def error_too_many - ServiceResponse.error(payload: issue, message: ["You can only add up to #{MAX_ADDITIONAL_CONTACTS} contacts at one time"]) + ServiceResponse.error(payload: issue, message: _("You can only add up to %{max_contacts} contacts at one time" % { max_contacts: MAX_ADDITIONAL_CONTACTS })) end end end diff --git a/app/services/loose_foreign_keys/batch_cleaner_service.rb b/app/services/loose_foreign_keys/batch_cleaner_service.rb index 06c05e8ff54..de52cbba576 100644 --- a/app/services/loose_foreign_keys/batch_cleaner_service.rb +++ b/app/services/loose_foreign_keys/batch_cleaner_service.rb @@ -2,11 +2,11 @@ module LooseForeignKeys class BatchCleanerService - def initialize(parent_klass:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new, models_by_table_name:) - @parent_klass = parent_klass + def initialize(parent_table:, loose_foreign_key_definitions:, deleted_parent_records:, modification_tracker: LooseForeignKeys::ModificationTracker.new) + @parent_table = parent_table + @loose_foreign_key_definitions = loose_foreign_key_definitions @deleted_parent_records = deleted_parent_records @modification_tracker = modification_tracker - @models_by_table_name = models_by_table_name @deleted_records_counter = Gitlab::Metrics.counter( :loose_foreign_key_processed_deleted_records, 'The number of processed loose foreign key deleted records' @@ -14,11 +14,11 @@ module LooseForeignKeys end def execute - parent_klass.loose_foreign_key_definitions.each do |foreign_key_definition| - run_cleaner_service(foreign_key_definition, with_skip_locked: true) + loose_foreign_key_definitions.each do |loose_foreign_key_definition| + run_cleaner_service(loose_foreign_key_definition, with_skip_locked: true) break if modification_tracker.over_limit? - run_cleaner_service(foreign_key_definition, with_skip_locked: false) + run_cleaner_service(loose_foreign_key_definition, with_skip_locked: false) break if modification_tracker.over_limit? end @@ -27,12 +27,12 @@ module LooseForeignKeys # At this point, all associations are cleaned up, we can update the status of the parent records update_count = LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records) - deleted_records_counter.increment({ table: parent_klass.table_name, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count) + deleted_records_counter.increment({ table: parent_table, db_config_name: LooseForeignKeys::DeletedRecord.connection.pool.db_config.name }, update_count) end private - attr_reader :parent_klass, :deleted_parent_records, :modification_tracker, :models_by_table_name, :deleted_records_counter + attr_reader :parent_table, :loose_foreign_key_definitions, :deleted_parent_records, :modification_tracker, :deleted_records_counter def record_result(cleaner, result) if cleaner.async_delete? @@ -42,19 +42,22 @@ module LooseForeignKeys end end - def run_cleaner_service(foreign_key_definition, with_skip_locked:) - cleaner = CleanerService.new( - model: models_by_table_name.fetch(foreign_key_definition.to_table), - foreign_key_definition: foreign_key_definition, - deleted_parent_records: deleted_parent_records, - with_skip_locked: with_skip_locked - ) + def run_cleaner_service(loose_foreign_key_definition, with_skip_locked:) + base_models_for_gitlab_schema = Gitlab::Database.schemas_to_base_models.fetch(loose_foreign_key_definition.options[:gitlab_schema]) + base_models_for_gitlab_schema.each do |base_model| + cleaner = CleanerService.new( + loose_foreign_key_definition: loose_foreign_key_definition, + connection: base_model.connection, + deleted_parent_records: deleted_parent_records, + with_skip_locked: with_skip_locked + ) - loop do - result = cleaner.execute - record_result(cleaner, result) + loop do + result = cleaner.execute + record_result(cleaner, result) - break if modification_tracker.over_limit? || result[:affected_rows] == 0 + break if modification_tracker.over_limit? || result[:affected_rows] == 0 + end end end end diff --git a/app/services/loose_foreign_keys/cleaner_service.rb b/app/services/loose_foreign_keys/cleaner_service.rb index 8fe053e2edf..44a922aad87 100644 --- a/app/services/loose_foreign_keys/cleaner_service.rb +++ b/app/services/loose_foreign_keys/cleaner_service.rb @@ -6,11 +6,9 @@ module LooseForeignKeys DELETE_LIMIT = 1000 UPDATE_LIMIT = 500 - delegate :connection, to: :model - - def initialize(model:, foreign_key_definition:, deleted_parent_records:, with_skip_locked: false) - @model = model - @foreign_key_definition = foreign_key_definition + def initialize(loose_foreign_key_definition:, connection:, deleted_parent_records:, with_skip_locked: false) + @loose_foreign_key_definition = loose_foreign_key_definition + @connection = connection @deleted_parent_records = deleted_parent_records @with_skip_locked = with_skip_locked end @@ -18,20 +16,20 @@ module LooseForeignKeys def execute result = connection.execute(build_query) - { affected_rows: result.cmd_tuples, table: foreign_key_definition.to_table } + { affected_rows: result.cmd_tuples, table: loose_foreign_key_definition.from_table } end def async_delete? - foreign_key_definition.on_delete == :async_delete + loose_foreign_key_definition.on_delete == :async_delete end def async_nullify? - foreign_key_definition.on_delete == :async_nullify + loose_foreign_key_definition.on_delete == :async_nullify end private - attr_reader :model, :foreign_key_definition, :deleted_parent_records, :with_skip_locked + attr_reader :loose_foreign_key_definition, :connection, :deleted_parent_records, :with_skip_locked def build_query query = if async_delete? @@ -39,10 +37,10 @@ module LooseForeignKeys elsif async_nullify? update_query else - raise "Invalid on_delete argument: #{foreign_key_definition.on_delete}" + raise "Invalid on_delete argument: #{loose_foreign_key_definition.on_delete}" end - unless query.include?(%{"#{foreign_key_definition.column}" IN (}) + unless query.include?(%{"#{loose_foreign_key_definition.column}" IN (}) raise("FATAL: foreign key condition is missing from the generated query: #{query}") end @@ -50,15 +48,15 @@ module LooseForeignKeys end def arel_table - @arel_table ||= model.arel_table + @arel_table ||= Arel::Table.new(loose_foreign_key_definition.from_table) end def primary_keys - @primary_keys ||= connection.primary_keys(model.table_name).map { |key| arel_table[key] } + @primary_keys ||= connection.primary_keys(loose_foreign_key_definition.from_table).map { |key| arel_table[key] } end def quoted_table_name - @quoted_table_name ||= Arel.sql(connection.quote_table_name(model.table_name)) + @quoted_table_name ||= Arel.sql(connection.quote_table_name(loose_foreign_key_definition.from_table)) end def delete_query @@ -71,7 +69,7 @@ module LooseForeignKeys def update_query query = Arel::UpdateManager.new query.table(quoted_table_name) - query.set([[arel_table[foreign_key_definition.column], nil]]) + query.set([[arel_table[loose_foreign_key_definition.column], nil]]) add_in_query_with_limit(query, UPDATE_LIMIT) end @@ -88,7 +86,7 @@ module LooseForeignKeys def in_query_with_limit(limit) in_query = Arel::SelectManager.new in_query.from(quoted_table_name) - in_query.where(arel_table[foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value))) + in_query.where(arel_table[loose_foreign_key_definition.column].in(deleted_parent_records.map(&:primary_key_value))) in_query.projections = primary_keys in_query.take(limit) in_query.lock(Arel.sql('FOR UPDATE SKIP LOCKED')) if with_skip_locked diff --git a/app/services/loose_foreign_keys/process_deleted_records_service.rb b/app/services/loose_foreign_keys/process_deleted_records_service.rb index 735fc8a2415..025829aa774 100644 --- a/app/services/loose_foreign_keys/process_deleted_records_service.rb +++ b/app/services/loose_foreign_keys/process_deleted_records_service.rb @@ -21,13 +21,16 @@ module LooseForeignKeys break if modification_tracker.over_limit? - model = find_parent_model!(table) + loose_foreign_key_definitions = Gitlab::Database::LooseForeignKeys.definitions_by_table[table] + + next if loose_foreign_key_definitions.empty? LooseForeignKeys::BatchCleanerService - .new(parent_klass: model, - deleted_parent_records: records, - modification_tracker: modification_tracker, - models_by_table_name: models_by_table_name) + .new( + parent_table: table, + loose_foreign_key_definitions: loose_foreign_key_definitions, + deleted_parent_records: records, + modification_tracker: modification_tracker) .execute break if modification_tracker.over_limit? @@ -45,30 +48,12 @@ module LooseForeignKeys LooseForeignKeys::DeletedRecord.load_batch_for_table(fully_qualified_table_name, BATCH_SIZE) end - def find_parent_model!(table) - models_by_table_name.fetch(table) - end - def current_schema @current_schema = connection.current_schema end def tracked_tables - @tracked_tables ||= models_by_table_name - .select { |table_name, model| model.respond_to?(:loose_foreign_key_definitions) } - .keys - end - - def models_by_table_name - @models_by_table_name ||= begin - all_models - .select(&:base_class?) - .index_by(&:table_name) - end - end - - def all_models - ApplicationRecord.descendants + @tracked_tables ||= Gitlab::Database::LooseForeignKeys.definitions_by_table.keys end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index cb905e01613..acd00d0d1ec 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -92,7 +92,6 @@ module Members super track_invite_source(member) - track_areas_of_focus(member) end def track_invite_source(member) @@ -110,14 +109,7 @@ module Members member.invite? ? 'net_new_user' : 'existing_user' end - def track_areas_of_focus(member) - areas_of_focus.each do |area_of_focus| - Gitlab::Tracking.event(self.class.name, 'area_of_focus', label: area_of_focus, property: member.id.to_s) - end - end - def create_tasks_to_be_done - return unless experiment(:invite_members_for_task).enabled? return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank? valid_members = members.select { |member| member.valid? && member.member_task.valid? } @@ -129,10 +121,6 @@ module Members TasksToBeDone::CreateWorker.perform_async(member_task.id, current_user.id, valid_members.map(&:user_id)) end - def areas_of_focus - params[:areas_of_focus] || [] - 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 f2c8a6f20a1..e766a7e9044 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -65,7 +65,6 @@ module Members end def create_member_task - return unless experiment(:invite_members_for_task).enabled? return unless member.persisted? return if member_task_attributes.value?(nil) diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 77564521d45..d2c83f82ff8 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -2,13 +2,22 @@ module MergeRequests class AfterCreateService < MergeRequests::BaseService + include Gitlab::Utils::StrongMemoize + def execute(merge_request) + prepare_for_mergeability(merge_request) if early_prepare_for_mergeability?(merge_request) prepare_merge_request(merge_request) - merge_request.mark_as_unchecked if merge_request.preparing? + mark_as_unchecked(merge_request) unless early_prepare_for_mergeability?(merge_request) end private + def prepare_for_mergeability(merge_request) + create_pipeline_for(merge_request, current_user) + merge_request.update_head_pipeline + mark_as_unchecked(merge_request) + end + def prepare_merge_request(merge_request) event_service.open_mr(merge_request, current_user) @@ -17,8 +26,10 @@ module MergeRequests notification_service.new_merge_request(merge_request, current_user) - create_pipeline_for(merge_request, current_user) - merge_request.update_head_pipeline + unless early_prepare_for_mergeability?(merge_request) + create_pipeline_for(merge_request, current_user) + merge_request.update_head_pipeline + end merge_request.diffs(include_stats: false).write_cache merge_request.create_cross_references!(current_user) @@ -37,6 +48,16 @@ module MergeRequests def link_lfs_objects(merge_request) LinkLfsObjectsService.new(project: merge_request.target_project).execute(merge_request) end + + def early_prepare_for_mergeability?(merge_request) + strong_memoize("early_prepare_for_mergeability_#{merge_request.target_project_id}".to_sym) do + Feature.enabled?(:early_prepare_for_mergeability, merge_request.target_project) + end + end + + def mark_as_unchecked(merge_request) + merge_request.mark_as_unchecked if merge_request.preparing? + end end end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 62e599e3e27..3f39b2742c6 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -14,6 +14,7 @@ module MergeRequests create_approval_note(merge_request) mark_pending_todos_as_done(merge_request) execute_approval_hooks(merge_request, current_user) + remove_attention_requested(merge_request, current_user) merge_request_activity_counter.track_approve_mr_action(user: current_user) success diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 0a652c58aab..d744881549a 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -58,6 +58,8 @@ module MergeRequests new_reviewers = merge_request.reviewers - old_reviewers merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) + + remove_attention_requested(merge_request, current_user) end def cleanup_environments(merge_request) @@ -238,6 +240,18 @@ module MergeRequests Milestones::MergeRequestsCountService.new(milestone).delete_cache end + + def remove_all_attention_requests(merge_request) + return unless merge_request.attention_requested_enabled? + + ::MergeRequests::BulkRemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request).execute + end + + def remove_attention_requested(merge_request, user) + return unless merge_request.attention_requested_enabled? + + ::MergeRequests::RemoveAttentionRequestedService.new(project: merge_request.project, current_user: current_user, merge_request: merge_request, user: user).execute + end end end diff --git a/app/services/merge_requests/bulk_remove_attention_requested_service.rb b/app/services/merge_requests/bulk_remove_attention_requested_service.rb new file mode 100644 index 00000000000..dd2ff741ba6 --- /dev/null +++ b/app/services/merge_requests/bulk_remove_attention_requested_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module MergeRequests + class BulkRemoveAttentionRequestedService < MergeRequests::BaseService + attr_accessor :merge_request + + def initialize(project:, current_user:, merge_request:) + super(project: project, current_user: current_user) + + @merge_request = merge_request + end + + def execute + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + merge_request.merge_request_assignees.update_all(state: :reviewed) + merge_request.merge_request_reviewers.update_all(state: :reviewed) + + success + end + end +end diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index f83b14c7269..e9b253129b4 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -17,6 +17,7 @@ module MergeRequests create_note(merge_request) notification_service.async.close_mr(merge_request, current_user) todo_service.close_merge_request(merge_request, current_user) + remove_all_attention_requests(merge_request) execute_hooks(merge_request, 'close') invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index 6b032545230..9d7f8393ba5 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -48,7 +48,7 @@ module MergeRequests end def can_create_pipeline_in_target_project?(merge_request) - if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) + if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project) merge_request.for_same_project? else can?(current_user, :create_pipeline, merge_request.target_project) && diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 87cd6544406..1d9f7ab59f4 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -22,6 +22,8 @@ module MergeRequests merge_request_activity_counter.track_assignees_changed_action(user: current_user) execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] + + remove_attention_requested(merge_request, current_user) end private diff --git a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb index a2de5a32963..a3d94e888df 100644 --- a/app/services/merge_requests/outdated_discussion_diff_lines_service.rb +++ b/app/services/merge_requests/outdated_discussion_diff_lines_service.rb @@ -14,13 +14,23 @@ module MergeRequests end def execute - end_position = position.line_range["end"] - diff_line_index = diff_lines.find_index do |l| - if end_position["new_line"] - l.new_line == end_position["new_line"] - elsif end_position["old_line"] - l.old_line == end_position["old_line"] + line_position = position.line_range["end"] || position.line_range["start"] + found_line = false + diff_line_index = -1 + diff_lines.each_with_index do |l, i| + if found_line + if !l.type + break + elsif l.type == 'new' + diff_line_index = i + break + end + else + # Find the old line + found_line = l.old_line == line_position["new_line"] end + + diff_line_index = i end initial_line_index = [diff_line_index - OVERFLOW_LINES_COUNT, 0].max last_line_index = [diff_line_index + OVERFLOW_LINES_COUNT, diff_lines.length].min diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index ea3071b3c2d..e475b57e4a2 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -28,6 +28,7 @@ module MergeRequests notification_service.merge_mr(merge_request, current_user) invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches + remove_all_attention_requests(merge_request) delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 9423194c01d..d1f45b4b49c 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -4,7 +4,7 @@ module MergeRequests class RebaseService < MergeRequests::BaseService REBASE_ERROR = 'Rebase failed. Please rebase locally' - attr_reader :merge_request + attr_reader :merge_request, :rebase_error def execute(merge_request, skip_ci: false) @merge_request = merge_request @@ -13,7 +13,7 @@ module MergeRequests if rebase success else - error(REBASE_ERROR) + error(rebase_error) end end @@ -22,11 +22,23 @@ module MergeRequests true rescue StandardError => e - log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true) + set_rebase_error(e) + log_error(exception: e, message: rebase_error, save_message_on_model: true) false ensure merge_request.update_column(:rebase_jid, nil) end + + private + + def set_rebase_error(exception) + @rebase_error = + if exception.is_a?(Gitlab::Git::PreReceiveError) + "Something went wrong during the rebase pre-receive hook: #{exception.message}." + else + REBASE_ERROR + end + end end end diff --git a/app/services/merge_requests/remove_attention_requested_service.rb b/app/services/merge_requests/remove_attention_requested_service.rb new file mode 100644 index 00000000000..b727c24415e --- /dev/null +++ b/app/services/merge_requests/remove_attention_requested_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module MergeRequests + class RemoveAttentionRequestedService < MergeRequests::BaseService + attr_accessor :merge_request, :user + + def initialize(project:, current_user:, merge_request:, user:) + super(project: project, current_user: current_user) + + @merge_request = merge_request + @user = user + end + + def execute + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + if reviewer || assignee + update_state(reviewer) + update_state(assignee) + + success + else + error("User is not a reviewer or assignee of the merge request") + end + end + + private + + def assignee + merge_request.find_assignee(user) + end + + def reviewer + merge_request.find_reviewer(user) + end + + def update_state(reviewer_or_assignee) + reviewer_or_assignee&.update(state: :reviewed) + end + end +end diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb index 03ded1512f9..6afd760386e 100644 --- a/app/services/merge_requests/resolved_discussion_notification_service.rb +++ b/app/services/merge_requests/resolved_discussion_notification_service.rb @@ -6,6 +6,7 @@ module MergeRequests return unless merge_request.discussions_resolved? SystemNoteService.resolve_all_discussions(merge_request, project, current_user) + execute_hooks(merge_request, 'update') notification_service.async.resolve_all_discussions(merge_request, current_user) end end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index 102f78c6a9b..0600fd1d740 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -5,7 +5,7 @@ module MergeRequests def execute # If performing a squash would result in no change, then # immediately return a success message without performing a squash - if merge_request.commits_count < 2 && message.nil? + if merge_request.commits_count == 1 && message == merge_request.first_commit.safe_message return success(squash_sha: merge_request.diff_head_sha) end @@ -17,7 +17,7 @@ module MergeRequests private def squash! - squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message) + squash_sha = repository.squash(current_user, merge_request, message) success(squash_sha: squash_sha) rescue StandardError => e @@ -39,7 +39,7 @@ module MergeRequests end def message - params[:squash_commit_message].presence + params[:squash_commit_message].presence || merge_request.default_squash_commit_message end end end diff --git a/app/services/merge_requests/toggle_attention_requested_service.rb b/app/services/merge_requests/toggle_attention_requested_service.rb index 66c5d6fce5d..d9f81ac310f 100644 --- a/app/services/merge_requests/toggle_attention_requested_service.rb +++ b/app/services/merge_requests/toggle_attention_requested_service.rb @@ -19,7 +19,14 @@ module MergeRequests update_state(assignee) if reviewer&.attention_requested? || assignee&.attention_requested? + create_attention_request_note notity_user + + if current_user.id != user.id + remove_attention_requested(merge_request, current_user) + end + else + create_remove_attention_request_note end success @@ -31,9 +38,18 @@ module MergeRequests private def notity_user + notification_service.async.attention_requested_of_merge_request(merge_request, current_user, user) todo_service.create_attention_requested_todo(merge_request, current_user, user) end + def create_attention_request_note + SystemNoteService.request_attention(merge_request, merge_request.project, current_user, user) + end + + def create_remove_attention_request_note + SystemNoteService.remove_attention_request(merge_request, merge_request.project, current_user, user) + end + def assignee merge_request.find_assignee(user) end diff --git a/app/services/namespaces/invite_team_email_service.rb b/app/services/namespaces/invite_team_email_service.rb index 45975d1953a..78edc205990 100644 --- a/app/services/namespaces/invite_team_email_service.rb +++ b/app/services/namespaces/invite_team_email_service.rb @@ -29,13 +29,12 @@ module Namespaces return if email_for_track_sent_to_user? experiment(:invite_team_email, group: group) do |e| + e.publish_to_database e.candidate do send_email(user, group) sent_email_records.add(user, track, series) sent_email_records.save! end - - e.record! end end diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index 52070abbad7..aeb859af4d9 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -40,5 +40,9 @@ module NotificationRecipients def self.build_requested_review_recipients(*args) ::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients end + + def self.build_attention_requested_recipients(*args) + ::NotificationRecipients::Builder::AttentionRequested.new(*args).notification_recipients + end end end diff --git a/app/services/notification_recipients/builder/attention_requested.rb b/app/services/notification_recipients/builder/attention_requested.rb new file mode 100644 index 00000000000..cdc371fcece --- /dev/null +++ b/app/services/notification_recipients/builder/attention_requested.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module NotificationRecipients + module Builder + class AttentionRequested < Base + attr_reader :merge_request, :current_user, :user + + def initialize(merge_request, current_user, user) + @merge_request = merge_request + @current_user = current_user + @user = user + end + + def target + merge_request + end + + def build! + add_recipients(user, :mention, NotificationReason::ATTENTION_REQUESTED) + end + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6ad3a74b85d..5b1733422d0 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -301,6 +301,14 @@ class NotificationService end end + def attention_requested_of_merge_request(merge_request, current_user, user) + recipients = NotificationRecipients::BuildService.build_attention_requested_recipients(merge_request, current_user, user) + + recipients.each do |recipient| + mailer.attention_requested_merge_request_email(recipient.user.id, merge_request.id, current_user.id, recipient.reason).deliver_later + end + end + # When we add labels to a merge request we should send an email to: # # * watchers of the mr's labels diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 74b07e05aa6..33bf877a153 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -91,7 +91,7 @@ module Packages generate_component_file(component, :packages, architecture, :deb) generate_component_file(component, :di_packages, architecture, :udeb) end - generate_component_file(component, :source, nil, :dsc) + generate_component_file(component, :sources, nil, :dsc) end end diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index ae9c92a3d3a..655616c3a28 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -23,9 +23,7 @@ module Packages ::Packages::CreateDependencyService.new(package, package_dependencies).execute ::Packages::Npm::CreateTagService.new(package, dist_tag).execute - if Feature.enabled?(:packages_npm_abbreviated_metadata, project, default_enabled: :yaml) - package.create_npm_metadatum!(package_json: package_json) - end + package.create_npm_metadatum!(package_json: package_json) package end diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb index 895614a84a0..c9029b9666a 100644 --- a/app/services/pages/zip_directory_service.rb +++ b/app/services/pages/zip_directory_service.rb @@ -25,7 +25,9 @@ module Pages FileUtils.rm_f(output_file) entries_count = 0 - ::Zip::File.open(output_file, ::Zip::File::CREATE) do |zipfile| + # 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 diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index b7ed9202b01..aef92b8adee 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -28,9 +28,7 @@ module Projects # Git data (e.g. a list of branch names). flush_caches(project) - if Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) - ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted) - end + ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted) Projects::UnlinkForkService.new(project, current_user).execute @@ -75,6 +73,18 @@ module Projects response.success? end + def destroy_events! + unless remove_events + raise_error(s_('DeleteProject|Failed to remove events. Please try again or contact administrator.')) + end + end + + def remove_events + response = ::Events::DestroyService.new(project).execute + + response.success? + end + def remove_repository(repository) return true unless repository @@ -117,14 +127,10 @@ module Projects log_destroy_event trash_relation_repositories! trash_project_repositories! + destroy_events! destroy_web_hooks! destroy_project_bots! - - if ::Feature.enabled?(:ci_optimize_project_records_destruction, project, default_enabled: :yaml) && - Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) - - destroy_ci_records! - end + destroy_ci_records! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 @@ -150,7 +156,7 @@ module Projects ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline) end - deleted_count = project.commit_statuses.delete_all + deleted_count = ::CommitStatus.for_project(project).delete_all Gitlab::AppLogger.info( class: 'Projects::DestroyService', diff --git a/app/services/projects/prometheus/alerts/create_service.rb b/app/services/projects/prometheus/alerts/create_service.rb index dc0cacf49f3..0d7d8ab1a62 100644 --- a/app/services/projects/prometheus/alerts/create_service.rb +++ b/app/services/projects/prometheus/alerts/create_service.rb @@ -3,7 +3,7 @@ module Projects module Prometheus module Alerts - class CreateService < BaseService + class CreateService < BaseProjectService include AlertParams def execute diff --git a/app/services/projects/prometheus/alerts/destroy_service.rb b/app/services/projects/prometheus/alerts/destroy_service.rb index 14e88a2e356..243b12eb654 100644 --- a/app/services/projects/prometheus/alerts/destroy_service.rb +++ b/app/services/projects/prometheus/alerts/destroy_service.rb @@ -3,7 +3,7 @@ module Projects module Prometheus module Alerts - class DestroyService < BaseService + class DestroyService < BaseProjectService def execute(alert) alert.destroy end diff --git a/app/services/projects/prometheus/alerts/update_service.rb b/app/services/projects/prometheus/alerts/update_service.rb index a0c8a5ccc2d..1802f35dae9 100644 --- a/app/services/projects/prometheus/alerts/update_service.rb +++ b/app/services/projects/prometheus/alerts/update_service.rb @@ -3,7 +3,7 @@ module Projects module Prometheus module Alerts - class UpdateService < BaseService + class UpdateService < BaseProjectService include AlertParams def execute(alert) diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index a69e6488ebc..17da77fe950 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -104,10 +104,10 @@ module Projects update_repository_configuration(@new_path) execute_system_hooks - - update_pending_builds! end + update_pending_builds + post_update_hooks(project) rescue Exception # rubocop:disable Lint/RescueException rollback_side_effects @@ -244,13 +244,15 @@ module Projects Integration.create_from_active_default_integrations(project, :project_id) end - def update_pending_builds! - update_params = { + def update_pending_builds + ::Ci::PendingBuilds::UpdateProjectWorker.perform_async(project.id, pending_builds_params) + end + + def pending_builds_params + { namespace_id: new_namespace.id, namespace_traversal_ids: new_namespace.traversal_ids } - - ::Ci::UpdatePendingBuildService.new(project, update_params).execute end end end diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb index df801311aaf..1ab3ccfcaae 100644 --- a/app/services/protected_branches/base_service.rb +++ b/app/services/protected_branches/base_service.rb @@ -2,6 +2,8 @@ module ProtectedBranches class BaseService < ::BaseService + include ProtectedRefNameSanitizer + # current_user - The user that performs the action # params - A hash of parameters def initialize(project, current_user = nil, params = {}) @@ -14,22 +16,13 @@ module ProtectedBranches # overridden in EE::ProtectedBranches module end + private + def filtered_params return unless params - params[:name] = sanitize_branch_name(params[:name]) if params[:name].present? + params[:name] = sanitize_name(params[:name]) if params[:name].present? params end - - private - - def sanitize_branch_name(name) - name = CGI.unescapeHTML(name) - name = Sanitize.fragment(name) - - # Sanitize.fragment escapes HTML chars, so unescape again to allow names - # like `feature->master` - CGI.unescapeHTML(name) - end end end diff --git a/app/services/protected_tags/base_service.rb b/app/services/protected_tags/base_service.rb new file mode 100644 index 00000000000..e0181815f0f --- /dev/null +++ b/app/services/protected_tags/base_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ProtectedTags + class BaseService < ::BaseService + include ProtectedRefNameSanitizer + + private + + def filtered_params + return unless params + + params[:name] = sanitize_name(params[:name]) if params[:name].present? + params + end + end +end diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb index 9aff55986b2..7d2b583a295 100644 --- a/app/services/protected_tags/create_service.rb +++ b/app/services/protected_tags/create_service.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module ProtectedTags - class CreateService < BaseService + class CreateService < ProtectedTags::BaseService attr_reader :protected_tag def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - project.protected_tags.create(params) + project.protected_tags.create(filtered_params) end end end diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb index 3eb5f4955ee..e337ec39898 100644 --- a/app/services/protected_tags/update_service.rb +++ b/app/services/protected_tags/update_service.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true module ProtectedTags - class UpdateService < BaseService + class UpdateService < ProtectedTags::BaseService def execute(protected_tag) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - protected_tag.update(params) + protected_tag.update(filtered_params) protected_tag end end diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb index 96db00fbc1b..eafd9d7a55e 100644 --- a/app/services/repositories/changelog_service.rb +++ b/app/services/repositories/changelog_service.rb @@ -60,7 +60,7 @@ module Repositories end # rubocop: enable Metrics/ParameterLists - def execute + def execute(commit_to_changelog: true) config = Gitlab::Changelog::Config.from_git(@project, @user) from = start_of_commit_range(config) @@ -93,9 +93,13 @@ module Repositories end end - Gitlab::Changelog::Committer - .new(@project, @user) - .commit(release: release, file: @file, branch: @branch, message: @message) + if commit_to_changelog + Gitlab::Changelog::Committer + .new(@project, @user) + .commit(release: release, file: @file, branch: @branch, message: @message) + else + Gitlab::Changelog::Generator.new.add(release) + end end def start_of_commit_range(config) diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 4ba1b3ade86..171d52c328d 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -2,42 +2,35 @@ class SearchService include Gitlab::Allowable + include Gitlab::Utils::StrongMemoize - SEARCH_TERM_LIMIT = 64 - SEARCH_CHAR_LIMIT = 4096 DEFAULT_PER_PAGE = Gitlab::SearchResults::DEFAULT_PER_PAGE MAX_PER_PAGE = 200 def initialize(current_user, params = {}) @current_user = current_user - @params = params.dup + @params = Gitlab::Search::Params.new(params, detect_abuse: prevent_abusive_searches?) end # rubocop: disable CodeReuse/ActiveRecord def project - return @project if defined?(@project) - - @project = - if params[:project_id].present? + strong_memoize(:project) do + if params[:project_id].present? && valid_request? the_project = Project.find_by(id: params[:project_id]) can?(current_user, :read_project, the_project) ? the_project : nil - else - nil end + end end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def group - return @group if defined?(@group) - - @group = - if params[:group_id].present? + strong_memoize(:group) do + if params[:group_id].present? && valid_request? the_group = Group.find_by(id: params[:group_id]) can?(current_user, :read_group, the_group) ? the_group : nil - else - nil end + end end # rubocop: enable CodeReuse/ActiveRecord @@ -45,24 +38,23 @@ class SearchService # overridden in EE end + def global_search? + project.blank? && group.blank? + end + def show_snippets? return @show_snippets if defined?(@show_snippets) @show_snippets = params[:snippets] == 'true' end - def valid_query_length? - params[:search].length <= SEARCH_CHAR_LIMIT - end - - def valid_terms_count? - params[:search].split.count { |word| word.length >= 3 } <= SEARCH_TERM_LIMIT - end - delegate :scope, to: :search_service + delegate :valid_terms_count?, :valid_query_length?, to: :params def search_results - @search_results ||= search_service.execute + strong_memoize(:search_results) do + abuse_detected? ? Gitlab::EmptySearchResults.new : search_service.execute + end end def search_objects(preload_method = nil) @@ -79,8 +71,30 @@ class SearchService search_results.aggregations(scope) end + def abuse_detected? + strong_memoize(:abuse_detected) do + params.abusive? + end + end + + def abuse_messages + return [] unless params.abusive? + + params.abuse_detection.errors.full_messages + end + + def valid_request? + strong_memoize(:valid_request) do + params.valid? + end + end + private + def prevent_abusive_searches? + Feature.enabled?(:prevent_abusive_searches, current_user) + end + def page [1, params[:page].to_i].max end diff --git a/app/services/service_ping/devops_report_service.rb b/app/services/service_ping/devops_report_service.rb new file mode 100644 index 00000000000..3b8f5dfdb82 --- /dev/null +++ b/app/services/service_ping/devops_report_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ServicePing + class DevopsReportService + def initialize(data) + @data = data + end + + def execute + # `conv_index` was previously named `dev_ops_score` in + # version-gitlab-com, so we check both for backwards compatibility. + metrics = @data['conv_index'] || @data['dev_ops_score'] + + # Do not attempt to save a report for the first Service Ping + # response for a given GitLab instance, which comes without + # metrics. + return if metrics.keys == ['usage_data_id'] + + report = DevOpsReport::Metric.create( + metrics.slice(*DevOpsReport::Metric::METRICS) + ) + + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ActiveRecord::RecordInvalid.new(report)) unless report.persisted? + end + end +end diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb index 63e01603d47..d3d9dcecb2b 100644 --- a/app/services/service_ping/submit_service.rb +++ b/app/services/service_ping/submit_service.rb @@ -6,29 +6,23 @@ module ServicePing STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org' USAGE_DATA_PATH = 'usage_data' - METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes - percentage_notes leader_milestones instance_milestones percentage_milestones - leader_boards instance_boards percentage_boards leader_merge_requests - instance_merge_requests percentage_merge_requests leader_ci_pipelines - instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments - percentage_environments leader_deployments instance_deployments percentage_deployments - leader_projects_prometheus_active instance_projects_prometheus_active - percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues - percentage_service_desk_issues].freeze - SubmissionError = Class.new(StandardError) + def initialize(skip_db_write: false) + @skip_db_write = skip_db_write + end + def execute return unless ServicePing::ServicePingSettings.product_intelligence_enabled? begin usage_data = BuildPayloadService.new.execute - raw_usage_data, response = submit_usage_data_payload(usage_data) + response = submit_usage_data_payload(usage_data) rescue StandardError return unless Gitlab::CurrentSettings.usage_ping_enabled? usage_data = Gitlab::UsageData.data(force_refresh: true) - raw_usage_data, response = submit_usage_data_payload(usage_data) + response = submit_usage_data_payload(usage_data) end version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id') @@ -37,9 +31,11 @@ module ServicePing raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}" end - raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) - - store_metrics(response) + unless @skip_db_write + raw_usage_data = save_raw_usage_data(usage_data) + raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) + DevopsReportService.new(response).execute + end end def url @@ -60,13 +56,11 @@ module ServicePing def submit_usage_data_payload(usage_data) raise SubmissionError, 'Usage data is blank' if usage_data.blank? - raw_usage_data = save_raw_usage_data(usage_data) - response = submit_payload(usage_data) raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success? - [raw_usage_data, response] + response end def save_raw_usage_data(usage_data) @@ -75,16 +69,6 @@ module ServicePing end end - def store_metrics(response) - metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com - - return unless metrics.except('usage_data_id').present? - - DevOpsReport::Metric.create!( - metrics.slice(*METRICS) - ) - end - # See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details def base_url Rails.env.production? ? PRODUCTION_BASE_URL : STAGING_BASE_URL diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index dc5cf0fe554..0d13c73d49d 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -115,6 +115,14 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source) end + def request_attention(noteable, project, author, user) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).request_attention(user) + end + + def remove_attention_request(noteable, project, author, user) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).remove_attention_request(user) + end + # Called when 'merge when pipeline succeeds' is executed def merge_when_pipeline_succeeds(noteable, project, author, sha) ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha) @@ -213,12 +221,12 @@ module SystemNoteService ::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_merge_request(merge_request) end - def cross_reference(noteable, mentioner, author) - ::SystemNotes::IssuablesService.new(noteable: noteable, author: author).cross_reference(mentioner) + def cross_reference(mentioned, mentioned_in, author) + ::SystemNotes::IssuablesService.new(noteable: mentioned, author: author).cross_reference(mentioned_in) end - def cross_reference_exists?(noteable, mentioner) - ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_exists?(mentioner) + def cross_reference_exists?(mentioned, mentioned_in) + ::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_exists?(mentioned_in) end def change_task_status(noteable, project, author, new_task) @@ -249,8 +257,8 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: issuable, project: issuable.project, author: author).discussion_lock end - def cross_reference_disallowed?(noteable, mentioner) - ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_disallowed?(mentioner) + def cross_reference_disallowed?(mentioned, mentioned_in) + ::SystemNotes::IssuablesService.new(noteable: mentioned).cross_reference_disallowed?(mentioned_in) end def zoom_link_added(issue, project, author) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 94629ae7609..d33dcd65589 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -154,9 +154,8 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) end - # Called when a Mentionable references a Noteable - # - # mentioner - Mentionable object + # Called when a Mentionable (the `mentioned_in`) references another Mentionable (the `mentioned`, + # passed to this service as `noteable`). # # Example Note text: # @@ -168,19 +167,20 @@ module SystemNotes # # See cross_reference_note_content. # - # Returns the created Note object - def cross_reference(mentioner) - return if cross_reference_disallowed?(mentioner) + # @param mentioned_in [Mentionable] + # @return [Note] + def cross_reference(mentioned_in) + return if cross_reference_disallowed?(mentioned_in) - gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) + gfm_reference = mentioned_in.gfm_reference(noteable.project || noteable.group) body = cross_reference_note_content(gfm_reference) if noteable.is_a?(ExternalIssue) Integrations::CreateExternalCrossReferenceWorker.perform_async( noteable.project_id, noteable.id, - mentioner.class.name, - mentioner.id, + mentioned_in.class.name, + mentioned_in.id, author.id ) else @@ -195,15 +195,14 @@ module SystemNotes # in a merge request. Additionally, it prevents the creation of references to # external issues (which would fail). # - # mentioner - Mentionable object - # - # Returns Boolean - def cross_reference_disallowed?(mentioner) + # @param mentioned_in [Mentionable] + # @return [Boolean] + def cross_reference_disallowed?(mentioned_in) return true if noteable.is_a?(ExternalIssue) && !noteable.project&.external_references_supported? - return false unless mentioner.is_a?(MergeRequest) + return false unless mentioned_in.is_a?(MergeRequest) return false unless noteable.is_a?(Commit) - mentioner.commits.include?(noteable) + mentioned_in.commits.include?(noteable) end # Called when the status of a Task has changed @@ -309,38 +308,49 @@ module SystemNotes create_resource_state_event(status: status, mentionable_source: source) end - # Check if a cross reference to a noteable from a mentioner already exists + # Check if a cross reference to a Mentionable from the `mentioned_in` Mentionable + # already exists. # # This method is used to prevent multiple notes being created for a mention - # when a issue is updated, for example. The method also calls notes_for_mentioner - # to check if the mentioner is a commit, and return matches only on commit hash + # when a issue is updated, for example. The method also calls `existing_mentions_for` + # to check if the mention is in a commit, and return matches only on commit hash # instead of project + commit, to avoid repeated mentions from forks. # - # mentioner - Mentionable object - # - # Returns Boolean - def cross_reference_exists?(mentioner) + # @param mentioned_in [Mentionable] + # @return [Boolean] + def cross_reference_exists?(mentioned_in) notes = noteable.notes.system - notes_for_mentioner(mentioner, noteable, notes).exists? + existing_mentions_for(mentioned_in, noteable, notes).exists? end - # Called when a Noteable has been marked as a duplicate of another Issue + # Called when a user's attention has been requested for a Notable # - # canonical_issue - Issue that this is a duplicate of + # user - User's whos attention has been requested # # Example Note text: # - # "marked this issue as a duplicate of #1234" - # - # "marked this issue as a duplicate of other_project#5678" + # "requested attention from @eli.wisoky" # # Returns the created Note object - def mark_duplicate_issue(canonical_issue) - body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" + def request_attention(user) + body = "requested attention from #{user.to_reference}" - issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue) + create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_requested')) + end - create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + # Called when a user's attention request has been removed for a Notable + # + # user - User's whos attention request has been removed + # + # Example Note text: + # + # "removed attention request from @eli.wisoky" + # + # Returns the created Note object + def remove_attention_request(user) + body = "removed attention request from #{user.to_reference}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'attention_request_removed')) end # Called when a Noteable has been marked as the canonical Issue of a duplicate @@ -359,6 +369,25 @@ module SystemNotes create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) end + # Called when a Noteable has been marked as a duplicate of another Issue + # + # canonical_issue - Issue that this is a duplicate of + # + # Example Note text: + # + # "marked this issue as a duplicate of #1234" + # + # "marked this issue as a duplicate of other_project#5678" + # + # Returns the created Note object + def mark_duplicate_issue(canonical_issue) + body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" + + issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue) + + create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) + end + def add_email_participants(body) create_note(NoteSummary.new(noteable, project, author, body)) end @@ -398,12 +427,12 @@ module SystemNotes "#{self.class.cross_reference_note_prefix}#{gfm_reference}" end - def notes_for_mentioner(mentioner, noteable, notes) - if mentioner.is_a?(Commit) - text = "#{self.class.cross_reference_note_prefix}%#{mentioner.to_reference(nil)}" + def existing_mentions_for(mentioned_in, noteable, notes) + if mentioned_in.is_a?(Commit) + text = "#{self.class.cross_reference_note_prefix}%#{mentioned_in.to_reference(nil)}" notes.like_note_or_capitalized_note(text) else - gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group) + gfm_reference = mentioned_in.gfm_reference(noteable.project || noteable.group) text = cross_reference_note_content(gfm_reference) notes.for_note_or_capitalized_note(text) end diff --git a/app/services/todos/destroy/private_features_service.rb b/app/services/todos/destroy/private_features_service.rb deleted file mode 100644 index 44c3ff231f8..00000000000 --- a/app/services/todos/destroy/private_features_service.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Todos - module Destroy - class PrivateFeaturesService < ::Todos::Destroy::BaseService - attr_reader :project_ids, :user_id - - def initialize(project_ids, user_id = nil) - @project_ids = project_ids - @user_id = user_id - end - - # rubocop: disable CodeReuse/ActiveRecord - def execute - ProjectFeature.where(project_id: project_ids).each do |project_features| - target_types = [] - target_types << Issue.name if private?(project_features.issues_access_level) - target_types << MergeRequest.name if private?(project_features.merge_requests_access_level) - target_types << Commit.name if private?(project_features.repository_access_level) - - next if target_types.empty? - - remove_todos(project_features.project_id, target_types) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def private?(feature_level) - feature_level == ProjectFeature::PRIVATE - end - - # rubocop: disable CodeReuse/ActiveRecord - def remove_todos(project_id, target_types) - items = Todo.where(project_id: project_id) - items = items.where(user_id: user_id) if user_id - - items.where.not(user_id: authorized_users) - .where(target_type: target_types) - .delete_all - end - # rubocop: enable CodeReuse/ActiveRecord - end - end -end diff --git a/app/services/todos/destroy/unauthorized_features_service.rb b/app/services/todos/destroy/unauthorized_features_service.rb new file mode 100644 index 00000000000..513def10575 --- /dev/null +++ b/app/services/todos/destroy/unauthorized_features_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Todos + module Destroy + class UnauthorizedFeaturesService < ::Todos::Destroy::BaseService + attr_reader :project_id, :user_id + + BATCH_SIZE = 1000 + + def initialize(project_id, user_id = nil) + @project_id = project_id + @user_id = user_id + end + + # rubocop: disable CodeReuse/ActiveRecord + def execute + return if user_id && authorized_users.where(user_id: user_id).exists? + + related_todos.each_batch(of: BATCH_SIZE) do |batch| + pending_delete = without_authorized(batch).includes(:target, :user).reject do |todo| + Ability.allowed?(todo.user, :read_todo, todo, scope: :user) + end + Todo.where(id: pending_delete).delete_all if pending_delete.present? + end + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def related_todos + base_scope = Todo.for_project(project_id) + base_scope = base_scope.for_user(user_id) if user_id + base_scope + end + + # Compatibility for #authorized_users in this class we always work + # with 1 project for queries efficiency + def project_ids + [project_id] + end + end + end +end diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_callout_service.rb index 96f3f3acb57..4324e6232c2 100644 --- a/app/services/users/dismiss_user_callout_service.rb +++ b/app/services/users/dismiss_callout_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class DismissUserCalloutService < BaseContainerService + class DismissCalloutService < BaseContainerService def execute callout.tap do |record| record.update(dismissed_at: Time.current) if record.valid? diff --git a/app/services/users/dismiss_group_callout_service.rb b/app/services/users/dismiss_group_callout_service.rb index 8afee6a8187..f482142b911 100644 --- a/app/services/users/dismiss_group_callout_service.rb +++ b/app/services/users/dismiss_group_callout_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Users - class DismissGroupCalloutService < DismissUserCalloutService + class DismissGroupCalloutService < DismissCalloutService private def callout diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 2d9766c3c56..fe61335f3ed 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -63,12 +63,12 @@ module Users # Updates the list of authorizations for the current user. # # remove - The IDs of the authorization rows to remove. - # add - Rows to insert in the form `[user id, project id, access level]` + # add - Rows to insert in the form `[{ user_id: user_id, project_id: project_id, access_level: access_level}, ...]` def update_authorizations(remove = [], add = []) log_refresh_details(remove, add) - user.remove_project_authorizations(remove) unless remove.empty? - ProjectAuthorization.insert_authorizations(add) unless add.empty? + user.remove_project_authorizations(remove) if remove.any? + ProjectAuthorization.insert_all_in_batches(add) if add.any? # Since we batch insert authorization rows, Rails' associations may get # out of sync. As such we force a reload of the User object. @@ -88,7 +88,7 @@ module Users # most often there's only a few entries in remove and add, but limit it to the first 5 # entries to avoid flooding the logs 'authorized_projects_refresh.rows_deleted_slice': remove.first(5), - 'authorized_projects_refresh.rows_added_slice': add.first(5)) + 'authorized_projects_refresh.rows_added_slice': add.first(5).map(&:values)) end end end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index eab1e91dc89..408ee429a74 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -85,7 +85,8 @@ class VerifyPagesDomainService < BaseService end def check(domain_name, resolver) - records = parse(txt_records(domain_name, resolver)) + # Append '.' to domain_name, indicating absolute FQDN + records = parse(txt_records(domain_name + '.', resolver)) records.any? do |record| record == domain.keyed_verification_code || record == domain.verification_code diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index 0a966f3d44f..027857500f4 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -9,7 +9,7 @@ class LfsObjectUploader < GitlabUploader alias_method :upload, :model def filename - model.oid[4..-1] + model.oid[4..] end def store_dir diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json index 73ff71043ce..1497a05a68f 100644 --- a/app/validators/json_schemas/error_tracking_event_payload.json +++ b/app/validators/json_schemas/error_tracking_event_payload.json @@ -57,7 +57,7 @@ "type": "array" }, "context_line": { - "type": "string" + "type": ["string", "null"] }, "post_context": { "type": "array" 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 19c38d7be62..65882491575 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -32,6 +32,7 @@ = render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f = render_if_exists 'admin/application_settings/enforce_pat_expiration', form: f + = render_if_exists 'admin/application_settings/ssh_key_expiration_policy', form: f = render_if_exists 'admin/application_settings/enforce_ssh_key_expiration', form: f .form-group diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 7cdadaaf37b..398e63cdfdc 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -33,6 +33,9 @@ = f.label :conan_max_file_size, _('Maximum Conan package file size in bytes'), class: 'label-bold' = f.number_field :conan_max_file_size, class: 'form-control gl-form-input' .form-group + = f.label :helm_max_file_size, _('Maximum Helm chart file size in bytes'), class: 'label-bold' + = f.number_field :helm_max_file_size, class: 'form-control gl-form-input' + .form-group = f.label :maven_max_file_size, _('Maximum Maven package file size in bytes'), class: 'label-bold' = f.number_field :maven_max_file_size, class: 'form-control gl-form-input' .form-group diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 756c0e770a6..f7a6a26c645 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -1,5 +1,5 @@ - expanded = integration_expanded?('snowplow_') -%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded) } +%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { qa_selector: 'snowplow_settings_content' } } .settings-header %h4 = _('Snowplow') @@ -15,7 +15,7 @@ %fieldset .form-group .form-check - = f.check_box :snowplow_enabled, class: 'form-check-input' + = f.check_box :snowplow_enabled, class: 'form-check-input', data: { qa_selector: 'snowplow_enabled_checkbox' } = f.label :snowplow_enabled, _('Enable Snowplow tracking'), class: 'form-check-label' .form-group = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' @@ -33,4 +33,4 @@ .form-text.text-muted = _('The Snowplow cookie domain.') - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 3a053205725..61a2f97764f 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -37,17 +37,16 @@ .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' } -- if Feature.enabled?(:files_api_throttling, default_enabled: :yaml) - %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Files API Rate Limits') - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.') - .settings-content - = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } +%section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Files API Rate Limits') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.') + .settings-content + = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } %section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml index ece0f7ca4d9..3aba91e8765 100644 --- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml +++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml @@ -5,7 +5,7 @@ variant: :tip, alert_class: 'js-security-newsletter-callout', is_contained: true, - alert_data: { feature_id: UserCalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: user_callouts_path, defer_links: 'true' }, + alert_data: { feature_id: Users::CalloutsHelper::SECURITY_NEWSLETTER_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-security-newsletter-callout' } do .gl-alert-body = s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 4197d5b961f..801b903395a 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -3,6 +3,8 @@ - billable_users_url = help_page_path('subscriptions/self_managed/index', anchor: 'billable-users') - billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url } += render_if_exists 'shared/manual_renewal_banner' += render_if_exists 'shared/manual_quarterly_reconciliation_banner' = render_if_exists 'shared/qrtly_reconciliation_alert' = render 'admin/dashboard/security_newsletter_callout' diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index ae809f01592..09f2d431197 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -70,15 +70,13 @@ .card .card-header = _('Projects') - %span.badge.badge-pill - #{@group.projects.count} + = gl_badge_tag @group.projects.count %ul.content-list - @projects.each do |project| %li %strong = link_to project.full_name, [:admin, project] - %span.badge.badge-pill - = storage_counter(project.statistics.storage_size) + = gl_badge_tag storage_counter(project.statistics.storage_size) %span.float-right.light %span.monospace= project.full_path + '.git' - unless @projects.size < Kaminari.config.default_per_page @@ -90,15 +88,13 @@ .card .card-header = _('Projects shared with %{group_name}') % { group_name: @group.name } - %span.badge.badge-pill - #{shared_projects.size} + = gl_badge_tag shared_projects.size %ul.content-list - shared_projects.each do |project| %li %strong = link_to project.full_name, [:admin, project] - %span.badge.badge-pill - = storage_counter(project.statistics.storage_size) + = gl_badge_tag storage_counter(project.statistics.storage_size) %span.float-right.light %span.monospace= project.full_path + '.git' @@ -126,7 +122,7 @@ .card .card-header = html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe } - %span.badge.badge-pill= @group.users_count + = gl_badge_tag @group.users_count = render 'shared/members/manage_access_button', path: group_group_members_path(@group) %ul.content-list.group-users-list.content-list.members-list = render partial: 'shared/members/member', diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 93038e63a2e..9c258e10008 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -1,6 +1,8 @@ - add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path - page_title _('Edit System Hook') += render 'shared/web_hooks/hook_errors', hook: @hook + .row.gl-mt-3 .col-lg-3 = render 'shared/web_hooks/title_and_docs', hook: @hook diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 6007d891aad..66fd18e1b76 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -6,13 +6,27 @@ %h3.page-title = _('Labels') %hr - -.labels.labels-container.admin-labels.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100 - - if @labels.present? +- if @labels.present? + .labels.labels-container.admin-labels.js-admin-labels-container.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100 %ul.manage-labels-list = render @labels = paginate @labels, theme: 'gitlab' - .nothing-here-block{ class: ('hidden' if @labels.present?) } - = _('There are no labels yet') +.js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) } + %section.row.empty-state.gl-text-center + .col-12 + .svg-content + = image_tag 'illustrations/labels.svg' + .col-12 + .gl-mx-auto.gl-my-0.gl-p-5 + %h1.gl-font-size-h-display.gl-line-height-36.h4 + = s_('AdminLabels|Define your default set of project labels') + %p.gl-mb-0 + = s_('AdminLabels|Labels created here will be automatically added to new projects.') + %p + = s_('AdminLabels|They can be used to categorize issues and merge requests.') + .gl-display-flex.gl-flex-wrap.gl-justify-content-center + = link_to new_admin_label_path, class: "btn gl-mb-3 btn-confirm btn-md gl-button gl-mx-2" do + %span.gl-button-text + = _('New label') diff --git a/app/views/admin/projects/_archived.html.haml b/app/views/admin/projects/_archived.html.haml index 8b4d5806c47..3d79cc7ca71 100644 --- a/app/views/admin/projects/_archived.html.haml +++ b/app/views/admin/projects/_archived.html.haml @@ -1,3 +1,2 @@ - if project.archived - %span.badge.badge-warning - = _('archived') + = gl_badge_tag _('archived'), variant: :warning diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 6f7cea85ed1..f56b77813b5 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -9,8 +9,7 @@ = s_('AdminProjects|Delete') .stats - %span.badge.badge-pill - = storage_counter(project.statistics&.storage_size) + = gl_badge_tag storage_counter(project.statistics&.storage_size) = render_if_exists 'admin/projects/archived', project: project .title = link_to(admin_project_path(project)) do diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 3069aab2710..ee2e63353f0 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -178,7 +178,7 @@ .card-header %strong= @group.name = _('group members') - %span.badge.badge-pill= @group_members.size + = gl_badge_tag @group_members.size = render 'shared/members/manage_access_button', path: group_group_members_path(@group) %ul.content-list.members-list = render partial: 'shared/members/member', @@ -195,7 +195,7 @@ .card-header %strong= @project.name = _('project members') - %span.badge.badge-pill= @project.users.size + = gl_badge_tag @project.users.size = render 'shared/members/manage_access_button', path: project_project_members_path(@project) %ul.content-list.project_members.members-list = render partial: 'shared/members/member', diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 6a5f07dd2db..5977de7c84c 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -43,9 +43,8 @@ = s_('AdminUsers|External') %p.light.gl-pl-2 = s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') - %row.hidden#warning_external_automatically_set.hidden - .badge.badge-warning.text-white - = s_('AdminUsers|Automatically marked as default internal user') + %row.hidden#warning_external_automatically_set + = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning .form-group.row - @user.credit_card_validation || @user.build_credit_card_validation diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml deleted file mode 100644 index 05e387e6479..00000000000 --- a/app/views/admin/users/_user_detail.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -.flex-list - .flex-row - = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } - .row-main-content - .row-title.str-truncated-100 - = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 gl-mt-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } - = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' } - - = render 'admin/users/user_listing_note', user: user - - - user_badges_in_admin_section(user).each do |badge| - - css_badge = "badge gl-badge sm badge-pill badge-#{badge[:variant]}" if badge[:variant].present? - %span.px-1.py-1 - %span{ class: css_badge } - = badge[:text] - - .row-second-line.str-truncated-100 - = mail_to user.email, user.email, class: 'text-secondary' - - unless Feature.disabled?(:security_auto_fix) || !user.internal? || user.website_url.blank? - = link_to "(#{_('more information')})", user.website_url diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index 8c56e888dcc..b47ed38f65f 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -17,7 +17,7 @@ %span.light.vertical-align-middle= group_member.human_access - unless group_member.owner? = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), testid: 'remove-user' }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('remove', size: 16, css_class: 'gl-icon') .row .col-md-6 @@ -47,6 +47,6 @@ - if member.respond_to? :project = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('remove', size: 16, css_class: 'gl-icon') = render partial: 'admin/users/modals' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 2a9b4694e7b..bdc5bdabb21 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -35,7 +35,7 @@ %span.light= _('Email:') %strong = render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? } - - @user.emails.each do |email| + - @user.emails.reject(&:user_primary_email?).each do |email| %li %span.light= _('Secondary email:') %strong diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 81f4be9fce5..9d249931a34 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,5 +1,5 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } +.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } } .gl-alert-container %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') } = sprite_icon('close', size: 16, css_class: 'gl-icon') diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index e4c8f225ed2..a6efe597f0c 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -24,10 +24,9 @@ .js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } } - %h4.gl-my-5 + %h4.gl-my-5.gl-display-flex.gl-align-items-center = @cluster.name - %span.badge.badge-info.badge-pill.gl-badge.md.gl-vertical-align-middle - = cluster_type_label(@cluster.cluster_type) + = gl_badge_tag cluster_type_label(@cluster.cluster_type), { variant: :info }, { class: 'gl-ml-3' } = render 'banner' diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 9fb0fb734f9..892ef730884 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -1,6 +1,8 @@ - user_email = "(#{params[:email]})" if params[:email].present? - request_link_start = '<a href="%{new_user_confirmation_path}">'.html_safe % { new_user_confirmation_path: new_user_confirmation_path } - request_link_end = '</a>'.html_safe +- content_for :page_specific_javascripts do + = render "layouts/one_trust" .well-confirmation.gl-text-center.gl-mb-6 %h1.gl-mt-0 diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index da6232b2a2b..175b45dbbfa 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,8 +1,6 @@ - page_title _("Sign in") - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" = render "layouts/one_trust" -= render "layouts/google_tag_manager_body" #signin-container - if any_form_based_providers_enabled? diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml index 5683b4207b4..1b5a932a09a 100644 --- a/app/views/devise/shared/_tab_single.html.haml +++ b/app/views/devise/shared/_tab_single.html.haml @@ -1,3 +1,2 @@ -%ul.nav-links.new-session-tabs.single-tab.nav-tabs.nav - %li.nav-item - %a.nav-link.active= tab_title += gl_tabs_nav({ class: 'new-session-tabs gl-border-0' }) do + = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1' } diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index 8d6e043ebf7..0644910dd3e 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -6,9 +6,17 @@ .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5 %h4 = _('Import group from file') - %p - = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') - .form-group.gl-display-flex.gl-flex-direction-column + .gl-alert.gl-alert-warning{ role: 'alert' } + = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } + - link_end = '</a>'.html_safe + = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end } + - if Feature.enabled?(:bulk_import, default_enabled: :yaml) + - enable_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'enable-or-disable-gitlab-group-migration') } + = s_('GroupsNew|Ask your administrator to %{enable_link_start}enable%{enable_link_end} Group Migration.').html_safe % { enable_link_start: enable_link_start, enable_link_end: link_end } + + .form-group.gl-display-flex.gl-flex-direction-column.gl-mt-5 = f.label :name, _('New group name'), for: 'import_group_name' = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8', required: true, diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml index bccfa9897da..3046669b53b 100644 --- a/app/views/groups/_invite_members_side_nav_link.html.haml +++ b/app/views/groups/_invite_members_side_nav_link.html.haml @@ -1,5 +1,4 @@ .js-invite-members-trigger{ data: { trigger_source: 'group-side-nav', - classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', icon: 'users', display_text: title, trigger_element: 'side-nav'} } diff --git a/app/views/groups/_personalize.html.haml b/app/views/groups/_personalize.html.haml index 5ecb0017cd8..07b3b29c20c 100644 --- a/app/views/groups/_personalize.html.haml +++ b/app/views/groups/_personalize.html.haml @@ -15,7 +15,7 @@ = f.label :setup_for_company, _('Who will be using this group?') .gl-display-flex.gl-flex-direction-column.gl-lg-flex-direction-row .gl-flex-grow-1.gl-display-flex.gl-align-items-center - = f.radio_button :setup_for_company, true, checked: true + = f.radio_button :setup_for_company, true = f.label :setup_for_company, _('My company or team'), class: 'gl-font-weight-normal gl-mb-0 gl-ml-2', value: 'true' .gl-flex-grow-1.gl-display-flex.gl-align-items-center = f.radio_button :setup_for_company, false diff --git a/app/views/groups/_project_badges.html.haml b/app/views/groups/_project_badges.html.haml index 1f7895e216c..4ea193b08c2 100644 --- a/app/views/groups/_project_badges.html.haml +++ b/app/views/groups/_project_badges.html.haml @@ -1,2 +1,2 @@ - if project.archived - %span.badge.badge-warning.badge-pill.gl-badge.md= _('archived') + = gl_badge_tag _('archived'), variant: :warning, size: :md diff --git a/app/views/groups/crm/contacts.html.haml b/app/views/groups/crm/contacts.html.haml deleted file mode 100644 index c452a969d17..00000000000 --- a/app/views/groups/crm/contacts.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- breadcrumb_title _('Customer Relations Contacts') -- page_title _('Customer Relations Contacts') - -#js-crm-contacts-app{ data: { group_full_path: @group.full_path } } diff --git a/app/views/groups/crm/contacts/index.html.haml b/app/views/groups/crm/contacts/index.html.haml new file mode 100644 index 00000000000..81293937f77 --- /dev/null +++ b/app/views/groups/crm/contacts/index.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Customer Relations Contacts') +- page_title _('Customer Relations Contacts') + +#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } } diff --git a/app/views/groups/crm/organizations.html.haml b/app/views/groups/crm/organizations.html.haml deleted file mode 100644 index e83dab9fda6..00000000000 --- a/app/views/groups/crm/organizations.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- breadcrumb_title _('Customer Relations Organizations') -- page_title _('Customer Relations Organizations') - -#js-crm-organizations-app{ data: { group_full_path: @group.full_path } } diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml new file mode 100644 index 00000000000..1647805b976 --- /dev/null +++ b/app/views/groups/crm/organizations/index.html.haml @@ -0,0 +1,4 @@ +- breadcrumb_title _('Customer Relations Organizations') +- page_title _('Customer Relations Organizations') + +#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } } diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml index 7910217c939..d56a806f082 100644 --- a/app/views/groups/packages/index.html.haml +++ b/app/views/groups/packages/index.html.haml @@ -3,4 +3,8 @@ .row .col-12 - #js-vue-packages-list{ data: packages_list_data('groups', @group) } + #js-vue-packages-list{ data: { resource_id: @group.id, + full_path: @group.full_path, + endpoint: group_packages_path(@group), + page_type: 'groups', + empty_list_illustration: image_path('illustrations/no-packages.svg'), } } diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 9dbf60b119c..3507f4574ab 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -18,12 +18,11 @@ = render 'delete_project_button', project: project .stats - %span.badge.badge-pill - = storage_counter(project.statistics&.storage_size) + = gl_badge_tag storage_counter(project.statistics&.storage_size) = render 'project_badges', project: project .title - = link_to(project_path(project)) do + = link_to project_path(project), class: 'js-prefetch-document' do .dash-project-avatar .avatar-container.rect-avatar.s40 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 2901c8fa46b..f6d05959d2e 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -18,6 +18,6 @@ "gid_prefix": container_repository_gid_prefix, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s } } diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index 1cccce9f59a..e7cfc87ac88 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -10,7 +10,7 @@ = _('These runners are shared across projects in this group.') = _('Group runners can be managed with the %{link}.').html_safe % { link: link } - - if can?(current_user, :admin_pipeline, @group) && valid_runner_registrars.include?('group') + - if can?(current_user, :register_group_runners, @group) - if params[:ci_runner_templates] %hr = render partial: 'ci/runner/setup_runner_in_aws', diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index a82f7803b44..ebeec2ee95a 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -7,9 +7,8 @@ = form_errors(@group) .form-group %p - = s_('GroupSettings|Changing group URL can have unintended side effects.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank' + = s_("GroupSettings|Changing a group's URL can have unintended side effects.") + = link_to _('Learn more.'), help_page_path('user/group/index', anchor: 'change-a-groups-path'), target: '_blank', rel: 'noopener noreferrer' .input-group.gl-field-error-anchor .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index f818f45cf53..ff00ff1f6e8 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -4,8 +4,17 @@ .sub-section %h4= s_('GroupSettings|Export group') - %p= _('Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the "New Group" page.') - + %p= _('Export this group with all related data.') + .gl-alert.gl-alert-warning.gl-mb-4{ role: 'alert' } + = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } + - docs_link_end = '</a>'.html_safe + = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}Group Migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } + %p + - export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe} + = export_information.html_safe + = link_to _('Learn more.'), help_page_path('user/group/settings/import_export.md'), target: '_blank', rel: 'noopener noreferrer' .bs-callout.bs-callout-info %p.gl-mb-0 %p= _('The following items will be exported:') @@ -17,7 +26,6 @@ %li= _('Projects') %li= _('Runner tokens') %li= _('SAML discovery tokens') - %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.') - if group.export_file_exists? = link_to _('Regenerate export'), export_group_path(group), method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'regenerate_export_group_link' } diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index b2379d77314..59d52e99dec 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -1,18 +1,19 @@ .sub-section %h4.warning-title= s_('GroupSettings|Transfer group') + %p= _('Transfer group to another parent group.') = form_for group, url: transfer_group_path(group), method: :put, html: { class: 'js-group-transfer-form' } do |f| - .form-group - = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } }) - = hidden_field_tag 'new_parent_group_id' %ul - - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'.html_safe - - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end: '</a>'.html_safe } + - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank" rel="noopener noreferrer">'.html_safe + - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe } %li= warning_text.html_safe %li= s_('GroupSettings|You can only transfer the group to a group you manage.') %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") + .form-group + = dropdown_tag(s_('GroupSettings|Select parent group'), options: { toggle_class: 'js-groups-dropdown', title: s_('GroupSettings|Parent Group'), filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: s_('GroupSettings|Search groups'), disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } }) + = hidden_field_tag 'new_parent_group_id' - if group.paid? .gl-alert.gl-alert-info.gl-mb-5 = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index dedb87c51ef..32da444d058 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -8,7 +8,7 @@ = f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled? = f.label :auto_devops_enabled, class: 'form-check-label' do %strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group') - %span.badge.badge-info#auto-devops-badge= badge_for_auto_devops_scope(group) + = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info .form-text.text-muted = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank' diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index 7be6dc73c49..78ce981eb07 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -5,4 +5,5 @@ %section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.full_path, - dependency_proxy_available: dependency_proxy_available.to_s } } + dependency_proxy_available: dependency_proxy_available.to_s, + group_dependency_proxy_path: group_dependency_proxy_path(@group) } } diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index ed3f2b0c6db..bb409190dd8 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -15,7 +15,7 @@ track_label: 'invite_members_banner', invite_members_path: group_group_members_path(@group), callouts_path: group_callouts_path, - callouts_feature_id: UserCalloutsHelper::INVITE_MEMBERS_BANNER, + callouts_feature_id: Users::GroupCalloutsHelper::INVITE_MEMBERS_BANNER, group_id: @group.id } } = render 'groups/invite_members_modal', group: @group diff --git a/app/views/help/instance_configuration/_package_registry.html.haml b/app/views/help/instance_configuration/_package_registry.html.haml index 38202b8d6e6..84b8accfebb 100644 --- a/app/views/help/instance_configuration/_package_registry.html.haml +++ b/app/views/help/instance_configuration/_package_registry.html.haml @@ -23,6 +23,10 @@ - package_file_size_limits.each_value do |limits| %td= instance_configuration_human_size_cell(limits[:conan]) %tr + %td= 'Helm' + - package_file_size_limits.each_value do |limits| + %td= instance_configuration_human_size_cell(limits[:helm]) + %tr %td= 'Maven' - package_file_size_limits.each_value do |limits| %td= instance_configuration_human_size_cell(limits[:maven]) diff --git a/app/views/import/bitbucket/deploy_key.js.haml b/app/views/import/bitbucket/deploy_key.js.haml deleted file mode 100644 index 99e8ac1afa1..00000000000 --- a/app/views/import/bitbucket/deploy_key.js.haml +++ /dev/null @@ -1,3 +0,0 @@ -:plain - job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("<p class='alert alert-danger'>#{_('Access denied! Please verify you can add deploy keys to this repository.')}</p>") diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index be2be7288f8..d92c30c8840 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -1,13 +1,6 @@ %header.jira-connect-header.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-px-5.gl-border-b-solid.gl-border-b-gray-100.gl-border-b-1.gl-bg-white = link_to brand_header_logo, Gitlab.config.gitlab.url, target: '_blank', rel: 'noopener noreferrer' -.jira-connect-user.gl-font-base - - if current_user - - user_link = link_to(current_user.to_reference, jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in') - = _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link } - - elsif @subscriptions.present? - = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' - %main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index a302fa605e7..dded5ba76b0 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,9 +1,11 @@ --# We currently only support `alert`, `notice`, `success`, 'toast' +-# We currently only support `alert`, `notice`, `success`, 'toast', and 'raw' - icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'} .flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } } - flash.each do |key, value| - if key == 'toast' && value .js-toast-message{ data: { message: value } } + - elsif key == 'raw' && value + = value - elsif value == I18n.t('devise.failure.unconfirmed') = render 'shared/confirm_your_email_alert' - elsif value diff --git a/app/views/layouts/_google_tag_manager_body.html.haml b/app/views/layouts/_google_tag_manager_body.html.haml index d62e52dc91b..98d7bf5d138 100644 --- a/app/views/layouts/_google_tag_manager_body.html.haml +++ b/app/views/layouts/_google_tag_manager_body.html.haml @@ -1,4 +1,4 @@ - return unless google_tag_manager_enabled? -<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{extra_config.google_tag_manager_id}" +<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 index 48eb9e40cc4..25af51ca9cb 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -1,8 +1,19 @@ -- if google_tag_manager_enabled? +- return unless google_tag_manager_enabled? + +- 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','#{extra_config.google_tag_manager_id}'); + })(window,document,'script','dataLayer','#{google_tag_manager_id}'); diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index dff1b5e3d04..3e875a0eb24 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -3,6 +3,7 @@ = render "layouts/nav/sidebar/#{nav}" .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } .mobile-overlay + = render_if_exists 'layouts/header/verification_reminder' = yield :group_invite_members_banner .alert-wrapper.gl-force-block-formatting-context = render 'shared/outdated_browser' @@ -20,7 +21,6 @@ = render_if_exists "shared/namespace_user_cap_reached_alert" = render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :page_level_alert - = yield :customize_homepage_banner - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 7e242fb4a8e..8e9a5ea9406 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -1,5 +1,7 @@ - return unless Gitlab::Tracking.enabled? +- namespace = @group || @project&.namespace + = javascript_tag do :plain ;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[]; @@ -10,6 +12,6 @@ window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json} gl = window.gl || {}; - gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: @group || @project&.namespace, + gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(namespace: namespace, project: @project, user: current_user).to_context.to_json.to_json} - gl.snowplowPseudonymizedPageUrl = #{masked_page_url.to_json}; + gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json}; diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 6bb51b01c13..daa48980c5b 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -45,7 +45,7 @@ = render 'shared/user_dropdown_instance_review' - if Gitlab.com_but_not_canary? %li.d-md-none - = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" + = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url - if current_user_menu?(:sign_out) %li.divider diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 83e8ff79aec..69b8518ef33 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -15,7 +15,7 @@ %span.logo-text.d-none.d-lg-block.gl-ml-3 = logo_text - if Gitlab.com_and_canary? - = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: :_noopener do + = link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do %span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1 = _('Next') @@ -36,7 +36,9 @@ 'issues-path' => issues_dashboard_path, 'mr-path' => merge_requests_dashboard_path, 'autocomplete-path' => search_autocomplete_path } } - %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' } + .gl-search-box-by-type + = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') + %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input gl-search-box-by-type-input', id: 'search', autocomplete: 'off' } - else = render 'layouts/search' %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } @@ -44,7 +46,7 @@ = sprite_icon(search_menu_item.fetch(:icon)) - if header_link?(:issues) = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do - = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, + = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues js-prefetch-document', aria: { label: _('Issues') }, data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_issues_link', @@ -73,18 +75,18 @@ %li.dropdown-header = _('Merge requests') %li - = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center' do + = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do = _('Assigned to you') %span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-assigned-mr-count{ class: "" } = user_merge_requests_counts[:assigned] %li - = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center' do + = link_to reviewer_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do = _('Review requests for you') %span.badge.gl-badge.badge-pill.badge-muted.merge-request-badge.gl-ml-auto.js-reviewer-mr-count{ class: "" } = user_merge_requests_counts[:review_requested] - if header_link?(:todos) = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do - = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos', + = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document', data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_to_do_link', @@ -94,7 +96,7 @@ %span.badge.badge-pill.todos-count.js-todos-count{ class: ('hidden' if todos_pending_count == 0) } = todos_count_format(todos_pending_count) %li.nav-item.header-help.dropdown.d-none.d-md-block{ **tracking_attrs('main_navigation', 'click_question_mark_link', 'navigation') } - = link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do + = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown" } do %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question-o') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index a03cd4cd68f..e2c7781da54 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -20,4 +20,4 @@ = render 'shared/user_dropdown_instance_review' - if Gitlab.com_but_not_canary? %li - = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" + = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index 25a7f7ba9d7..90f3ac61614 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -4,7 +4,7 @@ title: _('Open registration is enabled on your instance.'), variant: :warning, alert_class: 'js-registration-enabled-callout', - alert_data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path }, + alert_data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path }, close_button_data: { testid: 'close-registration-enabled-callout' } do .gl-alert-body = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe } diff --git a/app/views/layouts/in_product_marketing_mailer.html.haml b/app/views/layouts/in_product_marketing_mailer.html.haml new file mode 100644 index 00000000000..679a2d4b8b3 --- /dev/null +++ b/app/views/layouts/in_product_marketing_mailer.html.haml @@ -0,0 +1,194 @@ +!!! +%html{ lang: "en" } + %head + %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } + %meta{ content: "width=device-width, initial-scale=1", name: "viewport" } + %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css", data: { premailer: 'ignore' } } + %title= message.subject + :css + /* CLIENT-SPECIFIC STYLES */ + body, + table, + td, + a { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + -ms-interpolation-mode: bicubic; + } + + /* RESET STYLES */ + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + } + + table { + border-collapse: collapse !important; + } + + body { + height: 100% !important; + margin: 0 !important; + padding: 0 !important; + width: 100% !important; + background-color: #ffffff; + color: #424242; + } + + a { + color: #6b4fbb; + text-decoration: underline; + } + + .cta_link a { + font-size: 24px; + font-family: 'Source Sans Pro', helvetica, arial, sans-serif; + text-decoration: none; + display: inline-block; + } + + .cta_link_primary a { + color: #ffffff; + border-radius: 5px; + background-color: #6e49cb; + border-top: 15px solid #6e49cb; + border-bottom: 15px solid #6e49cb; + border-right: 40px solid #6e49cb; + border-left: 40px solid #6e49cb; + } + + .cta_link_secondary a { + color: #6e49cb; + padding: 25px 40px 15px; + } + + .footernav { + display: inline !important; + } + + .footernav a { + color: #6e49cb; + } + + .address { + margin: 0; + font-size: 16px; + line-height: 26px; + } + + :css + /* iOS BLUE LINKS */ + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + /[if gte mso 9] + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG/> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + /[if (mso)|(mso 16)] + <style type="text/css"> + body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; } + </style> + :css + @media only screen and (max-width: 595px) { + + .wrapper { + width: 100% !important; + margin: 0 auto !important; + padding: 0 !important; + } + + p, + li { + font-size: 18px !important; + line-height: 26px !important; + } + + .stack { + width: 100% !important; + } + + .stack-mobile-padding { + width: 100% !important; + margin-top: 20px !important; + } + + .callout { + padding-bottom: 20px !important; + } + + .redbutton { + text-align: center; + } + + .stack33 { + display: block !important; + width: 100% !important; + max-width: 100% !important; + direction: ltr !important; + text-align: center !important; + } + } + + @media only screen and (max-width: 480px) { + u~div { + width: 100vw !important; + } + + div>u~div { + width: 100% !important; + } + } + %body#body{ width: "100%" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } + %tr + %td{ align: "center", style: "padding: 0px;" } + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" } + %tr + %td{ style: "padding: 0px;" } + #main-story.mktEditable{ mktoname: "main-story" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } + %tr + %td{ align: "left", style: "padding: 0 20px;" } + = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200) + %tr + %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } + + = yield + + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:75px 20px 25px;" } + = about_link('gitlab_logo.png', 80) + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:0px ;" } + %tr{ style: "background-color: #ffffff;" } + %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " } + %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + = @message.footer_links.join(' ' * 3 + '|' + ' ' * 4).html_safe + %tr{ style: "background-color:#ffffff;" } + %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + .address= @message.address + %tr{ style: "background-color: #ffffff;" } + %td{ align: "left", style: "padding:20px 30px 20px 30px;" } + %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" } + = @message.unsubscribe.html_safe diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index 2cef6f97d48..ae9c8554e73 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -34,4 +34,4 @@ email: true } %div{ style: note_style } - = markdown(note.note, pipeline: :email, author: note.author) + = markdown(note.note, pipeline: :email, author: note.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/account_validation_email.html.haml b/app/views/notify/account_validation_email.html.haml new file mode 100644 index 00000000000..02256443430 --- /dev/null +++ b/app/views/notify/account_validation_email.html.haml @@ -0,0 +1,16 @@ +%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 + - @message.body_line2&.tap do |line| + %p{ style: "margin: 0 0 20px 0;" } + = line.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/account_validation_email.text.erb b/app/views/notify/account_validation_email.text.erb new file mode 100644 index 00000000000..c167eff9803 --- /dev/null +++ b/app/views/notify/account_validation_email.text.erb @@ -0,0 +1,15 @@ +<%= @message.title %> + +<%= @message.body_line1 %> + +<%= @message.body_line2 %> + +<%= @message.cta_link %> + +<%= @message.cta2_link %> + +<%= @message.footer_links %> + +<%= @message.address %> + +<%= @message.unsubscribe %> diff --git a/app/views/notify/attention_requested_merge_request_email.html.haml b/app/views/notify/attention_requested_merge_request_email.html.haml new file mode 100644 index 00000000000..af42f180ae7 --- /dev/null +++ b/app/views/notify/attention_requested_merge_request_email.html.haml @@ -0,0 +1,2 @@ +%p + #{sanitize_name(@updated_by.name)} requested your attention on #{merge_request_reference_link(@merge_request)}. diff --git a/app/views/notify/attention_requested_merge_request_email.text.erb b/app/views/notify/attention_requested_merge_request_email.text.erb new file mode 100644 index 00000000000..97b1d4a824b --- /dev/null +++ b/app/views/notify/attention_requested_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= sanitize_name(@updated_by.name) %> requested your attention on <%= merge_request_reference_link(@merge_request) %>. diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml index a85fa7c519f..a88d581c5de 100644 --- a/app/views/notify/in_product_marketing_email.html.haml +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -1,235 +1,51 @@ -!!! -%html{ lang: "en" } - %head - %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } - %meta{ content: "width=device-width, initial-scale=1", name: "viewport" } - %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css", data: { premailer: 'ignore' } } - %title= message.subject - :css - /* CLIENT-SPECIFIC STYLES */ - body, - table, - td, - a { - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; - } - - table, - td { - mso-table-lspace: 0pt; - mso-table-rspace: 0pt; - } - - img { - -ms-interpolation-mode: bicubic; - } - - /* RESET STYLES */ - img { - border: 0; - height: auto; - line-height: 100%; - outline: none; - text-decoration: none; - } - - table { - border-collapse: collapse !important; - } - - body { - height: 100% !important; - margin: 0 !important; - padding: 0 !important; - width: 100% !important; - background-color: #ffffff; - color: #424242; - } - - a { - color: #6b4fbb; - text-decoration: underline; - } - - .cta_link a { - font-size: 24px; - font-family: 'Source Sans Pro', helvetica, arial, sans-serif; - color: #ffffff; - text-decoration: none; - border-radius: 5px; - -webkit-border-radius: 5px; - background-color: #6e49cb; - border-top: 15px solid #6e49cb; - border-bottom: 15px solid #6e49cb; - border-right: 40px solid #6e49cb; - border-left: 40px solid #6e49cb; - display: inline-block; - } - - .footernav { - display: inline !important; - } - - .footernav a { - color: #6e49cb; - } - - .address { - margin: 0; - font-size: 16px; - line-height: 26px; - } - - :css - /* iOS BLUE LINKS */ - a[x-apple-data-detectors] { - color: inherit !important; - text-decoration: none !important; - font-size: inherit !important; - font-family: inherit !important; - font-weight: inherit !important; - line-height: inherit !important; - } - /[if gte mso 9] - <xml> - <o:OfficeDocumentSettings> - <o:AllowPNG/> - <o:PixelsPerInch>96</o:PixelsPerInch> - </o:OfficeDocumentSettings> - </xml> - /[if (mso)|(mso 16)] - <style type="text/css"> - body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; } - </style> - :css - @media only screen and (max-width: 595px) { - - .wrapper { - width: 100% !important; - margin: 0 auto !important; - padding: 0 !important; - } - - p, - li { - font-size: 18px !important; - line-height: 26px !important; - } - - .stack { - width: 100% !important; - } - - .stack-mobile-padding { - width: 100% !important; - margin-top: 20px !important; - } - - .callout { - padding-bottom: 20px !important; - } - - .redbutton { - text-align: center; - } - - .stack33 { - display: block !important; - width: 100% !important; - max-width: 100% !important; - direction: ltr !important; - text-align: center !important; - } - } - - @media only screen and (max-width: 480px) { - u~div { - width: 100vw !important; - } - - div>u~div { - width: 100% !important; - } - } - %body#body{ width: "100%" } - %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } - %tr - %td{ align: "center", style: "padding: 0px;" } - %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" } - %tr - %td{ style: "padding: 0px;" } - #main-story.mktEditable{ mktoname: "main-story" } - %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } - %tr - %td{ align: "left", style: "padding: 0 20px;" } - = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200) - %tr - %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } - - if @message.series? - %tr{ style: "background-color: #ffffff;" } - %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } - %p - = @message.progress.html_safe - %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 - %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" } - = @message.subtitle - %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 - - @message.body_line2&.tap do |line| - %p{ style: "margin: 0 0 20px 0;" } - = line.html_safe - - if @message.cta_text - %tr - %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .cta_link= @message.cta_link - - else - %tr - %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" } - %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" } - %tr - %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" } - = @message.feedback_ratings(1) - %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" } - = @message.feedback_ratings(5) - %tr - %td{ align: "center", style: "padding: 10px 1px 30px 1px;" } - %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" } - %tr - - (1..5).each do |rating| - %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" } - %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" } - %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" } - = rating - %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 50px 0;" } - = @message.feedback_thanks - - if @message.invite_members? - %tr - %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - = @message.invite_text - %br - = @message.invite_link - %tr{ style: "background-color: #ffffff;" } - %td{ align: "center", style: "padding:75px 20px 25px;" } - = about_link('gitlab_logo.png', 80) - %tr{ style: "background-color: #ffffff;" } - %td{ align: "center", style: "padding:0px ;" } - %tr{ style: "background-color: #ffffff;" } - %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " } - %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - = @message.footer_links.join(' ' * 3 + '|' + ' ' * 4).html_safe - %tr{ style: "background-color:#ffffff;" } - %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .address= @message.address - %tr{ style: "background-color: #ffffff;" } - %td{ align: "left", style: "padding:20px 30px 20px 30px;" } - %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" } - = @message.unsubscribe.html_safe +- if @message.series? + %tr{ style: "background-color: #ffffff;" } + %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } + %p + = @message.progress.html_safe +%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 + %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" } + = @message.subtitle +%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 + - @message.body_line2&.tap do |line| + %p{ style: "margin: 0 0 20px 0;" } + = line.html_safe +- if @message.cta_text + %tr + %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + .cta_link.cta_link_primary= @message.cta_link +- else + %tr + %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" } + %tr + %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" } + = @message.feedback_ratings(1) + %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" } + = @message.feedback_ratings(5) + %tr + %td{ align: "center", style: "padding: 10px 1px 30px 1px;" } + %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" } + %tr + - (1..5).each do |rating| + %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" } + %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" } + %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" } + = rating + %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 50px 0;" } + = @message.feedback_thanks +- if @message.invite_members? + %tr + %td{ align: "center", style: "padding: 0 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + = @message.invite_text + %br + = @message.invite_link diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml index adb9da05694..c9cd9c32b54 100644 --- a/app/views/notify/issue_due_email.html.haml +++ b/app/views/notify/issue_due_email.html.haml @@ -9,4 +9,4 @@ - if @issue.description %div - = markdown(@issue.description, pipeline: :email, author: @issue.author) + = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 1d1f696e1b2..6d5207510da 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -6,17 +6,15 @@ role: member.human_access.downcase } - join_text = s_('InviteEmail|Join now') - inviter_name = member.created_by.name if member.created_by +- join_url = invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE) -- experiment(:invite_email_preview_text, actor: member) do |experiment_instance| - - experiment_instance.use {} - - experiment_instance.candidate do - = content_for :preview_text do - %div{ style: "display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;" } - - if member.created_by - = s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] } - - else - = s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] } - = gmail_goto_action(join_text, invited_join_url(@token, member)) += content_for :preview_text do + %div{ style: "display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;" } + - if member.created_by + = s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] } + - else + = s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] } + = gmail_goto_action(join_text, join_url) %tr %td.text-content{ colspan: 2 } @@ -32,7 +30,7 @@ - else = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders %p.invite-actions - = link_to join_text, invited_join_url(@token, member), class: 'invite-btn-join' + = link_to join_text, join_url, class: 'invite-btn-join' %tr.border-top %td.text-content.mailer-align-left.half-width %h4 diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index 3219ee34736..439604a950a 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -8,4 +8,4 @@ - if @issue.description %div - = markdown(@issue.description, pipeline: :email, author: @issue.author) + = markdown(@issue.description, pipeline: :email, author: @issue.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index c8a0a6591a6..54fb6573c26 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -16,4 +16,4 @@ - if @merge_request.description %div - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author, current_user: @recipient, issuable_reference_expansion_enabled: true) diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml index 9cef4cd85cd..1cd3a2340c6 100644 --- a/app/views/notify/new_release_email.html.haml +++ b/app/views/notify/new_release_email.html.haml @@ -15,4 +15,4 @@ %p %h4= _("Release notes:") - = markdown(@release.description, pipeline: :email, author: @release.author) + = markdown(@release.description, pipeline: :email, author: @release.author, current_user: @recipient) diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml index 824b4ab712e..186bdf133e3 100644 --- a/app/views/notify/service_desk_new_note_email.html.haml +++ b/app/views/notify/service_desk_new_note_email.html.haml @@ -2,4 +2,4 @@ %div = _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) } %div - = markdown(@note.note, pipeline: :email, author: @note.author) + = markdown(@note.note, pipeline: :email, author: @note.author, issuable_reference_expansion_enabled: true) diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index 5c0044ed825..73a437a0702 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -6,11 +6,13 @@ - providers.each do |provider| - unlink_allowed = unlink_provider_allowed?(provider) - link_allowed = link_provider_allowed?(provider) + - has_icon = provider_has_icon?(provider) - if unlink_allowed || link_allowed - if auth_active?(provider) - if unlink_allowed = link_to unlink_profile_account_path(provider: provider), method: :delete, class: button_class do - .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + - if has_icon + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) .gl-button-text = s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) } - else @@ -19,7 +21,8 @@ = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) } - elsif link_allowed = link_to omniauth_authorize_path(:user, provider), method: :post, class: button_class do - .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) + - if has_icon + .social-provider-btn-image.gl-button-icon= provider_image_tag(provider) .gl-button-text = s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) } = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 74b48115d0e..2b3109225a8 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -1,3 +1,4 @@ +- max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled? %div = form_for [:profile, @key], html: { class: 'js-requires-input' } do |f| = form_errors(@key) @@ -13,8 +14,8 @@ %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publicly visible.') .col.form-group - = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' - = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' } + = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold' + = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, max: max_date, data: { qa_selector: 'key_expiry_date_field' } %p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description .js-add-ssh-key-validation-warning.hide diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index 82083af9ff1..23fce8e04b6 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -13,5 +13,5 @@ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } } .table-section.section-30 - = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f| + = form_for setting, url: profile_group_notifications_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f| = f.select :notification_email, @user.public_verified_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 7c1f28345fd..a8275576327 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -32,62 +32,64 @@ type_plural: type_plural, active_tokens: @active_personal_access_tokens, revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } +- if Feature.enabled?(:hide_access_tokens, default_enabled: :yaml) + #js-tokens-app{ data: { tokens_data: tokens_app_data } } +- else + - unless Gitlab::CurrentSettings.disable_feed_token + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_('AccessTokens|Feed token') + %p + = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8.feed-token-reset + = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' + = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true + %p.form-text.text-muted + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } + = reset_message.html_safe -- unless Gitlab::CurrentSettings.disable_feed_token - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Feed token') - %p - = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.feed-token-reset - = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' - = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe + - if incoming_email_token_enabled? + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_('AccessTokens|Incoming email token') + %p + = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8.incoming-email-token-reset + = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' + = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true + %p.form-text.text-muted + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } + = reset_message.html_safe -- if incoming_email_token_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Incoming email token') - %p - = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.incoming-email-token-reset - = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' - = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe - -- if static_objects_external_storage_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4 - %h4.gl-mt-0 - = s_('AccessTokens|Static object token') - %p - = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8 - = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" - = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()' - %p.form-text.text-muted - - reset_link = url_for [:reset, :static_object_token, :profile] - - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } - - reset_link_end = '</a>'.html_safe - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } - = reset_message.html_safe + - if static_objects_external_storage_enabled? + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4 + %h4.gl-mt-0 + = s_('AccessTokens|Static object token') + %p + = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8 + = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" + = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()' + %p.form-text.text-muted + - reset_link = url_for [:reset, :static_object_token, :profile] + - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } + - reset_link_end = '</a>'.html_safe + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } + = reset_message.html_safe diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index b1470520eea..f3993ad8c33 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -116,7 +116,7 @@ %h5= _('Private profile') .checkbox-icon-inline-wrapper - private_profile_label = capture do - = s_("Profiles|Don't display activity-related personal information on your profiles") + = s_("Profiles|Don't display activity-related personal information on your profile") = f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0' = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') %h5= s_("Profiles|Private contributions") diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 0eae3c95bf6..aae6212f964 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -2,7 +2,7 @@ - page_title _('Two-Factor Authentication'), _('Account') - add_to_breadcrumbs _('Account'), profile_account_path - @content_class = "limit-container-width" unless fluid_layout -- webauthn_enabled = Feature.enabled?(:webauthn) +- webauthn_enabled = Feature.enabled?(:webauthn, default_enabled: :yaml) .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } .row.gl-mt-3 diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index cdcc98552f9..2f4a61865f8 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -10,10 +10,11 @@ .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch = render 'projects/tree/tree_header', tree: @tree - #js-last-commit - .info-well.gl-display-none.gl-sm-display-flex.project-last-commit + .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column + #js-last-commit.gl-m-auto .gl-spinner-container.m-auto = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') + #js-code-owners - if is_project_overview .project-buttons.gl-mb-3.js-show-on-project-root diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml index ea6174d19f0..fae681b1a71 100644 --- a/app/views/projects/_invite_members_side_nav_link.html.haml +++ b/app/views/projects/_invite_members_side_nav_link.html.haml @@ -1,5 +1,4 @@ .js-invite-members-trigger{ data: { trigger_source: 'project-side-nav', - classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!', icon: 'users', display_text: title, trigger_element: 'side-nav'} } diff --git a/app/views/projects/_merge_request_merge_commit_template.html.haml b/app/views/projects/_merge_request_merge_commit_template.html.haml index 185b730e0bb..869d2d5d9ec 100644 --- a/app/views/projects/_merge_request_merge_commit_template.html.haml +++ b/app/views/projects/_merge_request_merge_commit_template.html.haml @@ -3,7 +3,7 @@ .form-group %b= s_('ProjectSettings|Merge commit message template') %p.text-secondary - - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md', anchor: 'merge-commit-message-template') + - configure_the_merge_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md') - configure_the_merge_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_merge_commit_message_help_link_url } = s_('ProjectSettings|The commit message used when merging, if the merge method creates a merge commit. %{link_start}Learn more about syntax and variables.%{link_end}').html_safe % { link_start: configure_the_merge_commit_message_help_link_start, link_end: '</a>'.html_safe } .mb-2 @@ -12,6 +12,6 @@ %p.form-text.text-muted = s_('ProjectSettings|Maximum 500 characters.') = s_('ProjectSettings|Supported variables:') - - Gitlab::MergeRequests::MergeCommitMessage::PLACEHOLDERS.keys.each do |placeholder| + - Gitlab::MergeRequests::CommitMessageGenerator::PLACEHOLDERS.keys.each do |placeholder| %code = "%{#{placeholder}}".html_safe diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index c5a25bec6eb..728ff597860 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -12,5 +12,7 @@ = render 'projects/merge_request_merge_commit_template', project: @project, form: form += render 'projects/merge_request_squash_commit_template', project: @project, form: form + - if @project.forked? = render 'projects/merge_request_target_project_settings', project: @project, form: form diff --git a/app/views/projects/_merge_request_squash_commit_template.html.haml b/app/views/projects/_merge_request_squash_commit_template.html.haml new file mode 100644 index 00000000000..81e4bbed166 --- /dev/null +++ b/app/views/projects/_merge_request_squash_commit_template.html.haml @@ -0,0 +1,16 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Squash commit message template') + %p.text-secondary + - configure_the_squash_commit_message_help_link_url = help_page_path('user/project/merge_requests/commit_templates.md') + - configure_the_squash_commit_message_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_squash_commit_message_help_link_url } + = s_('ProjectSettings|The commit message used when squashing commits. %{link_start}Learn more about syntax and variables.%{link_end}').html_safe % { link_start: configure_the_squash_commit_message_help_link_start, link_end: '</a>'.html_safe } + .mb-2 + = form.text_area :squash_commit_template, class: 'form-control gl-form-input', rows: 8, maxlength: 500, placeholder: '%{title}' + %p.form-text.text-muted + = s_('ProjectSettings|Maximum 500 characters.') + = s_('ProjectSettings|Supported variables:') + - Gitlab::MergeRequests::CommitMessageGenerator::PLACEHOLDERS.keys.each do |placeholder| + %code + = "%{#{placeholder}}".html_safe diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index c21240b340c..6fc78003df4 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -49,7 +49,7 @@ .gl-alert.gl-alert-success.gl-mb-4.gl-display-none.js-user-readme-repo = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'user-profile-readme') } + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') } = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe } .form-group @@ -74,7 +74,7 @@ = s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.') - experiment(:new_project_sast_enabled, user: current_user) do |e| - - e.try do + - e.try(:candidate) do .form-group .form-check.gl-mb-3 = check_box_tag 'project[initialize_with_sast]', '1', true, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } @@ -83,6 +83,15 @@ .form-text.text-muted = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } + - e.try(:unchecked_candidate) do + .form-group + .form-check.gl-mb-3 + = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do + = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') + .form-text.text-muted + = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') + = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } - e.try(:free_indicator) do .form-group .form-check.gl-mb-3 @@ -93,6 +102,16 @@ .form-text.text-muted = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } + - e.try(:unchecked_free_indicator) do + .form-group + .form-check.gl-mb-3 + = check_box_tag 'project[initialize_with_sast]', '1', false, class: 'form-check-input', data: { qa_selector: 'initialize_with_sast_checkbox', track_experiment: e.name, track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } + = label_tag 'project[initialize_with_sast]', class: 'form-check-label' do + = s_('ProjectsNew|Enable Static Application Security Testing (SAST)') + %span.badge.badge-info.badge-pill.gl-badge.sm= _('Free') + .form-text.text-muted + = s_('ProjectsNew|Analyze your source code for known security vulnerabilities.') + = link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed', track_experiment: e.name } = f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" } = link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml index 949397755ba..68489fba06c 100644 --- a/app/views/projects/_project_templates.html.haml +++ b/app/views/projects/_project_templates.html.haml @@ -5,7 +5,7 @@ %li.built-in-tab %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} } = _('Built-in') - %span.badge.badge-pill= Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count + = gl_tab_counter_badge Gitlab::ProjectTemplate.all.count + Gitlab::SampleDataTemplate.all.count .tab-content .project-templates-buttons.import-buttons.tab-pane.active#built-in diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml index 92eb29dc407..bb51aa86170 100644 --- a/app/views/projects/_remove_fork.html.haml +++ b/app/views/projects/_remove_fork.html.haml @@ -1,11 +1,12 @@ - return unless @project.forked? && can?(current_user, :remove_fork_project, @project) +- remove_form_id = "js-remove-project-fork-form" .sub-section %h4.danger-title= _('Remove fork relationship') %p= remove_fork_project_description_message(@project) - = form_for @project, url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' } do |f| + = form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f| %p %strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.') = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer' - = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-legacy-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } + .js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) } diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index e48008e1cc6..9f9daa7ec6f 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -1,8 +1,11 @@ - return unless can?(current_user, :change_namespace, @project) +- form_id = "transfer-project-form" +- hidden_input_id = "new_namespace_id" +- initial_data = { namespaces: namespaces_as_json, 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 } .sub-section %h4.danger-title= _('Transfer project') - = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f| + = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f| .form-group - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') } %p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } @@ -11,7 +14,6 @@ %li= _('You can only transfer the project to namespaces you manage.') %li= _('You will need to update your local repositories to point to the new location.') %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') + = hidden_field_tag(hidden_input_id) = label_tag :new_namespace_id, _('Select a new namespace'), class: 'gl-font-weight-bold' - .form-group - = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2' - = f.submit 'Transfer project', class: "gl-button btn btn-danger js-legacy-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } + .js-transfer-project-form{ data: initial_data } diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 704576619a7..ae8f89bf16a 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -27,7 +27,7 @@ - commit_data = @blame.commit_data(blame_group[:commit]) - line_count = blame_group[:lines].count - %tr + %tr{ style: intrinsic_row_css(line_count) } %td.blame-commit{ class: commit_data.age_map_class } .commit = commit_data.author_avatar diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml index 7afbd85cd6d..0031be36098 100644 --- a/app/views/projects/blob/_content.html.haml +++ b/app/views/projects/blob/_content.html.haml @@ -1,3 +1,4 @@ +- blob = local_assigns.fetch(:blob, nil) - simple_viewer = blob.simple_viewer - rich_viewer = blob.rich_viewer - rich_viewer_active = rich_viewer && params[:viewer] != 'simple' diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index dad4ea205b4..74df53a8d15 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,4 +1,5 @@ - blame = local_assigns.fetch(:blame, false) +- blob = local_assigns.fetch(:blob, nil) .js-file-title.file-title-flex-parent = render 'projects/blob/header_content', blob: blob diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 168b240c657..d4e7ee90a84 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -14,8 +14,8 @@ - if can_modify_blob?(@blob) = render 'projects/blob/remove' - - title = "Replace #{@blob.name}" - = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put + - title = _("Replace %{blob_name}") % { blob_name: @blob.name } + = render 'projects/blob/upload', title: title, placeholder: title, button_title: _('Replace file'), form_path: project_update_blob_path(@project, @id), method: :put = render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration? = render 'shared/web_ide_path' diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 6733db69c34..1c543d47ecf 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -42,7 +42,7 @@ %ul.content-list.all-branches - @branches.each do |branch| = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? - - if Feature.enabled?(:branches_pagination_without_count, @project, default_enabled: :yaml) + - if Feature.enabled?(:branch_list_keyset_pagination, @project, default_enabled: :yaml) = render('kaminari/gitlab/without_count', previous_path: @prev_path, next_path: @next_path) - else = paginate @branches, theme: 'gitlab' diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml deleted file mode 100644 index 12ce4667e1a..00000000000 --- a/app/views/projects/buttons/_dropdown.html.haml +++ /dev/null @@ -1,42 +0,0 @@ -- can_create_issue = show_new_issue_link?(@project) -- can_create_project_snippet = can?(current_user, :create_snippet, @project) -- can_push_code = can?(current_user, :push_code, @project) -- create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) -- merge_project = merge_request_source_project_for_project(@project) - -- show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project - -- if show_menu - .project-action-button.dropdown.inline< - %a.btn.btn-default.gl-button.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' } - = sprite_icon('plus', css_class: 'gl-icon') - = sprite_icon("chevron-down", css_class: 'gl-icon') - %ul.dropdown-menu.dropdown-menu-right.project-home-dropdown - - if can_create_issue || merge_project || can_create_project_snippet - %li.dropdown-header= _('This project') - - - if can_create_issue - %li= link_to _('New issue'), new_project_issue_path(@project) - - - if merge_project - %li= link_to _('New merge request'), project_new_merge_request_path(merge_project) - - - if can_create_project_snippet - %li= link_to _('New snippet'), new_project_snippet_path(@project) - - - if can_push_code - %li.dropdown-header= _('This repository') - - - if can_push_code - %li.qa-new-file-option= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main) - - unless @project.empty_repo? - %li= link_to _('New branch'), new_project_branch_path(@project) - %li= link_to _('New tag'), new_project_tag_path(@project) - - elsif can_collaborate_with_project?(@project) - %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main) - - elsif create_mr_from_new_fork - - continue_params = { to: project_new_blob_path(@project, @project.default_branch_or_main), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) - %li= link_to _('New file'), fork_path, method: :post diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index e4ec2e44298..3cec7fd9eb8 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -1,16 +1,18 @@ - unless @project.empty_repo? - - if current_user && can?(current_user, :fork_project, @project) + - if current_user .count-badge.btn-group - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default btn-sm has-tooltip fork-btn' do = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - else - - can_create_fork = current_user.can?(:create_fork) - - disabled_fork_tooltip = s_('ProjectOverview|You have reached your project limit') - %span.btn-group.has-tooltip{ title: (disabled_fork_tooltip unless can_create_fork) } - = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{' disabled' unless can_create_fork }", 'aria-label' => (disabled_fork_tooltip unless can_create_fork) do + - disabled_tooltip = fork_button_disabled_tooltip(@project) + - count_class = 'disabled' unless can?(current_user, :download_code, @project) + - button_class = 'disabled' if disabled_tooltip + + %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip } + = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{button_class}" do = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'gl-button btn btn-default btn-sm count has-tooltip' do + = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default btn-sm count has-tooltip #{count_class}" do = @project.forks_count diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 437529c3608..c33b9b538f3 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -50,16 +50,15 @@ .label-container - if job.tags.any? - job.tags.each do |tag| - %span.badge.badge-pill.gl-badge.sm.badge-primary - = tag + = gl_badge_tag tag, variant: :info, size: :sm - if job.try(:trigger_request) - %span.badge.badge-pill.gl-badge.sm.badge-info= _('triggered') + = gl_badge_tag _('triggered'), variant: :info, size: :sm - if job.try(:allow_failure) && !job.success? - %span.badge.badge-pill.gl-badge.sm.badge-warning= _('allowed to fail') + = gl_badge_tag _('allowed to fail'), variant: :warning, size: :sm - if job.schedulable? - %span.badge.badge-pill.gl-badge.sm.badge-info= s_('DelayedJobs|delayed') + = gl_badge_tag s_('DelayedJobs|delayed'), variant: :info, size: :sm - elsif job.action? - %span.badge.badge-pill.gl-badge.sm.badge-info= _('manual') + = gl_badge_tag _('manual'), variant: :info, size: :sm - if pipeline_link %td diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index cd49dd899a0..a46421120cd 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -1,5 +1,6 @@ - page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits') - add_page_specific_style 'page_bundles/pipelines' +- add_page_specific_style 'page_bundles/ci_status' = render 'commit_box' = render 'ci_menu' diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 426d022da26..8ca41941e07 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -8,7 +8,7 @@ %code.ref-name= @project.default_branch_or_main - example_sha = capture do %code.ref-name 4eedf23 - = html_escape(_("Choose a branch/tag (e.g. %{branch}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.")) % { branch: example_branch.html_safe, sha: example_sha.html_safe } + = html_escape(_("To see what's changed or create a merge request, choose a branch or tag (like %{branch}), or enter a commit (like %{sha}).")) % { branch: example_branch.html_safe, sha: example_sha.html_safe } %br = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index 68ca318e88c..e5f911d6f8b 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -26,6 +26,6 @@ %strong= _("Auto-close referenced issues on default branch") .form-text.text-muted = _("When merge requests and commits in the default branch close, any issues they reference also close.") - = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank' + = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'closing-issues-automatically'), target: '_blank' = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 590fcdb0234..718f129cba8 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,2 +1,3 @@ +- diff_file = local_assigns.fetch(:diff_file, nil) .diff-content = render 'projects/diffs/viewer', viewer: diff_file.viewer diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5a7830e306a..bf946b0ce73 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -1,4 +1,4 @@ -- too_big = diff_file.diff_lines.count > Commit.diff_safe_lines(project: @project) +- too_big = diff_file.diff_lines.count > Commit.diff_safe_max_lines - if too_big .suppressed-container %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.") diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index 097475d2928..9fef9864475 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -6,8 +6,8 @@ #js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json), - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scope-environments-with-specs'), diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index ba4e40a8675..d6f421e8ad6 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -1,40 +1,36 @@ +- sort_value = @sort || sort_value_recently_created +- sort_title = forks_sort_options_hash[sort_value] + .top-area .nav-text - full_count_title = "#{@public_forks_count} public, #{@internal_forks_count} internal, and #{@private_forks_count} private" #{pluralize(@total_forks_count, 'fork')}: #{full_count_title} - .nav-controls - = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short', + .gl-display-flex.gl-sm-flex-direction-column.gl-md-align-items-center + = form_tag request.original_url, method: :get, class: 'project-filter-form gl-display-flex gl-mt-3 gl-md-mt-0', id: 'project-filter-form' do |f| + = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short gl-flex-grow-1', spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } - .dropdown - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.light= _("sort:") - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - %ul.dropdown-menu.dropdown-menu-right - %li - - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] - = link_to page_filter_path(sort: sort_value_recently_created, without: excluded_filters) do - = sort_title_recently_created - = link_to page_filter_path(sort: sort_value_oldest_created, without: excluded_filters) do - = sort_title_oldest_created - = link_to page_filter_path(sort: sort_value_recently_updated, without: excluded_filters) do - = sort_title_recently_updated - = link_to page_filter_path(sort: sort_value_oldest_updated, without: excluded_filters) do - = sort_title_oldest_updated + .dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0 + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'gl-button btn btn-default' } + = sort_title + = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_recently_created, without: excluded_filters), sort_title) + = sortable_item(sort_title_latest_activity, page_filter_path(sort: sort_value_latest_activity, without: excluded_filters), sort_title) + = forks_sort_direction_button(sort_value) - if current_user && can?(current_user, :fork_project, @project) - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm' do + = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm gl-md-ml-3' do = sprite_icon('fork', size: 12) %span= _('Fork') - else - = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm' do + = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm gl-md-ml-3 gl-mt-3 gl-md-mt-0' do = sprite_icon('fork', size: 12) %span= _('Fork') diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 2627552058b..9e3d9b4258a 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -9,6 +9,9 @@ %td.status = render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user) + %td + = generic_commit_status.name + %td.generic-commit-status-link - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url = link_to generic_commit_status.target_url do @@ -66,9 +69,6 @@ = generic_commit_status.stage %td - = generic_commit_status.name - - %td - if generic_commit_status.duration %p.duration = custom_icon("icon_timer") diff --git a/app/views/projects/google_cloud/errors/gcp_error.html.haml b/app/views/projects/google_cloud/errors/gcp_error.html.haml new file mode 100644 index 00000000000..69e481501d5 --- /dev/null +++ b/app/views/projects/google_cloud/errors/gcp_error.html.haml @@ -0,0 +1,6 @@ +- breadcrumb_title _('Google Cloud') +- page_title _('Google Cloud') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml new file mode 100644 index 00000000000..69e481501d5 --- /dev/null +++ b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml @@ -0,0 +1,6 @@ +- breadcrumb_title _('Google Cloud') +- page_title _('Google Cloud') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml new file mode 100644 index 00000000000..9b82bc0acb5 --- /dev/null +++ b/app/views/projects/google_cloud/service_accounts/index.html.haml @@ -0,0 +1,8 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title _('Service Account') +- page_title _('Service Account') + +- @content_class = "limit-container-width" unless fluid_layout + += form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do + #js-google-cloud{ data: @js_data } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index e8ea4ad90dc..7d696a988d4 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -1,7 +1,9 @@ - @content_class = 'limit-container-width' unless fluid_layout -- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path +- add_to_breadcrumbs _('Webhook Settings'), project_hooks_path(@project) - page_title _('Webhook') += render 'shared/web_hooks/hook_errors', hook: @hook + .row.gl-mt-3 .col-lg-3 = render 'shared/web_hooks/title_and_docs', hook: @hook diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml new file mode 100644 index 00000000000..92b9e03d5bd --- /dev/null +++ b/app/views/projects/integrations/shimos/show.html.haml @@ -0,0 +1,10 @@ +- breadcrumb_title s_('Shimo|Shimo Workspace') +- page_title s_('Shimo|Shimo Workspace') +- add_page_specific_style 'page_bundles/wiki' += render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do + %h4 + = s_('Shimo|Shimo Workspace integration is enabled') + %p + = s_("Shimo|You've enabled the Shimo Workspace integration. You can view your wiki directly in Shimo.") + = link_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm', title: s_('Shimo|Go to Shimo Workspace') do + = s_('Shimo|Go to Shimo Workspace') diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 2de2c2cba6c..2dc21685057 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,7 +13,7 @@ %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) = hidden_issue_icon(issue) - = link_to issue.title, issue_path(issue) + = link_to issue.title, issue_path(issue), class: 'js-prefetch-document' = render_if_exists 'projects/issues/subepic_flag', issue: issue - if issue.tasks? %span.task-status.d-none.d-sm-inline-block @@ -25,10 +25,10 @@ #{issuable_reference(issue)} %span.issuable-authored.d-none.d-sm-inline-block · - created #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by - if issue.service_desk_reply_to - #{issue.service_desk_reply_to} via - #{link_to_member(@project, issue.author, avatar: false)} + #{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }} + - else + #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), user: link_to_member(@project, issue.author, avatar: false) }} = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author - if issue.milestone %span.issuable-milestone.d-none.d-sm-inline-block diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 07fec195899..1cf0551535b 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,7 +1,7 @@ - if can?(current_user, :push_code, @project) - can_create_merge_request = can?(current_user, :create_merge_request_in, @project) - data_action = can_create_merge_request ? 'create-mr' : 'create-branch' - - value = can_create_merge_request ? 'Create merge request' : 'Create branch' + - value = can_create_merge_request ? _('Create merge request') : _('Create branch') - value = can_create_confidential_merge_request? ? _('Create confidential merge request') : value - create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index c47257eec4a..310a0c1a61e 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,7 +1,7 @@ - if @related_branches.any? %h2.gl-font-lg = pluralize(@related_branches.size, 'Related Branch') - %ul.unstyled-list.related-merge-requests + %ul.related-merge-requests.gl-pl-0 - @related_branches.each do |branch| %li.gl-display-flex.gl-align-items-center - if branch[:pipeline_status].present? diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 53c2052bfab..10c48177ae4 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -30,7 +30,7 @@ = render 'issues' - if new_issue_email .gl-text-center.gl-pt-5.gl-pb-7 - .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } + .js-issuable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } - else - new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project) = render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 44336b95e0f..7af825b2819 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -7,4 +7,7 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -#js-job-vue-app{ data: jobs_data } +- if @build.is_a? ::Ci::Build + #js-job-page{ data: jobs_data } +- else + #js-bridge-page{ data: bridge_data(@build) } diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml index 9b17be99da0..6bca145dc18 100644 --- a/app/views/projects/learn_gitlab/index.html.haml +++ b/app/views/projects/learn_gitlab/index.html.haml @@ -4,9 +4,10 @@ - data = learn_gitlab_data(@project) - invite_members_open = session.delete(:confetti_post_signup) += render 'projects/invite_members_modal', project: @project + - experiment(:confetti_post_signup, actor: current_user) do |e| - e.control do #js-learn-gitlab-app{ data: data } - e.candidate do - = render 'projects/invite_members_modal', project: @project #js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 3e2c5f088f7..98d2928fc97 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -9,7 +9,7 @@ .issuable-main-info .merge-request-title.title %span.merge-request-title-text.js-onboarding-mr-item - = link_to merge_request.title, merge_request_path(merge_request) + = link_to merge_request.title, merge_request_path(merge_request), class: 'js-prefetch-document' - if merge_request.tasks? %span.task-status.d-none.d-sm-inline-block @@ -20,8 +20,7 @@ #{issuable_reference(merge_request)} %span.issuable-authored.d-none.d-sm-inline-block · - created #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} - by #{link_to_member(@project, merge_request.author, avatar: false)} + #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false) }} = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author - if merge_request.milestone %span.issuable-milestone.d-none.d-sm-inline-block diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 0e8de3c2bb8..9d5d1de1005 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -4,7 +4,7 @@ - are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false) - cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden] -= cache_if(Feature.enabled?(:cached_mr_title, @project, default_enabled: :yaml), cache_key, expires_in: 1.day) do += cache(cache_key, expires_in: 1.day) do - if @merge_request.closed_or_merged_without_fork? .gl-alert.gl-alert-danger.gl-mb-5 .gl-alert-container diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index eb5d052ec19..0036f1b4bde 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -26,16 +26,16 @@ %li.commits-tab.new-tab = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do Commits - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @total_commit_count + = gl_badge_tag @total_commit_count, { size: :sm }, { class: 'gl-tab-counter-badge' } - if @pipelines.any? %li.builds-tab = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do Pipelines - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @pipelines.size + = gl_badge_tag @pipelines.size, { size: :sm }, { class: 'gl-tab-counter-badge' } %li.diffs-tab = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do Changes - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @merge_request.diff_size + = gl_badge_tag @merge_request.diff_size, { size: :sm }, { class: 'gl-tab-counter-badge' } #diff-notes-app.tab-content #new.commits.tab-pane.active diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 41c6696789d..a3f40207d20 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -26,6 +26,6 @@ = render 'merge_requests', new_merge_request_path: new_merge_request_path - if new_merge_request_email .gl-text-center.gl-pt-5.gl-pb-7 - .js-issueable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } + .js-issuable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } - else = render 'shared/empty_states/merge_requests', button_path: new_merge_request_path diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index fd1b2328a98..eb8de425f61 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -1,7 +1,12 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests") -- badge_css_classes = "badge gl-text-white" -- badge_info_css_classes = "#{badge_css_classes} badge-info" -- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse" + +- badge_start = '<span class="badge badge-pill gl-badge sm badge-info">'.html_safe +- badge_end = '</span>'.html_safe + +- err_fork_project_removed = s_("MergeRequest|Can't show this merge request because the fork project was deleted.") +- err_source_branch = s_("MergeRequest|Can't show this merge request because the source branch %{badge_start}%{source_branch}%{badge_end} is missing from project %{badge_start}%{project_path}%{badge_end}. Close this merge request or update the source branch.") +- err_target_branch = s_("MergeRequest|Can't show this merge request because the target branch %{badge_start}%{target_branch}%{badge_end} is missing from project %{badge_start}%{project_path}%{badge_end}. Close this merge request or update the target branch.") +- err_internal = s_("MergeRequest|Can't show this merge request because of an internal error. Contact your administrator.") .merge-request = render "projects/merge_requests/mr_title" @@ -11,20 +16,12 @@ .gl-alert-container = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-content{ role: 'alert' } - %p - We cannot render this merge request properly because + .gl-alert-body - if @merge_request.for_fork? && !@merge_request.source_project - fork project was removed + = err_fork_project_removed - elsif !@merge_request.source_branch_exists? - %span{ class: badge_inverse_css_classes }= @merge_request.source_branch - does not exist in - %span{ class: badge_info_css_classes }= @merge_request.source_project_path + = err_source_branch.html_safe % { badge_start: badge_start, badge_end: badge_end, source_branch: @merge_request.source_branch, project_path: @merge_request.source_project_path } - elsif !@merge_request.target_branch_exists? - %span{ class: badge_inverse_css_classes }= @merge_request.target_branch - does not exist in - %span{ class: badge_info_css_classes }= @merge_request.target_project_path + = err_target_branch.html_safe % { badge_start: badge_start, badge_end: badge_end, target_branch: @merge_request.target_branch, project_path: @merge_request.source_project_path } - else - of internal error - - %strong - Please close merge request or change branches with existing one + = err_internal diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 2154ef6b596..eb1c9712c52 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -25,21 +25,21 @@ = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do = tab_link_for @merge_request, :show, force_link: @commit.present? do = _("Overview") - %span.badge.badge-pill.gl-badge.badge-muted.sm= @merge_request.related_notes.user.count + = gl_badge_tag @merge_request.related_notes.user.count, { size: :sm } - if @merge_request.source_project = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do = tab_link_for @merge_request, :commits do = _("Commits") - %span.badge.badge-pill.gl-badge.badge-muted.sm= @commits_count + = gl_badge_tag @commits_count, { size: :sm } - if @number_of_pipelines.nonzero? = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do = tab_link_for @merge_request, :pipelines do = _("Pipelines") - %span.badge.badge-pill.gl-badge.badge-muted.sm.js-pipelines-mr-count= @number_of_pipelines + = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' } = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do = tab_link_for @merge_request, :diffs do = _("Changes") - %span.badge.badge-pill.gl-badge.badge-muted.sm= @diffs_count + = gl_badge_tag @diffs_count, { size: :sm } .d-flex.flex-wrap.align-items-center.justify-content-lg-end #js-vue-discussion-counter diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml index 9c11b650f75..86e54acecc4 100644 --- a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml +++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml @@ -1 +1,2 @@ -.badge.badge-warning.qa-disabled-mirror-badge.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled') +%span.qa-disabled-mirror-badge.rspec-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') } + = gl_badge_tag _('Disabled'), variant: :warning diff --git a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml deleted file mode 100644 index e6f3060af3e..00000000000 --- a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -.modal.js-regenerate-public-ssh-key-confirm-modal{ tabindex: -1 } - .modal-dialog - .modal-content - .modal-header - %h3.modal-title.page-title - Regenerate public SSH key? - %button.close.js-cancel{ type: 'button', 'data-dismiss': 'modal', 'aria-label' => _('Close') } - %span{ 'aria-hidden': true } × - .modal-body - %p= _('Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again.') - .form-actions.modal-footer - = button_tag _('Cancel'), type: 'button', class: 'btn gl-button js-cancel' - = button_tag _('Regenerate key'), type: 'button', class: 'btn gl-button btn-inverted btn-warning js-confirm' diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb index a146d137c55..93b3c9911e2 100644 --- a/app/views/projects/network/show.json.erb +++ b/app/views/projects/network/show.json.erb @@ -2,7 +2,7 @@ <%= raw( { - days: @graph.days.compact.map { |d| [d.day, d.strftime("%b")] }, + days: @graph.days.compact.map { |d| [d.day, d.strftime("%b"), d.year] }, commits: @graph.commits.map do |c| { parents: parents_zip_spaces(c.parents(@graph.map), c.parent_spaces), diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml index 0d5350ab62b..c67b06218e2 100644 --- a/app/views/projects/packages/packages/index.html.haml +++ b/app/views/projects/packages/packages/index.html.haml @@ -3,4 +3,8 @@ .row .col-12 - #js-vue-packages-list{ data: packages_list_data('projects', @project) } + #js-vue-packages-list{ data: { resource_id: @project.id, + full_path: @project.full_path, + endpoint: project_packages_path(@project), + page_type: 'projects', + empty_list_illustration: image_path('illustrations/no-packages.svg'), } } diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml index 267317196f8..2732463020e 100644 --- a/app/views/projects/pages_domains/_dns.html.haml +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -1,5 +1,5 @@ - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? -- dns_record = "#{domain_presenter.domain} CNAME #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}." +- dns_record = "#{domain_presenter.domain} ALIAS #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}." .form-group.border-section .row diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml index 29896500ea1..51f0c58330d 100644 --- a/app/views/projects/pipeline_schedules/edit.html.haml +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -4,7 +4,7 @@ - add_page_specific_style 'page_bundles/pipeline_schedules' %h3.page-title - = _("Edit Pipeline Schedule %{id}") % { id: @schedule.id } + = _("Edit Pipeline Schedule") %hr = render "form" diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 93afddce779..e844a3d4779 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -11,16 +11,16 @@ %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _('Jobs') - %span.badge.badge-pill.gl-badge.badge-muted.sm.js-builds-counter= pipeline.total_size + = gl_badge_tag @pipeline.total_size, { size: :sm }, { class: 'js-builds-counter' } - if @pipeline.failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _('Failed Jobs') - %span.badge.badge-pill.gl-badge.badge-muted.sm.js-failures-counter= @pipeline.failed_builds.count + = gl_badge_tag @pipeline.failed_builds.count, { size: :sm }, { class: 'js-failures-counter' } %li.js-tests-tab-link = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do = s_('TestReports|Tests') - %span.badge.badge-pill.gl-badge.badge-muted.sm.js-test-report-badge-counter= @pipeline.test_report_summary.total[:count] + = gl_badge_tag @pipeline.test_report_summary.total[:count], { size: :sm }, { class: 'js-test-report-badge-counter' } = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -29,17 +29,20 @@ #js-tab-builds.tab-pane - if stages.present? - .table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th= _('Status') - %th= _('Name') - %th= _('Job ID') - %th - %th= _('Coverage') - %th - = render partial: "projects/stage/stage", collection: stages, as: :stage + - if Feature.enabled?(:jobs_tab_vue, @project, default_enabled: :yaml) + #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } + - else + .table-holder.pipeline-holder + %table.table.ci-table.pipeline + %thead + %tr + %th= _('Status') + %th= _('Name') + %th= _('Job ID') + %th + %th= _('Coverage') + %th + = render partial: "projects/stage/stage", collection: stages, as: :stage - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 21e871246d7..547e2c8a7f4 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -2,4 +2,7 @@ #js-project-pipelines-charts-app{ data: { project_path: @project.full_path, should_render_dora_charts: should_render_dora_charts.to_s, - should_render_quality_summary: should_render_quality_summary.to_s } } + should_render_quality_summary: should_render_quality_summary.to_s, + failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'), + coverage_chart_path: charts_project_graph_path(@project, @project.default_branch), + default_branch: @project.default_branch } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index c911fc8a203..ac5d34bfd44 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -10,8 +10,7 @@ .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } - - if Gitlab.com? && show_cc_validation_alert?(@pipeline) - #js-cc-validation-required-alert + = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline - if @pipeline.commit.present? = render "projects/pipelines/info", commit: @pipeline.commit diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/projects/protected_branches/shared/_matching_branch.html.haml index 9145be5d2f2..1a2ec38fae9 100644 --- a/app/views/projects/protected_branches/shared/_matching_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_matching_branch.html.haml @@ -3,7 +3,7 @@ = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name' - if @project.root_ref?(matching_branch.name) - %span.badge.badge-info.gl-ml-2 default + = gl_badge_tag s_('ProtectedBranch|default'), { variant: :info }, { class: 'gl-ml-2' } %td - commit = @project.commit(matching_branch.name) = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha') diff --git a/app/views/projects/protected_tags/shared/_matching_tag.html.haml b/app/views/projects/protected_tags/shared/_matching_tag.html.haml index bf030d36cd6..3b6a6bd876f 100644 --- a/app/views/projects/protected_tags/shared/_matching_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_matching_tag.html.haml @@ -3,7 +3,7 @@ = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name' - if @project.root_ref?(matching_tag.name) - %span.badge.badge-info.gl-ml-2 default + = gl_badge_tag s_('ProtectedTag|default'), { variant: :info }, { class: 'gl-ml-2' } %td - commit = @project.commit(matching_tag.name) = link_to(commit.short_id, project_commit_path(@project, commit.id), class: 'commit-sha') diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index 972c96dc882..70357f39e44 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -3,7 +3,7 @@ %span.ref-name= protected_tag.name - if @project.root_ref?(protected_tag.name) - %span.badge.badge-info.gl-ml-2 default + = gl_badge_tag s_('ProtectedTags|default'), variant: :info, class: 'gl-ml-2' %td - if protected_tag.wildcard? - matching_tags = protected_tag.matching(repository.tags) diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index cfdbf3410b1..03927cd3bfa 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -22,6 +22,6 @@ "cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project), connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, - user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, + user_callouts_path: callouts_path, + user_callout_id: Users::CalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, show_unfinished_tag_cleanup_callout: show_unfinished_tag_cleanup_callout?.to_s, } } diff --git a/app/views/projects/remove_fork.js.haml b/app/views/projects/remove_fork.js.haml deleted file mode 100644 index 6d083c5c516..00000000000 --- a/app/views/projects/remove_fork.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - location.href = "#{edit_project_path(@project)}"; diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 5d737bb3901..183e747afdd 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -27,7 +27,7 @@ - elsif @group_runners.empty? = _('This group does not have any group runners yet.') - - if can?(current_user, :admin_pipeline, @project.group) + - if can?(current_user, :admin_group_runners, @project.group) - group_link = link_to _("group's CI/CD settings."), group_settings_ci_cd_path(@project.group) = _('Group maintainers can register group runners in the %{link}').html_safe % { link: group_link } - else diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index bf2e746b4a4..28e5618f8b0 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -36,5 +36,4 @@ - if runner.tags.present? .gl-my-2 - runner.tags.map(&:name).sort.each do |tag| - %span.badge.gl-badge.sm.badge-pill.badge-primary - = tag + = gl_badge_tag tag, variant: :info, size: :sm diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 724684c9a0a..dbc204ff9bf 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -6,7 +6,7 @@ - if integration.operating? = sprite_icon('check', css_class: 'gl-text-green-500') -= form_for(integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, integration) } }) do |form| += form_for(integration, as: :service, url: scoped_integration_path(integration, project: @project, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, integration) } }) do |form| = render 'shared/service_settings', form: form, integration: integration %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referer } diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index 724950bcb39..4586ee844c0 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -10,9 +10,7 @@ .card-header %strong = s_('PrometheusService|Custom metrics') - -# haml-lint:disable NoPlainNodes - %span.badge.badge-pill.js-custom-monitored-count 0 - -# haml-lint:enable NoPlainNodes + = gl_badge_tag 0, nil, class: 'js-custom-monitored-count' = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' } .card-body .flash-container.hidden diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 09fe77b8a9c..0d41584652f 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -12,7 +12,7 @@ .card-header %strong = s_('PrometheusService|Common metrics') - %span.badge.badge-pill.js-monitored-count 0 + = gl_badge_tag 0, nil, class: 'js-monitored-count' .card-body .loading-metrics.js-loading-metrics %p.m-3 @@ -28,7 +28,7 @@ = sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right' ) = sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden' ) = s_('PrometheusService|Missing environment variable') - %span.badge.badge-pill.js-env-var-count 0 + = gl_badge_tag 0, nil, class: 'js-env-var-count' .card-body.hidden .flash-container .flash-notice diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 52ef2e7d1ee..4e946050881 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -39,7 +39,7 @@ access_levels: ProjectMember.access_level_roles, default_access_level: Gitlab::Access::MAINTAINER, prefix: :project_access_token, - help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'limiting-scopes-of-a-project-access-token') + help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token') = render 'shared/access_tokens/table', active_tokens: @active_project_access_tokens, 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 8563f28eb33..e200635ba82 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -23,7 +23,7 @@ = form.label :enabled, class: 'form-check-label' do %strong= s_('CICD|Default to Auto DevOps pipeline') - if auto_devops_enabled - %span.badge.badge-info.js-instance-default-badge= badge_for_auto_devops_scope(@project) + = gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge'} .form-text.text-muted = s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank' diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 75bd985560b..f342728feee 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -96,15 +96,14 @@ .settings-content = render 'ci/deploy_freeze/index' -- if Feature.enabled?(:ci_scoped_job_token, @project, default_enabled: :yaml) - %section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _("Token Access") - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') - %p - = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.") - = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer' - .settings-content - = render 'ci/token_access/index' +%section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _("Token Access") + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.") + = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = render 'ci/token_access/index' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 1cbb061784e..d840ea01b89 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -19,7 +19,6 @@ = render "archived_notice", project: @project = render_if_exists "projects/marked_for_deletion_notice", project: @project = render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project -= render_if_exists 'projects/sast_entry_points', project: @project - view_path = @project.default_view diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml index 28ec1ed206a..e24276fcaea 100644 --- a/app/views/projects/starrers/_starrer.html.haml +++ b/app/views/projects/starrers/_starrer.html.haml @@ -13,7 +13,7 @@ %span.cgray= starrer.user.to_reference - if starrer.user == current_user - %span.badge-pill.badge-success.gl-badge.gl-ml-2.sm= _("It's you") + = gl_badge_tag _("It's you"), variant: :success, size: :sm, class: 'gl-ml-2' .block-truncated = time_ago_with_tooltip(starrer.starred_since) diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 83a3cac487f..0ee3b89b629 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -2,18 +2,13 @@ - release = @releases.find { |release| release.tag == tag.name } - commit_status = @tag_pipeline_statuses[tag.name] unless @tag_pipeline_statuses.nil? -%li.flex-row.js-tag-list{ class: "gl-white-space-normal!" } +%li.flex-row.js-tag-list{ class: "gl-white-space-normal! gl-align-items-flex-start!" } .row-main-content = sprite_icon('tag') = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name' - if protected_tag?(@project, tag) - %span.badge.badge-success.gl-ml-2.gl-badge.sm.badge-pill - = s_('TagsPage|protected') - - - if tag.message.present? - - = strip_signature(tag.message) + = gl_badge_tag s_('TagsPage|protected'), variant: :success, size: :sm, class: 'gl-ml-2' - if commit .block-truncated @@ -28,6 +23,10 @@ = _("Release") = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!' + - if tag.message.present? + %pre.wrap + = strip_signature(tag.message) + .row-fixed-content.controls.flex-row - if tag.has_signature? = render partial: 'projects/commit/signature', object: tag.signature diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index b3a75494ccc..c1b78d3258d 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -13,8 +13,7 @@ = sprite_icon('tag') = @tag.name - if protected_tag?(@project, @tag) - %span.badge.badge-success - = s_('TagsPage|protected') + = gl_badge_tag s_('TagsPage|protected'), variant: :success - if user = link_to user_path(user) do diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml index 21c1d02d92e..813908e5a57 100644 --- a/app/views/projects/tracings/show.html.haml +++ b/app/views/projects/tracings/show.html.haml @@ -3,10 +3,12 @@ - if @project.tracing_external_url.present? %h3.page-title= _('Tracing') - .gl-alert.gl-alert-info.alert.flex-alert - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .alert-message - = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.") + .gl-alert.gl-alert-info.gl-mb-5 + .gl-alert-container + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.") - jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer") %p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link } diff --git a/app/views/projects/transfer.js.haml b/app/views/projects/transfer.js.haml deleted file mode 100644 index 6d083c5c516..00000000000 --- a/app/views/projects/transfer.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - location.href = "#{edit_project_path(@project)}"; diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index 6c7cccfb9b1..de1135cf928 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -16,4 +16,4 @@ = s_('UsageQuota|Storage') .tab-content .tab-pane#storage-quota-tab - #js-project-storage-count-app{ data: @storage_app_data } + #js-project-storage-count-app{ data: { project_path: @project.full_path } } diff --git a/app/views/root/index.html.haml b/app/views/root/index.html.haml deleted file mode 100644 index 97dd8e133f5..00000000000 --- a/app/views/root/index.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- if show_customize_homepage_banner? - = content_for :customize_homepage_banner do - .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" } - .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'), - preferences_behavior_path: profile_preferences_path(anchor: 'behavior'), - callouts_path: user_callouts_path, - callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE, - track_label: 'home_page' } } - -= render template: 'dashboard/projects/index' diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index aeb37022f99..3681f823ef5 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,5 +1,6 @@ - project = blob.project - return unless project - blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path)) +- blame_link = project_blame_path(project, tree_join(repository_ref(project), blob.path)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link, blame_link: blame_link } diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml index 88a2ab4bb42..c42367f45c5 100644 --- a/app/views/search/results/_blob_data.html.haml +++ b/app/views/search/results/_blob_data.html.haml @@ -9,7 +9,7 @@ - if blob.data - if blob.data.size > 0 .file-content.code.term{ data: { qa_selector: 'file_text_content' } } - = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, highlight_line: blob.highlight_line + = render 'search/results/blob_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, blame_link: blame_link, highlight_line: blob.highlight_line - else .file-content.code .nothing-here-block diff --git a/app/views/search/results/_blob_highlight.html.haml b/app/views/search/results/_blob_highlight.html.haml new file mode 100644 index 00000000000..de1fa9a7fd5 --- /dev/null +++ b/app/views/search/results/_blob_highlight.html.haml @@ -0,0 +1,22 @@ +- offset = defined?(first_line_number) ? first_line_number : 1 +- highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil + +#search-blob-content.file-content.code.js-syntax-highlight{ class: 'gl-py-3!' } + - if blob.present? + .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } + - blob.present.highlight.lines.each_with_index do |line, index| + - i = index + offset + .line_holder.code-search-line + .line-numbers + .gl-display-flex + %span.diff-line-num.gl-pl-3 + %a.has-tooltip{ href: "#{blame_link}#L#{i}", id: "blame-L#{i}", 'data-line-number' => i, title: _('View blame') } + = sprite_icon('git') + %span.diff-line-num.flex-grow-1.gl-pr-3 + %a{ href: "#{blob_link}#L#{i}", id: "blob-L#{i}", 'data-line-number' => i, class: 'gl-display-flex! gl-align-items-center gl-justify-content-end' } + = sprite_icon('link', css_class: 'gl-ml-3! gl-mr-1!') + = i + %pre.code.highlight + %code + = line.html_safe + diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index 41058034d6f..36458a909fc 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -1,7 +1,7 @@ %div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' } .col-sm-9 %span.gl-display-flex.gl-align-items-center - %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable) + = gl_badge_tag issuable_state_text(issuable), variant: issuable_state_to_badge_class(issuable), size: :sm = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential? = link_to issuable_path(issuable), data: { track_action: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml index 8b9ca966ed6..5d837657943 100644 --- a/app/views/shared/_email_with_badge.html.haml +++ b/app/views/shared/_email_with_badge.html.haml @@ -1,8 +1,5 @@ -- css_classes = %w(badge gl-badge) -- css_classes << (verified ? 'badge-success': 'badge-danger') +- variant = verified ? :success : :danger - text = verified ? _('Verified') : _('Unverified') -.email-badge - .email-badge-email= email - %div{ class: css_classes } - = text += email += gl_badge_tag text, { variant: variant }, { class: 'gl-ml-3' } diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml index d8032ac521d..7b2d59407b4 100644 --- a/app/views/shared/_flash_user_callout.html.haml +++ b/app/views/shared/_flash_user_callout.html.haml @@ -1,4 +1,4 @@ -- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: user_callouts_path } +- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: callouts_path } - extra_flash_class = local_assigns.fetch(:extra_flash_class, nil) .flash-container.flash-container-page.user-callout{ data: callout_data } diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 925344ab2f7..8c84f96932c 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,6 +1,6 @@ - if milestone.expired? && !milestone.closed? - .gl-badge.badge-warning.badge-pill.gl-mb-2= _('Expired') + = gl_badge_tag _('Expired'), { variant: :warning }, { class: "gl-mb-2" } - if milestone.upcoming? - .gl-badge.badge-primary.badge-pill.gl-mb-2= _('Upcoming') + = gl_badge_tag _('Upcoming'), { variant: :info }, { class: "gl-mb-2" } - if milestone.closed? - .gl-badge.badge-danger.badge-pill.gl-mb-2= _('Closed') + = gl_badge_tag _('Closed'), { variant: :danger }, { class: "gl-mb-2" } diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index 117ed212fd9..ef41dc9bb79 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,15 +1,12 @@ -- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex' +- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex' = gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } } ) do = gl_tab_link_to milestones_filter_path(state: 'opened'), { item_active: params[:state].blank? || params[:state] == 'opened' } do = _('Open') - %span{ class: count_badge_classes } - = counts[:opened] + = gl_tab_counter_badge counts[:opened], { class: count_badge_classes } = gl_tab_link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc'), { item_active: params[:state] == 'closed' } do = _('Closed') - %span{ class: count_badge_classes } - = counts[:closed] + = gl_tab_counter_badge counts[:closed], { class: count_badge_classes } = gl_tab_link_to milestones_filter_path(state: 'all', sort: 'due_date_desc'), { item_active: params[:state] == 'all' } do = _('All') - %span{ class: count_badge_classes } - = counts[:all] + = gl_tab_counter_badge counts[:all], { class: count_badge_classes } diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml new file mode 100644 index 00000000000..8bcd826d8c0 --- /dev/null +++ b/app/views/shared/_registration_features_discovery_message.html.haml @@ -0,0 +1,9 @@ +- license = local_assigns.fetch(:license) +- registration_features_docs_path = help_page_path('development/service_ping/index.md', anchor: 'registration-features-program') +- service_ping_settings_path = metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings') + +%div + %span= s_('RegistrationFeatures|Want to use this feature for free?') + - if license.present? + = link_to s_('RegistrationFeatures|Enable Service Ping and register for this feature.'), service_ping_settings_path + = sprintf(s_('RegistrationFeatures|Read more about the %{linkStart}Registration Features Program%{linkEnd}.') , { linkStart: "<a href=\"#{registration_features_docs_path}\" target=\"_blank\">", linkEnd: "</a>", }).html_safe diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index c70fce7a38f..adacaeadfab 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -2,8 +2,8 @@ .service-settings - if @default_integration - .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) } - .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) } + .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group, project: @project) } + .js-vue-integration-settings{ data: integration_form_data(integration, group: @group, project: @project) } .js-integration-help-html.gl-display-none -# All content below will be repositioned in Vue - if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true) diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml index d4764d1a5d9..e7239661313 100644 --- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml +++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml @@ -1,7 +1,7 @@ = render 'shared/global_alert', variant: :warning, alert_class: 'js-recovery-settings-callout', - alert_data: { feature_id: UserCalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: user_callouts_path, defer_links: 'true' }, + alert_data: { feature_id: Users::CalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: callouts_path, defer_links: 'true' }, close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do .gl-alert-body = s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.') diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml index 498e9cc33ce..3bbd7a32bda 100644 --- a/app/views/shared/builds/_tabs.html.haml +++ b/app/views/shared/builds/_tabs.html.haml @@ -1,19 +1,15 @@ -- count_badge_classes = 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex' +- count_badge_classes = 'gl-display-none gl-sm-display-inline-flex' = gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'jobs-tabs' } } ) do = gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do = _('All') - %span{ class: count_badge_classes } - = limited_counter_with_delimiter(all_builds) + = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds), { class: count_badge_classes }) = gl_tab_link_to build_path_proc.call('pending'), { item_active: scope == 'pending' } do = _('Pending') - %span{ class: count_badge_classes } - = limited_counter_with_delimiter(all_builds.pending) + = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.pending), { class: count_badge_classes }) = gl_tab_link_to build_path_proc.call('running'), { item_active: scope == 'running' } do = _('Running') - %span{ class: count_badge_classes } - = limited_counter_with_delimiter(all_builds.running) + = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.running), { class: count_badge_classes }) = gl_tab_link_to build_path_proc.call('finished'), { item_active: scope == 'finished' } do = _('Finished') - %span{ class: count_badge_classes } - = limited_counter_with_delimiter(all_builds.finished) + = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds.finished), { class: count_badge_classes }) diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml index 180c658dbdc..adfd7ea98b7 100644 --- a/app/views/shared/doorkeeper/applications/_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_form.html.haml @@ -16,13 +16,14 @@ = f.check_box :confidential, class: 'form-check-input' = f.label :confidential, class: 'label-bold form-check-label' %span.form-text.text-muted - = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.') + = _('Enable only for confidential applications exclusively used by a trusted backend server that can securely store the client secret. Do not enable for native-mobile, single-page, or other JavaScript applications because they cannot keep the client secret confidential.') .form-group.form-check = f.check_box :expire_access_tokens, class: 'form-check-input' = f.label :expire_access_tokens, class: 'label-bold form-check-label' %span.form-text.text-muted - = _('Access tokens expire after 2 hours. A refresh token may be used at any time to generate a new access token. Non-expiring access tokens are deprecated. Clear this setting to enable backward compatibility.') + = _('Enable access tokens to expire after 2 hours. If disabled, tokens do not expire.') + = link_to _('Learn more.'), help_page_path('integration/oauth_provider.md', anchor: 'expiring-access-tokens'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :scopes, class: 'label-bold' diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index 8ccb4bcdbe0..0359c28794c 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -9,7 +9,7 @@ - if oauth_authorized_applications_enabled = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.") - else - = _("Manage applications that can use GitLab as an OAuth provider.") + = _("Manage applications that use GitLab as an OAuth provider.") - else = _("Manage applications that you've authorized to use your account.") .col-lg-8 diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index f8942dddfb4..94818c13f76 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -7,7 +7,7 @@ - preview_url = preview_markdown_path(project, target_type: model.class.name) .form-group.row.detail-page-description - = form.label :description, 'Description', class: 'col-form-label col-sm-2' + = form.label :description, _('Description'), class: 'col-form-label col-sm-2' .col-sm-10 - if model.is_a?(MergeRequest) = hidden_field_tag :merge_request_diff_head_sha, model.diff_head_sha diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml index 35f302a28a6..89c127408e1 100644 --- a/app/views/shared/integrations/_form.html.haml +++ b/app/views/shared/integrations/_form.html.haml @@ -1,4 +1,4 @@ - integration = local_assigns.fetch(:integration) -= form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration) } } do |form| += form_for integration, as: :service, url: scoped_integration_path(integration, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration, group: @group) } } do |form| = render 'shared/service_settings', form: form, integration: integration diff --git a/app/views/shared/integrations/_index.html.haml b/app/views/shared/integrations/_index.html.haml index 39365280e71..872fc90f6db 100644 --- a/app/views/shared/integrations/_index.html.haml +++ b/app/views/shared/integrations/_index.html.haml @@ -1 +1 @@ -.js-integrations-list{ data: integration_list_data(integrations) } +.js-integrations-list{ data: integration_list_data(integrations, group: @group, project: @project) } diff --git a/app/views/shared/integrations/_tabs.html.haml b/app/views/shared/integrations/_tabs.html.haml index d6ca0bd7d1e..781db59592e 100644 --- a/app/views/shared/integrations/_tabs.html.haml +++ b/app/views/shared/integrations/_tabs.html.haml @@ -2,7 +2,7 @@ .tabs.gl-tabs %div = gl_tabs_nav({ class: 'gl-mb-5' }) do - = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration) + = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration, project: @project, group: @group) = gl_tab_link_to s_('Integrations|Projects using custom settings'), scoped_overrides_integration_path(integration) = yield diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml index 02cb94e3555..acb0c7ee52e 100644 --- a/app/views/shared/integrations/edit.html.haml +++ b/app/views/shared/integrations/edit.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Integrations'), scoped_integrations_path +- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group) - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') - @content_class = 'limit-container-width' unless fluid_layout diff --git a/app/views/shared/integrations/overrides.html.haml b/app/views/shared/integrations/overrides.html.haml index dc87fae704c..b8585fdef1f 100644 --- a/app/views/shared/integrations/overrides.html.haml +++ b/app/views/shared/integrations/overrides.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs _('Integrations'), scoped_integrations_path +- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group) - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') - @content_class = 'limit-container-width' unless fluid_layout diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index cb03bd4c473..4024c5b77f6 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -64,7 +64,7 @@ for this project. - if issuable.new_record? - = form.submit "Create #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' } + = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' } - else = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2' diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index f58156b7c08..dc713337747 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -4,20 +4,20 @@ - selected = local_assigns.fetch(:selected, nil) - selected_text = selected.try(:title) || params[:milestone_title] -- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone") +- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by milestone')) - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) = dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "qa-issuable-milestone-dropdown js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "qa-issuable-dropdown-menu-milestone dropdown-menu-selectable dropdown-menu-milestone", - placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: "Milestone" } }) do + placeholder: _('Search milestones'), footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), default_label: _('Milestone') } }) do - if project %ul.dropdown-footer-list - if can? current_user, :admin_milestone, project %li - = link_to new_project_milestone_path(project), title: "New Milestone" do - Create new + = link_to new_project_milestone_path(project), title: _('New Milestone') do + = _('Create new') %li = link_to project_milestones_path(project) do - if can? current_user, :admin_milestone, project - Manage milestones + = _('Manage milestones') - else - View milestones + = _('View milestones') diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 62539bfeffd..9a703b9d355 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -29,13 +29,17 @@ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:supports_milestone] - .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block' } } + .block.milestone{ :class => ("gl-border-b-0!" if in_group_context_with_iterations), data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } } .js-milestone-select{ 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 .block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }< = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type + - if @show_crm_contacts + .block.contact + #js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } } + - if issuable_sidebar[:supports_time_tracking] #issuable-time-tracker.block // Fallback while content is loading diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml index 3dc244677e2..50f30e58b35 100644 --- a/app/views/shared/issuable/form/_default_templates.html.haml +++ b/app/views/shared/issuable/form/_default_templates.html.haml @@ -1,4 +1,5 @@ %p.form-text.text-muted - Add - = link_to 'description templates', help_page_path('user/project/description_templates') - to help your contributors communicate effectively! + - template_link_url = help_page_path('user/project/description_templates') + - template_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: template_link_url } + = s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!').html_safe % { link_start: template_link_start, link_end: '</a>'.html_safe } + diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 2f05d272ca3..9e42c528a11 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,7 +10,7 @@ .form-check = form.check_box :confidential, class: 'form-check-input' = form.label :confidential, class: 'form-check-label' do - This issue is confidential and should only be visible to team members with at least Reporter access. + #{_('This issue is confidential and should only be visible to team members with at least Reporter access.')} - if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable) %hr @@ -27,13 +27,13 @@ - if issuable.supports_milestone? .form-group.row.issue-milestone - = form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" + = form.label :milestone_id, _('Milestone'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder - = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: _('Select milestone') .form-group.row - = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" + = form.label :label_ids, _('Labels'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" = form.hidden_field :label_ids, multiple: true, value: '' .col-sm-10{ class: "#{"col-md-8" if has_due_date}" } .issuable-form-select-holder @@ -45,7 +45,7 @@ .col-lg-6 = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form .form-group.row - = form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4" + = form.label :due_date, _('Due date'), class: "col-form-label col-md-2 col-lg-4" .col-8 .issuable-form-select-holder - = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off' + = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: _('Select due date'), autocomplete: 'off' 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 b437ee1ec5f..781ee8b5f80 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 qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + = link_to _('Assign to me'), '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 561ca0afd60..c0a6322eb1b 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -5,7 +5,7 @@ - div_class = no_issuable_templates ? 'col-sm-10' : 'col-sm-7 col-lg-8' - toggle_wip_link_start = '<a href="" class="js-toggle-wip">' - toggle_wip_link_end = '</a>' -- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request that is a work in progress from being merged before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe +- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe - remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe %div{ class: div_class } diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index ae0fe54de4f..0d86aa8c8e7 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -1,7 +1,7 @@ - return unless issuable.supports_issue_type? && can?(current_user, :create_issue, @project) .form-group.row.gl-mb-0 - = form.label :type, 'Type', class: 'col-form-label col-sm-2' + = form.label :type, _('Type'), class: 'col-form-label col-sm-2' .col-sm-10 .gl-display-flex.gl-align-items-center .issuable-form-select-holder.selectbox.form-group.gl-mb-0 diff --git a/app/views/shared/members/_filter_2fa_dropdown.html.haml b/app/views/shared/members/_filter_2fa_dropdown.html.haml deleted file mode 100644 index 8187a9bde15..00000000000 --- a/app/views/shared/members/_filter_2fa_dropdown.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- filter = params[:two_factor] || 'everyone' -- filter_options = { 'everyone' => _('Everyone'), 'enabled' => _('Enabled'), 'disabled' => _('Disabled') } -.dropdown.inline.member-filter-2fa-dropdown{ data: { testid: 'member-filter-2fa-dropdown' } } - = dropdown_toggle(filter_options[filter], { toggle: 'dropdown', testid: 'dropdown-toggle' }) - %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable - %li.dropdown-header - = _("Filter by two-factor authentication") - - filter_options.each do |value, title| - %li - = link_to filter_group_project_member_path(two_factor: value), class: ("is-active" if filter == value) do - = title diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 8b0a85656dc..ec08dde37bf 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -9,7 +9,7 @@ .card-header = _("Users requesting access to") %strong= membership_source.name - %span.badge.badge-pill= requesters.size + = gl_badge_tag requesters.size = render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests') %ul.content-list.members-list = render partial: 'shared/members/member', diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 44934a12559..4e06b7902bd 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -33,11 +33,9 @@ %div = render('shared/milestone_expired', milestone: milestone) - if milestone.group_milestone? - .gl-badge.badge-info.badge-pill - = milestone.group.full_name + = gl_badge_tag milestone.group.full_name, variant: :info - if milestone.project_milestone? - .gl-badge.badge-muted.badge-pill - = milestone.project.full_name + = gl_badge_tag milestone.project.full_name, variant: :muted .col-sm-4.milestone-progress = milestone_progress_bar(milestone) diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index c66ba5ba2e1..a1e94172ec3 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -79,7 +79,7 @@ %span= milestone.issues_visible_to_user(current_user).count .title.hide-collapsed = s_('MilestoneSidebar|Issues') - %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.issues_visible_to_user(current_user).count + = gl_badge_tag milestone.issues_visible_to_user(current_user).count, variant: :muted, size: :sm - if show_new_issue_link?(project) = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "float-right", title: s_('MilestoneSidebar|New Issue') do = s_('MilestoneSidebar|New issue') @@ -111,7 +111,7 @@ %span= milestone.merge_requests.count .title.hide-collapsed = s_('MilestoneSidebar|Merge requests') - %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.merge_requests.count + = gl_badge_tag milestone.merge_requests.count, variant: :muted, size: :sm .value.hide-collapsed.bold - if !project || can?(current_user, :read_merge_request, project) %span.milestone-stat diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 3524a1b17ea..8c49977fe82 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -3,24 +3,20 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs - %li.nav-item - = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do - = _('Issues') - %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size + = gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do + = gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do + = _('Issues') + = gl_tab_counter_badge milestone.issues_visible_to_user(current_user).size - if milestone.merge_requests_enabled? - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do - = _('Merge requests') - %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - %li.nav-item - = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do - = _('Participants') - %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count - %li.nav-item - = link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do - = _('Labels') - %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count + = gl_tab_link_to '#tab-merge-requests', data: { endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do + = _('Merge requests') + = gl_tab_counter_badge milestone.merge_requests_visible_to_user(current_user).size + = gl_tab_link_to '#tab-participants', data: { endpoint: milestone_tab_path(milestone, 'participants') } do + = _('Participants') + = gl_tab_counter_badge milestone.issue_participants_visible_by_user(current_user).count + = gl_tab_link_to '#tab-labels', data: { endpoint: milestone_tab_path(milestone, 'labels') } do + = _('Labels') + = gl_tab_counter_badge milestone.issue_labels_visible_by_user(current_user).count .tab-content.milestone-content .tab-pane.active#tab-issues diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml index 1a7089fb570..4e570086bf8 100644 --- a/app/views/shared/nav/_scope_menu.html.haml +++ b/app/views/shared/nav/_scope_menu.html.haml @@ -1,5 +1,5 @@ = nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do - = link_to scope_menu.link, **scope_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: scope_qa_menu_item(scope_menu.container) } do + = link_to scope_menu.link, **scope_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: scope_qa_menu_item(scope_menu.container) } do %span{ class: scope_avatar_classes(scope_menu.container) } = source_icon(scope_menu.container, alt: scope_menu.title, class: ['avatar', 'avatar-tile', 's32'], width: 32, height: 32) %span.sidebar-context-title diff --git a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml index 953f7a8ae60..d0ae5e99707 100644 --- a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml +++ b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml @@ -1,3 +1,3 @@ %li.hidden - = link_to sidebar_hidden_menu_item.link, **sidebar_hidden_menu_item.container_html_options do + = link_to sidebar_hidden_menu_item.link, **sidebar_hidden_menu_item.link_html_options do = sidebar_hidden_menu_item.title diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index 3f71368aff3..4c4ceb9ea70 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -2,7 +2,7 @@ - if sidebar_menu.menu_with_partial? = render_if_exists sidebar_menu.menu_partial, **sidebar_menu.menu_partial_options - else - = link_to sidebar_menu.link, **sidebar_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do + = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do - if sidebar_menu.icon_or_image? %span.nav-icon-container - if sidebar_menu.image_path diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml index 674ce593ee2..5452cd486da 100644 --- a/app/views/shared/nav/_sidebar_menu_item.html.haml +++ b/app/views/shared/nav/_sidebar_menu_item.html.haml @@ -1,5 +1,5 @@ = nav_link(**sidebar_menu_item.active_routes, html_options: sidebar_menu_item.nav_link_html_options) do - = link_to sidebar_menu_item.link, **sidebar_menu_item.container_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do + = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do %span = sidebar_menu_item.title - if sidebar_menu_item.sprite_icon diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml index f24fe3a8b89..018bf137cc6 100644 --- a/app/views/shared/projects/_archived.html.haml +++ b/app/views/shared/projects/_archived.html.haml @@ -1,3 +1,2 @@ - if project.archived - %span.d-flex.badge-pill.gl-badge.badge-warning.gl-ml-3 - = _('archived') + = gl_badge_tag _('archived'), { variant: :warning }, { class: 'gl-display-flex gl-ml-3' } diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 2136d287f53..ae264f2188f 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -31,7 +31,7 @@ .flex-wrapper .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.gl-mt-3 - = link_to project_path(project), class: 'text-plain' do + = link_to project_path(project), class: 'text-plain js-prefetch-document' do %span.project-full-name.gl-mr-3>< %span.namespace-name - if project.namespace && !skip_namespace diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index e303f05c5df..b7df369327c 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -1,6 +1,5 @@ - cache_enabled = false unless local_assigns[:cache_enabled] == true - max_project_topic_length = 15 -- project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" - if project.topics.present? = cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do @@ -10,11 +9,11 @@ - project.topics_to_show.each do |topic| - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - if topic.length > max_project_topic_length - %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } - = truncate(topic, length: max_project_topic_length) + %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic, length: max_project_topic_length) - else - %a{ class: project_topics_classes, href: explore_project_topic_path, itemprop: 'keywords' } - = topic + %a.gl-mr-3{ href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag topic - if project.has_extra_topics? - title = _('More topics') @@ -23,10 +22,10 @@ - project.topics_not_shown.each do |topic| - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - if topic.length > max_project_topic_length - %a{ class: "#{ project_topics_classes } gl-mb-3 str-truncated has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } - = truncate(topic, length: max_project_topic_length) + %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic, length: max_project_topic_length) - else - %a{ class: "#{ project_topics_classes } gl-mb-3", href: explore_project_topic_path, itemprop: 'keywords' } - = topic + %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag topic .text-nowrap{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown } diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index 6a65145d42b..436dbfd2b49 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -5,8 +5,8 @@ %div %ul %li - %span.badge.badge-pill.gl-badge.sm.badge-success active + = gl_badge_tag s_("Runners|active"), variant: :success, size: :sm = _('- Available to run jobs.') %li - %span.badge.badge-pill.gl-badge.sm.badge-danger paused + = gl_badge_tag s_("Runners|paused"), variant: :danger, size: :sm = _('- Not available to run jobs.') diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml index a7b2947057d..7a35b1cec0a 100644 --- a/app/views/shared/runners/_runner_details.html.haml +++ b/app/views/shared/runners/_runner_details.html.haml @@ -28,8 +28,7 @@ %td= s_('Runners|Tags') %td - runner.tag_list.sort.each do |tag| - %span.badge.badge-primary - = tag + = gl_badge_tag tag, variant: :info %tr %td= s_('Runners|Name') %td= runner.name diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml index e0318006f09..c6a18c804da 100644 --- a/app/views/shared/runners/_runner_type_badge.html.haml +++ b/app/views/shared/runners/_runner_type_badge.html.haml @@ -1,10 +1,7 @@ - if runner.instance_type? - %span.badge.badge-pill.gl-badge.badge-success - = s_('Runners|shared') + = gl_badge_tag s_('Runners|shared'), variant: :success - elsif runner.group_type? - %span.badge.badge-pill.gl-badge.badge-success - = s_('Runners|group') + = gl_badge_tag s_('Runners|group'), variant: :success - else - %span.badge.badge-pill.gl-badge.badge-info - = s_('Runners|specific') + = gl_badge_tag s_('Runners|specific'), variant: :info diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index b5abd00b8fd..5744fc9fba6 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -17,5 +17,5 @@ = embedded_raw_snippet_button(@snippet, blob) = embedded_snippet_download_button(@snippet, blob) - %article.file-holder.snippet-file-content + %figure.file-holder.snippet-file-content{ "aria-label" => _('Code snippet') } = render 'projects/blob/viewer', viewer: blob.simple_viewer, load_async: false, external_embed: true diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index fd124c2967d..45baa7e2184 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -1,7 +1,15 @@ %li .row .col-md-8.col-lg-7 - %strong.light-header= hook.url + %strong.light-header + = hook.url + - if hook.rate_limited? + %span.gl-badge.badge-danger.badge-pill.sm= _('Disabled') + - elsif hook.permanently_disabled? + %span.gl-badge.badge-danger.badge-pill.sm= s_('Webhooks|Failed to connect') + - elsif hook.temporarily_disabled? + %span.gl-badge.badge-warning.badge-pill.sm= s_('Webhooks|Fails to connect') + %div - hook.class.triggers.each_value do |trigger| - if hook.public_send(trigger) diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml new file mode 100644 index 00000000000..23010b8349c --- /dev/null +++ b/app/views/shared/web_hooks/_hook_errors.html.haml @@ -0,0 +1,41 @@ +- strong_start = '<strong>'.html_safe +- strong_end = '</strong>'.html_safe +- link_start = '<a href="%{url}">'.html_safe +- link_end = '</a>'.html_safe + +- if hook.rate_limited? + - support_path = 'https://support.gitlab.com/hc/en-us/requests/new' + - placeholders = { strong_start: strong_start, + strong_end: strong_end, + limit: hook.rate_limit, + support_link_start: link_start % { url: support_path }, + support_link_end: link_end } + = render 'shared/global_alert', + title: s_('Webhooks|Webhook was automatically disabled'), + variant: :danger, + is_contained: true, + close_button_class: 'js-close' do + .gl-alert-body + = s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders +- elsif hook.permanently_disabled? + = render 'shared/global_alert', + title: s_('Webhooks|Webhook failed to connect'), + variant: :danger, + is_contained: true, + close_button_class: 'js-close' do + .gl-alert-body + = s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end } +- elsif hook.temporarily_disabled? + - help_path = help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered') + - placeholders = { strong_start: strong_start, + strong_end: strong_end, + retry_time: time_interval_in_words(hook.disabled_until - Time.now), + help_link_start: link_start % { url: help_path }, + help_link_end: link_end } + = render 'shared/global_alert', + title: s_('Webhooks|Webhook fails to connect'), + variant: :warning, + is_contained: true, + close_button_class: 'js-close' do + .gl-alert-body + = s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml index f00f3473efa..c220b46f70f 100644 --- a/app/views/shared/web_hooks/_title_and_docs.html.haml +++ b/app/views/shared/web_hooks/_title_and_docs.html.haml @@ -4,7 +4,7 @@ = page_title - if @project - - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path } + - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path(project: @project) } %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } - else %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe } diff --git a/app/views/shared/wikis/_wiki_content.html.haml b/app/views/shared/wikis/_wiki_content.html.haml new file mode 100644 index 00000000000..42e8037bb0f --- /dev/null +++ b/app/views/shared/wikis/_wiki_content.html.haml @@ -0,0 +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 } } + = render_wiki_content(@page) diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 8a5cd94bde9..e6980aae3e1 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -26,7 +26,6 @@ %div - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' } - .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 } } - = render_wiki_content(@page) + = render 'shared/wikis/wiki_content' = render 'shared/wikis/sidebar' diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index ac6dac8b322..35d21e45c47 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -1,31 +1,18 @@ - subject = local_assigns.fetch(:subject, current_user) - include_private = local_assigns.fetch(:include_private, false) +- params[:scope] ||= [] -.nav-links.snippet-scope-menu.mobile-separator.nav.nav-tabs - %li{ class: active_when(params[:scope].nil?) } - = link_to subject_snippets_path(subject) do - = _("All") - %span.badge.badge-muted.badge-pill.gl-badge.sm - - if include_private - = counts[:total] - - else - = counts[:are_public_or_internal] - += gl_tabs_nav({ class: 'js-snippets-nav-tabs gl-border-b-0 gl-overflow-x-auto gl-flex-grow-1 gl-flex-nowrap' }) do + = gl_tab_link_to subject_snippets_path(subject), { item_active: params[:scope].empty? } do + = _('All') + = gl_tab_counter_badge(include_private ? counts[:total] : counts[:are_public_or_internal]) - if include_private - %li{ class: active_when(params[:scope] == "are_private") } - = link_to subject_snippets_path(subject, scope: 'are_private') do - = _("Private") - %span.badge.badge-muted.badge-pill.gl-badge.sm - = counts[:are_private] - - %li{ class: active_when(params[:scope] == "are_internal") } - = link_to subject_snippets_path(subject, scope: 'are_internal') do - = _("Internal") - %span.badge.badge-muted.badge-pill.gl-badge.sm - = counts[:are_internal] - - %li{ class: active_when(params[:scope] == "are_public") } - = link_to subject_snippets_path(subject, scope: 'are_public') do - = _("Public") - %span.badge.badge-muted.badge-pill.gl-badge.sm - = counts[:are_public] + = gl_tab_link_to subject_snippets_path(subject, scope: 'are_private') do + = _('Private') + = gl_tab_counter_badge(counts[:are_private]) + = gl_tab_link_to subject_snippets_path(subject, scope: 'are_internal') do + = _('Internal') + = gl_tab_counter_badge(counts[:are_internal]) + = gl_tab_link_to subject_snippets_path(subject, scope: 'are_public') do + = _('Public') + = gl_tab_counter_badge(counts[:are_public]) diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 0d904de9372..7e745efd069 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -2,14 +2,14 @@ .row.d-none.d-sm-flex .col-12.calendar-block.gl-my-3 - .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_time_instance(@user.timezone).now.utc_offset } } + .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } } .gl-spinner.gl-spinner-md.gl-my-8 .user-calendar-error.invisible = _('There was an error loading users activity calendar.') %a.js-retry-load{ href: '#' } = s_('UserProfile|Retry') .user-calendar-activities -- if @user.user_readme +- if @user.user_readme&.rich_viewer .row.justify-content-center .col-12.col-md-10.col-lg-8.gl-my-6 .gl-display-flex diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index cffeb55597f..8da1aa09215 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -7,7 +7,7 @@ %li %span.light.js-localtime{ :data => { :datetime => event.created_at.utc.strftime('%Y-%m-%dT%H:%M:%SZ'), :toggle => 'tooltip', :placement => 'top' } } = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') - = event.created_at.to_time.in_time_zone(@user.timezone).strftime('%-I:%M%P') + = event.created_at.to_time.in_time_zone(local_timezone_instance(@user.timezone)).strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - if event.push_action? #{event.action_name} #{event.ref_type} diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 522f0f771cd..ca276519758 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -2,8 +2,7 @@ - @hide_breadcrumbs = true - @no_container = true - page_title user_display_name(@user) -- page_description @user.bio -- header_title @user.name, user_path(@user) +- page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' - link_classes = "flex-grow-1 mx-1 " diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index 92095e78f69..c461250fc9b 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,23 +1,6 @@ -- redirect_params = { redirect: @redirect } if @redirect -- accept_term_link = accept_term_path(@term, redirect_params) +- content_for :page_specific_javascripts do + = render "layouts/google_tag_manager_head" + = render "layouts/one_trust" += render "layouts/google_tag_manager_body" -- if Feature.enabled?(:terms_of_service_vue, current_user, default_enabled: :yaml) - #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } } -- else - .card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } - = markdown_field(@term, :terms) - - if current_user - = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true - .card-footer.footer-block.clearfix - - if can?(current_user, :accept_terms, @term) - .float-right - = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do - = _('Accept terms') - - else - .float-right - = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do - = _('Continue') - - if can?(current_user, :decline_terms, @term) - .float-right - = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do - = _('Decline and sign out') +#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 699744b355c..e5ac9da37c6 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1447,6 +1447,24 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_pending_builds_update_group + :worker_name: Ci::PendingBuilds::UpdateGroupWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: pipeline_background:ci_pending_builds_update_project + :worker_name: Ci::PendingBuilds::UpdateProjectWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_background:ci_pipeline_artifacts_coverage_report :worker_name: Ci::PipelineArtifacts::CoverageReportWorker :feature_category: :code_testing @@ -1458,7 +1476,7 @@ :tags: [] - :name: pipeline_background:ci_pipeline_artifacts_create_quality_report :worker_name: Ci::PipelineArtifacts::CreateQualityReportWorker - :feature_category: :code_testing + :feature_category: :code_quality :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1559,7 +1577,7 @@ :worker_name: Ci::CreateDownstreamPipelineWorker :feature_category: :continuous_integration :has_external_dependencies: - :urgency: :low + :urgency: :high :resource_boundary: :cpu :weight: 3 :idempotent: @@ -1913,7 +1931,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: bulk_imports_export_request :worker_name: BulkImports::ExportRequestWorker @@ -2474,6 +2492,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: namespaces_process_sync_events + :worker_name: Namespaces::ProcessSyncEventsWorker + :feature_category: :sharding + :has_external_dependencies: + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: new_issue :worker_name: NewIssueWorker :feature_category: :team_planning @@ -2645,6 +2672,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: projects_process_sync_events + :worker_name: Projects::ProcessSyncEventsWorker + :feature_category: :sharding + :has_external_dependencies: + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_schedule_bulk_repository_shard_moves :worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly @@ -2717,15 +2753,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: propagate_service_template - :worker_name: PropagateServiceTemplateWorker - :feature_category: :integrations - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: reactive_caching :worker_name: ReactiveCachingWorker :feature_category: :not_owned diff --git a/app/workers/background_migration/single_database_worker.rb b/app/workers/background_migration/single_database_worker.rb new file mode 100644 index 00000000000..b6661d4fd14 --- /dev/null +++ b/app/workers/background_migration/single_database_worker.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module BackgroundMigration + module SingleDatabaseWorker + extend ActiveSupport::Concern + + include ApplicationWorker + + MAX_LEASE_ATTEMPTS = 5 + + included do + data_consistency :always + + sidekiq_options retry: 3 + + feature_category :database + urgency :throttled + loggable_arguments 0, 1 + end + + class_methods do + # The minimum amount of time between processing two jobs of the same migration + # class. + # + # This interval is set to 2 or 5 minutes so autovacuuming and other + # maintenance related tasks have plenty of time to clean up after a migration + # has been performed. + def minimum_interval + 2.minutes.to_i + end + + def tracking_database + raise NotImplementedError, "#{self.name} does not implement #{__method__}" + end + + def unhealthy_metric_name + raise NotImplementedError, "#{self.name} does not implement #{__method__}" + end + end + + # Performs the background migration. + # + # See Gitlab::BackgroundMigration.perform for more information. + # + # class_name - The class name of the background migration to run. + # arguments - The arguments to pass to the migration class. + # lease_attempts - The number of times we will try to obtain an exclusive + # lease on the class before giving up. See MR for more discussion. + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956 + def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS) + job_coordinator.with_shared_connection do + perform_with_connection(class_name, arguments, lease_attempts) + end + end + + private + + def job_coordinator + @job_coordinator ||= Gitlab::BackgroundMigration.coordinator_for_database(self.class.tracking_database) + end + + def perform_with_connection(class_name, arguments, lease_attempts) + with_context(caller_id: class_name.to_s) do + retried = lease_attempts != MAX_LEASE_ATTEMPTS + attempts_left = lease_attempts - 1 + should_perform, ttl = perform_and_ttl(class_name, attempts_left, retried) + + break if should_perform.nil? + + if should_perform + job_coordinator.perform(class_name, arguments) + else + # If the lease could not be obtained this means either another process is + # running a migration of this class or we ran one recently. In this case + # we'll reschedule the job in such a way that it is picked up again around + # the time the lease expires. + self.class + .perform_in(ttl || self.class.minimum_interval, class_name, arguments, attempts_left) + end + end + end + + def perform_and_ttl(class_name, attempts_left, retried) + # In test environments `perform_in` will run right away. This can then + # lead to stack level errors in the above `#perform`. To work around this + # we'll just perform the migration right away in the test environment. + return [true, nil] if always_perform? + + lease = lease_for(class_name, retried) + lease_obtained = !!lease.try_obtain + healthy_db = healthy_database? + perform = lease_obtained && healthy_db + + database_unhealthy_counter.increment if lease_obtained && !healthy_db + + # When the DB is unhealthy or the lease can't be obtained after several tries, + # then give up on the job and log a warning. Otherwise we could end up in + # an infinite rescheduling loop. Jobs can be tracked in the database with the + # use of Gitlab::Database::BackgroundMigrationJob + if !perform && attempts_left < 0 + msg = if !lease_obtained + 'Job could not get an exclusive lease after several tries. Giving up.' + else + 'Database was unhealthy after several tries. Giving up.' + end + + Sidekiq.logger.warn(class: class_name, message: msg, job_id: jid) + + return [nil, nil] + end + + [perform, lease.ttl] + end + + def lease_for(class_name, retried) + Gitlab::ExclusiveLease + .new(lease_key_for(class_name, retried), timeout: self.class.minimum_interval) + end + + def lease_key_for(class_name, retried) + key = "#{self.class.name}:#{class_name}" + # We use a different exclusive lock key for retried jobs to allow them running concurrently with the scheduled jobs. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68763 for more information. + key += ":retried" if retried + key + end + + def always_perform? + Rails.env.test? + end + + # Returns true if the database is healthy enough to allow the migration to be + # performed. + # + # class_name - The name of the background migration that we might want to + # run. + def healthy_database? + !Postgresql::ReplicationSlot.lag_too_great? + end + + def database_unhealthy_counter + Gitlab::Metrics.counter( + self.class.unhealthy_metric_name, + 'The number of times a background migration is rescheduled because the database is unhealthy.' + ) + end + end +end diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb index b771ab4d4e7..dea0d467eca 100644 --- a/app/workers/background_migration_worker.rb +++ b/app/workers/background_migration_worker.rb @@ -1,120 +1,13 @@ # frozen_string_literal: true class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker + include BackgroundMigration::SingleDatabaseWorker - MAX_LEASE_ATTEMPTS = 5 - - data_consistency :always - - sidekiq_options retry: 3 - - feature_category :database - urgency :throttled - loggable_arguments 0, 1 - - # The minimum amount of time between processing two jobs of the same migration - # class. - # - # This interval is set to 2 or 5 minutes so autovacuuming and other - # maintenance related tasks have plenty of time to clean up after a migration - # has been performed. - def self.minimum_interval - 2.minutes.to_i - end - - # Performs the background migration. - # - # See Gitlab::BackgroundMigration.perform for more information. - # - # class_name - The class name of the background migration to run. - # arguments - The arguments to pass to the migration class. - # lease_attempts - The number of times we will try to obtain an exclusive - # lease on the class before giving up. See MR for more discussion. - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956 - def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS) - with_context(caller_id: class_name.to_s) do - retried = lease_attempts != MAX_LEASE_ATTEMPTS - attempts_left = lease_attempts - 1 - should_perform, ttl = perform_and_ttl(class_name, attempts_left, retried) - - break if should_perform.nil? - - if should_perform - Gitlab::BackgroundMigration.perform(class_name, arguments) - else - # If the lease could not be obtained this means either another process is - # running a migration of this class or we ran one recently. In this case - # we'll reschedule the job in such a way that it is picked up again around - # the time the lease expires. - self.class - .perform_in(ttl || self.class.minimum_interval, class_name, arguments, attempts_left) - end - end - end - - def perform_and_ttl(class_name, attempts_left, retried) - # In test environments `perform_in` will run right away. This can then - # lead to stack level errors in the above `#perform`. To work around this - # we'll just perform the migration right away in the test environment. - return [true, nil] if always_perform? - - lease = lease_for(class_name, retried) - lease_obtained = !!lease.try_obtain - healthy_db = healthy_database? - perform = lease_obtained && healthy_db - - database_unhealthy_counter.increment if lease_obtained && !healthy_db - - # When the DB is unhealthy or the lease can't be obtained after several tries, - # then give up on the job and log a warning. Otherwise we could end up in - # an infinite rescheduling loop. Jobs can be tracked in the database with the - # use of Gitlab::Database::BackgroundMigrationJob - if !perform && attempts_left < 0 - msg = if !lease_obtained - 'Job could not get an exclusive lease after several tries. Giving up.' - else - 'Database was unhealthy after several tries. Giving up.' - end - - Sidekiq.logger.warn(class: class_name, message: msg, job_id: jid) - - return [nil, nil] - end - - [perform, lease.ttl] - end - - def lease_for(class_name, retried) - Gitlab::ExclusiveLease - .new(lease_key_for(class_name, retried), timeout: self.class.minimum_interval) - end - - def lease_key_for(class_name, retried) - key = "#{self.class.name}:#{class_name}" - # We use a different exclusive lock key for retried jobs to allow them running concurrently with the scheduled jobs. - # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68763 for more information. - key += ":retried" if retried - key - end - - def always_perform? - Rails.env.test? - end - - # Returns true if the database is healthy enough to allow the migration to be - # performed. - # - # class_name - The name of the background migration that we might want to - # run. - def healthy_database? - !Postgresql::ReplicationSlot.lag_too_great? + def self.tracking_database + @tracking_database ||= Gitlab::BackgroundMigration::DEFAULT_TRACKING_DATABASE end - def database_unhealthy_counter - Gitlab::Metrics.counter( - :background_migration_database_health_reschedules, - 'The number of times a background migration is rescheduled because the database is unhealthy.' - ) + def self.unhealthy_metric_name + @unhealthy_metric_name ||= :background_migration_database_health_reschedules end end diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 5c04cdc96a0..70d6626df91 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -12,6 +12,9 @@ module BulkImports worker_has_external_dependencies! + idempotent! + deduplicate :until_executed, including_scheduled: true + def perform(entity_id, current_stage = nil) return if stage_running?(entity_id, current_stage) @@ -48,7 +51,7 @@ module BulkImports end def next_pipeline_trackers_for(entity_id) - BulkImports::Tracker.next_pipeline_trackers_for(entity_id) + BulkImports::Tracker.next_pipeline_trackers_for(entity_id).update(status_event: 'enqueue') end def logger diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 35633b55489..8e5d7013c2c 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -16,7 +16,7 @@ module BulkImports def perform(pipeline_tracker_id, stage, entity_id) pipeline_tracker = ::BulkImports::Tracker - .with_status(:created, :started) + .with_status(:enqueued) .find_by_id(pipeline_tracker_id) if pipeline_tracker.present? @@ -68,6 +68,8 @@ module BulkImports message: "Retrying error: #{e.message}" ) + pipeline_tracker.update!(status_event: 'retry', jid: jid) + reenqueue(pipeline_tracker, delay: e.retry_delay) else fail_tracker(pipeline_tracker, e) diff --git a/app/workers/ci/create_downstream_pipeline_worker.rb b/app/workers/ci/create_downstream_pipeline_worker.rb index 6d4cd2539c1..747cb088272 100644 --- a/app/workers/ci/create_downstream_pipeline_worker.rb +++ b/app/workers/ci/create_downstream_pipeline_worker.rb @@ -7,6 +7,7 @@ module Ci sidekiq_options retry: 3 worker_resource_boundary :cpu + urgency :high def perform(bridge_id) ::Ci::Bridge.find_by_id(bridge_id).try do |bridge| diff --git a/app/workers/ci/pending_builds/update_group_worker.rb b/app/workers/ci/pending_builds/update_group_worker.rb new file mode 100644 index 00000000000..3ee3a9116d8 --- /dev/null +++ b/app/workers/ci/pending_builds/update_group_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + module PendingBuilds + class UpdateGroupWorker + include ApplicationWorker + include PipelineBackgroundQueue + + data_consistency :always + idempotent! + + def perform(group_id, update_params) + ::Group.find_by_id(group_id).try do |group| + ::Ci::UpdatePendingBuildService.new(group, update_params).execute + end + end + end + end +end diff --git a/app/workers/ci/pending_builds/update_project_worker.rb b/app/workers/ci/pending_builds/update_project_worker.rb new file mode 100644 index 00000000000..bac0316c80b --- /dev/null +++ b/app/workers/ci/pending_builds/update_project_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + module PendingBuilds + class UpdateProjectWorker + include ApplicationWorker + include PipelineBackgroundQueue + + data_consistency :always + idempotent! + + def perform(project_id, update_params) + ::Project.find_by_id(project_id).try do |project| + ::Ci::UpdatePendingBuildService.new(project, update_params).execute + end + end + end + end +end diff --git a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb index bb0a81a0a17..dc7e8f888c6 100644 --- a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb @@ -10,7 +10,7 @@ module Ci sidekiq_options retry: 3 queue_namespace :pipeline_background - feature_category :code_testing + feature_category :code_quality idempotent! diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 03a0b5fae00..d0b09c15289 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -93,9 +93,11 @@ module ApplicationWorker end def perform_async(*args) + return super if Gitlab::Database::LoadBalancing.primary_only? + # Worker execution for workers with data_consistency set to :delayed or :sticky # will be delayed to give replication enough time to complete - if utilizes_load_balancing_capabilities? + if utilizes_load_balancing_capabilities? && Feature.disabled?(:skip_scheduling_workers_for_replicas, default_enabled: :yaml) perform_in(delay_interval, *args) else super diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index 3c5a7717d70..49f0222e9c9 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -15,19 +15,10 @@ class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker idempotent! def perform(job_id) - job = CommitStatus.preload(:pipeline, :project).find_by_id(job_id) # rubocop: disable CodeReuse/ActiveRecord + job = CommitStatus.find_by_id(job_id) return unless job - pipeline = job.pipeline - project = job.project - - Gitlab::EtagCaching::Store.new.touch(project_job_path(project, job)) - ExpirePipelineCacheWorker.perform_async(pipeline.id) - end - - private - - def project_job_path(project, job) - Gitlab::Routing.url_helpers.project_build_path(project, job.id, format: :json) + job.expire_etag_cache! + ExpirePipelineCacheWorker.perform_async(job.pipeline_id) end end diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index 9d543a21dc3..ffa0ed68fc7 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -41,7 +41,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker def parse_params(params, project_id) params - .symbolize_keys + .with_indifferent_access .except(:sort) .merge(project_id: project_id) end diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index cfd72b90a42..26dec221f45 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# DEPRECATED. Will be removed in 14.7 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72803 +# Please use Issues::PlacementWorker instead +# # todo: remove this worker and it's queue definition from all_queues after Issues::PlacementWorker is deployed # We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker # to be available to finish those. All new jobs will be queued into the new queue. @@ -43,10 +46,10 @@ class IssuePlacementWorker Issue.move_nulls_to_end(to_place) Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position)) - IssuePlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? + Issues::PlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? rescue RelativePositioning::NoSpaceLeft => e Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) - IssueRebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id)) + Issues::RebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id)) end def find_issue(issue_id, project_id) diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index a43e76feae4..73edb2eb653 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# DEPRECATED. Will be removed in 14.7 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/72803 +# Please use Issues::RebalancingWorker instead +# # todo: remove this worker and it's queue definition from all_queues after Issue::RebalancingWorker is released. # We want to keep it for one release in case some jobs are already scheduled in the old queue so we need the worker # to be available to finish those. All new jobs will be queued into the new queue. diff --git a/app/workers/issues/rebalancing_worker.rb b/app/workers/issues/rebalancing_worker.rb index 466617d9fa1..8de0588a2a1 100644 --- a/app/workers/issues/rebalancing_worker.rb +++ b/app/workers/issues/rebalancing_worker.rb @@ -17,6 +17,7 @@ module Issues # we need to have exactly one of the project_id and root_namespace_id params be non-nil raise ArgumentError, "Expected only one of the params project_id: #{project_id} and root_namespace_id: #{root_namespace_id}" if project_id && root_namespace_id return if project_id.nil? && root_namespace_id.nil? + return if ::Gitlab::Issues::Rebalancing::State.rebalance_recently_finished?(project_id, root_namespace_id) # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce) # or the root namespace, this also makes the worker backward compatible with previous version where a project_id was diff --git a/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb index d1759589cc0..77cedae558b 100644 --- a/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb +++ b/app/workers/issues/reschedule_stuck_issue_rebalances_worker.rb @@ -20,13 +20,13 @@ module Issues namespaces = Namespace.id_in(namespace_ids) projects = Project.id_in(project_ids) - IssueRebalancingWorker.bulk_perform_async_with_contexts( + Issues::RebalancingWorker.bulk_perform_async_with_contexts( namespaces, arguments_proc: -> (namespace) { [nil, nil, namespace.id] }, context_proc: -> (namespace) { { namespace: namespace } } ) - IssueRebalancingWorker.bulk_perform_async_with_contexts( + Issues::RebalancingWorker.bulk_perform_async_with_contexts( projects, arguments_proc: -> (project) { [nil, project.id, nil] }, context_proc: -> (project) { { project: project } } diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb new file mode 100644 index 00000000000..f3c4f5bebb1 --- /dev/null +++ b/app/workers/namespaces/process_sync_events_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Namespaces + # This worker can be called multiple times at the same time but only one of them can + # process events at a time. This is ensured by `try_obtain_lease` in `Ci::ProcessSyncEventsService`. + # `until_executing` here is to reduce redundant worker enqueuing. + class ProcessSyncEventsWorker + include ApplicationWorker + + data_consistency :always + + feature_category :sharding + urgency :high + + idempotent! + deduplicate :until_executing + + def perform + ::Ci::ProcessSyncEventsService.new(::Namespaces::SyncEvent, ::Ci::NamespaceMirror).execute + end + end +end diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb new file mode 100644 index 00000000000..b7c4b4de3d0 --- /dev/null +++ b/app/workers/projects/process_sync_events_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Projects + # This worker can be called multiple times at the same time but only one of them can + # process events at a time. This is ensured by `try_obtain_lease` in `Ci::ProcessSyncEventsService`. + # `until_executing` here is to reduce redundant worker enqueuing. + class ProcessSyncEventsWorker + include ApplicationWorker + + data_consistency :always + + feature_category :sharding + urgency :high + + idempotent! + deduplicate :until_executing + + def perform + ::Ci::ProcessSyncEventsService.new(::Projects::SyncEvent, ::Ci::ProjectMirror).execute + end + end +end diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb index 9d21d92b6e3..099f423dc0f 100644 --- a/app/workers/propagate_integration_worker.rb +++ b/app/workers/propagate_integration_worker.rb @@ -12,6 +12,6 @@ class PropagateIntegrationWorker idempotent! def perform(integration_id) - Admin::PropagateIntegrationService.propagate(Integration.find(integration_id)) + ::Integrations::PropagateService.propagate(Integration.find(integration_id)) end end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb deleted file mode 100644 index 908f867279f..00000000000 --- a/app/workers/propagate_service_template_worker.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -# No longer in use https://gitlab.com/groups/gitlab-org/-/epics/5672 -# To be removed https://gitlab.com/gitlab-org/gitlab/-/issues/335178 -class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - feature_category :integrations - - LEASE_TIMEOUT = 4.hours.to_i - - def perform(template_id) - return unless try_obtain_lease_for(template_id) - - Admin::PropagateServiceTemplate.propagate(Integration.find_by_id(template_id)) - end - - private - - def try_obtain_lease_for(template_id) - Gitlab::ExclusiveLease - .new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT) - .try_obtain - end -end diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb index db43e4adf20..615fa81f28e 100644 --- a/app/workers/purge_dependency_proxy_cache_worker.rb +++ b/app/workers/purge_dependency_proxy_cache_worker.rb @@ -12,14 +12,21 @@ class PurgeDependencyProxyCacheWorker queue_namespace :dependency_proxy feature_category :dependency_proxy + UPDATE_BATCH_SIZE = 100 + def perform(current_user_id, group_id) @current_user = User.find_by_id(current_user_id) @group = Group.find_by_id(group_id) return unless valid? - @group.dependency_proxy_blobs.destroy_all # rubocop:disable Cop/DestroyAll - @group.dependency_proxy_manifests.destroy_all # rubocop:disable Cop/DestroyAll + @group.dependency_proxy_blobs.each_batch(of: UPDATE_BATCH_SIZE) do |batch| + batch.update_all(status: :expired) + end + + @group.dependency_proxy_manifests.each_batch(of: UPDATE_BATCH_SIZE) do |batch| + batch.update_all(status: :expired) + end end private diff --git a/app/workers/todos_destroyer/private_features_worker.rb b/app/workers/todos_destroyer/private_features_worker.rb index 150e1c8a50e..09e81216aab 100644 --- a/app/workers/todos_destroyer/private_features_worker.rb +++ b/app/workers/todos_destroyer/private_features_worker.rb @@ -10,7 +10,7 @@ module TodosDestroyer include TodosDestroyerQueue def perform(project_id, user_id = nil) - ::Todos::Destroy::PrivateFeaturesService.new(project_id, user_id).execute + ::Todos::Destroy::UnauthorizedFeaturesService.new(project_id, user_id).execute end end end |