diff options
Diffstat (limited to 'app')
1800 files changed, 26722 insertions, 17351 deletions
diff --git a/app/assets/images/logos/shimo.svg b/app/assets/images/logos/shimo.svg deleted file mode 100644 index 65bd1cc7167..00000000000 --- a/app/assets/images/logos/shimo.svg +++ /dev/null @@ -1 +0,0 @@ -<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/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 9a7296b6b1f..3b71e39d69b 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -158,7 +158,7 @@ export default { :aria-label="$options.i18n.revokeButton" :data-confirm="modalMessage(name)" data-confirm-btn-variant="danger" - data-qa-selector="revoke_button" + data-testid="revoke-button" data-method="put" :href="revokePath" icon="remove" diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue index 38501d63d3a..65206670a3c 100644 --- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -68,7 +68,7 @@ export default { :input-name="inputAttrs.name" :input-id="inputAttrs.id" :placeholder="inputAttrs.placeholder" - data-qa-selector="expiry_date_field" + data-testid="expiry-date-field" /> <template #description> <template v-if="description"> diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue index 4b51b4333aa..f476503c091 100644 --- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -45,7 +45,7 @@ export default { formInputGroupProps() { return { id: this.$options.tokenInputId, - 'data-qa-selector': 'created_access_token_field', + 'data-testid': 'created-access-token-field', name: this.$options.tokenInputId, }; }, @@ -110,7 +110,7 @@ export default { @[$options.EVENT_ERROR]="onError" @[$options.EVENT_SUCCESS]="onSuccess" > - <div ref="container" data-testid="access-token-section" data-qa-selector="access_token_section"> + <div ref="container" data-testid="access-token-section"> <gl-alert v-if="newToken" variant="success" diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js index 890db374160..b2d8791ee54 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { uniqBy, orderBy } from 'lodash'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api from '~/api'; import { createAlert } from '~/alert'; @@ -52,8 +52,8 @@ export const searchCommits = ({ dispatch, commit, state }, search = {}) => { }; export const setCommits = ({ commit }, { commits: data, silentAddition = false }) => { - let commits = _.uniqBy(data, 'short_id'); - commits = _.orderBy(data, (c) => new Date(c.committed_date), ['desc']); + let commits = uniqBy(data, 'short_id'); + commits = orderBy(data, (c) => new Date(c.committed_date), ['desc']); if (silentAddition) { commit(types.SET_COMMITS_SILENT, commits); } else { @@ -125,8 +125,8 @@ export const removeContextCommits = ({ state }, forceReload = false) => }); export const setSelectedCommits = ({ commit }, selected) => { - let selectedCommits = _.uniqBy(selected, 'short_id'); - selectedCommits = _.orderBy( + let selectedCommits = uniqBy(selected, 'short_id'); + selectedCommits = orderBy( selectedCommits, (selectedCommit) => new Date(selectedCommit.committed_date), ['desc'], diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue index 80af7d7400a..29de7e1ad1d 100644 --- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue @@ -6,6 +6,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_not import { SKELETON_NOTES_COUNT } from '~/admin/abuse_report/constants'; import abuseReportNotesQuery from '../graphql/notes/abuse_report_notes.query.graphql'; import AbuseReportDiscussion from './notes/abuse_report_discussion.vue'; +import AbuseReportAddNote from './notes/abuse_report_add_note.vue'; export default { name: 'AbuseReportNotes', @@ -16,6 +17,7 @@ export default { components: { SkeletonLoadingContainer, AbuseReportDiscussion, + AbuseReportAddNote, }, props: { abuseReportId: { @@ -60,6 +62,9 @@ export default { const discussionId = discussion.notes.nodes[0].id; return discussionId.split('/')[discussionId.split('/').length - 1]; }, + updateKey() { + this.addNoteKey = uniqueId(`abuse-report-add-note-${this.abuseReportId}`); + }, }, }; </script> @@ -86,6 +91,16 @@ export default { :abuse-report-id="abuseReportId" /> </ul> + <div class="js-comment-form"> + <ul class="notes notes-form timeline"> + <abuse-report-add-note + :key="addNoteKey" + :is-new-discussion="true" + :abuse-report-id="abuseReportId" + @cancelEditing="updateKey" + /> + </ul> + </div> </template> </div> </div> diff --git a/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue b/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue index 5962203c382..ac7eeece694 100644 --- a/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue +++ b/app/assets/javascripts/admin/abuse_report/components/activity_history_item.vue @@ -31,7 +31,7 @@ export default { <template> <history-item icon="warning"> - <div class="gl-display-flex gl-xs-flex-direction-column"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> <gl-sprintf :message="$options.i18n.reportedByForCategory"> <template #name>{{ reporterName }}</template> <template #category>{{ report.category }}</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_add_note.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_add_note.vue new file mode 100644 index 00000000000..610b34a466f --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_add_note.vue @@ -0,0 +1,156 @@ +<script> +import { sprintf, __ } from '~/locale'; +import { createAlert } from '~/alert'; +import { clearDraft } from '~/lib/utils/autosave'; +import createNoteMutation from '../../graphql/notes/create_abuse_report_note.mutation.graphql'; +import AbuseReportCommentForm from './abuse_report_comment_form.vue'; + +export default { + name: 'AbuseReportAddNote', + i18n: { + reply: __('Reply…'), + replyToComment: __('Reply to comment'), + commentError: __('Your comment could not be submitted because %{reason}.'), + genericError: __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ), + }, + components: { + AbuseReportCommentForm, + }, + props: { + abuseReportId: { + type: String, + required: true, + }, + discussionId: { + type: String, + required: false, + default: '', + }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, + showCommentForm: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isEditing: this.isNewDiscussion, + isSubmitting: false, + }; + }, + computed: { + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.discussionId ? `${this.discussionId}-comment` : `${this.abuseReportId}-comment`; + }, + timelineEntryClasses() { + return this.isNewDiscussion + ? 'timeline-entry note-form' + : // eslint-disable-next-line @gitlab/require-i18n-strings + 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix'; + }, + timelineEntryInnerClasses() { + return this.isNewDiscussion ? 'timeline-entry-inner' : ''; + }, + commentFormWrapperClasses() { + return !this.isEditing + ? 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap' + : ''; + }, + commentButtonText() { + return this.isNewDiscussion ? __('Comment') : __('Reply'); + }, + }, + watch: { + showCommentForm: { + immediate: true, + handler(focus) { + if (focus) { + this.isEditing = true; + } + }, + }, + }, + methods: { + async addNote({ commentText }) { + this.isSubmitting = true; + + this.$apollo + .mutate({ + mutation: createNoteMutation, + variables: { + input: { + noteableId: this.abuseReportId, + body: commentText, + discussionId: this.discussionId || null, + }, + }, + }) + .then(() => { + clearDraft(this.autosaveKey); + this.cancelEditing(); + }) + .catch((error) => { + const errorMessage = error?.message + ? sprintf(this.$options.i18n.commentError, { reason: error.message.toLowerCase() }) + : this.$options.i18n.genericError; + + createAlert({ + message: errorMessage, + parent: this.$el, + captureError: true, + }); + }) + .finally(() => { + this.isSubmitting = false; + }); + }, + cancelEditing() { + this.isEditing = this.isNewDiscussion; + this.$emit('cancelEditing'); + }, + showReplyForm() { + this.isEditing = true; + }, + }, +}; +</script> + +<template> + <li :class="timelineEntryClasses" data-testid="abuse-report-note-timeline-entry"> + <div :class="timelineEntryInnerClasses" data-testid="abuse-report-note-timeline-entry-inner"> + <div class="timeline-content"> + <div class="flash-container"></div> + <div :class="commentFormWrapperClasses" data-testid="abuse-report-comment-form-wrapper"> + <abuse-report-comment-form + v-if="isEditing" + :abuse-report-id="abuseReportId" + :is-submitting="isSubmitting" + :autosave-key="autosaveKey" + :comment-button-text="commentButtonText" + @submitForm="addNote" + @cancelEditing="cancelEditing" + /> + <textarea + v-else + ref="textarea" + rows="1" + class="reply-placeholder-text-field" + data-testid="abuse-report-note-reply-textarea" + :placeholder="$options.i18n.reply" + :aria-label="$options.i18n.replyToComment" + @focus="showReplyForm" + @click="showReplyForm" + ></textarea> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_comment_form.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_comment_form.vue new file mode 100644 index 00000000000..e7ee916fe5d --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_comment_form.vue @@ -0,0 +1,133 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; + +export default { + name: 'AbuseReportCommentForm', + i18n: { + addReplyText: __('Add a reply'), + placeholderText: __('Write a comment or drag your files here…'), + cancelButtonText: __('Cancel'), + confirmText: s__('Notes|Are you sure you want to cancel creating this comment?'), + discardText: __('Discard changes'), + continueEditingText: __('Continue editing'), + }, + components: { + GlButton, + MarkdownEditor, + }, + inject: ['uploadNoteAttachmentPath'], + props: { + abuseReportId: { + type: String, + required: true, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + autosaveKey: { + type: String, + required: true, + }, + initialValue: { + type: String, + required: false, + default: '', + }, + commentButtonText: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + commentText: getDraft(this.autosaveKey) || this.initialValue || '', + }; + }, + computed: { + formFieldProps() { + return { + 'aria-label': this.$options.i18n.addReplyText, + placeholder: this.$options.i18n.placeholderText, + id: 'abuse-report-add-or-edit-comment', + name: 'abuse-report-add-or-edit-comment', + }; + }, + markdownDocsPath() { + return helpPagePath('user/markdown'); + }, + }, + methods: { + setCommentText(newText) { + if (!this.isSubmitting) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + } + }, + async cancelEditing() { + if (this.commentText && this.commentText !== this.initialValue) { + const confirmed = await confirmAction(this.$options.i18n.confirmText, { + primaryBtnText: this.$options.i18n.discardText, + cancelBtnText: this.$options.i18n.continueEditingText, + primaryBtnVariant: 'danger', + }); + + if (!confirmed) { + return; + } + } + + this.$emit('cancelEditing'); + clearDraft(this.autosaveKey); + }, + }, +}; +</script> + +<template> + <div class="timeline-discussion-body gl-overflow-visible!"> + <div class="note-body gl-p-0! gl-overflow-visible!"> + <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1 new-note"> + <markdown-editor + :value="commentText" + :enable-content-editor="false" + render-markdown-path="" + :uploads-path="uploadNoteAttachmentPath" + :markdown-docs-path="markdownDocsPath" + :form-field-props="formFieldProps" + :autofocus="true" + @input="setCommentText" + @keydown.meta.enter="$emit('submitForm', { commentText })" + @keydown.ctrl.enter="$emit('submitForm', { commentText })" + @keydown.esc.stop="cancelEditing" + /> + <div class="note-form-actions"> + <gl-button + category="primary" + variant="confirm" + data-testid="comment-button" + :disabled="!commentText.length" + :loading="isSubmitting" + @click="$emit('submitForm', { commentText })" + > + {{ commentButtonText }} + </gl-button> + <gl-button + data-testid="cancel-button" + category="primary" + class="gl-ml-3" + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} + </gl-button> + </div> + </form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue index 4d24471fa43..4f28ec65e87 100644 --- a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue @@ -4,6 +4,7 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue'; import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; import AbuseReportNote from './abuse_report_note.vue'; +import AbuseReportAddNote from './abuse_report_add_note.vue'; export default { name: 'AbuseReportDiscussion', @@ -12,6 +13,7 @@ export default { DiscussionNotesRepliesWrapper, ToggleRepliesWidget, AbuseReportNote, + AbuseReportAddNote, }, props: { abuseReportId: { @@ -26,6 +28,7 @@ export default { data() { return { isExpanded: true, + showCommentForm: false, }; }, computed: { @@ -52,16 +55,24 @@ export default { toggleDiscussion() { this.isExpanded = !this.isExpanded; }, + startReplying() { + this.showCommentForm = true; + }, + stopReplying() { + this.showCommentForm = false; + }, }, }; </script> <template> <abuse-report-note - v-if="!hasReplies" + v-if="!hasReplies && !showCommentForm" :note="note" :abuse-report-id="abuseReportId" + show-reply-button class="gl-mb-4" + @startReplying="startReplying" /> <timeline-entry-item v-else :data-note-id="noteId" class="note note-discussion gl-px-0"> <div class="timeline-content"> @@ -74,7 +85,9 @@ export default { :note="note" :discussion-id="discussionId" :abuse-report-id="abuseReportId" + show-reply-button class="gl-mb-4" + @startReplying="startReplying" /> <discussion-notes-replies-wrapper> <toggle-replies-widget @@ -92,6 +105,13 @@ export default { :abuse-report-id="abuseReportId" /> </template> + <abuse-report-add-note + :discussion-id="discussionId" + :is-new-discussion="false" + :show-comment-form="showCommentForm" + :abuse-report-id="abuseReportId" + @cancelEditing="stopReplying" + /> </template> </discussion-notes-replies-wrapper> </ul> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue new file mode 100644 index 00000000000..e2c348f8079 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue @@ -0,0 +1,98 @@ +<script> +import { sprintf, __ } from '~/locale'; +import { createAlert } from '~/alert'; +import { clearDraft } from '~/lib/utils/autosave'; +import updateNoteMutation from '../../graphql/notes/update_abuse_report_note.mutation.graphql'; + +import AbuseReportCommentForm from './abuse_report_comment_form.vue'; + +export default { + name: 'AbuseReportEditNote', + i18n: { + updateError: __('Your comment could not be updated because %{reason}.'), + genericError: __('Something went wrong while editing your comment. Please try again.'), + }, + components: { + AbuseReportCommentForm, + }, + props: { + abuseReportId: { + type: String, + required: true, + }, + discussionId: { + type: String, + required: false, + default: '', + }, + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isSubmitting: false, + }; + }, + computed: { + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.note.id}-comment`; + }, + commentButtonText() { + return __('Save comment'); + }, + }, + methods: { + async updateNote({ commentText }) { + this.isSubmitting = true; + + this.$apollo + .mutate({ + mutation: updateNoteMutation, + variables: { + input: { + id: this.note.id, + body: commentText, + }, + }, + }) + .then(() => { + clearDraft(this.autosaveKey); + this.$emit('cancelEditing'); + }) + .catch((error) => { + const errorMessage = error?.message + ? sprintf(this.$options.i18n.updateError, { reason: error.message.toLowerCase() }) + : this.$options.i18n.genericError; + + createAlert({ + message: errorMessage, + parent: this.$el, + captureError: true, + }); + }) + .finally(() => { + this.isSubmitting = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <div class="flash-container"></div> + <abuse-report-comment-form + :abuse-report-id="abuseReportId" + :initial-value="note.body" + :is-submitting="isSubmitting" + :autosave-key="autosaveKey" + :comment-button-text="commentButtonText" + class="gl-pl-3 gl-mt-3" + @submitForm="updateNote" + @cancelEditing="$emit('cancelEditing')" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue index 6da3017e11e..4423eb9e7b2 100644 --- a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue @@ -4,7 +4,10 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteHeader from '~/notes/components/note_header.vue'; +import EditedAt from '~/issues/show/components/edited.vue'; +import AbuseReportEditNote from './abuse_report_edit_note.vue'; import NoteBody from './abuse_report_note_body.vue'; +import AbuseReportNoteActions from './abuse_report_note_actions.vue'; export default { name: 'AbuseReportNote', @@ -15,8 +18,11 @@ export default { GlAvatarLink, GlAvatar, TimelineEntryItem, + AbuseReportEditNote, NoteHeader, NoteBody, + AbuseReportNoteActions, + EditedAt, }, props: { abuseReportId: { @@ -27,6 +33,16 @@ export default { type: Object, required: true, }, + showReplyButton: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isEditing: false, + }; }, computed: { noteAnchorId() { @@ -38,6 +54,20 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + showEditButton() { + return this.note.userPermissions.resolveNote; + }, + editedAtClasses() { + return this.showReplyButton ? 'gl-text-secondary gl-pl-3' : 'gl-text-secondary gl-pl-8'; + }, + }, + methods: { + startEditing() { + this.isEditing = true; + }, + cancelEditing() { + this.isEditing = false; + }, }, }; </script> @@ -59,8 +89,14 @@ export default { /> </gl-avatar-link> </div> - <div class="timeline-content"> - <div data-testid="note-wrapper"> + <div class="timeline-content gl-pb-4!"> + <abuse-report-edit-note + v-if="isEditing" + :abuse-report-id="abuseReportId" + :note="note" + @cancelEditing="cancelEditing" + /> + <div v-else data-testid="note-wrapper"> <div class="note-header"> <note-header :author="author" @@ -70,11 +106,27 @@ export default { > <span v-if="note.createdAt" class="d-none d-sm-inline">·</span> </note-header> + <div class="gl-display-inline-flex"> + <abuse-report-note-actions + :show-reply-button="showReplyButton" + :show-edit-button="showEditButton" + @startReplying="$emit('startReplying')" + @startEditing="startEditing" + /> + </div> </div> <div class="timeline-discussion-body"> <note-body ref="noteBody" :note="note" /> </div> + + <edited-at + v-if="note.lastEditedBy" + :updated-at="note.lastEditedAt" + :updated-by-name="note.lastEditedBy.name" + :updated-by-path="note.lastEditedBy.webPath" + :class="editedAtClasses" + /> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_actions.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_actions.vue new file mode 100644 index 00000000000..e35e231fc1b --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_actions.vue @@ -0,0 +1,49 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; + +export default { + name: 'AbuseReportNoteActions', + i18n: { + editButtonText: __('Edit comment'), + }, + components: { + GlButton, + ReplyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + showReplyButton: { + type: Boolean, + required: false, + default: false, + }, + showEditButton: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="note-actions"> + <reply-button + v-if="showReplyButton" + ref="replyButton" + @startReplying="$emit('startReplying')" + /> + <gl-button + v-if="showEditButton" + v-gl-tooltip + category="tertiary" + icon="pencil" + :title="$options.i18n.editButtonText" + :aria-label="$options.i18n.editButtonText" + @click="$emit('startEditing')" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue index e005e183c9f..3e9cc36b8b2 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -64,7 +64,6 @@ export default { }, computed: { getDrawerHeaderHeight() { - if (!this.showActionsDrawer || gon.use_new_navigation) return '0'; return getContentWrapperHeight(); }, isFormValid() { diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue index 90c1943cb27..78689c58ecc 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_header.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue @@ -53,7 +53,7 @@ export default { <template> <header - class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-flex-direction-column gl-sm-flex-direction-row" > <div class="gl-display-flex gl-align-items-center gl-gap-3"> <gl-badge :variant="badgeVariant" :aria-label="badgeText"> @@ -67,7 +67,7 @@ export default { <gl-link :href="user.path"> @{{ user.username }} </gl-link> </div> <nav - class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-xs-flex-direction-column" + class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-flex-direction-column gl-sm-flex-direction-row" > <gl-button :href="user.adminPath"> {{ $options.i18n.adminProfile }} diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue index 99c8b3ece10..07e91f8bf6a 100644 --- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -65,14 +65,14 @@ export default { <template> <div class="gl-pt-6"> <div - class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column gl-align-items-center" + class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-flex-direction-column gl-sm-flex-direction-row gl-align-items-center" > <h2 class="gl-font-size-h1 gl-mt-2 gl-mb-2"> {{ $options.i18n.reportTypes[reportType] }} </h2> <div - class="gl-display-flex gl-align-items-stretch gl-xs-flex-direction-column gl-mt-3 gl-sm-mt-0" + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-sm-flex-direction-row gl-mt-3 gl-sm-mt-0" > <template v-if="report.screenshot"> <gl-button data-testid="screenshot-button" @click="toggleScreenshotModal"> diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql index 01436436b93..31ca24e675f 100644 --- a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql @@ -1,3 +1,3 @@ fragment AbuseReportNotePermissions on NotePermissions { - adminNote + resolveNote } diff --git a/app/assets/javascripts/admin/abuse_report/index.js b/app/assets/javascripts/admin/abuse_report/index.js index c2117130d26..e4319b3edef 100644 --- a/app/assets/javascripts/admin/abuse_report/index.js +++ b/app/assets/javascripts/admin/abuse_report/index.js @@ -30,6 +30,7 @@ export const initAbuseReportApp = () => { allowScopedLabels: false, updatePath: abuseReport.report.updatePath, listPath: abuseReportsListPath, + uploadNoteAttachmentPath: abuseReport.uploadNoteAttachmentPath, labelsManagePath: '', allowLabelCreate: true, }, diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index 662451c5eb4..62924dcd0a8 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -281,7 +281,7 @@ export default { :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> - <div data-testid="project-name" data-qa-selector="project_name">{{ item.name }}</div> + <div data-testid="project-name">{{ item.name }}</div> <div class="gl-text-gray-500" data-testid="project-full-path"> {{ item.fullPath }} </div> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index f0d9bf201e5..e48281a7453 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -6,6 +6,7 @@ import { getDateInPast, getCurrentUtcDate, nWeeksBefore, + nYearsBefore, } from '~/lib/utils/datetime_utility'; import { s__, __, sprintf, n__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -14,11 +15,11 @@ export const DATE_RANGE_LIMIT = 180; export const DEFAULT_DATE_RANGE = 29; // 30 including current date export const PROJECTS_PER_PAGE = 50; -const { isoDate, mediumDate } = masks; +const { isoDate } = masks; export const dateFormats = { isoDate, - defaultDate: mediumDate, - defaultDateTime: 'mmm d, yyyy h:MMtt', + defaultDate: 'mmm dd, yyyy', + defaultDateTime: 'mmm dd, yyyy h:MMtt', month: 'mmmm', }; @@ -251,3 +252,43 @@ export const METRICS_POPOVER_CONTENT = { ), }, }; + +export const USAGE_OVERVIEW_NO_DATA_ERROR = s__( + 'ValueStreamAnalytics|Failed to load usage overview data', +); + +export const USAGE_OVERVIEW_DEFAULT_DATE_RANGE = { + endDate: TODAY, + startDate: nYearsBefore(TODAY, 1), +}; + +export const USAGE_OVERVIEW_IDENTIFIER_GROUPS = 'groups'; +export const USAGE_OVERVIEW_IDENTIFIER_PROJECTS = 'projects'; +export const USAGE_OVERVIEW_IDENTIFIER_ISSUES = 'issues'; +export const USAGE_OVERVIEW_IDENTIFIER_MERGE_REQUESTS = 'merge_requests'; +export const USAGE_OVERVIEW_IDENTIFIER_PIPELINES = 'pipelines'; + +// Defines the constants used for querying the API as well as the order they appear +export const USAGE_OVERVIEW_METADATA = { + [USAGE_OVERVIEW_IDENTIFIER_GROUPS]: { options: { title: __('Groups'), titleIcon: 'group' } }, + [USAGE_OVERVIEW_IDENTIFIER_PROJECTS]: { + options: { title: __('Projects'), titleIcon: 'project' }, + }, + [USAGE_OVERVIEW_IDENTIFIER_ISSUES]: { + options: { title: __('Issues'), titleIcon: 'issues' }, + }, + [USAGE_OVERVIEW_IDENTIFIER_MERGE_REQUESTS]: { + options: { title: __('Merge requests'), titleIcon: 'merge-request' }, + }, + [USAGE_OVERVIEW_IDENTIFIER_PIPELINES]: { + options: { title: __('Pipelines'), titleIcon: 'pipeline' }, + }, +}; + +export const USAGE_OVERVIEW_QUERY_INCLUDE_KEYS = { + [USAGE_OVERVIEW_IDENTIFIER_GROUPS]: 'includeGroups', + [USAGE_OVERVIEW_IDENTIFIER_PROJECTS]: 'includeProjects', + [USAGE_OVERVIEW_IDENTIFIER_ISSUES]: 'includeIssues', + [USAGE_OVERVIEW_IDENTIFIER_MERGE_REQUESTS]: 'includeMergeRequests', + [USAGE_OVERVIEW_IDENTIFIER_PIPELINES]: 'includePipelines', +}; diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js index 248f5601705..a5c9f904c1f 100644 --- a/app/assets/javascripts/api/bulk_imports_api.js +++ b/app/assets/javascripts/api/bulk_imports_api.js @@ -1,12 +1,22 @@ import { buildApiUrl } from '~/api/api_utils'; import axios from '~/lib/utils/axios_utils'; -const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; +const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/:id/entities'; +const BULK_IMPORTS_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; const BULK_IMPORT_ENTITIES_FAILURES_PATH = '/api/:version/bulk_imports/:id/entities/:entity_id/failures'; +export const getBulkImportHistory = (id, params = {}) => { + const bulkImportHistoryUrl = buildApiUrl(BULK_IMPORT_ENTITIES_PATH).replace( + ':id', + encodeURIComponent(id), + ); + + return axios.get(bulkImportHistoryUrl, { params }); +}; + export const getBulkImportsHistory = (params) => - axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params }); + axios.get(buildApiUrl(BULK_IMPORTS_ENTITIES_PATH), { params }); export const getBulkImportFailures = (id, entityId, { page, perPage }) => { const failuresPath = buildApiUrl(BULK_IMPORT_ENTITIES_FAILURES_PATH) diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index c056b42b5b6..302de976080 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -4,6 +4,7 @@ import { buildApiUrl } from './api_utils'; const USER_COUNTS_PATH = '/api/:version/user_counts'; const USERS_PATH = '/api/:version/users.json'; +const USERS_SAML_PATH = '/api/:version/groups/:id/users.json'; const USER_PATH = '/api/:version/users/:id'; const USER_STATUS_PATH = '/api/:version/users/:id/status'; const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; @@ -25,6 +26,25 @@ export function getUsers(query, options) { }); } +/** + * Returns a list of SAML users and service accounts that contains the query string. + * If the query string is less than 3 characters it returns an empty list. + * + * @param {string} query - query string to search + * @param {string} groupId -- top-level group id + * @param {object} options + */ +export function getGroupUsers(query, groupId, options) { + const url = buildApiUrl(USERS_SAML_PATH).replace(':id', groupId); + return axios.get(url, { + params: { + search: query, + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); +} + export function getUser(id, options) { const url = buildApiUrl(USER_PATH).replace(':id', encodeURIComponent(id)); return axios.get(url, { diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue index 6e3af96cf33..7f2a2beaa47 100644 --- a/app/assets/javascripts/authentication/password/components/password_input.vue +++ b/app/assets/javascripts/authentication/password/components/password_input.vue @@ -27,11 +27,6 @@ export default { required: false, default: null, }, - qaSelector: { - type: String, - required: false, - default: null, - }, testid: { type: String, required: false, @@ -80,7 +75,6 @@ export default { :autocomplete="autocomplete" :name="name" :minlength="minimumPasswordLength" - :data-qa-selector="qaSelector" :data-testid="testid" :title="title" :type="type" diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js index a4f2d038cf7..903512a7b53 100644 --- a/app/assets/javascripts/authentication/password/index.js +++ b/app/assets/javascripts/authentication/password/index.js @@ -9,7 +9,7 @@ export const initPasswordInput = () => { } const { form } = el; - const { title, id, minimumPasswordLength, qaSelector, testid, autocomplete, name } = el.dataset; + const { title, id, minimumPasswordLength, testid, autocomplete, name } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -21,7 +21,6 @@ export const initPasswordInput = () => { title, id, minimumPasswordLength, - qaSelector, testid, autocomplete, name, diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue index 907b68e6ffc..e97846bae29 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue @@ -119,7 +119,6 @@ export default { type="password" name="current_password" :state="currentPasswordState" - data-qa-selector="current_password_field" /> </gl-form-group> diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue index d3b914ea8aa..240bf005532 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue @@ -115,14 +115,10 @@ export default { </gl-sprintf> </p> - <gl-card - class="codes-to-print gl-my-5" - data-testid="recovery-codes" - data-qa-selector="codes_content" - > + <gl-card class="codes-to-print gl-my-5" data-testid="recovery-codes"> <ul class="gl-m-0 gl-pl-5"> <li v-for="(code, index) in codes" :key="index"> - <span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span> + <span class="gl-font-monospace" data-testid="code-content">{{ code }}</span> </li> </ul> </gl-card> @@ -131,7 +127,7 @@ export default { <clipboard-button :title="$options.i18n.copyButton" :text="codesAsString" - data-qa-selector="copy_button" + data-testid="copy-button" @click="handleButtonClick($options.copyButtonAction)" > {{ $options.i18n.copyButton }} @@ -163,7 +159,7 @@ export default { :disabled="proceedButtonDisabled" :title="$options.i18n.proceedButton" variant="confirm" - data-qa-selector="proceed_button" + data-testid="proceed-button" data-track-action="click_button" :data-track-label="`${$options.trackingLabelPrefix}proceed_button`" >{{ $options.i18n.proceedButton }}</gl-button diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 19da1253a17..fbe773e6e2d 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -183,9 +183,7 @@ export default { }) .catch((error) => { createAlert({ - message: s__( - 'Badges|Adding the badge failed, please check the entered URLs and try again.', - ), + message: s__('Badges|Failed to add new badge. Check the URLs, then try again.'), }); throw error; }); @@ -215,7 +213,7 @@ export default { @submit.prevent.stop="onSubmit" > <gl-form-group :label="s__('Badges|Name')" label-for="badge-name" class="gl-max-w-48"> - <gl-form-input id="badge-name" v-model="name" data-qa-selector="badge_name_field" /> + <gl-form-input id="badge-name" v-model="name" data-testid="badge-name-field" /> </gl-form-group> <div class="form-group"> @@ -224,7 +222,7 @@ export default { <input id="badge-link-url" v-model="linkUrl" - data-qa-selector="badge_link_url_field" + data-testid="badge-link-url-field" type="URL" class="form-control gl-form-input gl-max-w-80" required @@ -240,7 +238,7 @@ export default { <input id="badge-image-url" v-model="imageUrl" - data-qa-selector="badge_image_url_field" + data-testid="badge-image-url-field" type="URL" class="form-control gl-form-input gl-max-w-80" required @@ -272,7 +270,7 @@ export default { type="submit" variant="confirm" category="primary" - data-qa-selector="add_badge_button" + data-testid="add-badge-button" class="gl-mr-3" > {{ saveText }} diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index 12c9662b30d..a4f88067fa9 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -29,10 +29,8 @@ export default { GlModal: GlModalDirective, }, i18n: { - emptyGroupMessage: s__('Badges|This group has no badges, start by creating a new one above.'), - emptyProjectMessage: s__( - 'Badges|This project has no badges, start by creating a new one above.', - ), + emptyGroupMessage: s__('Badges|This group has no badges. Add an existing badge or create one.'), + emptyProjectMessage: s__('Badges|This project has no badges. Start by adding a new badge.'), }, data() { return { diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index f0d354c6378..32c24564d21 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -25,7 +25,7 @@ export default { addButton: s__('Badges|Add badge'), addFormTitle: s__('Badges|Add new badge'), deleteModalText: s__( - 'Badges|You are going to delete this badge. Deleted badges %{strongStart}cannot%{strongEnd} be restored.', + 'Badges|If you delete this badge, you %{strongStart}cannot%{strongEnd} restore it.', ), }, data() { @@ -74,7 +74,7 @@ export default { }) .catch((error) => { createAlert({ - message: s__('Badges|Deleting the badge failed, please try again.'), + message: s__('Badges|Failed to delete the badge. Try again.'), }); throw error; }); diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index fddb843bb52..da7c7809bed 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -25,11 +25,10 @@ export default { resolvedStatusMessage() { let message; const discussionResolved = this.isDiscussionResolved( - this.draft ? this.draft.discussion_id : this.discussionId, + 'draft' in this ? this.draft.discussion_id : this.discussionId, ); - const discussionToBeResolved = this.draft - ? this.draft.resolve_discussion - : this.resolveDiscussion; + const discussionToBeResolved = + 'draft' in this ? this.draft.resolve_discussion : this.resolveDiscussion; if (discussionToBeResolved && discussionResolved && !this.$options.showStaysResolved) { return undefined; diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 84ff8fa7f33..fe3868fdd04 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -6,9 +6,9 @@ import installGlEmojiElement from './gl_emoji'; import initCopyAsGFM from './markdown/copy_as_gfm'; import './quick_submit'; import './requires_input'; -import initPageShortcuts from './shortcuts'; import { initToastMessages } from './toasts'; import { initGlobalAlerts } from './global_alerts'; +import './shortcuts'; import './toggler_behavior'; import './preview_markdown'; @@ -17,7 +17,6 @@ installGlEmojiElement(); initCopyAsGFM(); initCopyToClipboard(); -initPageShortcuts(); initCollapseSidebarOnWindowResize(); initToastMessages(); diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 36317444af9..72aae254584 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -152,7 +152,9 @@ export class CopyAsGFM { if (lineElements.length > 0) { for (let i = 0; i < lineElements.length; i += 1) { const lineElement = lineElements[i]; - codeElement.appendChild(lineElement); + const line = document.createElement('span'); + line.append(...lineElement.childNodes); + codeElement.appendChild(line); codeElement.appendChild(document.createTextNode('\n')); } } else { diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js deleted file mode 100644 index 22a8be92e52..00000000000 --- a/app/assets/javascripts/behaviors/shortcuts.js +++ /dev/null @@ -1,36 +0,0 @@ -export default function initPageShortcuts() { - const { page } = document.body.dataset; - const pagesWithCustomShortcuts = [ - 'projects:activity', - 'projects:artifacts:browse', - 'projects:artifacts:file', - 'projects:blame:show', - 'projects:blob:show', - 'projects:commit:show', - 'projects:commits:show', - 'projects:find_file:show', - 'projects:issues:edit', - 'projects:issues:index', - 'projects:issues:new', - 'projects:issues:show', - 'projects:merge_requests:creations:diffs', - 'projects:merge_requests:creations:new', - 'projects:merge_requests:edit', - 'projects:merge_requests:index', - 'projects:merge_requests:show', - 'projects:network:show', - 'projects:show', - 'projects:tree:show', - 'groups:show', - ]; - - // the pages above have their own shortcuts sub-classes instantiated elsewhere - // TODO: replace this whitelist with something more automated/maintainable - // https://gitlab.com/gitlab-org/gitlab/-/issues/392845 - if (page && !pagesWithCustomShortcuts.includes(page)) { - import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts') - .then(({ default: Shortcuts }) => new Shortcuts()) - .catch(() => {}); - } - return false; -} diff --git a/app/assets/javascripts/behaviors/shortcuts/index.js b/app/assets/javascripts/behaviors/shortcuts/index.js new file mode 100644 index 00000000000..cc6d8a23f68 --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/index.js @@ -0,0 +1,16 @@ +const shortcutsPromise = import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts') + .then(({ default: Shortcuts }) => new Shortcuts()) + .catch(() => {}); + +export const addShortcutsExtension = (ShortcutExtension, ...args) => + shortcutsPromise.then((shortcuts) => shortcuts.addExtension(ShortcutExtension, args)); + +export const resetShortcutsForTests = async () => { + if (process.env.NODE_ENV === 'test') { + const { Mousetrap, clearStopCallbacksForTests } = await import('~/lib/mousetrap'); + clearStopCallbacksForTests(); + Mousetrap.reset(); + const shortcuts = await shortcutsPromise; + shortcuts.extensions.clear(); + } +}; diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 941662635ea..15229689306 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -538,13 +538,10 @@ const GLOBAL_SHORTCUTS_GROUP = { GO_TO_YOUR_TODO_LIST, TOGGLE_PERFORMANCE_BAR, HIDE_APPEARING_CONTENT, + TOGGLE_SUPER_SIDEBAR, ], }; -if (gon.use_new_navigation) { - GLOBAL_SHORTCUTS_GROUP.keybindings.push(TOGGLE_SUPER_SIDEBAR); -} - export const EDITING_SHORTCUTS_GROUP = { id: 'editing', name: __('Editing'), diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 9514ad853b0..e05694c0907 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -63,11 +63,17 @@ function getToolbarBtnToShortcutsMap($textarea) { export default class Shortcuts { constructor() { + if (process.env.NODE_ENV !== 'production' && this.constructor !== Shortcuts) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Shortcuts cannot be subclassed.'); + } + + this.extensions = new Map(); this.onToggleHelp = this.onToggleHelp.bind(this); this.helpModalElement = null; this.helpModalVueInstance = null; - this.bindCommands([ + this.addAll([ [TOGGLE_KEYBOARD_SHORTCUTS_DIALOG, this.onToggleHelp], [START_SEARCH, Shortcuts.focusSearch], [FOCUS_FILTER_BAR, this.focusFilter.bind(this)], @@ -94,16 +100,12 @@ export default class Shortcuts { const findFileURL = document.body.dataset.findFile; if (typeof findFileURL !== 'undefined' && findFileURL !== null) { - this.bindCommand(GO_TO_PROJECT_FIND_FILE, () => { + this.add(GO_TO_PROJECT_FIND_FILE, () => { visitUrl(findFileURL); }); } - const shortcutsModalTriggerEvent = 'click.shortcutsModalTrigger'; - // eslint-disable-next-line @gitlab/no-global-event-off - $(document) - .off(shortcutsModalTriggerEvent) - .on(shortcutsModalTriggerEvent, '.js-shortcuts-modal-trigger', this.onToggleHelp); + $(document).on('click', '.js-shortcuts-modal-trigger', this.onToggleHelp); if (shouldDisableShortcuts()) { disableShortcuts(); @@ -111,6 +113,62 @@ export default class Shortcuts { } /** + * Instantiate a legacy shortcut extension class. + * + * NOTE: The preferred approach for adding shortcuts is described in + * https://docs.gitlab.com/ee/development/fe_guide/keyboard_shortcuts.html. + * This method is only for existing legacy shortcut classes. + * + * A shortcut extension class packages up several shortcuts and behaviors for + * a page or set of pages. They are considered legacy because they usually do + * not follow modern best practices. For instance, they may hook into the UI + * in brittle ways, e.g.. querySelectors. + * + * Extension classes can declare dependencies on other shortcut extension + * classes by listing them in a static `dependencies` property. This is + * essentially a reimplementation of the previous subclassing approach, but + * with idempotency: a shortcut extension class can now only be added at most + * one time. + * + * Extension classes are instantiated and given the Shortcuts singleton + * instance as their first argument. If the class constructor needs + * additional arguments, pass them via the second argument as an array. + * + * See https://gitlab.com/gitlab-org/gitlab/-/issues/392845 for more context. + * + * @param {Function} Extension The extension class to add/instantiate. + * @param {Array} [args] A list of additional args to pass to the extension + * class constructor. + * @param {Set} [extensionsCurrentlyLoading] For internal use only. Do not + * use. + * @returns The instantiated shortcut extension class. + */ + addExtension(Extension, args = [], extensionsCurrentlyLoading = new Set()) { + extensionsCurrentlyLoading.add(Extension); + + let instance = this.extensions.get(Extension); + if (!instance) { + for (const Dep of Extension.dependencies ?? []) { + if (extensionsCurrentlyLoading.has(Dep) || Dep === Shortcuts) { + // We've encountered a circular dependency, so stop recursing. + // eslint-disable-next-line no-continue + continue; + } + + extensionsCurrentlyLoading.add(Dep); + + this.addExtension(Dep, [], extensionsCurrentlyLoading); + } + + instance = new Extension(this, ...args); + this.extensions.set(Extension, instance); + } + + extensionsCurrentlyLoading.delete(Extension); + return instance; + } + + /** * Bind the keyboard shortcut(s) defined by the given command to the given * callback. * @@ -120,7 +178,7 @@ export default class Shortcuts { * @returns {void} */ // eslint-disable-next-line class-methods-use-this - bindCommand(command, callback) { + add(command, callback) { Mousetrap.bind(keysFor(command), callback); } @@ -132,8 +190,8 @@ export default class Shortcuts { * command/callback pairs. * @returns {void} */ - bindCommands(commandsAndCallbacks) { - commandsAndCallbacks.forEach((commandAndCallback) => this.bindCommand(...commandAndCallback)); + addAll(commandsAndCallbacks) { + commandsAndCallbacks.forEach((commandAndCallback) => this.add(...commandAndCallback)); } onToggleHelp(e) { @@ -198,11 +256,7 @@ export default class Shortcuts { } static focusSearch(e) { - if (gon.use_new_navigation) { - document.querySelector('#super-sidebar-search')?.click(); - } else { - document.querySelector('#search')?.focus(); - } + document.querySelector('#super-sidebar-search')?.click(); if (e.preventDefault) { e.preventDefault(); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js index 65ae67d156f..a0bfd337d10 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js @@ -7,7 +7,6 @@ import { getShaFromUrl, } from '~/lib/utils/url_utility'; import { updateRefPortionOfTitle } from '~/repository/utils/title'; -import Shortcuts from './shortcuts'; const defaults = { fileBlobPermalinkUrl: null, @@ -19,15 +18,14 @@ function eventHasModifierKeys(event) { return event.ctrlKey || event.metaKey || event.shiftKey; } -export default class ShortcutsBlob extends Shortcuts { - constructor(opts) { +export default class ShortcutsBlob { + constructor(shortcuts, opts) { const options = { ...defaults, ...opts }; - super(); this.options = options; this.shortcircuitPermalinkButton(); - this.bindCommand(PROJECT_FILES_GO_TO_PERMALINK, this.moveToFilePermalink.bind(this)); + shortcuts.add(PROJECT_FILES_GO_TO_PERMALINK, this.moveToFilePermalink.bind(this)); } moveToFilePermalink() { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js index f26878cf161..393d0165a07 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js @@ -8,10 +8,8 @@ import { import { addStopCallback } from '~/lib/mousetrap'; import ShortcutsNavigation from './shortcuts_navigation'; -export default class ShortcutsFindFile extends ShortcutsNavigation { - constructor(projectFindFile) { - super(); - +export default class ShortcutsFindFile { + constructor(shortcuts, projectFindFile) { addStopCallback((e, element, combo) => { if ( element === projectFindFile.inputElement[0] && @@ -28,11 +26,13 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { return undefined; }); - this.bindCommands([ + shortcuts.addAll([ [PROJECT_FILES_MOVE_SELECTION_UP, projectFindFile.selectRowUp], [PROJECT_FILES_MOVE_SELECTION_DOWN, projectFindFile.selectRowDown], [PROJECT_FILES_GO_BACK, projectFindFile.goToTree], [PROJECT_FILES_OPEN_SELECTION, projectFindFile.goToBlob], ]); } + + static dependencies = [ShortcutsNavigation]; } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index b0e515ac19d..cde6d59b210 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -16,12 +16,9 @@ import { MR_COPY_SOURCE_BRANCH_NAME, ISSUABLE_COPY_REF, } from './keybindings'; -import Shortcuts from './shortcuts'; - -export default class ShortcutsIssuable extends Shortcuts { - constructor() { - super(); +export default class ShortcutsIssuable { + constructor(shortcuts) { this.branchInMemoryButton = document.createElement('button'); this.branchClipboardInstance = new ClipboardJS(this.branchInMemoryButton); this.branchClipboardInstance.on('success', () => { @@ -40,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts { toast(s__('GlobalShortcuts|Unable to copy the reference at this time.')); }); - this.bindCommands([ + shortcuts.addAll([ [ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')], [ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')], [ISSUABLE_CHANGE_LABEL, () => ShortcutsIssuable.openSidebarDropdown('labels')], diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index 4691a4228e6..bae50c02599 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -21,13 +21,10 @@ import { PROJECT_FILES_GO_TO_COMPARE, NEW_ISSUE, } from './keybindings'; -import Shortcuts from './shortcuts'; -export default class ShortcutsNavigation extends Shortcuts { - constructor() { - super(); - - this.bindCommands([ +export default class ShortcutsNavigation { + constructor(shortcuts) { + shortcuts.addAll([ [GO_TO_PROJECT_OVERVIEW, () => findAndFollowLink('.shortcuts-project')], [GO_TO_PROJECT_ACTIVITY_FEED, () => findAndFollowLink('.shortcuts-project-activity')], [GO_TO_PROJECT_RELEASES, () => findAndFollowLink('.shortcuts-deployments-releases')], diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js index 02c6af53fc2..eee8c1acf1a 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js @@ -8,11 +8,9 @@ import { } from './keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; -export default class ShortcutsNetwork extends ShortcutsNavigation { - constructor(graph) { - super(); - - this.bindCommands([ +export default class ShortcutsNetwork { + constructor(shortcuts, graph) { + shortcuts.addAll([ [REPO_GRAPH_SCROLL_LEFT, graph.scrollLeft], [REPO_GRAPH_SCROLL_RIGHT, graph.scrollRight], [REPO_GRAPH_SCROLL_UP, graph.scrollUp], @@ -21,4 +19,6 @@ export default class ShortcutsNetwork extends ShortcutsNavigation { [REPO_GRAPH_SCROLL_BOTTOM, graph.scrollBottom], ]); } + + static dependencies = [ShortcutsNavigation]; } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js index 62d612cfa6d..5f45331bf76 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js @@ -2,13 +2,13 @@ import findAndFollowLink from '~/lib/utils/navigation_utility'; import { EDIT_WIKI_PAGE } from './keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; -export default class ShortcutsWiki extends ShortcutsNavigation { - constructor() { - super(); - - this.bindCommand(EDIT_WIKI_PAGE, ShortcutsWiki.editWiki); +export default class ShortcutsWiki { + constructor(shortcuts) { + shortcuts.add(EDIT_WIKI_PAGE, ShortcutsWiki.editWiki); } + static dependencies = [ShortcutsNavigation]; + static editWiki() { findAndFollowLink('.js-wiki-edit'); } diff --git a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue index 379d5e38197..e9f54639fdd 100644 --- a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue +++ b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue @@ -149,7 +149,6 @@ export default { block class="gl-font-regular" data-testid="template-selector" - data-qa-selector="template_selector" :toggle-text="dropdownToggleText" :search-placeholder="$options.i18n.searchPlaceholder" :items="dropdownItems" diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index b3bd23e49f8..be96c83aea2 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -40,6 +40,7 @@ export default () => { const filePath = `${editBlobForm.data('blobFilename')}`; const currentAction = $('.js-file-title').data('currentAction'); const projectId = editBlobForm.data('project-id'); + const projectPath = editBlobForm.data('project-path'); const isMarkdown = editBlobForm.data('is-markdown'); const previewMarkdownPath = editBlobForm.data('previewMarkdownPath'); const commitButton = $('.js-commit-button'); @@ -54,6 +55,7 @@ export default () => { filePath, currentAction, projectId, + projectPath, isMarkdown, previewMarkdownPath, }); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 007fbd29e82..78ccacd9f57 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -24,6 +24,10 @@ export default class EditBlob { this.fetchMarkdownExtension(); } + if (this.options.filePath === '.gitlab/security-policies/policy.yml') { + this.fetchSecurityPolicyExtension(this.options.projectPath); + } + this.initModePanesAndLinks(); this.initFilepathForm(); this.initSoftWrap(); @@ -54,6 +58,20 @@ export default class EditBlob { addEditorMarkdownListeners(this.editor); } + async fetchSecurityPolicyExtension(projectPath) { + try { + const { SecurityPolicySchemaExtension } = await import( + '~/editor/extensions/source_editor_security_policy_schema_ext' + ); + this.editor.use([{ definition: SecurityPolicySchemaExtension }]); + this.editor.registerSecurityPolicySchema(projectPath); + } catch (e) { + createAlert({ + message: `${BLOB_EDITOR_ERROR}: ${e}`, + }); + } + } + configureMonacoEditor() { const editorEl = document.getElementById('editor'); const fileContentEl = document.getElementById('file-content'); @@ -64,6 +82,7 @@ export default class EditBlob { this.editor = rootEditor.createInstance({ el: editorEl, blobContent: editorEl.innerText, + blobPath: this.options.filePath, }); this.editor.use([ { definition: ToolbarExtension }, diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index f103feecab2..477fc3d9b7b 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -7,8 +7,6 @@ import { GlCollapsibleListbox, GlIcon, } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapGetters, mapState } from 'vuex'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import { __, s__ } from '~/locale'; @@ -31,7 +29,7 @@ export default { directives: { GlTooltip, }, - inject: ['scopedLabelsAvailable', 'issuableType', 'fullPath', 'boardType', 'isApolloBoard'], + inject: ['scopedLabelsAvailable', 'issuableType', 'fullPath', 'boardType'], props: { listQueryVariables: { type: Object, @@ -51,12 +49,12 @@ export default { selectedId: null, selectedLabel: null, selectedIdValid: true, - labelsApollo: [], + labels: [], searchTerm: '', }; }, apollo: { - labelsApollo: { + labels: { query: boardLabelsQuery, variables() { return { @@ -69,9 +67,6 @@ export default { update(data) { return data[this.boardType].labels.nodes; }, - skip() { - return !this.isApolloBoard; - }, error(error) { setError({ error, @@ -81,36 +76,22 @@ export default { }, }, computed: { - ...mapState(['labels', 'labelsLoading']), - ...mapGetters(['getListByLabelId']), - labelsToUse() { - return this.isApolloBoard ? this.labelsApollo : this.labels; - }, isLabelsLoading() { - return this.isApolloBoard ? this.$apollo.queries.labelsApollo.loading : this.labelsLoading; + return this.$apollo.queries.labels.loading; }, columnForSelected() { - if (this.isApolloBoard) { - return getListByTypeId(this.lists, ListType.label, this.selectedId); - } - return this.getListByLabelId(this.selectedId); + return getListByTypeId(this.lists, ListType.label, this.selectedId); }, items() { - return (this.labelsToUse || []).map((i) => ({ + return (this.labels || []).map((i) => ({ ...i, text: i.title, value: i.id, })); }, }, - created() { - if (!this.isApolloBoard) { - this.filterItems(); - } - }, methods: { - ...mapActions(['createList', 'fetchLabels', 'highlightList']), - async createListApollo({ labelId }) { + async createList({ labelId }) { try { await this.$apollo.mutate({ mutation: createListMutations[this.issuableType].mutation, @@ -156,38 +137,23 @@ export default { if (this.columnForSelected) { const listId = this.columnForSelected.id; - if (this.isApolloBoard) { - this.$emit('highlight-list', listId); - } else { - this.highlightList(listId); - } + this.$emit('highlight-list', listId); return; } - if (this.isApolloBoard) { - this.createListApollo({ labelId: this.selectedId }); - } else { - this.createList({ labelId: this.selectedId }); - } + this.createList({ labelId: this.selectedId }); this.$emit('setAddColumnFormVisibility', false); }, - filterItems(searchTerm) { - this.fetchLabels(searchTerm); - }, - onSearch: debounce(function debouncedSearch(searchTerm) { this.searchTerm = searchTerm; - if (!this.isApolloBoard) { - this.filterItems(searchTerm); - } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), setSelectedItem(selectedId) { this.selectedId = selectedId; - const label = this.labelsToUse.find(({ id }) => id === selectedId); + const label = this.labels.find(({ id }) => id === selectedId); if (!selectedId || !label) { this.selectedLabel = null; } else { diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 4d915ff341a..2c8aa1cbe21 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -1,6 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapGetters } from 'vuex'; import { omit } from 'lodash'; import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -33,11 +31,10 @@ export default { 'isGroupBoard', 'issuableType', 'boardType', - 'isApolloBoard', ], data() { return { - boardListsApollo: {}, + boardLists: {}, activeListId: '', boardId: this.initialBoardId, filterParams: { ...this.initialFilterParams }, @@ -59,20 +56,14 @@ export default { this.setActiveId(''); } }, - skip() { - return !this.isApolloBoard; - }, }, - boardListsApollo: { + boardLists: { query() { return listsQuery[this.issuableType].query; }, variables() { return this.listQueryVariables; }, - skip() { - return !this.isApolloBoard; - }, update(data) { const { lists } = data[this.boardType].board; return formatBoardLists(lists); @@ -91,7 +82,6 @@ export default { }, computed: { - ...mapGetters(['isSidebarOpen']), listQueryVariables() { return { ...(this.isIssueBoard && { @@ -107,13 +97,10 @@ export default { return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false; }, isAnySidebarOpen() { - if (this.isApolloBoard) { - return this.activeBoardItem?.id || this.activeListId; - } - return this.isSidebarOpen; + return this.activeBoardItem?.id || this.activeListId; }, activeList() { - return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined; + return this.activeListId ? this.boardLists[this.activeListId] : undefined; }, formattedFilterParams() { return filterVariables({ @@ -134,7 +121,7 @@ export default { }, methods: { refetchLists() { - this.$apollo.queries.boardListsApollo.refetch(); + this.$apollo.queries.boardLists.refetch(); }, setActiveId(id) { this.activeListId = id; @@ -167,14 +154,14 @@ export default { :add-column-form-visible="addColumnFormVisible" :is-swimlanes-on="isSwimlanesOn" :filter-params="formattedFilterParams" - :board-lists-apollo="boardListsApollo" + :board-lists="boardLists" :apollo-error="error" :list-query-variables="listQueryVariables" @setActiveList="setActiveId" @setAddColumnFormVisibility="addColumnFormVisible = $event" /> <board-settings-sidebar - v-if="!isApolloBoard || activeList" + v-if="activeList" :list="activeList" :list-id="activeListId" :board-id="boardId" diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index fd45d2d31c3..6966a4e5d48 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,6 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; import setSelectedBoardItemsMutation from '~/boards/graphql/client/set_selected_board_items.mutation.graphql'; import unsetSelectedBoardItemsMutation from '~/boards/graphql/client/unset_selected_board_items.mutation.graphql'; @@ -15,7 +13,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled', 'isIssueBoard', 'isApolloBoard'], + inject: ['disabled', 'isIssueBoard'], props: { list: { type: Object, @@ -51,18 +49,14 @@ export default { isIssue: this.isIssueBoard, }; }, - skip() { - return !this.isApolloBoard; - }, }, selectedBoardItems: { query: selectedBoardItemsQuery, }, }, computed: { - ...mapState(['activeId']), activeItemId() { - return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId; + return this.activeBoardItem?.id; }, isActive() { return this.item.id === this.activeItemId; @@ -86,17 +80,14 @@ export default { return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : ''; }, formattedItem() { - return this.isApolloBoard - ? { - ...this.item, - assignees: this.item.assignees?.nodes || [], - labels: this.item.labels?.nodes || [], - } - : this.item; + return { + ...this.item, + assignees: this.item.assignees?.nodes || [], + labels: this.item.labels?.nodes || [], + }; }, }, methods: { - ...mapActions(['toggleBoardItem']), toggleIssue(e) { // Don't do anything if this happened on a no trigger element if (e.target.closest('.js-no-trigger')) return; @@ -105,11 +96,7 @@ export default { if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { - if (this.isApolloBoard) { - this.toggleItem(); - } else { - this.toggleBoardItem({ boardItem: this.item }); - } + this.toggleItem(); this.track('click_card', { label: 'right_sidebar' }); } }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index a7f46dc9325..97dab2e1d34 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -8,14 +8,11 @@ import { GlSprintf, } from '@gitlab/ui'; import { sortBy } from 'lodash'; -// eslint-disable-next-line no-restricted-imports -import { mapActions } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; @@ -32,7 +29,6 @@ export default { GlLoadingIcon, GlIcon, UserAvatarLink, - TooltipOnTruncate, IssueDueDate, IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), @@ -53,7 +49,6 @@ export default { 'isEpicBoard', 'issuableType', 'isGroupBoard', - 'isApolloBoard', ], props: { item: { @@ -155,6 +150,9 @@ export default { const { referencePath } = this.item; return referencePath.split(this.itemPrefix)[0]; }, + directNamespaceReference() { + return this.itemReferencePath.split('/').slice(-1)[0]; + }, orderedLabels() { return sortBy(this.item.labels.filter(this.isNonListLabel), 'title'); }, @@ -186,7 +184,6 @@ export default { }, }, methods: { - ...mapActions(['performSearch']), setError, isIndexLessThanlimit(index) { return index < this.limitBeforeCounter; @@ -224,9 +221,6 @@ export default { updateHistory({ url: `${filterPath}${filter}`, }); - if (!this.isApolloBoard) { - this.performSearch(); - } eventHub.$emit('updateTokens'); } }, @@ -308,13 +302,15 @@ export default { :work-item-type="item.type" show-tooltip-on-hover /> - <tooltip-on-truncate + <span v-if="showReferencePath" + v-gl-tooltip :title="itemReferencePath" - placement="bottom" - class="board-item-path gl-text-truncate gl-font-weight-bold" - >{{ itemReferencePath }}</tooltip-on-truncate + data-placement="bottom" + class="board-item-path gl-text-truncate gl-font-weight-bold gl-cursor-help" > + {{ directNamespaceReference }} + </span> {{ itemId }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> @@ -411,7 +407,7 @@ export default { </span> </span> </div> - <div class="board-card-assignee gl-display-flex gl-gap-3 gl-mb-n2"> + <div class="board-card-assignee gl-display-flex gl-mb-n2"> <user-avatar-link v-for="assignee in cappedAssignees" :key="assignee.id" @@ -432,7 +428,7 @@ export default { v-if="shouldRenderCounter" v-gl-tooltip :title="assigneeCounterTooltip" - class="avatar-counter gl-bg-gray-400 gl-cursor-help gl-font-weight-bold gl-ml-n4 gl-border-0 gl-line-height-24" + class="avatar-counter gl-bg-gray-100 gl-text-gray-900 gl-cursor-help gl-font-weight-bold gl-border-0 gl-line-height-24 gl-ml-n3" data-placement="bottom" >{{ assigneeCounterLabel }}</span > diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index 8034819732a..9173503c888 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -1,7 +1,5 @@ <script> import { GlDisclosureDropdown } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; import { BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, @@ -15,7 +13,6 @@ export default { GlDisclosureDropdown, }, mixins: [Tracking.mixin()], - inject: ['isApolloBoard'], props: { item: { type: Object, @@ -37,7 +34,6 @@ export default { }, }, computed: { - ...mapState(['pageInfoByListId']), tracking() { return { category: 'boards:list', @@ -45,9 +41,6 @@ export default { property: `type_card`, }; }, - listHasNextPage() { - return this.pageInfoByListId[this.list.id]?.hasNextPage; - }, itemIdentifier() { return `${this.item.id}-${this.item.iid}-${this.index}`; }, @@ -59,7 +52,6 @@ export default { }, }, methods: { - ...mapActions(['moveItem']), moveToStart() { this.track('click_toggle_button', { label: 'move_to_start', @@ -85,20 +77,7 @@ export default { }); }, moveToPosition({ positionInList }) { - if (this.isApolloBoard) { - this.$emit('moveToPosition', positionInList); - } else { - this.moveItem({ - itemId: this.item.id, - itemIid: this.item.iid, - itemPath: this.item.referencePath, - fromListId: this.list.id, - toListId: this.list.id, - positionInList, - atIndex: this.index, - allItemsLoadedInList: !this.listHasNextPage, - }); - } + this.$emit('moveToPosition', positionInList); }, selectMoveAction({ text }) { if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 67a4c5eba45..0ba8b958428 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,6 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapGetters, mapActions, mapState } from 'vuex'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import { isListDraggable } from '../boards_util'; import BoardList from './board_list.vue'; @@ -10,7 +8,6 @@ export default { BoardListHeader, BoardList, }, - inject: ['isApolloBoard'], props: { list: { type: Object, @@ -25,48 +22,21 @@ export default { type: Object, required: true, }, - highlightedListsApollo: { + highlightedLists: { type: Array, required: false, default: () => [], }, }, computed: { - ...mapState(['filterParams', 'highlightedLists']), - ...mapGetters(['getBoardItemsByList']), - highlightedListsToUse() { - return this.isApolloBoard ? this.highlightedListsApollo : this.highlightedLists; - }, highlighted() { - return this.highlightedListsToUse.includes(this.list.id); - }, - listItems() { - return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id); + return this.highlightedLists.includes(this.list.id); }, isListDraggable() { return isListDraggable(this.list); }, - filtersToUse() { - return this.isApolloBoard ? this.filters : this.filterParams; - }, }, watch: { - filterParams: { - handler() { - if (!this.isApolloBoard && this.list.id && !this.list.collapsed) { - this.fetchItemsForList({ listId: this.list.id }); - } - }, - deep: true, - immediate: true, - }, - 'list.id': { - handler(id) { - if (!this.isApolloBoard && id) { - this.fetchItemsForList({ listId: this.list.id }); - } - }, - }, highlighted: { handler(highlighted) { if (highlighted) { @@ -78,9 +48,6 @@ export default { immediate: true, }, }, - methods: { - ...mapActions(['fetchItemsForList']), - }, }; </script> @@ -101,17 +68,11 @@ export default { > <board-list-header :list="list" - :filter-params="filtersToUse" + :filter-params="filters" :board-id="boardId" @setActiveList="$emit('setActiveList', $event)" /> - <board-list - ref="board-list" - :board-id="boardId" - :board-items="listItems" - :list="list" - :filter-params="filtersToUse" - /> + <board-list ref="board-list" :board-id="boardId" :list="list" :filter-params="filters" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index a6ff1653c17..2b9c5d52d5e 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -3,11 +3,9 @@ import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; import produce from 'immer'; import Draggable from 'vuedraggable'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { s__ } from '~/locale'; -import { defaultSortableOptions } from '~/sortable/constants'; +import { defaultSortableOptions, DRAG_DELAY } from '~/sortable/constants'; import { DraggableItemTypes, flashAnimationDuration, @@ -29,15 +27,7 @@ export default { EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, - inject: [ - 'boardType', - 'canAdminList', - 'isIssueBoard', - 'isEpicBoard', - 'disabled', - 'issuableType', - 'isApolloBoard', - ], + inject: ['boardType', 'canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'issuableType'], props: { boardId: { type: String, @@ -51,7 +41,7 @@ export default { type: Boolean, required: true, }, - boardListsApollo: { + boardLists: { type: Object, required: false, default: () => {}, @@ -77,12 +67,11 @@ export default { }; }, computed: { - ...mapState(['boardLists', 'error']), boardListsById() { - return this.isApolloBoard ? this.boardListsApollo : this.boardLists; + return this.boardLists; }, boardListsToUse() { - const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists; + const lists = this.boardLists; return sortBy([...Object.values(lists)], 'position'); }, canDragColumns() { @@ -100,7 +89,7 @@ export default { group: 'boards-list', tag: 'div', value: this.boardListsToUse, - delay: 100, + delay: DRAG_DELAY, delayOnTouchOnly: true, filter: 'input', preventOnFilter: false, @@ -109,11 +98,10 @@ export default { return this.canDragColumns ? options : {}; }, errorToDisplay() { - return this.apolloError || this.error || null; + return this.apolloError || null; }, }, methods: { - ...mapActions(['moveList', 'unsetError']), afterFormEnters() { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); @@ -126,11 +114,7 @@ export default { }, flashAnimationDuration); }, dismissError() { - if (this.isApolloBoard) { - setError({ message: null, captureError: false }); - } else { - this.unsetError(); - } + setError({ message: null, captureError: false }); }, async updateListPosition({ item: { @@ -139,17 +123,6 @@ export default { newIndex, to: { children }, }) { - if (!this.isApolloBoard) { - this.moveList({ - item: { - dataset: { listId: movedListId, draggableItemType }, - }, - newIndex, - to: { children }, - }); - return; - } - if (draggableItemType !== DraggableItemTypes.list) { return; } @@ -199,7 +172,7 @@ export default { __typename: 'UpdateBoardListPayload', errors: [], list: { - ...this.boardListsApollo[movedListId], + ...this.boardLists[movedListId], position: targetPosition, }, }, @@ -240,7 +213,7 @@ export default { :board-id="boardId" :list="list" :filters="filterParams" - :highlighted-lists-apollo="highlightedLists" + :highlighted-lists="highlightedLists" :data-draggable-item-type="$options.draggableItemTypes.list" :class="{ 'gl-display-none! gl-sm-display-inline-block!': addColumnFormVisible }" @setActiveList="$emit('setActiveList', $event)" diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index bb740c0e7eb..7929c1ad488 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,8 +1,6 @@ <script> import { GlDrawer } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; @@ -10,7 +8,6 @@ import { __, s__, sprintf } from '~/locale'; import SidebarTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { INCIDENT } from '~/boards/constants'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; @@ -19,6 +16,7 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { setError } from '../graphql/cache_updates'; export default { @@ -42,6 +40,7 @@ export default { SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), }, + mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -73,9 +72,6 @@ export default { isGroupBoard: { default: false, }, - isApolloBoard: { - default: false, - }, timeTrackingLimitToHours: { default: false, }, @@ -96,9 +92,6 @@ export default { assignees: data.activeBoardItem.assignees?.nodes || [], }; }, - skip() { - return !this.isApolloBoard; - }, error(error) { setError({ error, @@ -108,10 +101,8 @@ export default { }, }, computed: { - ...mapGetters(['activeBoardItem']), - ...mapState(['sidebarType']), activeBoardIssuable() { - return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem; + return this.activeBoardCard; }, isSidebarOpen() { return Boolean(this.activeBoardIssuable?.id); @@ -154,44 +145,25 @@ export default { const { referencePath = '' } = this.activeBoardIssuable; return referencePath.slice(0, referencePath.indexOf('#')); }, + showWorkItemEpics() { + return this.glFeatures.displayWorkItemEpicIssueSidebar; + }, + showEpicSidebarDropdownWidget() { + return this.epicFeatureAvailable && !this.isIncidentSidebar && this.activeBoardIssuable.id; + }, + showIterationSidebarDropdownWidget() { + return ( + this.iterationFeatureAvailable && !this.isIncidentSidebar && this.activeBoardIssuable.id + ); + }, }, methods: { - ...mapActions([ - 'toggleBoardItem', - 'setAssignees', - 'setActiveItemConfidential', - 'setActiveBoardItemLabels', - 'setActiveItemWeight', - 'setActiveItemHealthStatus', - ]), handleClose() { - if (this.isApolloBoard) { - this.$apollo.mutate({ - mutation: setActiveBoardItemMutation, - variables: { - boardItem: null, - }, - }); - } else { - this.toggleBoardItem({ - boardItem: this.activeBoardIssuable, - sidebarType: this.sidebarType, - }); - } - }, - handleUpdateSelectedLabels({ labels, id }) { - this.setActiveBoardItemLabels({ - id, - projectPath: this.projectPathForActiveIssue, - labelIds: labels.map((label) => getIdFromGraphQLId(label.id)), - labels, - }); - }, - handleLabelRemove(removeLabelId) { - this.setActiveBoardItemLabels({ - iid: this.activeBoardIssuable.iid, - projectPath: this.projectPathForActiveIssue, - removeLabelIds: [removeLabelId], + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { + boardItem: null, + }, }); }, }, @@ -228,32 +200,36 @@ export default { :initial-assignees="activeBoardIssuable.assignees" :allow-multiple-assignees="multipleAssigneesFeatureAvailable" :editable="canUpdate" - @assignees-updated="!isApolloBoard && setAssignees($event)" /> <sidebar-dropdown-widget - v-if="epicFeatureAvailable && !isIncidentSidebar" + v-if="showEpicSidebarDropdownWidget" :key="`epic-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" + :issue-id="activeBoardIssuable.id" + :show-work-item-epics="showWorkItemEpics" data-testid="sidebar-epic" /> <div> <sidebar-dropdown-widget + v-if="activeBoardIssuable.id" :key="`milestone-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="projectPathForActiveIssue" :issuable-type="issuableType" + :issue-id="activeBoardIssuable.id" data-testid="sidebar-milestones" /> <sidebar-iteration-widget - v-if="iterationFeatureAvailable && !isIncidentSidebar" + v-if="showIterationSidebarDropdownWidget" :key="`iteration-${activeBoardIssuable.iid}`" :iid="activeBoardIssuable.iid" + :issue-id="activeBoardIssuable.id" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" @@ -290,8 +266,6 @@ export default { workspace-type="project" :issuable-type="issuableType" :label-create-type="labelType" - @onLabelRemove="!isApolloBoard && handleLabelRemove($event)" - @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)" > {{ __('None') }} </sidebar-labels-widget> @@ -306,20 +280,17 @@ export default { :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @weightUpdated="!isApolloBoard && setActiveItemWeight($event)" /> <sidebar-health-status-widget v-if="healthStatusFeatureAvailable" :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)" /> <sidebar-confidentiality-widget :iid="activeBoardIssuable.iid" :full-path="projectPathForActiveIssue" :issuable-type="issuableType" - @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)" /> <sidebar-subscriptions-widget :iid="activeBoardIssuable.iid" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 91dd5c81f77..faaef226c21 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,8 +1,6 @@ <script> import { pickBy, isEmpty, mapValues } from 'lodash'; -// eslint-disable-next-line no-restricted-imports -import { mapActions } from 'vuex'; -import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -24,7 +22,6 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants'; -import { TYPENAME_ITERATION } from '~/graphql_shared/constants'; import eventHub from '../eventhub'; export default { @@ -32,7 +29,7 @@ export default { search: __('Search'), }, components: { FilteredSearch }, - inject: ['initialFilterParams', 'isApolloBoard'], + inject: ['initialFilterParams'], props: { isSwimlanesOn: { type: Boolean, @@ -342,18 +339,6 @@ export default { }, ); }, - formattedFilterParams() { - const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); - const filtersCopy = convertObjectPropsToCamelCase(rawFilterParams, {}); - if (this.filterParams?.iterationId) { - filtersCopy.iterationId = convertToGraphQLId( - TYPENAME_ITERATION, - this.filterParams.iterationId, - ); - } - - return filtersCopy; - }, }, created() { eventHub.$on('updateTokens', this.updateTokens); @@ -366,11 +351,15 @@ export default { eventHub.$off('updateTokens', this.updateTokens); }, methods: { - ...mapActions(['performSearch']), - updateTokens() { + formattedFilterParams() { const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); - this.filterParams = convertObjectPropsToCamelCase(rawFilterParams, {}); - this.$emit('setFilters', this.formattedFilterParams); + const filtersCopy = convertObjectPropsToCamelCase(rawFilterParams, {}); + this.filterParams = filtersCopy; + + return filtersCopy; + }, + updateTokens() { + this.$emit('setFilters', this.formattedFilterParams()); this.filteredSearchKey += 1; }, handleFilter(filters) { @@ -382,11 +371,7 @@ export default { replace: true, }); - if (this.isApolloBoard) { - this.$emit('setFilters', this.formattedFilterParams); - } else { - this.performSearch(); - } + this.$emit('setFilters', this.formattedFilterParams()); }, getFilterParams(filters = []) { const notFilters = filters.filter((item) => item.value.operator === '!='); diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index a3d55ac8306..5f4917ea487 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,9 +1,6 @@ <script> import { GlModal, GlAlert } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions } from 'vuex'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import eventHub from '~/boards/eventhub'; import { formType } from '../constants'; @@ -61,9 +58,6 @@ export default { isProjectBoard: { default: false, }, - isApolloBoard: { - default: false, - }, }, props: { canAdminBoard: { @@ -184,7 +178,6 @@ export default { } }, methods: { - ...mapActions(['setBoard']), setError, isFocusMode() { return Boolean(document.querySelector('.content-wrapper > .js-focus-mode-board.is-focused')); @@ -227,23 +220,12 @@ export default { } else { try { const board = await this.createOrUpdateBoard(); - if (this.isApolloBoard) { - if (this.board.id) { - eventHub.$emit('updateBoard', board); - } else { - this.$emit('addBoard', board); - } + if (this.board.id) { + eventHub.$emit('updateBoard', board); } else { - this.setBoard(board); + this.$emit('addBoard', board); } this.cancel(); - - if (!this.isApolloBoard) { - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); - } } catch (error) { setError({ error, message: this.$options.i18n.saveErrorMessage }); } finally { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index ca10cbbad5e..8a5c6882e56 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,11 +1,9 @@ <script> import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; import { STATUS_CLOSED } from '~/issues/constants'; import { sprintf, __, s__ } from '~/locale'; -import { defaultSortableOptions } from '~/sortable/constants'; +import { defaultSortableOptions, DRAG_DELAY } from '~/sortable/constants'; import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; @@ -57,7 +55,6 @@ export default { 'fullPath', 'boardType', 'issuableType', - 'isApolloBoard', ], props: { list: { @@ -68,10 +65,6 @@ export default { type: String, required: true, }, - boardItems: { - type: Array, - required: true, - }, filterParams: { type: Object, required: true, @@ -115,7 +108,7 @@ export default { }; }, skip() { - return !this.isApolloBoard || this.list.collapsed; + return this.list.collapsed; }, update(data) { return data[this.boardType].board.lists.nodes[0]; @@ -157,11 +150,8 @@ export default { }, }, computed: { - ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']), boardListItems() { - return this.isApolloBoard - ? this.currentList?.[`${this.issuableType}s`].nodes || [] - : this.boardItems; + return this.currentList?.[`${this.issuableType}s`].nodes || []; }, listQueryVariables() { return { @@ -190,17 +180,10 @@ export default { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { - return this.isApolloBoard - ? this.currentList?.[`${this.issuableType}s`].pageInfo?.hasNextPage - : this.pageInfoByListId[this.list.id]?.hasNextPage; + return this.currentList?.[`${this.issuableType}s`].pageInfo?.hasNextPage; }, loading() { - return this.isApolloBoard - ? this.$apollo.queries.currentList.loading && !this.isLoadingMore - : this.listsFlags[this.list.id]?.isLoading; - }, - loadingMore() { - return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; + return this.$apollo.queries.currentList.loading && !this.isLoadingMore; }, epicCreateFormVisible() { return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm; @@ -224,10 +207,7 @@ export default { return !this.disabled; }, treeRootWrapper() { - return this.canMoveIssue && - (!this.listsFlags[this.list.id]?.addItemToListInProgress || this.addItemToListInProgress) - ? Draggable - : 'ul'; + return this.canMoveIssue && !this.addItemToListInProgress ? Draggable : 'ul'; }, treeRootOptions() { const options = { @@ -238,16 +218,14 @@ export default { 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, value: this.boardListItems, - delay: 100, + delay: DRAG_DELAY, delayOnTouchOnly: true, }; return this.canMoveIssue ? options : {}; }, disableScrollingWhenMutationInProgress() { - return ( - this.hasNextPage && (this.isUpdateIssueOrderInProgress || this.updateIssueOrderInProgress) - ); + return this.hasNextPage && this.updateIssueOrderInProgress; }, showMoveToPosition() { return !this.disabled && this.list.listType !== ListType.closed; @@ -280,7 +258,6 @@ export default { eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, methods: { - ...mapActions(['fetchItemsForList', 'moveItem']), listHeight() { return this.listRef?.getBoundingClientRect()?.height || 0; }, @@ -294,19 +271,15 @@ export default { this.listRef.scrollTop = 0; }, async loadNextPage() { - if (this.isApolloBoard) { - this.isLoadingMore = true; - await this.$apollo.queries.currentList.fetchMore({ - variables: { - ...this.listQueryVariables, - id: this.list.id, - after: this.currentList?.[`${this.issuableType}s`].pageInfo.endCursor, - }, - }); - this.isLoadingMore = false; - } else { - this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); - } + this.isLoadingMore = true; + await this.$apollo.queries.currentList.fetchMore({ + variables: { + ...this.listQueryVariables, + id: this.list.id, + after: this.currentList?.[`${this.issuableType}s`].pageInfo.endCursor, + }, + }); + this.isLoadingMore = false; }, toggleForm() { if (this.isEpicBoard) { @@ -320,7 +293,7 @@ export default { return index !== 0 && index % 6 === 0; }, onReachingListBottom() { - if (!this.loadingMore && this.hasNextPage) { + if (!this.isLoadingMore && this.hasNextPage) { this.showCount = true; this.loadNextPage(); } @@ -343,7 +316,7 @@ export default { from, to, item: { - dataset: { draggableItemType, itemId, itemIid, itemPath }, + dataset: { draggableItemType, itemId, itemIid }, }, }) { if (draggableItemType !== DraggableItemTypes.card) { @@ -387,32 +360,20 @@ export default { } } - if (this.isApolloBoard) { - this.updateIssueOrderInProgress = true; - await this.moveBoardItem( - { - itemId, - iid: itemIid, - fromListId: from.dataset.listId, - toListId: to.dataset.listId, - moveBeforeId, - moveAfterId, - }, - newIndex, - ).finally(() => { - this.updateIssueOrderInProgress = false; - }); - } else { - this.moveItem({ + this.updateIssueOrderInProgress = true; + await this.moveBoardItem( + { itemId, - itemIid, - itemPath, + iid: itemIid, fromListId: from.dataset.listId, toListId: to.dataset.listId, moveBeforeId, moveAfterId, - }); - } + }, + newIndex, + ).finally(() => { + this.updateIssueOrderInProgress = false; + }); }, isItemInTheList(itemIid) { const items = this.toList?.[`${this.issuableType}s`]?.nodes || []; @@ -718,7 +679,7 @@ export default { data-issue-id="-1" > <gl-loading-icon - v-if="loadingMore" + v-if="isLoadingMore" size="sm" :label="$options.i18n.loadingMoreBoardItems" /> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bedb3a75a70..f50c510fcf6 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -8,14 +8,11 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { fetchPolicies } from '~/lib/graphql'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__ } from '~/locale'; -import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -23,8 +20,6 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import AccessorUtilities from '~/lib/utils/accessor'; import { - inactiveId, - LIST, ListType, toggleFormEventPrefix, updateListQueries, @@ -81,9 +76,6 @@ export default { issuableType: { default: TYPE_ISSUE, }, - isApolloBoard: { - default: false, - }, }, props: { list: { @@ -106,7 +98,6 @@ export default { }, }, computed: { - ...mapState(['activeId']), isLoggedIn() { return Boolean(this.currentUserId); }, @@ -238,21 +229,12 @@ export default { } }, methods: { - ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), openSidebarSettings() { - if (this.activeId === inactiveId) { - sidebarEventHub.$emit('sidebar.closeAll'); - } - - if (this.isApolloBoard) { - this.$apollo.mutate({ - mutation: setActiveBoardItemMutation, - variables: { boardItem: null }, - }); - this.$emit('setActiveList', this.list.id); - } else { - this.setActiveId({ id: this.list.id, sidebarType: LIST }); - } + this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { boardItem: null }, + }); + this.$emit('setActiveList', this.list.id); this.track('click_button', { label: 'list_settings' }); }, @@ -297,33 +279,29 @@ export default { } }, async updateListFunction(collapsed) { - if (this.isApolloBoard) { - try { - await this.$apollo.mutate({ - mutation: updateListQueries[this.issuableType].mutation, - variables: { - listId: this.list.id, - collapsed, - }, - optimisticResponse: { - updateBoardList: { - __typename: 'UpdateBoardListPayload', - errors: [], - list: { - ...this.list, - collapsed, - }, + try { + await this.$apollo.mutate({ + mutation: updateListQueries[this.issuableType].mutation, + variables: { + listId: this.list.id, + collapsed, + }, + optimisticResponse: { + updateBoardList: { + __typename: 'UpdateBoardListPayload', + errors: [], + list: { + ...this.list, + collapsed, }, }, - }); - } catch (error) { - setError({ - error, - message: s__('Boards|An error occurred while updating the list. Please try again.'), - }); - } - } else { - this.updateList({ listId: this.list.id, collapsed }); + }, + }); + } catch (error) { + setError({ + error, + message: s__('Boards|An error occurred while updating the list. Please try again.'), + }); } }, /** @@ -337,17 +315,13 @@ export default { return `${start} - ${due}`; }, updateLocalCollapsedStatus(collapsed) { - if (this.isApolloBoard) { - this.$apollo.mutate({ - mutation: toggleCollapsedMutations[this.issuableType].mutation, - variables: { - list: this.list, - collapsed, - }, - }); - } else { - this.toggleListCollapsed({ listId: this.list.id, collapsed }); - } + this.$apollo.mutate({ + mutation: toggleCollapsedMutations[this.issuableType].mutation, + variables: { + list: this.list, + collapsed, + }, + }); }, }, }; diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index d78b60e91a8..ea22bb08f2a 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; @@ -22,7 +20,7 @@ export default { ProjectSelect, }, mixins: [BoardNewIssueMixin], - inject: ['boardType', 'groupId', 'fullPath', 'isGroupBoard', 'isEpicBoard', 'isApolloBoard'], + inject: ['boardType', 'groupId', 'fullPath', 'isGroupBoard', 'isEpicBoard'], props: { list: { type: Object, @@ -50,9 +48,6 @@ export default { boardId: this.boardId, }; }, - skip() { - return !this.isApolloBoard; - }, update(data) { const { board } = data.workspace; return { @@ -69,7 +64,6 @@ export default { }, }, computed: { - ...mapGetters(['getBoardItemsByList']), formEventPrefix() { return toggleFormEventPrefix.issue; }, @@ -81,37 +75,19 @@ export default { }, }, methods: { - ...mapActions(['addListNewIssue']), submit({ title }) { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - if (this.isApolloBoard) { - return this.addNewIssueToList({ - issueInput: { - title, - labelIds: labels?.map((l) => l.id), - assigneeIds: assignees?.map((a) => a?.id), - milestoneId: milestone?.id, - projectPath: this.projectPath, - }, - }); - } - - const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; - return this.addListNewIssue({ - list: this.list, + return this.addNewIssueToList({ issueInput: { title, labelIds: labels?.map((l) => l.id), assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, projectPath: this.projectPath, - moveAfterId: firstItemId, }, - }).then(() => { - this.cancel(); }); }, addNewIssueToList({ issueInput }) { diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 89e13625210..7e8f0ffdc60 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -2,10 +2,7 @@ import produce from 'immer'; import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui'; import { MountingPortal } from 'portal-vue'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState, mapGetters } from 'vuex'; import { - LIST, ListType, ListTypeTitles, listsQuery, @@ -13,7 +10,6 @@ import { } from 'ee_else_ce/boards/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { setError } from '../graphql/cache_updates'; @@ -40,14 +36,7 @@ export default { GlModal: GlModalDirective, }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], - inject: [ - 'boardType', - 'canAdminList', - 'issuableType', - 'scopedLabelsAvailable', - 'isIssueBoard', - 'isApolloBoard', - ], + inject: ['boardType', 'canAdminList', 'issuableType', 'scopedLabelsAvailable', 'isIssueBoard'], inheritAttrs: false, props: { listId: { @@ -61,7 +50,7 @@ export default { list: { type: Object, required: false, - default: () => null, + default: () => {}, }, queryVariables: { type: Object, @@ -75,16 +64,14 @@ export default { }, modalId: 'board-settings-sidebar-modal', computed: { - ...mapGetters(['isSidebarOpen']), - ...mapState(['activeId', 'sidebarType', 'boardLists']), isWipLimitsOn() { return this.glFeatures.wipLimits && this.isIssueBoard; }, activeListId() { - return this.isApolloBoard ? this.listId : this.activeId; + return this.listId; }, activeList() { - return (this.isApolloBoard ? this.list : this.boardLists[this.activeId]) || {}; + return this.list; }, activeListLabel() { return this.activeList.label; @@ -96,20 +83,10 @@ export default { return ListTypeTitles[ListType.label]; }, showSidebar() { - if (this.isApolloBoard) { - return Boolean(this.listId); - } - return this.sidebarType === LIST && this.isSidebarOpen; + return Boolean(this.listId); }, }, - created() { - eventHub.$on('sidebar.closeAll', this.unsetActiveListId); - }, - beforeDestroy() { - eventHub.$off('sidebar.closeAll', this.unsetActiveListId); - }, methods: { - ...mapActions(['unsetActiveId', 'removeList']), handleModalPrimary() { this.deleteBoardList(); }, @@ -118,19 +95,11 @@ export default { }, deleteBoardList() { this.track('click_button', { label: 'remove_list' }); - if (this.isApolloBoard) { - this.deleteList(this.activeListId); - } else { - this.removeList(this.activeId); - } + this.deleteList(this.activeListId); this.unsetActiveListId(); }, unsetActiveListId() { - if (this.isApolloBoard) { - this.$emit('unsetActiveId'); - } else { - this.unsetActiveId(); - } + this.$emit('unsetActiveId'); }, async deleteList(listId) { try { diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 31664c28831..d2be511343d 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -31,7 +31,6 @@ export default { 'fullPath', 'boardType', 'isEpicBoard', - 'isApolloBoard', ], props: { boardId: { @@ -63,9 +62,6 @@ export default { boardId: this.boardId, }; }, - skip() { - return !this.isApolloBoard; - }, update(data) { const { board } = data.workspace; return { @@ -110,7 +106,7 @@ export default { class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full gl-min-w-0" > <boards-selector - :board-apollo="board" + :board="board" :is-current-board-loading="isLoading" @switchBoard="$emit('switchBoard', $event)" /> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index cd2a4a02b2e..e9ff390c488 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -2,8 +2,6 @@ import { GlButton, GlCollapsibleListbox, GlModalDirective } from '@gitlab/ui'; import { produce } from 'immer'; import { differenceBy, debounce } from 'lodash'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; @@ -51,10 +49,9 @@ export default { 'weights', 'boardType', 'isGroupBoard', - 'isApolloBoard', ], props: { - boardApollo: { + board: { type: Object, required: false, default: () => ({}), @@ -79,18 +76,11 @@ export default { }, computed: { - ...mapState(['board', 'isBoardLoading']), - boardToUse() { - return this.isApolloBoard ? this.boardApollo : this.board; + boardName() { + return this.board?.name || s__('IssueBoards|Select board'); }, - boardToUseName() { - return this.boardToUse?.name || s__('IssueBoards|Select board'); - }, - boardToUseId() { - return getIdFromGraphQLId(this.boardToUse.id) || ''; - }, - isBoardToUseLoading() { - return this.isApolloBoard ? this.isCurrentBoardLoading : this.isBoardLoading; + boardId() { + return getIdFromGraphQLId(this.board.id) || ''; }, parentType() { return this.boardType; @@ -147,7 +137,7 @@ export default { }, }, watch: { - boardToUse(newBoard) { + board(newBoard) { document.title = newBoard.name; }, }, @@ -162,7 +152,6 @@ export default { eventHub.$off('showBoardModal', this.showPage); }, methods: { - ...mapActions(['fetchBoard', 'unsetActiveId']), fullBoardId(boardId) { return fullBoardId(boardId); }, @@ -251,13 +240,6 @@ export default { this.$emit('switchBoard', board.id); }, - fetchCurrentBoard(boardId) { - this.fetchBoard({ - fullPath: this.fullPath, - fullBoardId: fullBoardId(boardId), - boardType: this.boardType, - }); - }, setFilterTerm(value) { this.filterTerm = value; }, @@ -268,15 +250,9 @@ export default { } }, switchBoardGroup(value) { - if (this.isApolloBoard) { - // Epic board ID is supported in EE version of this file - this.$emit('switchBoard', this.fullBoardId(value)); - updateHistory({ url: `${this.boardBaseUrl}/${value}` }); - } else { - this.unsetActiveId(); - this.fetchCurrentBoard(value); - updateHistory({ url: `${this.boardBaseUrl}/${value}` }); - } + // Epic board ID is supported in EE version of this file + this.$emit('switchBoard', this.fullBoardId(value)); + updateHistory({ url: `${this.boardBaseUrl}/${value}` }); }, }, }; @@ -294,10 +270,10 @@ export default { toggle-class="gl-min-w-20" :header-text="$options.i18n.headerText" :no-results-text="$options.i18n.noResultsText" - :loading="isBoardToUseLoading" + :loading="isCurrentBoardLoading" :items="listBoxItems" - :toggle-text="boardToUseName" - :selected="boardToUseId" + :toggle-text="boardName" + :selected="boardId" @search="handleSearch" @select="switchBoardGroup" @shown="loadBoards" @@ -350,7 +326,7 @@ export default { :can-admin-board="canAdminBoard" :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" - :current-board="boardToUse" + :current-board="board" :current-page="currentPage" @addBoard="addBoard" @cancel="cancel" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index a2c4b42b6c5..f86bab40c93 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -1,7 +1,5 @@ <script> import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -22,7 +20,7 @@ export default { directives: { autofocusonshow, }, - inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'], + inject: ['fullPath', 'issuableType', 'isEpicBoard'], props: { activeItem: { type: Object, @@ -37,15 +35,11 @@ export default { }; }, computed: { - ...mapGetters(['activeBoardItem']), - item() { - return this.isApolloBoard ? this.activeItem : this.activeBoardItem; - }, pendingChangesStorageKey() { - return this.getPendingChangesKey(this.item); + return this.getPendingChangesKey(this.activeItem); }, projectPath() { - const referencePath = this.item.referencePath || ''; + const referencePath = this.activeItem.referencePath || ''; return referencePath.slice(0, referencePath.indexOf('#')); }, validationState() { @@ -53,7 +47,7 @@ export default { }, }, watch: { - item: { + activeItem: { handler(updatedItem, formerItem) { if (formerItem?.title !== this.title) { localStorage.setItem(this.getPendingChangesKey(formerItem), this.title); @@ -66,7 +60,6 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemTitle']), getPendingChangesKey(item) { if (!item) { return ''; @@ -92,16 +85,12 @@ export default { } }, cancel() { - this.title = this.item.title; + this.title = this.activeItem.title; this.$refs.sidebarItem.collapse(); this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); }, async setActiveBoardItemTitle() { - if (!this.isApolloBoard) { - await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); - return; - } const { fullPath, issuableType, isEpicBoard, title } = this; const workspacePath = isEpicBoard ? { groupPath: fullPath } @@ -111,7 +100,7 @@ export default { variables: { input: { ...workspacePath, - iid: String(this.item.iid), + iid: String(this.activeItem.iid), title, }, }, @@ -120,7 +109,7 @@ export default { async setTitle() { this.$refs.sidebarItem.collapse(); - if (!this.title || this.title === this.item.title) { + if (!this.title || this.title === this.activeItem.title) { return; } @@ -130,14 +119,14 @@ export default { localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { - this.title = this.item.title; + this.title = this.activeItem.title; setError({ error: e, message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } }, handleOffClick() { - if (this.title !== this.item.title) { + if (this.title !== this.activeItem.title) { this.showChangesAlert = true; localStorage.setItem(this.pendingChangesStorageKey, this.title); } else { @@ -166,13 +155,13 @@ export default { > <template #title> <span data-testid="item-title"> - <gl-link class="gl-reset-color gl-hover-text-blue-800" :href="item.webUrl"> - {{ item.title }} + <gl-link class="gl-reset-color gl-hover-text-blue-800" :href="activeItem.webUrl"> + {{ activeItem.title }} </gl-link> </span> </template> <template #collapsed> - <span class="gl-text-gray-800">{{ item.referencePath }}</span> + <span class="gl-text-gray-800">{{ activeItem.referencePath }}</span> </template> <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> {{ $options.i18n.reviewYourChanges }} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 9d7b7a38c6d..72b8aef31a4 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -24,7 +24,7 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; - const isApolloBoard = window.gon?.features?.apolloBoards; + const isApolloBoard = true; const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js index 113840dbc52..0dacd5af5cc 100644 --- a/app/assets/javascripts/breadcrumb.js +++ b/app/assets/javascripts/breadcrumb.js @@ -23,11 +23,11 @@ export default () => { topLevelLinks.forEach((el) => addTooltipToEl(el)); $expanderBtn.on('click', () => { - const detailItems = $('.breadcrumbs-detail-item'); + const detailItems = $('.gl-breadcrumb-item'); const hiddenClass = 'gl-display-none!'; $.each(detailItems, (_key, item) => { - $(item).toggleClass(hiddenClass); + $(item).removeClass(hiddenClass); }); // remove the ellipsis diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index de37aa431e6..3a0fd376d3c 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -10,7 +10,7 @@ import { GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -40,7 +40,6 @@ import { I18N_BULK_DELETE_ERROR, I18N_BULK_DELETE_PARTIAL_ERROR, I18N_BULK_DELETE_CONFIRMATION_TOAST, - SELECTED_ARTIFACTS_MAX_COUNT, I18N_BULK_DELETE_MAX_SELECTED, } from '../constants'; import JobCheckbox from './job_checkbox.vue'; @@ -77,7 +76,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['projectId', 'projectPath', 'canDestroyArtifacts'], + inject: ['projectId', 'projectPath', 'canDestroyArtifacts', 'jobArtifactsCountLimit'], apollo: { jobArtifacts: { query: getJobArtifactsQuery, @@ -151,7 +150,7 @@ export default { return Boolean(this.selectedArtifacts.length); }, isSelectedArtifactsLimitReached() { - return this.selectedArtifacts.length >= SELECTED_ARTIFACTS_MAX_COUNT; + return this.selectedArtifacts.length >= this.jobArtifactsCountLimit; }, canBulkDestroyArtifacts() { return this.canDestroyArtifacts; diff --git a/app/assets/javascripts/ci/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 28c371cda1e..166946035d1 100644 --- a/app/assets/javascripts/ci/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js @@ -47,7 +47,6 @@ export const I18N_MODAL_BODY = s__( export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact'); export const I18N_MODAL_CANCEL = __('Cancel'); -export const SELECTED_ARTIFACTS_MAX_COUNT = 50; export const I18N_BULK_DELETE_MAX_SELECTED = s__( 'Artifacts|Maximum selected artifacts limit reached', ); diff --git a/app/assets/javascripts/ci/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js index c6021eb056f..0a84b94f5fa 100644 --- a/app/assets/javascripts/ci/artifacts/index.js +++ b/app/assets/javascripts/ci/artifacts/index.js @@ -19,7 +19,7 @@ export const initArtifactsTable = () => { return false; } - const { projectPath, projectId, canDestroyArtifacts } = el.dataset; + const { projectPath, projectId, canDestroyArtifacts, jobArtifactsCountLimit } = el.dataset; return new Vue({ el, @@ -28,6 +28,7 @@ export const initArtifactsTable = () => { projectPath, projectId, canDestroyArtifacts: parseBoolean(canDestroyArtifacts), + jobArtifactsCountLimit: parseInt(jobArtifactsCountLimit, 10), }, render: (createElement) => createElement(App), }); diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue index 572a8183730..349ce761d25 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue @@ -101,7 +101,7 @@ export default { <span v-for="item in projectInfoItems" :key="`${item.icon}`" - class="gl-display-flex gl-align-items-center gl-xs-mb-3" + class="gl-display-flex gl-align-items-center gl-mb-3 gl-sm-mb-0" > <gl-icon class="gl-text-primary gl-mr-2" :name="item.icon" /> <div diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue index fbc7ddf5c91..6d062d8b7f1 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue @@ -12,7 +12,7 @@ export default { GlTableLite, }, props: { - resourceId: { + resourcePath: { type: String, required: true, }, @@ -27,11 +27,11 @@ export default { query: getCiCatalogResourceComponents, variables() { return { - id: this.resourceId, + fullPath: this.resourcePath, }; }, update(data) { - return data?.ciCatalogResource?.components?.nodes || []; + return data?.ciCatalogResource?.latestVersion?.components?.nodes || []; }, error() { createAlert({ message: this.$options.i18n.fetchError }); @@ -64,7 +64,7 @@ export default { thClass: 'gl-w-40p', }, { - key: 'defaultValue', + key: 'default', label: s__('CiCatalogComponent|Default Value'), thClass: 'gl-w-40p', }, @@ -103,7 +103,6 @@ export default { data-testid="component-section" > <h3 class="gl-font-size-h2" data-testid="component-name">{{ component.name }}</h3> - <p class="gl-mt-5">{{ component.description }}</p> <div class="gl-display-flex"> <pre class="gl-w-85p gl-py-4 gl-display-flex gl-justify-content-space-between gl-m-0 gl-border-r-none" @@ -124,7 +123,7 @@ export default { </div> <div class="gl-mt-5"> <b class="gl-display-block gl-mb-4"> {{ $options.i18n.inputTitle }}</b> - <gl-table-lite :items="component.inputs.nodes" :fields="$options.fields"> + <gl-table-lite :items="component.inputs" :fields="$options.fields"> <template #cell(required)="{ item }"> {{ humanizeBoolean(item.required) }} </template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue index 026a30988fd..b1170b13ef6 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue @@ -14,7 +14,7 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { - resourceId: { + resourcePath: { type: String, required: true, }, @@ -31,10 +31,10 @@ export default { <template> <gl-tabs> <gl-tab :title="$options.i18n.tabs.readme" lazy> - <ci-resource-readme :resource-id="resourceId" /> + <ci-resource-readme :resource-path="resourcePath" /> </gl-tab> <gl-tab v-if="glFeatures.ciCatalogComponentsTab" :title="$options.i18n.tabs.components" lazy> - <ci-resource-components :resource-id="resourceId" + <ci-resource-components :resource-path="resourcePath" /></gl-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue index 29009c14e1b..b9d6173a777 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue @@ -1,8 +1,8 @@ <script> import { GlAvatar, GlAvatarLink, GlBadge } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { isNumeric } from '~/lib/utils/number_utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import CiResourceAbout from './ci_resource_about.vue'; import CiResourceHeaderSkeletonLoader from './ci_resource_header_skeleton_loader.vue'; @@ -48,9 +48,6 @@ export default { entityId() { return getIdFromGraphQLId(this.resource.id); }, - fullPath() { - return `${this.rootNamespace.fullPath}/${this.rootNamespace.name}`; - }, hasLatestVersion() { return this.latestVersion?.tagName; }, @@ -60,13 +57,11 @@ export default { latestVersion() { return this.resource.latestVersion; }, - rootNamespace() { - return this.resource.rootNamespace; - }, versionBadgeText() { - return isNumeric(this.latestVersion.tagName) - ? `v${this.latestVersion.tagName}` - : this.latestVersion.tagName; + return this.latestVersion.tagName; + }, + webPath() { + return cleanLeadingSeparator(this.resource?.webPath); }, }, }; @@ -89,7 +84,7 @@ export default { class="gl-display-flex gl-flex-direction-column gl-align-items-flex-start gl-justify-content-center" > <div class="gl-font-sm gl-text-secondary"> - {{ fullPath }} + {{ webPath }} </div> <span class="gl-display-flex"> <div class="gl-font-lg gl-font-weight-bold">{{ resource.name }}</div> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue index d473833869d..343b555c4d8 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue @@ -11,7 +11,7 @@ export default { }, directives: { SafeHtml }, props: { - resourceId: { + resourcePath: { type: String, required: true, }, @@ -26,7 +26,7 @@ export default { query: getCiCatalogResourceReadme, variables() { return { - id: this.resourceId, + fullPath: this.resourcePath, }; }, update(data) { diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue index db84eaa82c2..3a9ec341789 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue @@ -2,15 +2,17 @@ import { GlBanner, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; +import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; import { CATALOG_FEEDBACK_DISMISSED_KEY } from '../../constants'; const defaultTitle = __('CI/CD Catalog'); const defaultDescription = s__( - 'CiCatalog|Discover CI configuration resources for a seamless CI/CD experience.', + 'CiCatalog|Discover CI/CD components that can improve your pipeline with additional functionality.', ); export default { components: { + BetaBadge, GlBanner, GlLink, }, @@ -45,7 +47,7 @@ export default { }; </script> <template> - <div class="gl-border-b-1 gl-border-gray-100 gl-border-b-solid"> + <div class="page-title-holder"> <gl-banner v-if="!isFeedbackBannerDismissed" class="gl-mt-5" @@ -58,9 +60,12 @@ export default { {{ $options.i18n.banner.description }} </p> </gl-banner> - <h1 class="gl-font-size-h-display">{{ pageTitle }}</h1> + <div class="gl-my-4 gl-display-flex gl-align-items-center"> + <h1 class="gl-m-0 gl-font-size-h-display">{{ pageTitle }}</h1> + <beta-badge class="gl-ml-3" /> + </div> <p> - <span data-testid="description">{{ pageDescription }}</span> + <span data-testid="page-description">{{ pageDescription }}</span> <gl-link :href="$options.learnMorePath" target="_blank">{{ $options.i18n.learnMore }}</gl-link> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue index 3722b8e6c59..5de71fa1fc5 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue @@ -37,21 +37,19 @@ export default { > <!-- Catalog project avatar --> <rect x="0" y="0" width="48" height="48" rx="4" ry="4" /> - <!-- namespace path --> - <rect x="60" y="4" width="400" height="16" rx="2" ry="2" /> + <!-- resource path --> + <rect x="60" y="0" width="200" height="10" rx="2" ry="2" /> + <!-- resource name --> + <rect x="60" y="14" width="400" height="16" rx="2" ry="2" /> <!-- Project description --> - <rect x="60" y="30" width="500" height="12" rx="2" ry="2" /> + <rect x="60" y="34" width="500" height="12" rx="2" ry="2" /> <!-- Release date line --> <rect :x="coordinates.releaseDateX" y="30" width="200" height="12" rx="2" ry="2" /> <!-- Favorites --> - <rect :x="coordinates.statsX" y="4" width="16" height="16" rx="2" ry="2" /> - <rect :x="coordinates.statsX + 18" y="7" width="18" height="10" rx="2" ry="2" /> - - <!-- Forks --> <rect :x="coordinates.statsX + 50" y="4" width="16" height="16" rx="2" ry="2" /> - <rect :x="coordinates.statsX + 68" y="7" width="18" height="10" rx="2" ry="2" /> + <rect :x="coordinates.statsX + 70" y="7" width="18" height="10" rx="2" ry="2" /> </gl-skeleton-loader> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue new file mode 100644 index 00000000000..e074cfda6f7 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_search.vue @@ -0,0 +1,81 @@ +<script> +import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { SORT_ASC, SORT_DESC, SORT_OPTION_CREATED } from '../../constants'; + +export default { + components: { + GlSearchBoxByClick, + GlSorting, + GlSortingItem, + }, + data() { + return { + currentSortOption: SORT_OPTION_CREATED, + isAscending: false, + searchTerm: '', + }; + }, + computed: { + currentSortDirection() { + return this.isAscending ? SORT_ASC : SORT_DESC; + }, + currentSorting() { + return `${this.currentSortOption}_${this.currentSortDirection}`; + }, + currentSortText() { + const currentSort = this.$options.sortOptions.find( + (sort) => sort.key === this.currentSortOption, + ); + return currentSort.text; + }, + }, + watch: { + currentSorting(newSorting) { + this.$emit('update-sorting', newSorting); + }, + }, + methods: { + isActiveSort(sortItem) { + return sortItem === this.currentSortOption; + }, + onClear() { + this.$emit('update-search-term', ''); + }, + onSortDirectionChange() { + this.isAscending = !this.isAscending; + }, + onSubmitSearch() { + this.$emit('update-search-term', this.searchTerm); + }, + setSelectedSortOption(sortingItem) { + this.currentSortOption = sortingItem.key; + }, + }, + sortOptions: [{ key: SORT_OPTION_CREATED, text: __('Created at') }], +}; +</script> +<template> + <div class="gl-display-flex gl-gap-3"> + <gl-search-box-by-click + v-model="searchTerm" + data-testid="catalog-search-bar" + @submit="onSubmitSearch" + @clear="onClear" + /> + <gl-sorting + :is-ascending="isAscending" + :text="currentSortText" + @sortDirectionChange="onSortDirectionChange" + > + <gl-sorting-item + v-for="sortingItem in $options.sortOptions" + :key="sortingItem.key" + :active="isActiveSort(sortingItem.key)" + @click="setSelectedSortOption(sortingItem)" + > + {{ sortingItem.text }} + </gl-sorting-item> + </gl-sorting> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue index 080955b4322..57d19af614f 100644 --- a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue +++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue @@ -1,16 +1,9 @@ <script> -import { - GlAvatar, - GlBadge, - GlButton, - GlIcon, - GlLink, - GlSprintf, - GlTooltipDirective, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatar, GlBadge, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { s__, n__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import { CI_RESOURCE_DETAILS_PAGE_NAME } from '../../router/constants'; export default { @@ -21,7 +14,6 @@ export default { components: { GlAvatar, GlBadge, - GlButton, GlIcon, GlLink, GlSprintf, @@ -42,12 +34,27 @@ export default { authorProfileUrl() { return this.latestVersion.author.webUrl; }, + resourceId() { + return cleanLeadingSeparator(this.resource.webPath); + }, + detailsPageResolved() { + return this.$router.resolve({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { id: this.resourceId }, + }); + }, + detailsPageHref() { + return decodeURIComponent(this.detailsPageResolved.href); + }, entityId() { return getIdFromGraphQLId(this.resource.id); }, starCount() { return this.resource?.starCount || 0; }, + starCountText() { + return n__('Star', 'Stars', this.starCount); + }, hasReleasedVersion() { return Boolean(this.latestVersion?.releasedAt); }, @@ -60,26 +67,33 @@ export default { releasedAt() { return getTimeago().format(this.latestVersion?.releasedAt); }, - resourcePath() { - return `${this.resource.rootNamespace?.name} / ${this.resource.rootNamespace?.fullPath} / `; - }, tagName() { return this.latestVersion?.tagName || this.$options.i18n.unreleased; }, + webPath() { + return cleanLeadingSeparator(this.resource?.webPath); + }, }, methods: { - navigateToDetailsPage() { - this.$router.push({ - name: CI_RESOURCE_DETAILS_PAGE_NAME, - params: { id: this.entityId }, - }); + navigateToDetailsPage(e) { + // Open link in a new tab if any of these modifier key is held down. + if (e?.ctrlKey || e?.metaKey) { + return; + } + + // Override the <a> tag if no modifier key is held down to use Vue router and not + // open a new tab. + e.preventDefault(); + + // Push to the decoded URL to avoid all the / being encoded + this.$router.push({ path: decodeURIComponent(this.resourceId) }); }, }, }; </script> <template> <li - class="gl-display-flex gl-display-flex-wrap gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-text-gray-500 gl-py-3" + class="gl-display-flex gl-display-flex-wrap gl-align-items-center gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-text-gray-500 gl-py-3" data-testid="catalog-resource-item" > <gl-avatar @@ -92,36 +106,40 @@ export default { @click="navigateToDetailsPage" /> <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> - <div class="gl-display-flex gl-flex-wrap gl-gap-2 gl-mb-2"> - <gl-button - variant="link" + <span class="gl-font-sm gl-mb-1">{{ webPath }}</span> + <div class="gl-display-flex gl-flex-wrap gl-gap-2 gl-mb-1"> + <gl-link class="gl-text-gray-900! gl-mr-1" + :href="detailsPageHref" data-testid="ci-resource-link" @click="navigateToDetailsPage" > - {{ resourcePath }} <b> {{ resource.name }}</b> - </gl-button> + <b> {{ resource.name }}</b> + </gl-link> <div class="gl-display-flex gl-flex-grow-1 gl-md-justify-content-space-between"> - <gl-badge size="sm">{{ tagName }}</gl-badge> + <gl-badge size="sm" class="gl-h-5 gl-align-self-center">{{ tagName }}</gl-badge> <span class="gl-display-flex gl-align-items-center gl-ml-5"> - <span class="gl--flex-center" data-testid="stats-favorites"> - <gl-icon name="star" :size="14" class="gl-mr-1" /> + <span + v-gl-tooltip.top + :title="starCountText" + class="gl--flex-center" + data-testid="stats-favorites" + > + <gl-icon name="star-o" :size="14" class="gl-mr-2" /> <span class="gl-mr-3">{{ starCount }}</span> </span> </span> </div> </div> <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between gl-font-sm" > - <span class="gl-display-flex gl-flex-basis-two-thirds gl-font-sm">{{ - resource.description - }}</span> + <span class="gl-display-flex gl-flex-basis-two-thirds">{{ resource.description }}</span> <div class="gl-display-flex gl-justify-content-end"> <span v-if="hasReleasedVersion"> <gl-sprintf :message="$options.i18n.releasedMessage"> <template #timeAgo> - <span v-gl-tooltip.bottom :title="formattedDate"> + <span v-gl-tooltip.top :title="formattedDate"> {{ releasedAt }} </span> </template> diff --git a/app/assets/javascripts/ci/catalog/components/list/empty_state.vue b/app/assets/javascripts/ci/catalog/components/list/empty_state.vue index a53ddefaa50..e53a10d8935 100644 --- a/app/assets/javascripts/ci/catalog/components/list/empty_state.vue +++ b/app/assets/javascripts/ci/catalog/components/list/empty_state.vue @@ -1,22 +1,70 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { COMPONENTS_DOCS_URL } from '~/ci/catalog/constants'; export default { - i18n: { - title: s__('CiCatalog|Get started with the CI/CD Catalog'), - description: s__( - 'CiCatalog|Create a pipeline component repository and make reusing pipeline configurations faster and easier.', - ), - }, name: 'CiCatalogEmptyState', + COMPONENTS_DOCS_URL, components: { GlEmptyState, + GlLink, + GlSprintf, + }, + props: { + searchTerm: { + type: String, + required: false, + default: '', + }, + }, + computed: { + searchTitle() { + return this.isQueryTooSmall + ? this.$options.i18n.searchTooSmall.title + : this.$options.i18n.search.title; + }, + isSearching() { + return this.searchTerm?.length > 0; + }, + isQueryTooSmall() { + return this.isSearching && this.searchTerm?.length < 3; + }, + }, + i18n: { + default: { + title: s__('CiCatalog|Get started with the CI/CD Catalog'), + description: s__( + 'CiCatalog|Create a pipeline component repository and make reusing pipeline configurations faster and easier.', + ), + }, + search: { + title: s__('CiCatalog|No result found'), + description: s__( + 'CiCatalog|Edit your search and try again. Or %{linkStart}learn to create a component repository%{linkEnd}.', + ), + }, + searchTooSmall: { + title: s__('CiCatalog|Search must be at least 3 characters'), + }, }, }; </script> <template> <div> - <gl-empty-state :title="$options.i18n.title" :description="$options.i18n.description" /> + <gl-empty-state v-if="isSearching" :title="searchTitle"> + <template #description> + <gl-sprintf :message="$options.i18n.search.description"> + <template #link="{ content }"> + <gl-link :href="$options.COMPONENTS_DOCS_URL" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + <gl-empty-state + v-else + :title="$options.i18n.default.title" + :description="$options.i18n.default.description" + /> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue index da2c73be900..b7e117f9c26 100644 --- a/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue +++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue @@ -2,8 +2,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { CI_CATALOG_RESOURCE_TYPE } from '../../graphql/settings'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import getCatalogCiResourceDetails from '../../graphql/queries/get_ci_catalog_resource_details.query.graphql'; import getCatalogCiResourceSharedData from '../../graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; import CiResourceDetails from '../details/ci_resource_details.vue'; @@ -28,7 +27,7 @@ export default { query: getCatalogCiResourceSharedData, variables() { return { - id: this.graphQLId, + fullPath: this.cleanFullPath, }; }, update(data) { @@ -43,7 +42,7 @@ export default { query: getCatalogCiResourceDetails, variables() { return { - id: this.graphQLId, + fullPath: this.cleanFullPath, }; }, update(data) { @@ -56,8 +55,8 @@ export default { }, }, computed: { - graphQLId() { - return convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, this.$route.params.id); + cleanFullPath() { + return cleanLeadingSeparator(this.$route.params.id); }, isLoadingDetails() { return this.$apollo.queries.resourceAdditionalDetails.loading; @@ -103,7 +102,7 @@ export default { :pipeline-status="pipelineStatus" :resource="resourceSharedData" /> - <ci-resource-details :resource-id="graphQLId" /> + <ci-resource-details :resource-path="cleanFullPath" /> </div> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue index 5e8727a3ed0..e1c86f38d7e 100644 --- a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue +++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue @@ -1,17 +1,21 @@ <script> import { createAlert } from '~/alert'; import { s__ } from '~/locale'; -import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; -import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue'; -import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue'; -import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings'; +import CatalogSearch from '../list/catalog_search.vue'; +import CiResourcesList from '../list/ci_resources_list.vue'; +import CatalogListSkeletonLoader from '../list/catalog_list_skeleton_loader.vue'; +import CatalogHeader from '../list/catalog_header.vue'; +import EmptyState from '../list/empty_state.vue'; import getCatalogResources from '../../graphql/queries/get_ci_catalog_resources.query.graphql'; +import getCurrentPage from '../../graphql/queries/client/get_current_page.query.graphql'; +import updateCurrentPageMutation from '../../graphql/mutations/client/update_current_page.mutation.graphql'; export default { components: { CatalogHeader, CatalogListSkeletonLoader, + CatalogSearch, CiResourcesList, EmptyState, }, @@ -19,8 +23,9 @@ export default { return { catalogResources: [], currentPage: 1, - totalCount: 0, pageInfo: {}, + searchTerm: '', + totalCount: 0, }; }, apollo: { @@ -43,6 +48,12 @@ export default { createAlert({ message: e.message || this.$options.i18n.fetchError, variant: 'danger' }); }, }, + currentPage: { + query: getCurrentPage, + update(data) { + return data?.page?.current || 1; + }, + }, }, computed: { hasResources() { @@ -51,6 +62,12 @@ export default { isLoading() { return this.$apollo.queries.catalogResources.loading; }, + isSearching() { + return this.searchTerm?.length > 0; + }, + showEmptyState() { + return !this.hasResources && !this.isSearching; + }, }, methods: { async handlePrevPage() { @@ -63,7 +80,7 @@ export default { }, }); - this.currentPage -= 1; + this.decrementPage(); } catch (e) { // Ensure that the current query is properly stoped if an error occurs. this.$apollo.queries.catalogResources.stop(); @@ -78,7 +95,7 @@ export default { }, }); - this.currentPage += 1; + this.incrementPage(); } catch (e) { // Ensure that the current query is properly stoped if an error occurs. this.$apollo.queries.catalogResources.stop(); @@ -86,6 +103,36 @@ export default { createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); } }, + updatePageCount(pageNumber) { + this.$apollo.mutate({ + mutation: updateCurrentPageMutation, + variables: { + pageNumber, + }, + }); + }, + decrementPage() { + this.updatePageCount(this.currentPage - 1); + }, + incrementPage() { + this.updatePageCount(this.currentPage + 1); + }, + onUpdateSearchTerm(searchTerm) { + this.searchTerm = !searchTerm.length ? null : searchTerm; + this.resetPageCount(); + this.$apollo.queries.catalogResources.refetch({ + searchTerm: this.searchTerm, + }); + }, + onUpdateSorting(sortValue) { + this.resetPageCount(); + this.$apollo.queries.catalogResources.refetch({ + sortValue, + }); + }, + resetPageCount() { + this.updatePageCount(1); + }, }, i18n: { fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'), @@ -95,18 +142,24 @@ export default { <template> <div> <catalog-header /> - <catalog-list-skeleton-loader v-if="isLoading" class="gl-w-full gl-mt-3" /> - <empty-state v-else-if="!hasResources" /> - <ci-resources-list - v-else - :current-page="currentPage" - :page-info="pageInfo" - :prev-text="__('Prev')" - :next-text="__('Next')" - :resources="catalogResources" - :total-count="totalCount" - @onPrevPage="handlePrevPage" - @onNextPage="handleNextPage" + <catalog-search + class="gl-py-4 gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-border-t-1 gl-border-t-solid" + @update-search-term="onUpdateSearchTerm" + @update-sorting="onUpdateSorting" /> + <catalog-list-skeleton-loader v-if="isLoading" class="gl-w-full gl-mt-3" /> + <empty-state v-else-if="!hasResources" :search-term="searchTerm" /> + <template v-else> + <ci-resources-list + :current-page="currentPage" + :page-info="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + :resources="catalogResources" + :total-count="totalCount" + @onPrevPage="handlePrevPage" + @onNextPage="handleNextPage" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/ci/catalog/constants.js b/app/assets/javascripts/ci/catalog/constants.js index ab067f991cd..34c0ac797c1 100644 --- a/app/assets/javascripts/ci/catalog/constants.js +++ b/app/assets/javascripts/ci/catalog/constants.js @@ -1,35 +1,9 @@ -// We disable this for the entire file until the mock data is cleanup -/* eslint-disable @gitlab/require-i18n-strings */ +import { helpPagePath } from '~/helpers/help_page_helper'; + export const CATALOG_FEEDBACK_DISMISSED_KEY = 'catalog_feedback_dismissed'; -export const componentsMockData = { - __typename: 'CiComponentConnection', - nodes: [ - { - id: 'gid://gitlab/Ci::Component/1', - name: 'Ruby gal', - description: 'This is a pretty amazing component that does EVERYTHING ruby.', - path: 'gitlab.com/gitlab-org/ruby-gal@~latest', - inputs: { nodes: [{ name: 'version', defaultValue: '1.0.0', required: true }] }, - }, - { - id: 'gid://gitlab/Ci::Component/2', - name: 'Javascript madness', - description: 'Adds some spice to your life.', - path: 'gitlab.com/gitlab-org/javascript-madness@~latest', - inputs: { - nodes: [ - { name: 'isFun', defaultValue: 'true', required: true }, - { name: 'RandomNumber', defaultValue: '10', required: false }, - ], - }, - }, - { - id: 'gid://gitlab/Ci::Component/3', - name: 'Go go go', - description: 'When you write Go, you gotta go go go.', - path: 'gitlab.com/gitlab-org/go-go-go@~latest', - inputs: { nodes: [{ name: 'version', defaultValue: '1.0.0', required: true }] }, - }, - ], -}; +export const SORT_OPTION_CREATED = 'CREATED'; +export const SORT_ASC = 'ASC'; +export const SORT_DESC = 'DESC'; + +export const COMPONENTS_DOCS_URL = helpPagePath('ci/components/index'); diff --git a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql index a86db4c1b03..b3a750e9604 100644 --- a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql @@ -1,5 +1,6 @@ fragment CatalogResourceFields on CiCatalogResource { id + webPath icon name description @@ -15,10 +16,4 @@ fragment CatalogResourceFields on CiCatalogResource { webUrl } } - rootNamespace { - id - fullPath - name - } - webPath } diff --git a/app/assets/javascripts/ci/catalog/graphql/mutations/client/update_current_page.mutation.graphql b/app/assets/javascripts/ci/catalog/graphql/mutations/client/update_current_page.mutation.graphql new file mode 100644 index 00000000000..7ffd8f6ea61 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/mutations/client/update_current_page.mutation.graphql @@ -0,0 +1,7 @@ +mutation updateCurrentPage($pageNumber: Int!) { + updateCurrentPage(pageNumber: $pageNumber) @client { + page { + current + } + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/client/get_current_page.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/client/get_current_page.query.graphql new file mode 100644 index 00000000000..b49895a64aa --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/client/get_current_page.query.graphql @@ -0,0 +1,5 @@ +query getCurrentPage { + page @client { + current + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql index 6aef5dcc4e7..41ac72aa9de 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql @@ -1,17 +1,18 @@ -query getCiCatalogResourceComponents($id: CiCatalogResourceID!) { - ciCatalogResource(id: $id) { +query getCiCatalogResourceComponents($fullPath: ID!) { + ciCatalogResource(fullPath: $fullPath) { id - components @client { - nodes { - id - name - description - path - inputs { - nodes { + webPath + latestVersion { + id + components { + nodes { + id + name + path + inputs { name - defaultValue required + default } } } diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql index 382d3866795..a77e8f12d03 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql @@ -1,6 +1,7 @@ -query getCiCatalogResourceDetails($id: CiCatalogResourceID!) { - ciCatalogResource(id: $id) { +query getCiCatalogResourceDetails($fullPath: ID!) { + ciCatalogResource(fullPath: $fullPath) { id + webPath openIssuesCount openMergeRequestsCount versions(first: 1) { diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql index 6b3d0cdcfc7..c1fde8dcb43 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql @@ -1,6 +1,7 @@ -query getCiCatalogResourceReadme($id: CiCatalogResourceID!) { - ciCatalogResource(id: $id) { +query getCiCatalogResourceReadme($fullPath: ID!) { + ciCatalogResource(fullPath: $fullPath) { id + webPath readmeHtml } } diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql index 4ac4cb0e394..3d5d139a334 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql @@ -1,7 +1,7 @@ #import "../fragments/catalog_resource.fragment.graphql" -query getCiCatalogResourceSharedData($id: CiCatalogResourceID!) { - ciCatalogResource(id: $id) { +query getCiCatalogResourceSharedData($fullPath: ID!) { + ciCatalogResource(fullPath: $fullPath) { ...CatalogResourceFields } } diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql index aae29edef5e..1cf213dec63 100644 --- a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql @@ -1,7 +1,21 @@ #import "~/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql" -query getCatalogResources($after: String, $before: String, $first: Int = 20, $last: Int) { - ciCatalogResources(after: $after, before: $before, first: $first, last: $last) { +query getCatalogResources( + $searchTerm: String + $sortValue: CiCatalogResourceSort + $after: String + $before: String + $first: Int = 20 + $last: Int +) { + ciCatalogResources( + search: $searchTerm + sort: $sortValue + after: $after + before: $before + first: $first + last: $last + ) { pageInfo { startCursor endCursor diff --git a/app/assets/javascripts/ci/catalog/graphql/settings.js b/app/assets/javascripts/ci/catalog/graphql/settings.js index a87b26ca4fc..4038188a7ce 100644 --- a/app/assets/javascripts/ci/catalog/graphql/settings.js +++ b/app/assets/javascripts/ci/catalog/graphql/settings.js @@ -1,32 +1,42 @@ -import { componentsMockData } from '../constants'; +import getCurrentPage from './queries/client/get_current_page.query.graphql'; export const ciCatalogResourcesItemsCount = 20; export const CI_CATALOG_RESOURCE_TYPE = 'Ci::Catalog::Resource'; export const cacheConfig = { - cacheConfig: { - typePolicies: { - Query: { - fields: { - ciCatalogResource(_, { args, toReference }) { - return toReference({ - __typename: 'CiCatalogResource', - id: args.id, - }); - }, - ciCatalogResources: { - keyArgs: false, - }, + typePolicies: { + Query: { + fields: { + ciCatalogResource(_, { args, toReference }) { + return toReference({ + __typename: 'CiCatalogResource', + // Webpath is the fullpath with a leading slash + webPath: `/${args.fullPath}`, + }); + }, + ciCatalogResources: { + keyArgs: false, }, }, }, + CiCatalogResource: { + keyFields: ['webPath'], + }, }, }; export const resolvers = { - CiCatalogResource: { - components() { - return componentsMockData; + Mutation: { + updateCurrentPage: (_, { pageNumber }, { cache }) => { + cache.writeQuery({ + query: getCurrentPage, + data: { + page: { + __typename: 'CatalogPage', + current: pageNumber, + }, + }, + }); }, }, }; diff --git a/app/assets/javascripts/ci/catalog/graphql/typedefs.graphql b/app/assets/javascripts/ci/catalog/graphql/typedefs.graphql new file mode 100644 index 00000000000..8604fae0655 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/typedefs.graphql @@ -0,0 +1,11 @@ +type CatalogPage { + current: Int +} + +extend type Query { + page: CatalogPage +} + +extend type Mutation { + updateCurrentPage(pageNumber: Int!): CatalogPage +} diff --git a/app/assets/javascripts/ci/catalog/index.js b/app/assets/javascripts/ci/catalog/index.js index 5815245506c..34866bfb821 100644 --- a/app/assets/javascripts/ci/catalog/index.js +++ b/app/assets/javascripts/ci/catalog/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { cacheConfig, resolvers } from '~/ci/catalog/graphql/settings'; +import typeDefs from '~/ci/catalog/graphql/typedefs.graphql'; import GlobalCatalog from './global_catalog.vue'; import CiResourcesPage from './components/pages/ci_resources_page.vue'; @@ -19,7 +20,7 @@ export const initCatalog = (selector = '#js-ci-cd-catalog') => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers, cacheConfig), + defaultClient: createDefaultClient(resolvers, { cacheConfig, typeDefs }), }); return new Vue({ diff --git a/app/assets/javascripts/ci/catalog/router/routes.js b/app/assets/javascripts/ci/catalog/router/routes.js index ccfb0673c83..ce859e266d7 100644 --- a/app/assets/javascripts/ci/catalog/router/routes.js +++ b/app/assets/javascripts/ci/catalog/router/routes.js @@ -4,6 +4,6 @@ import { CI_RESOURCES_PAGE_NAME, CI_RESOURCE_DETAILS_PAGE_NAME } from './constan export const createRoutes = (listComponent) => { return [ { name: CI_RESOURCES_PAGE_NAME, path: '', component: listComponent }, - { name: CI_RESOURCE_DETAILS_PAGE_NAME, path: '/:id', component: CiResourceDetailsPage }, + { name: CI_RESOURCE_DETAILS_PAGE_NAME, path: '/:id+', component: CiResourceDetailsPage }, ]; }; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index ccfe773b01f..2ad6c7c6578 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -29,13 +29,16 @@ import { EVENT_ACTION, EXPANDED_VARIABLES_NOTE, FLAG_LINK_TITLE, + MASKED_VALUE_MIN_LENGTH, VARIABLE_ACTIONS, variableOptions, + WHITESPACE_REG_EX, } from '../constants'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL }); +const KEY_REGEX = /^\w+$/; export const i18n = { addVariable: s__('CiVariables|Add variable'), @@ -50,25 +53,38 @@ export const i18n = { flags: __('Flags'), flagsLinkTitle: FLAG_LINK_TITLE, key: __('Key'), + keyFeedback: s__("CiVariables|A variable key can only contain letters, numbers, and '_'."), + keyHelpText: s__( + 'CiVariables|You can use CI/CD variables with the same name in different places, but the variables might overwrite each other. %{linkStart}What is the order of precedence for variables?%{linkEnd}', + ), maskedField: s__('CiVariables|Mask variable'), maskedDescription: s__( 'CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements.', ), + maskedValueMinLengthValidationText: s__( + 'CiVariables|The value must have at least %{charsAmount} characters.', + ), modalDeleteMessage: s__('CiVariables|Do you want to delete the variable %{key}?'), protectedField: s__('CiVariables|Protect variable'), protectedDescription: s__( 'CiVariables|Export variable to pipelines running on protected branches and tags only.', ), + unsupportedCharsValidationText: s__( + 'CiVariables|This value cannot be masked because it contains the following characters: %{unsupportedChars}.', + ), + unsupportedAndWhitespaceCharsValidationText: s__( + 'CiVariables|This value cannot be masked because it contains the following characters: %{unsupportedChars} and whitespace characters.', + ), valueFeedback: { rawHelpText: s__('CiVariables|Variable value will be evaluated as raw string.'), - maskedReqsNotMet: s__( - 'CiVariables|This variable value does not meet the masking requirements.', - ), }, variableReferenceTitle: s__('CiVariables|Value might contain a variable reference'), variableReferenceDescription: s__( 'CiVariables|Unselect "Expand variable reference" if you want to use the variable value as a raw string.', ), + whitespaceCharsValidationText: s__( + 'CiVariables|This value cannot be masked because it contains the following characters: whitespace characters.', + ), type: __('Type'), value: __('Value'), }; @@ -146,7 +162,7 @@ export default { return regex.test(this.variable.value); }, canSubmit() { - return this.variable.key.length > 0 && this.isValueValid; + return this.variable.key.length > 0 && this.isKeyValid && this.isValueValid; }, getDrawerHeaderHeight() { return getContentWrapperHeight(); @@ -157,6 +173,9 @@ export default { isExpanded() { return !this.variable.raw; }, + isKeyValid() { + return KEY_REGEX.test(this.variable.key); + }, isMaskedReqsMet() { return !this.variable.masked || this.isValueMasked; }, @@ -169,11 +188,76 @@ export default { isEditing() { return this.mode === EDIT_VARIABLE_ACTION; }, + isMaskedValueContainsWhitespaceChars() { + return this.isValueMaskable && WHITESPACE_REG_EX.test(this.variable.value); + }, maskedRegexToUse() { return this.variable.raw ? this.maskableRawRegex : this.maskableRegex; }, - maskedReqsNotMetText() { - return !this.isMaskedReqsMet ? this.$options.i18n.valueFeedback.maskedReqsNotMet : ''; + maskedSupportedCharsRegEx() { + const supportedChars = this.maskedRegexToUse.replace('^', '').replace(/{(\d,)}\$/, ''); + return new RegExp(supportedChars, 'g'); + }, + maskedValueMinLengthValidationText() { + return sprintf(this.$options.i18n.maskedValueMinLengthValidationText, { + charsAmount: MASKED_VALUE_MIN_LENGTH, + }); + }, + unsupportedCharsList() { + if (this.isMaskedReqsMet) { + return []; + } + + return [ + ...new Set( + this.variable.value + .replace(WHITESPACE_REG_EX, '') + .replace(this.maskedSupportedCharsRegEx, '') + .split(''), + ), + ]; + }, + unsupportedChars() { + return this.unsupportedCharsList.join(', '); + }, + unsupportedCharsValidationText() { + return sprintf( + this.$options.i18n.unsupportedCharsValidationText, + { + unsupportedChars: this.unsupportedChars, + }, + false, + ); + }, + unsupportedAndWhitespaceCharsValidationText() { + return sprintf( + this.$options.i18n.unsupportedAndWhitespaceCharsValidationText, + { + unsupportedChars: this.unsupportedChars, + }, + false, + ); + }, + maskedValidationIssuesText() { + if (this.isMaskedReqsMet) { + return ''; + } + + let validationIssuesText = ''; + + if (this.unsupportedCharsList.length && !this.isMaskedValueContainsWhitespaceChars) { + validationIssuesText = this.unsupportedCharsValidationText; + } else if (this.unsupportedCharsList.length && this.isMaskedValueContainsWhitespaceChars) { + validationIssuesText = this.unsupportedAndWhitespaceCharsValidationText; + } else if (!this.unsupportedCharsList.length && this.isMaskedValueContainsWhitespaceChars) { + validationIssuesText = this.$options.i18n.whitespaceCharsValidationText; + } + + if (this.variable.value.length < MASKED_VALUE_MIN_LENGTH) { + validationIssuesText += ` ${this.maskedValueMinLengthValidationText}`; + } + + return validationIssuesText.trim(); }, modalActionText() { return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable; @@ -218,9 +302,7 @@ export default { let property; if (this.isValueMaskable) { - const supportedChars = this.maskedRegexToUse.replace('^', '').replace(/{(\d,)}\$/, ''); - const regex = new RegExp(supportedChars, 'g'); - property = this.variable.value.replace(regex, ''); + property = this.variable.value.replace(this.maskedSupportedCharsRegEx, ''); } else if (this.hasVariableReference) { property = '$'; } @@ -246,6 +328,9 @@ export default { flagLink: helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui', }), + variablesPrecedenceLink: helpPagePath('ci/variables/index', { + anchor: 'cicd-variable-precedence', + }), i18n, variableOptions, deleteModal: { @@ -339,6 +424,7 @@ export default { class="gl-display-flex" :title="$options.i18n.flagsLinkTitle" :href="$options.flagLink" + data-testid="ci-variable-flags-docs-link" target="_blank" > <gl-icon name="question-o" :size="14" /> @@ -377,22 +463,39 @@ export default { class="gl-border-none gl-pb-0! gl-mb-n5" data-testid="ci-variable-key" /> + <p + v-if="variable.key.length > 0 && !isKeyValid" + class="gl-pt-3! gl-pb-0! gl-mb-0 gl-text-red-500 gl-border-none" + > + {{ $options.i18n.keyFeedback }} + </p> + <p class="gl-pt-3! gl-pb-0! gl-mb-0 gl-text-secondary gl-border-none"> + <gl-sprintf :message="$options.i18n.keyHelpText"> + <template #link="{ content }" + ><gl-link + :href="$options.variablesPrecedenceLink" + data-testid="ci-variable-precedence-docs-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> <gl-form-group :label="$options.i18n.value" label-for="ci-variable-value" class="gl-border-none gl-mb-n2" data-testid="ci-variable-value-label" - :invalid-feedback="maskedReqsNotMetText" + :invalid-feedback="maskedValidationIssuesText" :state="isValueValid" > <gl-form-textarea id="ci-variable-value" v-model="variable.value" + :spellcheck="false" class="gl-border-none gl-font-monospace!" rows="3" max-rows="10" data-testid="ci-variable-value" - spellcheck="false" /> <p v-if="variable.raw" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index d85827b8220..4ec7333f465 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -2,6 +2,10 @@ import { __, s__, sprintf } from '~/locale'; export const ENVIRONMENT_QUERY_LIMIT = 30; +export const MASKED_VALUE_MIN_LENGTH = 8; + +export const WHITESPACE_REG_EX = /\s/; + export const SORT_DIRECTIONS = { ASC: 'KEY_ASC', DESC: 'KEY_DESC', diff --git a/app/assets/javascripts/ci/common/private/job_name_component.vue b/app/assets/javascripts/ci/common/private/job_name_component.vue index b4e831d69d4..a0e611acc9d 100644 --- a/app/assets/javascripts/ci/common/private/job_name_component.vue +++ b/app/assets/javascripts/ci/common/private/job_name_component.vue @@ -1,5 +1,5 @@ <script> -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; /** * Component that renders both the CI icon status and the job name. @@ -30,7 +30,13 @@ export default { </script> <template> <span class="mw-100 gl-display-flex gl-align-items-center gl-flex-grow-1"> - <ci-icon :size="iconSize" :status="status" :show-tooltip="false" class="gl-line-height-0" /> + <ci-icon + :size="iconSize" + :status="status" + :show-tooltip="false" + :use-link="false" + class="gl-line-height-0" + /> <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> {{ name }} </span> diff --git a/app/assets/javascripts/ci/job_details/components/environments_block.vue b/app/assets/javascripts/ci/job_details/components/environments_block.vue index 4046e1ade82..16d553fd071 100644 --- a/app/assets/javascripts/ci/job_details/components/environments_block.vue +++ b/app/assets/javascripts/ci/job_details/components/environments_block.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { __ } from '~/locale'; export default { diff --git a/app/assets/javascripts/ci/job_details/components/job_header.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue index 1aa83a94bc5..031abc7a36c 100644 --- a/app/assets/javascripts/ci/job_details/components/job_header.vue +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { diff --git a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue index 4a30878bec5..837efa154e2 100644 --- a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue +++ b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue @@ -20,6 +20,9 @@ export default { 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.', ), logLineNumberNotFound: s__('Job|We could not find this element'), + enterFullscreen: s__('Job|Show full screen'), + exitFullScreen: s__('Job|Exit full screen'), + fullScreenNotAvailable: s__('Job|Full screen mode is not available'), }, components: { GlLink, @@ -65,6 +68,16 @@ export default { type: Array, required: true, }, + fullScreenModeAvailable: { + type: Boolean, + required: false, + default: false, + }, + fullScreenEnabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -80,34 +93,32 @@ export default { size: numberToHumanSize(this.size), }); }, - showJumpToFailures() { - return this.glFeatures.jobLogJumpToFailures; - }, hasFailures() { return this.failureCount > 0; }, shouldDisableJumpToFailures() { return !this.hasFailures; }, + fullScreenTooltipContent() { + return this.fullScreenModeAvailable ? '' : this.$options.i18n.fullScreenNotAvailable; + }, }, mounted() { this.checkFailureCount(); }, methods: { checkFailureCount() { - if (this.glFeatures.jobLogJumpToFailures) { - backOff((next, stop) => { - this.failureCount = document.querySelectorAll('.term-fg-l-red').length; + backOff((next, stop) => { + this.failureCount = document.querySelectorAll('.term-fg-l-red').length; - if (this.hasFailures || (this.isComplete && !this.hasFailures)) { - stop(); - } else { - next(); - } - }).catch(() => { - this.failureCount = null; - }); - } + if (this.hasFailures || (this.isComplete && !this.hasFailures)) { + stop(); + } else { + next(); + } + }).catch(() => { + this.failureCount = null; + }); }, handleScrollToNextFailure() { const failures = document.querySelectorAll('.term-fg-l-red'); @@ -126,6 +137,12 @@ export default { this.$emit('scrollJobLogBottom'); this.failureIndex = 0; }, + handleFullscreenMode() { + this.$emit('enterFullscreen'); + }, + handleExitFullscreenMode() { + this.$emit('exitFullscreen'); + }, searchJobLog() { this.searchResults = []; @@ -221,7 +238,6 @@ export default { <!-- scroll buttons --> <gl-button - v-if="showJumpToFailures" v-gl-tooltip :title="$options.i18n.scrollToNextFailureButtonLabel" :aria-label="$options.i18n.scrollToNextFailureButtonLabel" @@ -255,6 +271,29 @@ export default { /> </div> <!-- eo scroll buttons --> + + <div v-gl-tooltip="fullScreenTooltipContent"> + <gl-button + v-if="!fullScreenEnabled" + :disabled="!fullScreenModeAvailable" + :title="$options.i18n.enterFullscreen" + :aria-label="$options.i18n.enterFullscreen" + class="btn-scroll gl-ml-3" + data-testid="job-controller-enter-fullscreen" + icon="maximize" + @click="handleFullscreenMode" + /> + </div> + + <gl-button + v-if="fullScreenEnabled" + :title="$options.i18n.exitFullScreen" + :aria-label="$options.i18n.exitFullScreen" + class="btn-scroll gl-ml-3" + data-testid="job-controller-exit-fullscreen" + icon="minimize" + @click="handleExitFullscreenMode" + /> </div> </div> </template> diff --git a/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue deleted file mode 100644 index 39c612bc600..00000000000 --- a/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import LogLine from './line.vue'; -import LogLineHeader from './line_header.vue'; - -export default { - name: 'CollapsibleLogSection', - components: { - LogLine, - LogLineHeader, - }, - props: { - section: { - type: Object, - required: true, - }, - jobLogEndpoint: { - type: String, - required: true, - }, - searchResults: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - badgeDuration() { - return this.section.line && this.section.line.section_duration; - }, - highlightedLines() { - return this.searchResults.map((result) => result.lineNumber); - }, - headerIsHighlighted() { - const { - line: { lineNumber }, - } = this.section; - - return this.highlightedLines.includes(lineNumber); - }, - }, - methods: { - handleOnClickCollapsibleLine(section) { - this.$emit('onClickCollapsibleLine', section); - }, - lineIsHighlighted({ lineNumber }) { - return this.highlightedLines.includes(lineNumber); - }, - }, -}; -</script> -<template> - <div> - <log-line-header - :line="section.line" - :duration="badgeDuration" - :path="jobLogEndpoint" - :is-closed="section.isClosed" - :is-highlighted="headerIsHighlighted" - @toggleLine="handleOnClickCollapsibleLine(section)" - /> - <template v-if="!section.isClosed"> - <log-line - v-for="line in section.lines" - :key="line.offset" - :line="line" - :path="jobLogEndpoint" - :is-highlighted="lineIsHighlighted(line)" - /> - </template> - </div> -</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line.vue b/app/assets/javascripts/ci/job_details/components/log/line.vue index 416f75372f9..6ff2bb766c7 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line.vue @@ -68,7 +68,7 @@ export default { { class: [ 'js-log-line', - 'log-line', + 'job-log-line', { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }, ], }, diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue index d36701323da..4716f1e5162 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_header.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -24,6 +24,11 @@ export default { type: String, required: true, }, + hideDuration: { + type: Boolean, + required: false, + default: false, + }, duration: { type: String, required: false, @@ -63,7 +68,7 @@ export default { <template> <div - class="js-log-line log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" + class="js-log-line job-log-line-header job-log-line" :class="{ 'gl-bg-gray-700': isHighlighted || applyHashHighlight }" role="button" @click="handleOnClick" @@ -73,10 +78,10 @@ export default { <span v-for="(content, i) in line.content" :key="i" - class="line-text w-100 gl-white-space-pre-wrap" + class="gl-flex-grow-1 gl-white-space-pre-wrap" :class="content.style" >{{ content.text }}</span > - <duration-badge v-if="duration" :duration="duration" /> + <duration-badge v-if="duration && !hideDuration" :duration="duration" /> </div> </template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_number.vue b/app/assets/javascripts/ci/job_details/components/log/line_number.vue index 30b4c80f3fa..ea39c00c8a3 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_number.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_number.vue @@ -20,7 +20,7 @@ export default { return h( 'a', { - class: 'gl-link d-inline-block text-right line-number flex-shrink-0', + class: 'job-log-line-number', attrs: { id: lineId, href: lineHref, diff --git a/app/assets/javascripts/ci/job_details/components/log/log.vue b/app/assets/javascripts/ci/job_details/components/log/log.vue index fb6a6a58074..8ca9515996c 100644 --- a/app/assets/javascripts/ci/job_details/components/log/log.vue +++ b/app/assets/javascripts/ci/job_details/components/log/log.vue @@ -4,14 +4,15 @@ import { mapState, mapActions } from 'vuex'; import { scrollToElement } from '~/lib/utils/common_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; -import CollapsibleLogSection from './collapsible_section.vue'; import LogLine from './line.vue'; +import LogLineHeader from './line_header.vue'; export default { components: { - CollapsibleLogSection, + LogLineHeader, LogLine, }, + inject: ['pagePath'], props: { searchResults: { type: Array, @@ -20,23 +21,11 @@ export default { }, }, computed: { - ...mapState([ - 'jobLogEndpoint', - 'jobLog', - 'isJobLogComplete', - 'isScrolledToBottomBeforeReceivingJobLog', - ]), + ...mapState(['jobLog', 'jobLogSections', 'isJobLogComplete']), highlightedLines() { return this.searchResults.map((result) => result.lineNumber); }, }, - updated() { - this.$nextTick(() => { - if (!window.location.hash) { - this.handleScrollDown(); - } - }); - }, mounted() { if (window.location.hash) { const lineNumber = getLocationHash(); @@ -51,25 +40,27 @@ export default { } }); } + + this.setupFullScreenListeners(); }, methods: { - ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), + ...mapActions(['toggleCollapsibleLine', 'scrollBottom', 'setupFullScreenListeners']), handleOnClickCollapsibleLine(section) { this.toggleCollapsibleLine(section); }, - /** - * The job log is sent in HTML, which means we need to use `v-html` to render it - * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated - * in this case because it runs before `v-html` has finished running, since there's no - * Vue binding. - * In order to scroll the page down after `v-html` has finished, we need to use setTimeout - */ - handleScrollDown() { - if (this.isScrolledToBottomBeforeReceivingJobLog) { - setTimeout(() => { - this.scrollBottom(); - }, 0); + isLineVisible(line) { + const { lineNumber, section } = line; + + if (!section) { + // lines outside of sections can't be collapsed + return true; } + + return !Object.values(this.jobLogSections).find( + ({ isClosed, startLineNumber, endLineNumber }) => { + return isClosed && lineNumber > startLineNumber && lineNumber <= endLineNumber; + }, + ); }, isHighlighted({ lineNumber }) { return this.highlightedLines.includes(lineNumber); @@ -78,23 +69,28 @@ export default { }; </script> <template> - <code class="job-log d-block" data-testid="job-log-content"> - <template v-for="(section, index) in jobLog"> - <collapsible-log-section - v-if="section.isHeader" - :key="`collapsible-${index}`" - :section="section" - :job-log-endpoint="jobLogEndpoint" - :search-results="searchResults" - @onClickCollapsibleLine="handleOnClickCollapsibleLine" - /> - <log-line - v-else - :key="section.offset" - :line="section" - :path="jobLogEndpoint" - :is-highlighted="isHighlighted(section)" - /> + <code class="job-log gl-display-block" data-testid="job-log-content"> + <template v-for="line in jobLog"> + <template v-if="isLineVisible(line)"> + <log-line-header + v-if="line.isHeader" + :key="line.offset" + :line="line" + :path="pagePath" + :is-closed="jobLogSections[line.section].isClosed" + :duration="jobLogSections[line.section].duration" + :hide-duration="jobLogSections[line.section].hideDuration" + :is-highlighted="isHighlighted(line)" + @toggleLine="handleOnClickCollapsibleLine(line.section)" + /> + <log-line + v-else + :key="line.offset" + :line="line" + :path="pagePath" + :is-highlighted="isHighlighted(line)" + /> + </template> </template> <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3"> diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue index 7f419a249cf..836426f0bde 100644 --- a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue +++ b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue @@ -50,6 +50,11 @@ export default { id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), }; }, + skip() { + // variables list always contains one empty variable + // skip refetch if form already has non-empty variables + return this.variables.length > 1; + }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, update(data) { const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue index 4ec9044a21c..19027265a12 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue @@ -2,7 +2,7 @@ import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue index 231f45d7ae6..08eaa7c8ecd 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -85,7 +85,7 @@ export default { }; </script> <template> - <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> + <aside class="right-sidebar build-sidebar"> <div class="sidebar-container"> <div class="blocks-container gl-p-4 gl-pt-0"> <sidebar-header diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue index f04987a87b5..a8b29e7c581 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -4,6 +4,7 @@ import { mapState } from 'vuex'; import { GlBadge } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import DetailRow from './sidebar_detail_row.vue'; @@ -15,8 +16,9 @@ export default { GlBadge, }, mixins: [timeagoMixin], + inject: ['pipelineTestReportUrl'], computed: { - ...mapState(['job']), + ...mapState(['job', 'testSummary']), coverage() { return `${this.job.coverage}%`; }, @@ -74,6 +76,32 @@ export default { runnerAdminPath() { return this.job?.runner?.admin_path || ''; }, + hasTestSummaryDetails() { + return Object.keys(this.testSummary).length > 0; + }, + testSummaryDescription() { + let message; + + if (this.testSummary?.total?.failed > 0) { + message = sprintf(__('%{failures} of %{total} failed'), { + failures: this.testSummary?.total?.failed, + total: this.testSummary?.total.count, + }); + } else { + message = sprintf(__('%{total}'), { + total: this.testSummary?.total.count, + }); + } + + return message; + }, + testReportUrlWithJobName() { + const urlParams = { + job_name: this.job.name, + }; + + return mergeUrlParams(urlParams, this.pipelineTestReportUrl); + }, }, i18n: { COVERAGE: __('Coverage'), @@ -82,6 +110,7 @@ export default { QUEUED: __('Queued'), RUNNER: __('Runner'), TAGS: __('Tags'), + TEST_SUMMARY: __('Test summary'), TIMEOUT: __('Timeout'), }, TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { @@ -115,6 +144,13 @@ export default { :path="runnerAdminPath" /> <detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" /> + <detail-row + v-if="hasTestSummaryDetails" + :value="testSummaryDescription" + :title="$options.i18n.TEST_SUMMARY" + :path="testReportUrlWithJobName" + data-testid="test-summary" + /> <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> <span class="font-weight-bold">{{ $options.i18n.TAGS }}:</span> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue index e229abcbe12..413eba4fb52 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { Mousetrap } from '~/lib/mousetrap'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js index 20235015ce6..9aa01c4686e 100644 --- a/app/assets/javascripts/ci/job_details/index.js +++ b/app/assets/javascripts/ci/job_details/index.js @@ -20,30 +20,41 @@ export const initJobDetails = () => { } const { + jobEndpoint, + logEndpoint, + pagePath, + projectPath, artifactHelpUrl, deploymentHelpUrl, runnerSettingsUrl, subscriptionsMoreMinutesUrl, - endpoint, - pagePath, - buildStatus, - projectPath, retryOutdatedJobDocsUrl, aiRootCauseAnalysisAvailable, + testReportSummaryUrl, + pipelineTestReportUrl, } = el.dataset; + const fullScreenAPIAvailable = document.fullscreenEnabled; + // init store to start fetching log const store = createStore(); - store.dispatch('init', { endpoint, pagePath }); + store.dispatch('init', { + jobEndpoint, + logEndpoint, + testReportSummaryUrl, + fullScreenAPIAvailable, + }); return new Vue({ el, apolloProvider, store, provide: { + pagePath, projectPath, retryOutdatedJobDocsUrl, aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), + pipelineTestReportUrl, }, render(h) { return h(JobApp, { @@ -52,10 +63,6 @@ export const initJobDetails = () => { deploymentHelpUrl, runnerSettingsUrl, subscriptionsMoreMinutesUrl, - endpoint, - pagePath, - buildStatus, - projectPath, }, }); }, diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index e0708289b43..c2394aa4fac 100644 --- a/app/assets/javascripts/ci/job_details/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -56,15 +56,6 @@ export default { required: false, default: null, }, - terminalPath: { - type: String, - required: false, - default: null, - }, - projectPath: { - type: String, - required: true, - }, subscriptionsMoreMinutesUrl: { type: String, required: false, @@ -88,9 +79,9 @@ export default { 'isJobLogSizeVisible', 'isScrollBottomDisabled', 'isScrollTopDisabled', - 'isScrolledToBottomBeforeReceivingJobLog', 'hasError', 'selectedStage', + 'fullScreenEnabled', ]), ...mapGetters([ 'headerTime', @@ -104,6 +95,7 @@ export default { 'isScrollingDown', 'emptyStateAction', 'hasOfflineRunnersForProject', + 'fullScreenAPIAndContainerAvailable', ]), shouldRenderContent() { @@ -182,6 +174,8 @@ export default { 'stopPolling', 'toggleScrollButtons', 'toggleScrollAnimation', + 'enterFullscreen', + 'exitFullscreen', ]), onHideManualVariablesForm() { this.showUpdateVariablesState = false; @@ -262,7 +256,6 @@ export default { v-if="shouldRenderSharedRunnerLimitWarning" :quota-used="job.runners.quota.used" :quota-limit="job.runners.quota.limit" - :project-path="projectPath" :subscriptions-more-minutes-url="subscriptionsMoreMinutesUrl" /> @@ -303,9 +296,13 @@ export default { :is-scrolling-down="isScrollingDown" :is-complete="isJobLogComplete" :job-log="jobLog" + :full-screen-mode-available="fullScreenAPIAndContainerAvailable" + :full-screen-enabled="fullScreenEnabled" @scrollJobLogTop="scrollTop" @scrollJobLogBottom="scrollBottom" @searchResults="setSearchResults" + @enterFullscreen="enterFullscreen" + @exitFullscreen="exitFullscreen" /> <log :search-results="searchResults" /> </div> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js index 6f538e3b3d4..e1225ecd2c9 100644 --- a/app/assets/javascripts/ci/job_details/store/actions.js +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -15,20 +15,85 @@ import { __ } from '~/locale'; import { reportToSentry } from '~/ci/utils'; import * as types from './mutation_types'; -export const init = ({ dispatch }, { endpoint, pagePath }) => { - dispatch('setJobLogOptions', { - endpoint, - pagePath, +export const init = ( + { commit, dispatch }, + { jobEndpoint, logEndpoint, testReportSummaryUrl, fullScreenAPIAvailable = false }, +) => { + commit(types.SET_JOB_LOG_OPTIONS, { + jobEndpoint, + logEndpoint, + testReportSummaryUrl, + fullScreenAPIAvailable, }); return dispatch('fetchJob'); }; -export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options); - export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR); export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR); +export const enterFullscreen = ({ dispatch }) => { + const el = document.querySelector('.build-log-container'); + + if (!document.fullscreenElement && el) { + el.requestFullscreen() + .then(() => { + dispatch('enterFullscreenSuccess'); + }) + .catch((err) => { + reportToSentry('job_enter_fullscreen_mode', err); + }); + } +}; + +export const enterFullscreenSuccess = ({ commit }) => { + commit(types.ENTER_FULLSCREEN_SUCCESS); +}; + +export const exitFullscreen = ({ dispatch }) => { + if (document.fullscreenElement) { + document + .exitFullscreen() + .then(() => { + dispatch('exitFullscreenSuccess'); + }) + .catch((err) => { + reportToSentry('job_exit_fullscreen_mode', err); + }); + } +}; + +export const exitFullscreenSuccess = ({ commit }) => { + commit(types.EXIT_FULLSCREEN_SUCCESS); +}; + +export const fullScreenContainerSetUpResult = ({ commit }, value) => { + commit(types.FULL_SCREEN_CONTAINER_SET_UP, value); +}; + +export const fullScreenModeAvailableSuccess = ({ commit }) => { + commit(types.FULL_SCREEN_MODE_AVAILABLE_SUCCESS); +}; + +export const setupFullScreenListeners = ({ dispatch, state, getters }) => { + if (!state.fullScreenContainerSetUp && getters.hasJobLog) { + const el = document.querySelector('.build-log-container'); + + if (el) { + dispatch('fullScreenModeAvailableSuccess'); + + el.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement) { + // Leaving fullscreen mode + dispatch('exitFullscreenSuccess'); + } + }); + + dispatch('fullScreenContainerSetUpResult', true); + } + } +}; + export const toggleSidebar = ({ dispatch, state }) => { if (state.isSidebarOpen) { dispatch('hideSidebar'); @@ -149,39 +214,46 @@ export const enableScrollTop = ({ commit }) => commit(types.ENABLE_SCROLL_TOP); export const toggleScrollAnimation = ({ commit }, toggle) => commit(types.TOGGLE_SCROLL_ANIMATION, toggle); -/** - * Responsible to handle automatic scroll - */ -export const toggleScrollisInBottom = ({ commit }, toggle) => { - commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle); -}; - export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG); -export const fetchJobLog = ({ dispatch, state }) => - // update trace endpoint once BE compeletes trace re-naming in #340626 - axios - .get(`${state.jobLogEndpoint}/trace.json`, { - params: { state: state.jobLogState }, - }) - .then(({ data }) => { - dispatch('toggleScrollisInBottom', isScrolledToBottom()); - dispatch('receiveJobLogSuccess', data); - - if (data.complete) { - dispatch('stopPollingJobLog'); - } else if (!state.jobLogTimeout) { - dispatch('startPollingJobLog'); - } - }) - .catch((e) => { - if (e.response?.status === HTTP_STATUS_FORBIDDEN) { - dispatch('receiveJobLogUnauthorizedError'); - } else { - reportToSentry('job_actions', e); - dispatch('receiveJobLogError'); - } - }); +export const fetchJobLog = ({ commit, dispatch, state }) => { + let isScrolledToBottomBeforeReceivingJobLog; + + return ( + axios + .get(state.logEndpoint, { + params: { state: state.jobLogState }, + }) + .then(({ data }) => { + isScrolledToBottomBeforeReceivingJobLog = isScrolledToBottom(); + + commit(types.RECEIVE_JOB_LOG_SUCCESS, data); + + if (data.complete) { + dispatch('stopPollingJobLog'); + dispatch('requestTestSummary'); + } else if (!state.jobLogTimeout) { + dispatch('startPollingJobLog'); + } + }) + // place `scrollBottom` in a separate `then()` block + // to wait on related components to update + // after the RECEIVE_JOB_LOG_SUCCESS commit + .then(() => { + if (isScrolledToBottomBeforeReceivingJobLog) { + dispatch('scrollBottom'); + } + }) + .catch((e) => { + if (e.response?.status === HTTP_STATUS_FORBIDDEN) { + dispatch('receiveJobLogUnauthorizedError'); + } else { + reportToSentry('job_actions', e); + dispatch('receiveJobLogError'); + } + }) + ); +}; export const startPollingJobLog = ({ dispatch, commit }) => { const jobLogTimeout = setTimeout(() => { @@ -198,8 +270,6 @@ export const stopPollingJobLog = ({ state, commit }) => { commit(types.STOP_POLLING_JOB_LOG); }; -export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log); - export const receiveJobLogError = ({ dispatch }) => { dispatch('stopPollingJobLog'); createAlert({ @@ -273,3 +343,23 @@ export const triggerManualJob = ({ state }, variables) => { }), ); }; + +export const requestTestSummary = ({ state, commit, dispatch }) => { + if (!state.testSummaryComplete && state.testReportSummaryUrl?.length) { + axios + .get(state.testReportSummaryUrl) + .then(({ data }) => { + dispatch('receiveTestSummarySuccess', data); + }) + .catch((e) => { + reportToSentry('job_test_summary_report', e); + }) + .finally(() => { + commit(types.RECEIVE_TEST_SUMMARY_COMPLETE); + }); + } +}; + +export const receiveTestSummarySuccess = ({ commit }, data) => { + commit(types.RECEIVE_TEST_SUMMARY_SUCCESS, data); +}; diff --git a/app/assets/javascripts/ci/job_details/store/getters.js b/app/assets/javascripts/ci/job_details/store/getters.js index a0f9db7409d..db967da87fb 100644 --- a/app/assets/javascripts/ci/job_details/store/getters.js +++ b/app/assets/javascripts/ci/job_details/store/getters.js @@ -48,3 +48,6 @@ export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLo export const hasOfflineRunnersForProject = (state) => state?.job?.runners?.available && !state?.job?.runners?.online; + +export const fullScreenAPIAndContainerAvailable = (state) => + state.fullScreenAPIAvailable && state.fullScreenModeAvailable; diff --git a/app/assets/javascripts/ci/job_details/store/mutation_types.js b/app/assets/javascripts/ci/job_details/store/mutation_types.js index e125538317d..382bee9059f 100644 --- a/app/assets/javascripts/ci/job_details/store/mutation_types.js +++ b/app/assets/javascripts/ci/job_details/store/mutation_types.js @@ -11,8 +11,6 @@ export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM'; export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP'; export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION'; -export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; - export const REQUEST_JOB = 'REQUEST_JOB'; export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS'; export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR'; @@ -28,3 +26,11 @@ export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE'; export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS'; export const RECEIVE_JOBS_FOR_STAGE_ERROR = 'RECEIVE_JOBS_FOR_STAGE_ERROR'; + +export const RECEIVE_TEST_SUMMARY_SUCCESS = 'RECEIVE_TEST_SUMMARY_SUCCESS'; +export const RECEIVE_TEST_SUMMARY_COMPLETE = 'RECEIVE_TEST_SUMMARY_COMPLETE'; + +export const ENTER_FULLSCREEN_SUCCESS = 'ENTER_FULLSCREEN_SUCCESS'; +export const EXIT_FULLSCREEN_SUCCESS = 'EXIT_FULLSCREEN_SUCCESS'; +export const FULL_SCREEN_CONTAINER_SET_UP = 'FULL_SCREEN_CONTAINER_SET_UP'; +export const FULL_SCREEN_MODE_AVAILABLE_SUCCESS = 'FULL_SCREEN_MODE_AVAILABLE_SUCCESS'; diff --git a/app/assets/javascripts/ci/job_details/store/mutations.js b/app/assets/javascripts/ci/job_details/store/mutations.js index fe6506bf8a5..866ce48ce9f 100644 --- a/app/assets/javascripts/ci/job_details/store/mutations.js +++ b/app/assets/javascripts/ci/job_details/store/mutations.js @@ -1,11 +1,12 @@ -import Vue from 'vue'; import * as types from './mutation_types'; -import { logLinesParser, updateIncrementalJobLog } from './utils'; +import { logLinesParser } from './utils'; export default { [types.SET_JOB_LOG_OPTIONS](state, options = {}) { - state.jobLogEndpoint = options.pagePath; - state.jobEndpoint = options.endpoint; + state.jobEndpoint = options.jobEndpoint; + state.logEndpoint = options.logEndpoint; + state.testReportSummaryUrl = options.testReportSummaryUrl; + state.fullScreenAPIAvailable = options.fullScreenAPIAvailable; }, [types.HIDE_SIDEBAR](state) { @@ -21,15 +22,27 @@ export default { } if (log.append) { - state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; - + if (log.lines) { + const { sections, lines } = logLinesParser(log.lines, { + currentLines: state.jobLog, + currentSections: state.jobLogSections, + }); + + state.jobLog = lines; + state.jobLogSections = sections; + } state.jobLogSize += log.size; } else { // When the job still does not have a log // the job log response will not have a defined // html or size. We keep the old value otherwise these // will be set to `null` - state.jobLog = log.lines ? logLinesParser(log.lines, [], window.location.hash) : state.jobLog; + if (log.lines) { + const { sections, lines } = logLinesParser(log.lines, {}, window.location.hash); + + state.jobLog = lines; + state.jobLogSections = sections; + } state.jobLogSize = log.size || state.jobLogSize; } @@ -63,7 +76,9 @@ export default { * @param {Object} section */ [types.TOGGLE_COLLAPSIBLE_LINE](state, section) { - Vue.set(section, 'isClosed', !section.isClosed); + if (state.jobLogSections[section]) { + state.jobLogSections[section].isClosed = !state.jobLogSections[section].isClosed; + } }, [types.REQUEST_JOB](state) { @@ -110,11 +125,6 @@ export default { [types.TOGGLE_SCROLL_ANIMATION](state, toggle) { state.isScrollingDown = toggle; }, - - [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) { - state.isScrolledToBottomBeforeReceivingJobLog = toggle; - }, - [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { state.isLoadingJobs = true; state.selectedStage = stage.name; @@ -127,4 +137,22 @@ export default { state.isLoadingJobs = false; state.jobs = []; }, + [types.RECEIVE_TEST_SUMMARY_SUCCESS](state, testSummary) { + state.testSummary = testSummary; + }, + [types.RECEIVE_TEST_SUMMARY_COMPLETE](state) { + state.testSummaryComplete = true; + }, + [types.ENTER_FULLSCREEN_SUCCESS](state) { + state.fullScreenEnabled = true; + }, + [types.EXIT_FULLSCREEN_SUCCESS](state) { + state.fullScreenEnabled = false; + }, + [types.FULL_SCREEN_CONTAINER_SET_UP](state, value) { + state.fullScreenContainerSetUp = value; + }, + [types.FULL_SCREEN_MODE_AVAILABLE_SUCCESS](state) { + state.fullScreenModeAvailable = true; + }, }; diff --git a/app/assets/javascripts/ci/job_details/store/state.js b/app/assets/javascripts/ci/job_details/store/state.js index dfff65c364d..a3c1e7692c3 100644 --- a/app/assets/javascripts/ci/job_details/store/state.js +++ b/app/assets/javascripts/ci/job_details/store/state.js @@ -1,9 +1,12 @@ export default () => ({ jobEndpoint: null, - jobLogEndpoint: null, + logEndpoint: null, + testReportSummaryUrl: null, // sidebar isSidebarOpen: true, + testSummary: {}, + testSummaryComplete: false, isLoading: false, hasError: false, @@ -13,10 +16,14 @@ export default () => ({ isScrollBottomDisabled: true, isScrollTopDisabled: true, - // Used to check if we should keep the automatic scroll - isScrolledToBottomBeforeReceivingJobLog: true, + // fullscreen mode + fullScreenAPIAvailable: false, + fullScreenModeAvailable: false, + fullScreenEnabled: false, + fullScreenContainerSetUp: false, jobLog: [], + jobLogSections: {}, isJobLogComplete: false, jobLogSize: 0, isJobLogSizeVisible: false, diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js index c8b33638821..1536c1140d0 100644 --- a/app/assets/javascripts/ci/job_details/store/utils.js +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -1,193 +1,105 @@ import { parseBoolean } from '~/lib/utils/common_utils'; /** - * Adds the line number property - * @param Object line - * @param Number lineNumber - */ -export const parseLine = (line = {}, lineNumber) => ({ - ...line, - lineNumber, -}); - -/** - * When a line has `section_header` set to true, we create a new - * structure to allow to nest the lines that belong to the - * collapsible section + * Filters out lines that have an offset lower than the offset provided. * - * @param Object line - * @param Number lineNumber - */ -export const parseHeaderLine = (line = {}, lineNumber, hash) => { - let isClosed = parseBoolean(line.section_options?.collapsed); - - // if a hash is present in the URL then we ensure - // all sections are visible so we can scroll to the hash - // in the DOM - if (hash) { - isClosed = false; - } - - return { - isClosed, - isHeader: true, - line: parseLine(line, lineNumber), - lines: [], - }; -}; - -/** - * Finds the matching header section - * for the section_duration object and adds it to it + * If no offset is provided, all the lines are returned back. * - * { - * isHeader: true, - * line: { - * content: [], - * lineNumber: 0, - * section_duration: "", - * }, - * lines: [] - * } - * - * @param Array data - * @param Object durationLine + * @param {Array} newLines + * @param {Number} offset + * @returns Lines to be added to the log that have not been added. */ -export function addDurationToHeader(data, durationLine) { - data.forEach((el) => { - if (el.line && el.line.section === durationLine.section) { - el.line.section_duration = durationLine.section_duration; - } - }); -} - -/** - * Check is the current section belongs to a collapsible section - * - * @param Array acc - * @param Object last - * @param Object section - * - * @returns Boolean - */ -export const isCollapsibleSection = (acc = [], last = {}, section = {}) => - acc.length > 0 && - last.isHeader === true && - !section.section_duration && - section.section === last.line.section; - -/** - * Returns the next line number in the parsed log - * - * @param Array acc - * @returns Number - */ -export const getNextLineNumber = (acc) => { - if (!acc?.length) { - return 1; - } - - const lastElement = acc[acc.length - 1]; - const nestedLines = lastElement.lines; - - if (lastElement.isHeader && !nestedLines.length && lastElement.line) { - return lastElement.line.lineNumber + 1; +const linesAfterOffset = (newLines = [], offset = -1) => { + if (offset === -1) { + return newLines; } - - if (lastElement.isHeader && nestedLines.length) { - return nestedLines[nestedLines.length - 1].lineNumber + 1; - } - - return lastElement.lineNumber + 1; + return newLines.filter((newLine) => newLine.offset > offset); }; /** - * Parses the job log content into a structure usable by the template + * Parses a series of trace lines from a job and returns lines and + * sections of the log. Each line is annotated with a lineNumber. * - * For collaspible lines (section_header = true): - * - creates a new array to hold the lines that are collapsible, - * - adds a isClosed property to handle toggle - * - adds a isHeader property to handle template logic - * - adds the section_duration - * For each line: - * - adds the index as lineNumber + * Sections have a range: starting line and ending line, plus a + * "duration" string. * - * @param Array lines - * @param Array accumulator - * @returns Array parsed log lines + * @param {Array} newLines - Lines to add to the log + * @param {Object} currentState - Current log: lines and sections + * @returns Consolidated lines and sections to be displayed */ -export const logLinesParser = (lines = [], prevLogLines = [], hash = '') => - lines.reduce( - (acc, line) => { - const lineNumber = getNextLineNumber(acc); +export const logLinesParser = ( + newLines = [], + { currentLines = [], currentSections = {} } = {}, + hash = '', +) => { + const lastCurrentLine = currentLines[currentLines.length - 1]; + const newLinesToAppend = linesAfterOffset(newLines, lastCurrentLine?.offset); + + if (!newLinesToAppend.length) { + return { lines: currentLines, sections: currentSections }; + } - const last = acc[acc.length - 1]; + let lineNumber = lastCurrentLine?.lineNumber || 0; + const lines = [...currentLines]; + const sections = { ...currentSections }; + + newLinesToAppend.forEach((line) => { + const { + offset, + content, + section, + section_header: isHeader, + section_footer: isFooter, + section_duration: duration, + section_options: options, + } = line; + + if (content.length) { + lineNumber += 1; + lines.push({ + offset, + lineNumber, + content, + ...(section ? { section } : {}), + ...(isHeader ? { isHeader: true } : {}), + }); + } - // If the object is an header, we parse it into another structure - if (line.section_header) { - acc.push(parseHeaderLine(line, lineNumber, hash)); - } else if (isCollapsibleSection(acc, last, line)) { - // if the object belongs to a nested section, we append it to the new `lines` array of the - // previously formatted header - last.lines.push(parseLine(line, lineNumber)); - } else if (line.section_duration) { - // if the line has section_duration, we look for the correct header to add it - addDurationToHeader(acc, line); - } else { - // otherwise it's a regular line - acc.push(parseLine(line, lineNumber)); + // root level lines have no section, skip creating one + if (section) { + sections[section] = sections[section] || { + startLineNumber: 0, + endLineNumber: Infinity, // by default, sections are unbounded / have no end + duration: null, + isClosed: false, + }; + + if (isHeader) { + sections[section].startLineNumber = lineNumber; } - - return acc; - }, - [...prevLogLines], - ); - -/** - * Finds the repeated offset, removes the old one - * - * Returns a new array with the updated log without - * the repeated offset - * - * @param Array newLog - * @param Array oldParsed - * @returns Array - * - */ -export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { - const cloneOldLog = [...oldParsed]; - const lastIndex = cloneOldLog.length - 1; - const last = cloneOldLog[lastIndex]; - - const firstNew = newLog[0]; - - if (last && firstNew) { - if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) { - cloneOldLog.splice(lastIndex); - } else if (last.lines && last.lines.length) { - const lastNestedIndex = last.lines.length - 1; - const lastNested = last.lines[lastNestedIndex]; - if (lastNested.offset === firstNew.offset) { - last.lines.splice(lastNestedIndex); + if (options) { + let isClosed = parseBoolean(options?.collapsed); + // if a hash is present in the URL then we ensure + // all sections are visible so we can scroll to the hash + // in the DOM + if (hash) { + isClosed = false; + } + sections[section].isClosed = isClosed; + + const hideDuration = parseBoolean(options?.hide_duration); + if (hideDuration) { + sections[section].hideDuration = hideDuration; + } + } + if (duration) { + sections[section].duration = duration; + } + if (isFooter) { + sections[section].endLineNumber = lineNumber; } } - } - - return cloneOldLog; -}; - -/** - * When the job log is not complete, backend may send the last received line - * in the new response. - * - * We need to check if that is the case by looking for the offset property - * before parsing the incremental part - * - * @param array oldLog - * @param array newLog - */ -export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => { - const parsedLog = findOffsetAndRemove(newLog, oldParsed); + }); - return logLinesParser(newLog, parsedLog); + return { lines, sections }; }; diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue index 3ad2582e36b..458281eb385 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue @@ -84,6 +84,9 @@ export default { artifactDownloadPath() { return this.hasArtifacts.downloadPath; }, + canCancelJob() { + return this.job.userPermissions?.cancelBuild; + }, canReadJob() { return this.job.userPermissions?.readBuild; }, @@ -185,7 +188,7 @@ export default { <gl-button-group> <template v-if="canReadJob && canUpdateJob"> <gl-button - v-if="isActive" + v-if="isActive && canCancelJob" v-gl-tooltip icon="cancel" :title="$options.CANCEL" diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue index efa74d86bd6..0ff535add6b 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import { formatTime } from '~/lib/utils/datetime_utility'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; diff --git a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql index 69719011079..b1ce3a8597a 100644 --- a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql @@ -71,6 +71,7 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo readBuild readJobArtifacts updateBuild + cancelBuild } } } diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index c6340e6787a..afe66588fb9 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -5,7 +5,7 @@ import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import ActionComponent from '../../../common/private/job_action_component.vue'; import JobNameComponent from '../../../common/private/job_name_component.vue'; import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from '../constants'; @@ -343,7 +343,7 @@ export default { </div> <gl-badge v-if="isBridge" - class="gl-mt-3" + class="gl-mt-3 gl-ml-7" variant="info" size="sm" data-testid="job-bridge-badge" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue index 26521f87426..76ff662cd3f 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -14,7 +14,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { reportToSentry } from '~/ci/utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from '../constants'; @@ -287,7 +287,7 @@ export default { /> <div v-else :class="$options.styles.actionSizeClasses"></div> </div> - <div class="gl-pt-2"> + <div class="gl-pt-2 gl-ml-7"> <gl-badge size="sm" variant="info" data-testid="downstream-pipeline-label"> {{ label }} </gl-badge> diff --git a/app/assets/javascripts/ci/pipeline_details/header/constants.js b/app/assets/javascripts/ci/pipeline_details/header/constants.js new file mode 100644 index 00000000000..a4aed7b8f46 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/constants.js @@ -0,0 +1,9 @@ +export const DELETE_MODAL_ID = 'pipeline-delete-modal'; + +export const POLL_INTERVAL = 10000; + +export const SCHEDULE_SOURCE = 'schedule'; +export const AUTO_DEVOPS_SOURCE = 'AUTO_DEVOPS_SOURCE'; +export const DETACHED_EVENT_TYPE = 'DETACHED'; +export const MERGED_RESULT_EVENT_TYPE = 'MERGED_RESULT'; +export const MERGE_TRAIN_EVENT_TYPE = 'MERGE_TRAIN'; diff --git a/app/assets/javascripts/ci/pipeline_details/header/graphql/fragments/pipeline_header.fragment.graphql b/app/assets/javascripts/ci/pipeline_details/header/graphql/fragments/pipeline_header.fragment.graphql new file mode 100644 index 00000000000..80fc8b92a47 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/graphql/fragments/pipeline_header.fragment.graphql @@ -0,0 +1,4 @@ +fragment PipelineHeaderData on Pipeline { + id + iid +} diff --git a/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql index eb5643126a2..4ef79aaa03c 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql @@ -1,15 +1,16 @@ +#import "ee_else_ce/ci/pipeline_details/header/graphql/fragments/pipeline_header.fragment.graphql" + query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { id pipeline(iid: $iid) { - id - iid status retryable cancelable userPermissions { destroyPipeline updatePipeline + cancelPipeline } detailedStatus { id @@ -41,6 +42,19 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { finishedAt queuedDuration duration + name + totalJobs + refText + triggeredByPath + stuck + child + complete + latest + mergeRequestEventType + configSource + failureReason + source + ...PipelineHeaderData } } } diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index 651662d6395..1ecc4b2e1c1 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -17,7 +17,7 @@ import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint- import { __, s__, sprintf, formatNumber } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; @@ -26,9 +26,15 @@ import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutatio import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; import { getQueryHeaders } from '../graph/utils'; import getPipelineQuery from './graphql/queries/get_pipeline_header_data.query.graphql'; - -const DELETE_MODAL_ID = 'pipeline-delete-modal'; -const POLL_INTERVAL = 10000; +import { + DELETE_MODAL_ID, + POLL_INTERVAL, + DETACHED_EVENT_TYPE, + AUTO_DEVOPS_SOURCE, + SCHEDULE_SOURCE, + MERGE_TRAIN_EVENT_TYPE, + MERGED_RESULT_EVENT_TYPE, +} from './constants'; export default { name: 'PipelineDetailsHeader', @@ -129,40 +135,14 @@ export default { }, }, props: { - name: { - type: String, - required: false, - default: '', - }, - totalJobs: { - type: String, - required: false, - default: '', - }, - computeMinutes: { - type: String, - required: false, - default: '', - }, yamlErrors: { type: String, required: false, default: '', }, - failureReason: { - type: String, - required: false, - default: '', - }, - refText: { - type: String, - required: false, - default: '', - }, - badges: { - type: Object, - required: false, - default: () => {}, + trigger: { + type: Boolean, + required: true, }, }, apollo: { @@ -270,7 +250,7 @@ export default { }, totalJobsText() { return sprintf(__('%{jobs} Jobs'), { - jobs: this.totalJobs, + jobs: this.pipeline?.totalJobs || 0, }); }, triggeredText() { @@ -312,10 +292,61 @@ export default { canCancelPipeline() { const { cancelable, userPermissions } = this.pipeline; - return cancelable && userPermissions.updatePipeline; + return cancelable && userPermissions.cancelPipeline; + }, + computeMinutes() { + return this.pipeline?.computeMinutes; }, showComputeMinutes() { - return this.isFinished && this.computeMinutes !== '0.0'; + return this.isFinished && this.computeMinutes; + }, + pipelineName() { + return this.pipeline?.name; + }, + refText() { + return this.pipeline?.refText; + }, + triggeredByPath() { + return this.pipeline?.triggeredByPath; + }, + mergeRequestEventType() { + return this.pipeline.mergeRequestEventType; + }, + isMergeTrainPipeline() { + return this.mergeRequestEventType === MERGE_TRAIN_EVENT_TYPE; + }, + isMergedResultsPipeline() { + return this.mergeRequestEventType === MERGED_RESULT_EVENT_TYPE; + }, + isDetachedPipeline() { + return this.mergeRequestEventType === DETACHED_EVENT_TYPE; + }, + isAutoDevopsPipeline() { + return this.pipeline.configSource === AUTO_DEVOPS_SOURCE; + }, + isScheduledPipeline() { + return this.pipeline.source === SCHEDULE_SOURCE; + }, + isInvalidPipeline() { + return Boolean(this.yamlErrors); + }, + failureReason() { + return this.pipeline.failureReason; + }, + badges() { + return { + schedule: this.isScheduledPipeline, + trigger: this.trigger, + invalid: this.isInvalidPipeline, + child: this.pipeline.child, + latest: this.pipeline.latest, + mergeTrainPipeline: this.isMergeTrainPipeline, + mergedResultsPipeline: this.isMergedResultsPipeline, + detached: this.isDetachedPipeline, + failed: Boolean(this.failureReason), + autoDevops: this.isAutoDevopsPipeline, + stuck: this.pipeline.stuck, + }; }, }, methods: { @@ -406,7 +437,9 @@ export default { <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> <div v-else class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> <div> - <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3> + <h3 v-if="pipelineName" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name"> + {{ pipelineName }} + </h3> <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title"> {{ commitTitle }} </h3> @@ -483,7 +516,7 @@ export default { > <gl-sprintf :message="$options.i18n.childBadgeText"> <template #link="{ content }"> - <gl-link :href="paths.triggeredByPath" target="_blank"> + <gl-link :href="triggeredByPath" target="_blank"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue index 287f6e045c6..1823908c231 100644 --- a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue @@ -5,7 +5,7 @@ import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; import Tracking from '~/tracking'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { TRACKING_CATEGORIES } from '~/ci/constants'; import RetryFailedJobMutation from '../graphql/mutations/retry_failed_job.mutation.graphql'; import { DEFAULT_FIELDS } from '../../constants'; diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js index 4966b657887..0430bc83dd7 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js @@ -12,29 +12,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph return; } - const { - fullPath, - pipelineIid, - pipelinesPath, - name, - totalJobs, - computeMinutes, - yamlErrors, - failureReason, - triggeredByPath, - schedule, - trigger, - child, - latest, - mergeTrainPipeline, - mergedResultsPipeline, - invalid, - failed, - autoDevops, - detached, - stuck, - refText, - } = el.dataset; + const { fullPath, pipelineIid, pipelinesPath, yamlErrors, trigger } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -46,32 +24,14 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph fullProject: fullPath, graphqlResourceEtag, pipelinesPath, - triggeredByPath, }, pipelineIid, }, render(createElement) { return createElement(PipelineDetailsHeader, { props: { - name, - totalJobs, - computeMinutes, yamlErrors, - failureReason, - refText, - badges: { - schedule: parseBoolean(schedule), - trigger: parseBoolean(trigger), - child: parseBoolean(child), - latest: parseBoolean(latest), - mergeTrainPipeline: parseBoolean(mergeTrainPipeline), - mergedResultsPipeline: parseBoolean(mergedResultsPipeline), - invalid: parseBoolean(invalid), - failed: parseBoolean(failed), - autoDevops: parseBoolean(autoDevops), - detached: parseBoolean(detached), - stuck: parseBoolean(stuck), - }, + trigger: parseBoolean(trigger), }, }); }, diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js index ea2875713a9..b4528ab895d 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js +++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js @@ -44,6 +44,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { params, fullPath, visibilityPipelineIdType, + showJenkinsCiPrompt, } = el.dataset; return new Vue({ @@ -57,6 +58,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { pipelineEditorPath, pipelineSchedulesPath, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), + showJenkinsCiPrompt: parseBoolean(showJenkinsCiPrompt), }, data() { return { diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js index 6b616601bc5..e3984685094 100644 --- a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js @@ -1,4 +1,5 @@ import { __, sprintf } from '~/locale'; +import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { TestStatus } from '../../constants'; /** @@ -25,15 +26,27 @@ export function iconForTestStatus(status) { return 'status_notfound'; } } - export const formattedTime = (seconds = 0) => { if (seconds < 1) { - const milliseconds = seconds * 1000; - return sprintf(__('%{milliseconds}ms'), { milliseconds: milliseconds.toFixed(2) }); + return sprintf(__('%{milliseconds}ms'), { + milliseconds: (seconds * 1000).toFixed(2), + }); + } + if (seconds < 60) { + return sprintf(__('%{seconds}s'), { + seconds: (seconds % 60).toFixed(2), + }); } - return sprintf(__('%{seconds}s'), { seconds: seconds.toFixed(2) }); -}; + const hoursAndMinutes = stringifyTime(parseSeconds(seconds)); + const remainingSeconds = + seconds % 60 >= 1 + ? sprintf(__('%{seconds}s'), { + seconds: Math.floor(seconds % 60), + }) + : ''; + return `${hoursAndMinutes} ${remainingSeconds}`.trim(); +}; export const addIconStatus = (testCase) => ({ ...testCase, icon: iconForTestStatus(testCase.status), diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue index a7737d33285..6e9a705c046 100644 --- a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; +import { getParameterValues } from '~/lib/utils/url_utility'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; @@ -19,7 +20,7 @@ export default { inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], computed: { ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), - ...mapGetters('testReports', ['getSelectedSuite']), + ...mapGetters('testReports', ['getSelectedSuite', 'getTestSuites']), showSuite() { return this.selectedSuiteIndex !== null; }, @@ -28,8 +29,16 @@ export default { return testSuites.length > 0; }, }, - created() { - this.fetchSummary(); + async created() { + await this.fetchSummary(); + const jobName = getParameterValues('job_name')[0] || ''; + if (jobName.length > 0) { + // get the index from the job name + const indexToSelect = this.getTestSuites.findIndex((test) => test.name === jobName); + + this.setSelectedSuiteIndex(indexToSelect); + this.fetchTestSuite(indexToSelect); + } }, methods: { ...mapActions('testReports', [ diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 204eaf20664..956f02de09d 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -4,7 +4,6 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - EDITOR_APP_DRAWER_AI_ASSISTANT, EDITOR_APP_DRAWER_HELP, EDITOR_APP_DRAWER_JOB_ASSISTANT, EDITOR_APP_DRAWER_NONE, @@ -14,17 +13,17 @@ import { export default { i18n: { + browseCatalog: __('Browse CI/CD Catalog'), browseTemplates: __('Browse templates'), help: __('Help'), jobAssistant: s__('JobAssistant|Job assistant'), - aiAssistant: s__('PipelinesAiAssistant|Ai assistant'), }, TEMPLATE_REPOSITORY_URL, components: { GlButton, }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], - inject: ['aiChatAvailable'], + inject: ['ciCatalogPath'], props: { showHelpDrawer: { type: Boolean, @@ -34,15 +33,6 @@ export default { type: Boolean, required: true, }, - showAiAssistantDrawer: { - type: Boolean, - required: true, - }, - }, - computed: { - isAiConfigChatAvailable() { - return this.glFeatures.aiCiConfigGenerator && this.aiChatAvailable; - }, }, methods: { toggleHelpDrawer() { @@ -59,11 +49,10 @@ export default { this.showJobAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_JOB_ASSISTANT, ); }, - toggleAiAssistantDrawer() { - this.$emit( - 'switch-drawer', - this.showAiAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_AI_ASSISTANT, - ); + trackCatalogBrowsing() { + const { label, actions } = pipelineEditorTrackingOptions; + + this.track(actions.browseCatalog, { label }); }, trackHelpDrawerClick() { const { label, actions } = pipelineEditorTrackingOptions; @@ -84,6 +73,16 @@ export default { > <slot></slot> <gl-button + :href="ciCatalogPath" + size="small" + icon="external-link" + target="_blank" + data-testid="catalog-repo-link" + @click="trackCatalogBrowsing" + > + {{ $options.i18n.browseCatalog }} + </gl-button> + <gl-button :href="$options.TEMPLATE_REPOSITORY_URL" size="small" icon="external-link" @@ -109,14 +108,5 @@ export default { > {{ $options.i18n.jobAssistant }} </gl-button> - <gl-button - v-if="isAiConfigChatAvailable" - icon="bulb" - size="small" - data-testid="ai-assistant-drawer-toggle" - @click="toggleAiAssistantDrawer" - > - {{ $options.i18n.aiAssistant }} - </gl-button> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index 21e21d54758..0064dc51d97 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -105,9 +105,6 @@ export default { branchesData() { return this.availableBranches.map((branch) => ({ text: branch, - extraAttrs: { - 'data-qa-selector': 'branch_menu_item_button', - }, value: branch, })); }, @@ -211,7 +208,6 @@ export default { <gl-collapsible-listbox v-model="currentBranch" v-gl-tooltip.hover - data-qa-selector="branch_selector_button" searchable :items="branchesData" :title="$options.i18n.dropdownHeader" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index f00098105d3..f76243e81b9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -19,6 +19,11 @@ export default { required: true, }, }, + data() { + return { + linkedPipelines: null, + }; + }, apollo: { linkedPipelines: { query: getLinkedPipelinesQuery, diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 7c4a07e3f83..9c1bbff1cc4 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -7,7 +7,7 @@ import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.quer import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index c7c15cdd76e..cd6150031d4 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,6 +1,5 @@ <script> import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; -import CiEditorHeader from 'ee_else_ce/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import { s__, __ } from '~/locale'; import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -20,6 +19,7 @@ import { } from '../constants'; 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 CiValidate from './validate/ci_validate.vue'; import TextEditor from './editor/text_editor.vue'; import EditorTab from './ui/editor_tab.vue'; @@ -95,10 +95,6 @@ export default { type: Boolean, required: true, }, - showAiAssistantDrawer: { - type: Boolean, - required: true, - }, isNewCiConfigFile: { type: Boolean, required: true, @@ -198,7 +194,6 @@ export default { <ci-editor-header :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" - :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue index 617088f303b..1d152a63407 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue @@ -9,6 +9,7 @@ import { GlTooltip, GlTooltipDirective, GlSprintf, + GlEmptyState, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; @@ -67,6 +68,7 @@ export default { GlLink, GlSprintf, GlTooltip, + GlEmptyState, ValidatePipelinePopover, }, directives: { @@ -226,38 +228,44 @@ export default { </gl-button> </div> </div> - <div v-if="isInitState" :class="$options.BASE_CLASSES"> - <img :src="validateTabIllustrationPath" /> - <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1> - <ul> - <li class="gl-mb-3">{{ $options.i18n.contentNote }}</li> - <li class="gl-mb-3"> + <gl-empty-state + v-if="isInitState" + :svg-path="validateTabIllustrationPath" + :title="$options.i18n.title" + :primary-button-link="validateYaml" + :primary-button-text="$options.i18n.cta" + > + <template #description> + <p>{{ $options.i18n.contentNote }}</p> + <p> <gl-sprintf :message="$options.i18n.simulationNote"> <template #code="{ content }"> <code>{{ content }}</code> </template> </gl-sprintf> - </li> - </ul> - <div ref="simulatePipelineButton"> - <gl-button - ref="simulatePipelineButton" - variant="confirm" - class="gl-mt-3" - :disabled="isInitialCiContentLoading" - data-testid="simulate-pipeline-button" - @click="validateYaml" - > - {{ $options.i18n.cta }} - </gl-button> - </div> - <gl-tooltip - v-if="isInitialCiContentLoading" - :target="() => $refs.simulatePipelineButton" - :title="$options.i18n.ctaDisabledTooltip" - data-testid="cta-tooltip" - /> - </div> + </p> + </template> + <template #actions> + <div ref="simulatePipelineButton"> + <gl-button + ref="simulatePipelineButton" + variant="confirm" + class="gl-mt-3" + :disabled="isInitialCiContentLoading" + data-testid="simulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + <gl-tooltip + v-if="isInitialCiContentLoading" + :target="() => $refs.simulatePipelineButton" + :title="$options.i18n.ctaDisabledTooltip" + data-testid="cta-tooltip" + /> + </template> + </gl-empty-state> <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES"> <gl-loading-icon size="lg" class="gl-m-3" /> <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1> diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index e85138e361f..66725df15f0 100644 --- a/app/assets/javascripts/ci/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const EDITOR_APP_DRAWER_HELP = 'HELP'; export const EDITOR_APP_DRAWER_JOB_ASSISTANT = 'JOB_ASSISTANT'; @@ -93,6 +94,9 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/- export const COMMIT_SHA_POLL_INTERVAL = 1000; +export const MIGRATION_PLAN_HELP_PATH = helpPagePath('ci/migration/plan_a_migration'); +export const MIGRATE_FROM_JENKINS_TRACKING_LABEL = 'migrate_from_jenkins_prompt'; + export const I18N = { title: s__('Pipelines|Get started with GitLab CI/CD'), learnBasics: { @@ -107,6 +111,13 @@ export const I18N = { ), cta: s__('Pipelines|Try test template'), }, + migrateFromJenkins: { + title: s__('Pipelines|Migrate to GitLab CI/CD from Jenkins'), + description: s__( + 'Pipelines|Take advantage of simple, scalable pipelines and CI/CD-enabled features. You can view integration results, security scans, tests, code coverage and more directly in merge requests!', + ), + cta: s__('Pipelines|Start with a migration plan'), + }, }, templates: { title: s__('Pipelines|Ready to set up CI/CD for your project?'), diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index bc20e478876..408e91a4d62 100644 --- a/app/assets/javascripts/ci/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { createAppOptions } from 'ee_else_ce/ci/pipeline_editor/options'; +import { createAppOptions } from '~/ci/pipeline_editor/options'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); diff --git a/app/assets/javascripts/ci/pipeline_editor/options.js b/app/assets/javascripts/ci/pipeline_editor/options.js index 340cb6ab979..9520295c94d 100644 --- a/app/assets/javascripts/ci/pipeline_editor/options.js +++ b/app/assets/javascripts/ci/pipeline_editor/options.js @@ -19,6 +19,7 @@ export const createAppOptions = (el) => { initialBranchName, pipelineEtag, // Add to provide/inject API for static values + ciCatalogPath, ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, @@ -40,7 +41,6 @@ export const createAppOptions = (el) => { usesExternalConfig, validateTabIllustrationPath, ymlHelpPagePath, - aiChatAvailable, } = el.dataset; const configurationPaths = Object.fromEntries( @@ -109,7 +109,7 @@ export const createAppOptions = (el) => { el, apolloProvider, provide: { - aiChatAvailable: parseBoolean(aiChatAvailable), + ciCatalogPath, ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 09ba6292e13..ca2e1fbf37d 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -2,7 +2,6 @@ import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import JobAssistantDrawer from 'jh_else_ce/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; @@ -19,9 +18,6 @@ import { EDITOR_APP_DRAWER_NONE, } from './constants'; -const AiAssistantDrawer = () => - import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue'); - export default { EDITOR_APP_DRAWER_HELP, EDITOR_APP_DRAWER_JOB_ASSISTANT, @@ -45,13 +41,11 @@ export default { GlModal, PipelineEditorDrawer, JobAssistantDrawer, - AiAssistantDrawer, PipelineEditorFileNav, PipelineEditorFileTree, PipelineEditorHeader, PipelineEditorTabs, }, - mixins: [glFeatureFlagMixin()], props: { ciConfigData: { type: Object, @@ -105,9 +99,6 @@ export default { showJobAssistantDrawer() { return this.currentDrawer === EDITOR_APP_DRAWER_JOB_ASSISTANT; }, - showAiAssistantDrawer() { - return this.currentDrawer === EDITOR_APP_DRAWER_AI_ASSISTANT; - }, }, mounted() { this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false; @@ -189,7 +180,6 @@ export default { :is-new-ci-config-file="isNewCiConfigFile" :show-help-drawer="showHelpDrawer" :show-job-assistant-drawer="showJobAssistantDrawer" - :show-ai-assistant-drawer="showAiAssistantDrawer" v-on="$listeners" @switch-drawer="switchDrawer" @set-current-tab="setCurrentTab" @@ -222,11 +212,5 @@ export default { v-on="$listeners" @switch-drawer="switchDrawer" /> - <ai-assistant-drawer - v-if="glFeatures.aiCiConfigGenerator" - :is-visible="showAiAssistantDrawer" - :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_AI_ASSISTANT]" - @switch-drawer="switchDrawer" - /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue index 4fded3aec60..4238f0e3872 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -38,7 +38,7 @@ export default { }, tooltipConfig: { boundary: 'viewport', - placement: 'bottom', + placement: 'top', customClass: 'gl-pointer-events-none', }, components: { @@ -161,7 +161,6 @@ export default { :tooltip-text="jobActionTooltipText" :link="status.action.path" :action-icon="status.action.icon" - data-qa-selector="action_button" /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue index ed78a335453..38a071a0319 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -13,7 +13,7 @@ */ import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { createAlert } from '~/alert'; import eventHub from '~/ci/event_hub'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue index f6a375ab94c..bbe17a3eb22 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { accessValue } from './accessors/linked_pipelines_accessors'; /** * Renders the upstream/downstream portions of the pipeline mini graph. @@ -92,7 +92,7 @@ export default { 'is-upstream': isUpstream, 'is-downstream': isDownstream, }" - class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle" + class="linked-pipeline-mini-list gl-display-inline-flex gl-gap-2 gl-vertical-align-middle" > <ci-icon v-for="pipeline in linkedPipelinesTrimmed" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 5444e66cbdf..44a377144a5 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -391,7 +391,6 @@ export default { :placeholder="s__('CiVariables|Input variable key')" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" - data-qa-selector="ci_variable_key_field" @change="addEmptyVariable(variable)" /> @@ -411,7 +410,6 @@ export default { class="gl-mb-3 gl-h-7!" :no-resize="false" data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" @change="resetVariable(index)" /> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index d979c0efaf2..245d4257bbb 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -1,5 +1,5 @@ <script> -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue index a6297213402..c9631d8f36b 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue @@ -1,7 +1,12 @@ <script> import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants'; +import { + STARTER_TEMPLATE_NAME, + I18N, + MIGRATION_PLAN_HELP_PATH, + MIGRATE_FROM_JENKINS_TRACKING_LABEL, +} from '~/ci/pipeline_editor/constants'; import Tracking from '~/tracking'; import CiTemplates from './ci_templates.vue'; @@ -15,7 +20,7 @@ export default { mixins: [Tracking.mixin()], STARTER_TEMPLATE_NAME, I18N, - inject: ['pipelineEditorPath'], + inject: ['pipelineEditorPath', 'showJenkinsCiPrompt'], data() { return { gettingStartedTemplateUrl: mergeUrlParams( @@ -23,17 +28,23 @@ export default { this.pipelineEditorPath, ), tracker: null, + migrationPlanUrl: MIGRATION_PLAN_HELP_PATH, + migrationPromptTrackingLabel: MIGRATE_FROM_JENKINS_TRACKING_LABEL, }; }, + mounted() { + if (this.showJenkinsCiPrompt) { + this.trackEvent('render', this.migrationPromptTrackingLabel); + } + }, methods: { - trackEvent(template) { - this.track('template_clicked', { - label: template, - }); + trackEvent(action, label) { + this.track(action, { label }); }, }, }; </script> + <template> <div> <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2> @@ -47,28 +58,62 @@ export default { </gl-sprintf> </p> - <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> - <gl-card> - <div class="gl-flex-direction-row"> - <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> - <div class="gl-mb-3"> - <strong class="gl-text-gray-800 gl-mb-2"> - {{ $options.I18N.learnBasics.gettingStarted.title }} - </strong> + <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap"> + <div + v-if="showJenkinsCiPrompt" + class="gl-lg-w-25p gl-md-w-half gl-w-full gl-md-pr-5 gl-pb-8" + data-testid="migrate-from-jenkins-prompt" + > + <gl-card class="gl-bg-blue-50"> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="rocket" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2">{{ + $options.I18N.learnBasics.migrateFromJenkins.title + }}</strong> + </div> + <p class="gl-font-sm gl-h-13"> + {{ $options.I18N.learnBasics.migrateFromJenkins.description }} + </p> + </div> + + <gl-button + category="primary" + variant="confirm" + :href="migrationPlanUrl" + target="_blank" + @click="trackEvent('template_clicked', migrationPromptTrackingLabel)" + > + {{ $options.I18N.learnBasics.migrateFromJenkins.cta }} + </gl-button> + </gl-card> + </div> + + <div class="gl-lg-w-25p gl-md-w-half gl-w-full gl-pb-8"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2"> + {{ $options.I18N.learnBasics.gettingStarted.title }} + </strong> + </div> + <p class="gl-font-sm gl-h-13"> + {{ $options.I18N.learnBasics.gettingStarted.description }} + </p> </div> - <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> - </div> - <gl-button - category="primary" - variant="confirm" - :href="gettingStartedTemplateUrl" - data-testid="test-template-link" - @click="trackEvent($options.STARTER_TEMPLATE_NAME)" - > - {{ $options.I18N.learnBasics.gettingStarted.cta }} - </gl-button> - </gl-card> + <gl-button + category="primary" + variant="confirm" + :href="gettingStartedTemplateUrl" + data-testid="test-template-link" + @click="trackEvent('template_clicked', $options.STARTER_TEMPLATE_NAME)" + > + {{ $options.I18N.learnBasics.gettingStarted.cta }} + </gl-button> + </gl-card> + </div> </div> <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> diff --git a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue index 82f1d57912a..7a49bf6a809 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue @@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlLink, GlTooltip } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __, s__, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue index 380f8ce172f..2d5fb8c9799 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue @@ -1,6 +1,6 @@ <script> import { TRACKING_CATEGORIES } from '~/ci/constants'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue index d62a68f0dcc..9ccb7012897 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue @@ -2,7 +2,7 @@ import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { __, s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; /** * Pipeline Stop Modal. diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue index 3021b4a2ef8..a45387ca676 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue @@ -70,3 +70,11 @@ export default { :items="items" /> </template> + +<style scoped> +/* TODO: Use max-height prop when gitlab-ui got updated. +See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2374 */ +::v-deep .gl-new-dropdown-inner { + max-height: 310px !important; +} +</style> diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js deleted file mode 100644 index 04aca11b945..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js +++ /dev/null @@ -1,30 +0,0 @@ -import pollUntilComplete from '~/lib/utils/poll_until_complete'; -import { STATUS_NOT_FOUND } from '../../constants'; -import * as types from './mutation_types'; -import { parseCodeclimateMetrics } from './utils/codequality_parser'; - -export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); - -export const fetchReports = ({ state, dispatch, commit }) => { - commit(types.REQUEST_REPORTS); - - return pollUntilComplete(state.reportsPath) - .then(({ data }) => { - if (data.status === STATUS_NOT_FOUND) { - return dispatch('receiveReportsError', data); - } - return dispatch('receiveReportsSuccess', { - newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), - resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), - }); - }) - .catch((error) => dispatch('receiveReportsError', error)); -}; - -export const receiveReportsSuccess = ({ commit }, data) => { - commit(types.RECEIVE_REPORTS_SUCCESS, data); -}; - -export const receiveReportsError = ({ commit }, error) => { - commit(types.RECEIVE_REPORTS_ERROR, error); -}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js deleted file mode 100644 index 70d11e96a54..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/getters.js +++ /dev/null @@ -1,63 +0,0 @@ -import { spriteIcon } from '~/lib/utils/common_utils'; -import { sprintf, s__, n__ } from '~/locale'; -import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants'; - -export const hasCodequalityIssues = (state) => - Boolean(state.newIssues?.length || state.resolvedIssues?.length); - -export const codequalityStatus = (state) => { - if (state.isLoading) { - return LOADING; - } - if (state.hasError) { - return ERROR; - } - - return SUCCESS; -}; - -export const codequalityText = (state) => { - const { newIssues, resolvedIssues } = state; - let text; - if (!newIssues.length && !resolvedIssues.length) { - text = s__('ciReport|No changes to code quality'); - } else if (newIssues.length && resolvedIssues.length) { - text = sprintf( - s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`), - { - issueCount: newIssues.length + resolvedIssues.length, - }, - ); - } else if (resolvedIssues.length) { - text = n__( - `ciReport|Code quality improved due to 1 resolved issue`, - `ciReport|Code quality improved due to %d resolved issues`, - resolvedIssues.length, - ); - } else if (newIssues.length) { - text = n__( - `ciReport|Code quality degraded due to 1 new issue`, - `ciReport|Code quality degraded due to %d new issues`, - newIssues.length, - ); - } - - return text; -}; - -export const codequalityPopover = (state) => { - if (state.status === STATUS_NOT_FOUND) { - return { - title: s__('ciReport|Base pipeline codequality artifact not found'), - content: sprintf( - s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'), - { - linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`, - linkEndTag: `${spriteIcon('external-link', 's16')}</a>`, - }, - false, - ), - }; - } - return {}; -}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js deleted file mode 100644 index c2f706e56e6..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const getStoreConfig = (initialState) => ({ - actions, - getters, - mutations, - state: state(initialState), -}); - -export default (initialState) => new Vuex.Store(getStoreConfig(initialState)); diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js deleted file mode 100644 index c362c973ae1..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SET_PATHS = 'SET_PATHS'; - -export const REQUEST_REPORTS = 'REQUEST_REPORTS'; -export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; -export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js deleted file mode 100644 index 249c2f35c0b..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_PATHS](state, paths) { - state.baseBlobPath = paths.baseBlobPath; - state.headBlobPath = paths.headBlobPath; - state.reportsPath = paths.reportsPath; - state.helpPath = paths.helpPath; - }, - [types.REQUEST_REPORTS](state) { - state.isLoading = true; - }, - [types.RECEIVE_REPORTS_SUCCESS](state, data) { - state.hasError = false; - state.status = ''; - state.statusReason = ''; - state.isLoading = false; - state.newIssues = data.newIssues; - state.resolvedIssues = data.resolvedIssues; - }, - [types.RECEIVE_REPORTS_ERROR](state, error) { - state.isLoading = false; - state.hasError = true; - state.status = error?.status || ''; - state.statusReason = error?.response?.data?.status_reason; - }, -}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js deleted file mode 100644 index f68dbc2a5fa..00000000000 --- a/app/assets/javascripts/ci/reports/codequality_report/store/state.js +++ /dev/null @@ -1,16 +0,0 @@ -export default () => ({ - reportsPath: null, - - baseBlobPath: null, - headBlobPath: null, - - isLoading: false, - hasError: false, - status: '', - statusReason: '', - - newIssues: [], - resolvedIssues: [], - - helpPath: null, -}); diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/utils/codequality_parser.js index 417297df43c..417297df43c 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js +++ b/app/assets/javascripts/ci/reports/codequality_report/utils/codequality_parser.js diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index fd6c6cca6b7..4a6a5e6e221 100644 --- a/app/assets/javascripts/ci/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue @@ -207,7 +207,6 @@ export default { > <gl-button data-testid="report-section-expand-button" - data-qa-selector="expand_report_button" category="tertiary" size="small" :icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 8a920c85e06..a099d238c79 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -5,12 +5,11 @@ import { sprintf, __, formatNumber } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerCreatedAt from '../runner_created_at.vue'; +import RunnerJobCount from '../runner_job_count.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; import RunnerManagersBadge from '../runner_managers_badge.vue'; - -import { formatJobCount } from '../../utils'; import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, @@ -25,6 +24,7 @@ export default { TimeAgo, RunnerSummaryField, RunnerCreatedAt, + RunnerJobCount, RunnerName, RunnerTags, RunnerTypeBadge, @@ -52,9 +52,6 @@ export default { additionalIpAddressCount() { return this.managersCount - 1; }, - jobCount() { - return formatJobCount(this.runner.jobCount); - }, createdBy() { return this.runner?.createdBy; }, @@ -135,7 +132,7 @@ export default { </runner-summary-field> <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> - {{ jobCount }} + <runner-job-count :runner="runner" /> </runner-summary-field> <runner-summary-field icon="calendar"> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue index b1b61e03eec..5ed987d28e7 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -26,8 +26,8 @@ export default { <template> <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-mb-3 gl-mr-4"> <gl-icon v-if="icon" :name="icon" :size="12" /> - <!-- display tooltip as a label for screen readers --> - <span class="gl-sr-only">{{ tooltip }}</span> + <!-- display tooltip as a label for screen readers and make it unavailable for copying --> + <span class="gl-sr-only gl-user-select-none">{{ tooltip }}</span> <slot></slot> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_count.vue b/app/assets/javascripts/ci/runner/components/runner_job_count.vue new file mode 100644 index 00000000000..596e027efef --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_job_count.vue @@ -0,0 +1,36 @@ +<script> +import runnerJobCountQuery from '../graphql/list/runner_job_count.query.graphql'; +import { formatJobCount } from '../utils'; + +export default { + name: 'RunnerJobCount', + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobCount: '-', + }; + }, + apollo: { + jobCount: { + query: runnerJobCountQuery, + variables() { + return { id: this.runner.id }; + }, + context: { + batchKey: 'RunnerJobCount', + }, + update(data) { + return formatJobCount(data?.runner?.jobCount); + }, + }, + }, +}; +</script> +<template> + <span>{{ jobCount }}</span> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index 653d9b05330..3cad0c52cd7 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -3,7 +3,7 @@ import { GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { formatTime } from '~/lib/utils/datetime_utility'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index ec04701db2c..0282ac10fba 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -4,7 +4,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; -import { formatJobCount, tableField } from '../utils'; +import { tableField } from '../utils'; import RunnerBulkDelete from './runner_bulk_delete.vue'; import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; @@ -95,9 +95,6 @@ export default { onDeleted(event) { this.$emit('deleted', event); }, - formatJobCount(jobCount) { - return formatJobCount(jobCount); - }, runnerTrAttr(runner) { if (runner) { return { diff --git a/app/assets/javascripts/ci/runner/components/runner_list_header.vue b/app/assets/javascripts/ci/runner/components/runner_list_header.vue index e4367db035e..6ed271d15ab 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list_header.vue @@ -7,7 +7,7 @@ export default { <header class="gl-my-5 gl-display-flex gl-align-items-flex-start gl-flex-wrap gl-justify-content-space-between" > - <h1 v-if="$scopedSlots.title" class="gl-my-0 gl-font-size-h1 header-title"> + <h1 v-if="$scopedSlots.title" class="gl-mt-0 gl-mb-3 gl-font-size-h1 header-title"> <slot name="title"></slot> </h1> <div v-if="$scopedSlots.actions" class="gl-display-flex gl-gap-3"> diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 7ad9605d0a4..f6c96802004 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -8,7 +8,6 @@ fragment ListItemShared on CiRunner { version paused locked - jobCount tagList createdAt createdBy { diff --git a/app/assets/javascripts/ci/runner/graphql/list/runner_job_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/runner_job_count.query.graphql new file mode 100644 index 00000000000..79ea19b048a --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/runner_job_count.query.graphql @@ -0,0 +1,6 @@ +query runnerJobCount($id: CiRunnerID!) { + runner(id: $id) { + id + jobCount + } +} diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index b5042936b1e..cafac061c12 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -159,7 +159,7 @@ export default { search: { deep: true, handler() { - // TODO Implement back button reponse using onpopstate + // TODO Implement back button response using onpopstate // See https://gitlab.com/gitlab-org/gitlab/-/issues/333804 updateHistory({ url: fromSearchToUrl(this.search), diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index fb2e24e15f6..8ad599db6a7 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -223,7 +223,6 @@ export default { ref="fileUpload" type="file" class="hidden" - data-qa-selector="file_upload_field" @change="uploadSecureFile" /> </div> 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 e1f6006fedf..d0675ba96fd 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 @@ -121,7 +121,6 @@ export default { v-if="item.hasTokenExposed" :text="item.token" data-testid="clipboard-btn" - data-qa-selector="clipboard_button" :title="$options.i18n.copyTrigger" css-class="gl-border-none gl-py-0 gl-px-2" /> diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js index 79280c13f0f..d2a5d5a9db7 100644 --- a/app/assets/javascripts/clone_panel.js +++ b/app/assets/javascripts/clone_panel.js @@ -14,7 +14,7 @@ export default function initClonePanel() { $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); } - $('a', $cloneOptions).on('click', (e) => { + $('.js-clone-links a', $cloneOptions).on('click', (e) => { const $this = $(e.currentTarget); const url = $this.attr('href'); if ( diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 33d98c381fb..39b6e287288 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -1,6 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui'; +import feedbackBannerIllustration from '@gitlab/svgs/dist/illustrations/chat-sm.svg?url'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -112,6 +113,9 @@ export default { feedbackBannerClasses() { return this.isChildComponent ? 'gl-my-2' : 'gl-mb-4'; }, + feedbackBannerIllustration() { + return feedbackBannerIllustration; + }, }, methods: { updateTreeList(data) { @@ -145,11 +149,11 @@ export default { > <gl-banner v-if="!feedbackBannerDismissed" - variant="introduction" :class="feedbackBannerClasses" :title="$options.i18n.feedbackBannerTitle" :button-text="$options.i18n.feedbackBannerButton" :button-link="$options.AGENT_FEEDBACK_ISSUE" + :svg-path="feedbackBannerIllustration" @close="handleBannerClose" > <p>{{ $options.i18n.feedbackBannerText }}</p> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 0258d8e0da0..77c962e4056 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -252,12 +252,7 @@ export default { class="gl-w-6 gl-h-6 gl-display-flex gl-align-items-center" /> - <gl-link - data-qa-selector="cluster" - :data-qa-cluster-name="item.name" - :href="item.path" - class="gl-px-3" - > + <gl-link :href="item.path" class="gl-px-3"> {{ item.name }} </gl-link> diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index d2a5ef83faf..6c9283a22cf 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -3,6 +3,3 @@ import './bootstrap'; import './vue'; import './gitlab_ui'; import '../lib/utils/axios_utils'; -import { openUserCountsBroadcast } from './nav/user_merge_requests'; - -openUserCountsBroadcast(); diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js deleted file mode 100644 index 90dca0310f3..00000000000 --- a/app/assets/javascripts/commons/nav/user_merge_requests.js +++ /dev/null @@ -1,93 +0,0 @@ -import { getUserCounts } from '~/rest_api'; - -let channel; - -function broadcastCount(newCount) { - if (!channel) { - return; - } - - channel.postMessage(newCount); -} - -function updateUserMergeRequestCounts(newCount) { - const mergeRequestsCountEl = document.querySelector('.js-assigned-mr-count'); - mergeRequestsCountEl.textContent = newCount.toLocaleString(); -} - -function updateReviewerMergeRequestCounts(newCount) { - const mergeRequestsCountEl = document.querySelector('.js-reviewer-mr-count'); - mergeRequestsCountEl.textContent = newCount.toLocaleString(); -} - -function updateMergeRequestCounts(newCount) { - const mergeRequestsCountEl = document.querySelector('.js-merge-requests-count'); - mergeRequestsCountEl.textContent = newCount.toLocaleString(); - mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0); -} - -/** - * Refresh user counts (and broadcast if open) - */ -export function refreshUserMergeRequestCounts() { - if (gon?.use_new_navigation) { - // The new sidebar manages _all_ the counts in - // ~/super_sidebar/user_counts_manager.js - document.dispatchEvent(new CustomEvent('userCounts:fetch')); - return Promise.resolve(); - } - return getUserCounts() - .then(({ data }) => { - const assignedMergeRequests = data.assigned_merge_requests; - const reviewerMergeRequests = data.review_requested_merge_requests; - const fullCount = assignedMergeRequests + reviewerMergeRequests; - - updateUserMergeRequestCounts(assignedMergeRequests); - updateReviewerMergeRequestCounts(reviewerMergeRequests); - updateMergeRequestCounts(fullCount); - broadcastCount(fullCount); - }) - .catch((ex) => { - console.error(ex); // eslint-disable-line no-console - }); -} - -/** - * Close the broadcast channel for user counts - */ -export function closeUserCountsBroadcast() { - if (!channel) { - return; - } - - channel.close(); - channel = null; -} - -/** - * Open the broadcast channel for user counts, adds user id so we only update - * - * **Please note:** - * Not supported in all browsers, but not polyfilling for now - * to keep bundle size small and - * no special functionality lost except cross tab notifications - */ -export function openUserCountsBroadcast() { - if (gon?.use_new_navigation) { - // The new sidebar broadcasts _all counts_ and updates - // them accordingly. Therefore we do not need this manager - // ~/super_sidebar/user_counts_manager.js - return; - } - closeUserCountsBroadcast(); - - if (window.BroadcastChannel) { - const currentUserId = typeof gon !== 'undefined' && gon && gon.current_user_id; - if (currentUserId) { - channel = new BroadcastChannel(`mr_count_channel_${currentUserId}`); - channel.onmessage = (ev) => { - updateMergeRequestCounts(ev.data); - }; - } - } -} diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue index caac61fe9a6..3dc3436347c 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue @@ -148,7 +148,7 @@ export default { <gl-button-group> <gl-dropdown category="tertiary" - contenteditable="false" + :contenteditable="false" boundary="viewport" :text="selectedLanguage.label" @hide="clearCustomLanguageForm" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index 6ce6e731551..0818228e886 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -238,7 +238,6 @@ export default { name="content_editor_image" :accept="$options.acceptedMimes[mediaType]" class="gl-display-none" - data-qa-selector="file_upload_field" @change="onFileSelect" /> <gl-link diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 2e9388c1e20..a48245f732d 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -6,6 +6,7 @@ import { VARIANT_DANGER } from '~/alert'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; import { CONTENT_EDITOR_READY_EVENT } from '~/vue_shared/constants'; import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; import { createContentEditor } from '../services/create_content_editor'; import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; @@ -157,6 +158,7 @@ export default { enableAutocomplete, autocompleteDataSources, codeSuggestionsConfig, + sidebarMediator: SidebarMediator.singleton, tiptapOptions: { autofocus, editable, diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index dc27278d255..c1eae345f72 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -3,6 +3,7 @@ import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_t import { __, sprintf } from '~/locale'; import { getModifierKey } from '~/constants'; import trackUIControl from '../services/track_ui_control'; +import HeaderDivider from '../../vue_shared/components/markdown/header_divider.vue'; import ToolbarButton from './toolbar_button.vue'; import ToolbarAttachmentButton from './toolbar_attachment_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue'; @@ -17,6 +18,7 @@ export default { ToolbarAttachmentButton, ToolbarMoreDropdown, CommentTemplatesDropdown, + HeaderDivider, }, inject: { newCommentTemplatePath: { default: null }, @@ -73,14 +75,17 @@ export default { </script> <template> <div - class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-border-b gl-border-gray-100 gl-px-3 gl-rounded-top-base gl-justify-content-space-between" + class="gl-w-full gl-py-3 gl-row-gap-2 gl-display-flex gl-align-items-center gl-flex-wrap gl-border-b gl-border-gray-100 gl-px-3 gl-rounded-top-base" data-testid="formatting-toolbar" > - <div class="gl-py-3 gl-display-flex gl-flex-wrap"> + <div class="gl-display-flex"> <toolbar-text-style-dropdown data-testid="text-styles" @execute="trackToolbarControlExecution" /> + <header-divider /> + </div> + <div v-if="codeSuggestionsEnabled" class="gl-display-flex"> <toolbar-button v-if="codeSuggestionsEnabled" data-testid="code-suggestion" @@ -91,22 +96,25 @@ export default { :show-active-state="false" @execute="trackToolbarControlExecution" /> - <toolbar-button - data-testid="bold" - content-type="bold" - icon-name="bold" - editor-command="toggleBold" - :label="i18n.bold" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="italic" - content-type="italic" - icon-name="italic" - editor-command="toggleItalic" - :label="i18n.italic" - @execute="trackToolbarControlExecution" - /> + <header-divider /> + </div> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="i18n.bold" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="i18n.italic" + @execute="trackToolbarControlExecution" + /> + <div class="gl-display-flex"> <toolbar-button data-testid="strike" content-type="strike" @@ -115,48 +123,51 @@ export default { :label="i18n.strike" @execute="trackToolbarControlExecution" /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - editor-command="toggleBlockquote" - :label="i18n.quote" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code" - content-type="code" - icon-name="code" - editor-command="toggleCode" - :label="i18n.code" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="link" - content-type="link" - icon-name="link" - editor-command="editLink" - :label="i18n.link" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bullet-list" - content-type="bulletList" - icon-name="list-bulleted" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleBulletList" - :label="i18n.bulletList" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="ordered-list" - content-type="orderedList" - icon-name="list-numbered" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleOrderedList" - :label="i18n.numberedList" - @execute="trackToolbarControlExecution" - /> + <header-divider /> + </div> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="i18n.quote" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="i18n.code" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="editLink" + :label="i18n.link" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleBulletList" + :label="i18n.bulletList" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleOrderedList" + :label="i18n.numberedList" + @execute="trackToolbarControlExecution" + /> + <div class="gl-display-flex"> <toolbar-button data-testid="task-list" content-type="taskList" @@ -166,7 +177,12 @@ export default { :label="i18n.taskList" @execute="trackToolbarControlExecution" /> - <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <div class="gl-display-none gl-sm-display-flex"> + <header-divider /> + </div> + </div> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <div class="gl-display-flex"> <toolbar-attachment-button v-if="!hideAttachmentButton" data-testid="attachment" @@ -183,12 +199,13 @@ export default { :label="__('Add a quick action')" @execute="trackToolbarControlExecution" /> - <comment-templates-dropdown - v-if="newCommentTemplatePath" - :new-comment-template-path="newCommentTemplatePath" - @select="insertSavedReply" - /> - <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> + <header-divider v-if="newCommentTemplatePath" /> </div> + <comment-templates-dropdown + v-if="newCommentTemplatePath" + :new-comment-template-path="newCommentTemplatePath" + @select="insertSavedReply" + /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> </div> </template> diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index b34ebe85eb4..12d7114b036 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,12 +1,17 @@ <script> -import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatar, GlLoadingIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { components: { - GlAvatarLabeled, + GlAvatar, GlLoadingIcon, }, + directives: { + SafeHtml, + }, + props: { char: { type: String, @@ -38,6 +43,12 @@ export default { required: false, default: false, }, + + query: { + type: String, + required: false, + default: '', + }, }, data() { @@ -90,20 +101,32 @@ export default { isEmoji() { return this.nodeType === 'emoji'; }, + + shouldSelectFirstItem() { + return this.items.length && this.query; + }, }, watch: { items() { - this.selectedIndex = -1; + this.selectedIndex = this.shouldSelectFirstItem ? 0 : -1; }, - selectedIndex() { + async selectedIndex() { + // wait for the DOM to update before scrolling + await this.$nextTick(); this.scrollIntoView(); }, }, + mounted() { + if (this.shouldSelectFirstItem) { + this.selectedIndex = 0; + } + }, + methods: { getText(item) { - if (this.isEmoji) return item.e; + if (this.isEmoji) return item.emoji.e; switch (this.isReference && this.nodeProps.referenceType) { case 'user': @@ -133,10 +156,10 @@ export default { if (this.isEmoji) { Object.assign(props, { - name: item.name, - unicodeVersion: item.u, - title: item.d, - moji: item.e, + name: item.emoji.name, + unicodeVersion: item.emoji.u, + title: item.emoji.d, + moji: item.emoji.e, }); } @@ -173,7 +196,7 @@ export default { return true; } - if (event.key === 'Enter') { + if (event.key === 'Enter' || event.key === 'Tab') { this.enterHandler(); return true; } @@ -194,7 +217,7 @@ export default { }, scrollIntoView() { - this.$refs.dropdownItems[this.selectedIndex]?.scrollIntoView({ block: 'nearest' }); + this.$refs.dropdownItems?.[this.selectedIndex]?.scrollIntoView({ block: 'nearest' }); }, selectItem(index) { @@ -211,7 +234,17 @@ export default { avatarSubLabel(item) { return item.count ? `${item.name} (${item.count})` : item.name; }, + + highlight(text) { + return this.query + ? String(text).replace( + new RegExp(this.query, 'i'), + (match) => `<strong class="gl-text-body!">${match}</strong>`, + ) + : text; + }, }, + safeHtmlConfig: { ALLOWED_TAGS: ['strong'] }, }; </script> @@ -238,29 +271,45 @@ export default { @click="selectItem(index)" > <div class="gl-new-dropdown-item-text-wrapper"> - <gl-avatar-labeled - v-if="isUser" - :label="item.username" - :sub-label="avatarSubLabel(item)" - :src="item.avatar_url" - :entity-name="item.username" - :shape="item.type === 'Group' ? 'rect' : 'circle'" - :size="32" - /> + <span v-if="isUser" class="gl-flex"> + <gl-avatar + :src="item.avatar_url" + :entity-name="item.username" + :size="24" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + class="gl-vertical-align-middle gl-mx-2" + /> + <span class="gl-vertical-align-middle"> + <span v-safe-html:safeHtmlConfig="highlight(item.username)"></span> + <small + v-safe-html:safeHtmlConfig="highlight(avatarSubLabel(item))" + class="gl-text-gray-500" + ></small> + </span> + </span> <span v-if="isIssue || isMergeRequest"> - <small>{{ item.iid }}</small> - {{ item.title }} + <small + v-safe-html:safeHtmlConfig="highlight(item.iid)" + class="gl-text-gray-500" + ></small> + <span v-safe-html:safeHtmlConfig="highlight(item.title)"></span> </span> <span v-if="isVulnerability || isSnippet"> - <small>{{ item.id }}</small> - {{ item.title }} + <small + v-safe-html:safeHtmlConfig="highlight(item.id)" + class="gl-text-gray-500" + ></small> + <span v-safe-html:safeHtmlConfig="highlight(item.title)"></span> </span> <span v-if="isEpic"> - <small>{{ item.reference }}</small> - {{ item.title }} + <small + v-safe-html:safeHtmlConfig="highlight(item.reference)" + class="gl-text-gray-500" + ></small> + <span v-safe-html:safeHtmlConfig="highlight(item.title)"></span> </span> <span v-if="isMilestone"> - {{ item.title }} + <span v-safe-html:safeHtmlConfig="highlight(item.title)"></span> </span> <span v-if="isLabel" class="gl-display-flex"> <span @@ -268,20 +317,31 @@ export default { class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" :style="{ backgroundColor: item.color }" ></span> - {{ item.title }} + <span v-safe-html:safeHtmlConfig="highlight(item.title)"></span> </span> <div v-if="isCommand"> <div class="gl-mb-1"> - <span class="gl-font-weight-bold">/{{ item.name }}</span> - <em class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</em> + /<span v-safe-html:safeHtmlConfig="highlight(item.name)"></span> + <span class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</span> </div> - <small class="gl-text-gray-500"> {{ item.description }} </small> + <em + v-safe-html:safeHtmlConfig="highlight(item.description)" + class="gl-text-gray-500 gl-font-sm" + ></em> </div> <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> - <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-pr-4 gl-font-lg"> + <gl-emoji + :key="item.emoji.e" + :data-name="item.emoji.name" + :title="item.emoji.d" + :data-unicode-version="item.emoji.u" + :data-fallback-src="item.emoji.src" + >{{ item.emoji.e }}</gl-emoji + > + </div> <div class="gl-flex-grow-1"> - {{ item.name }}<br /> - <small>{{ item.d }}</small> + <span v-safe-html:safeHtmlConfig="highlight(item.fieldValue)"></span> </div> </div> </div> diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue index 78a01693f14..9093fc323cc 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -47,7 +47,7 @@ export default { category="tertiary" icon="paperclip" size="small" - class="gl-mr-3" + class="gl-mr-2" lazy @click="openFileUpload" /> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index 60bfaab25a5..a4c4814fde9 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -87,7 +87,7 @@ export default { :aria-label="label" :title="label" :icon="iconName" - class="gl-mr-3" + class="gl-mr-2" @click="execute" /> </editor-state-observer> diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index ab1546b9016..f09d583996c 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -93,7 +93,7 @@ export default { :aria-label="__('Insert table')" :toggle-text="__('Insert table')" positioning-strategy="fixed" - class="content-editor-table-dropdown gl-mr-3" + class="content-editor-table-dropdown gl-mr-2" text-sr-only :fluid-width="true" @shown="setFocus(1, 1)" diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index efd0926d7ed..93de4cd9744 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -170,7 +170,8 @@ export default { > <div v-if="node.attrs.showPreview" - contenteditable="false" + :contenteditable="false" + data-testid="sandbox-preview" class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > <sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" /> @@ -178,14 +179,14 @@ export default { </div> <span v-if="node.attrs.isFrontmatter" - contenteditable="false" + :contenteditable="false" data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" >{{ __('frontmatter') }}:{{ node.attrs.language }}</span > <div v-if="isCodeSuggestion" - contenteditable="false" + :contenteditable="false" class="gl-relative gl-z-index-0" data-testid="code-suggestion-box" > diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue index b96b7400d85..06dc59c2ad8 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue @@ -18,8 +18,8 @@ export default { <template> <node-view-wrapper class="gl-display-flex gl-font-sm" as="div"> <span + :contenteditable="false" data-testid="footnote-label" - contenteditable="false" class="gl-display-inline-flex gl-mr-2" dir="auto" >{{ node.attrs.label }}:</span diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 44f5a2895fd..e7a1b058341 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -149,7 +149,8 @@ export default { > <span v-if="displayActionsDropdown" - contenteditable="false" + :contenteditable="false" + data-testid="actions-dropdown" class="gl-absolute gl-right-0 gl-top-0 gl-pr-1 gl-pt-1" > <gl-disclosure-dropdown diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index d29a407c5ca..23f2da7bc28 100644 --- a/app/assets/javascripts/content_editor/extensions/copy_paste.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -149,21 +149,26 @@ export default Extension.create({ const { clipboardData } = event; const gfmContent = clipboardData.getData(GFM_FORMAT); - - if (gfmContent) { - return this.editor.commands.pasteContent(gfmContent, true); - } - const textContent = clipboardData.getData(TEXT_FORMAT); const htmlContent = clipboardData.getData(HTML_FORMAT); const { from, to } = view.state.selection; + const isCodeBlockActive = CODE_BLOCK_NODE_TYPES.some((type) => + this.editor.isActive(type), + ); - if (pasteRaw) { - this.editor.commands.insertContentAt( - { from, to }, - textContent.replace(/^\s+|\s+$/gm, ''), - ); + if (pasteRaw || isCodeBlockActive) { + const isMarkdownCodeBlockActive = this.editor.isActive(CodeBlockHighlight.name, { + language: 'markdown', + }); + + const contentToInsert = isMarkdownCodeBlockActive + ? gfmContent || textContent + : textContent.replace(/^\s+|\s+$/gm, ''); + + if (!contentToInsert) return false; + + this.editor.commands.insertContentAt({ from, to }, contentToInsert); return true; } @@ -172,11 +177,6 @@ export default Extension.create({ const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; const language = vsCodeMeta.mode; - // if a code block is active, paste as plain text - if (!textContent || CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { - return false; - } - if (hasVsCode) { return this.editor.commands.pasteContent( language === 'markdown' ? textContent : `\`\`\`${language}\n${textContent}\n\`\`\``, @@ -184,6 +184,10 @@ export default Extension.create({ ); } + if (gfmContent) { + return this.editor.commands.pasteContent(gfmContent, true); + } + const preStartRegex = /^<pre[^>]*lang="markdown"[^>]*>/; const preEndRegex = /<\/pre>$/; const htmlContentWithoutMeta = htmlContent?.replace(/^<meta[^>]*>/, ''); diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index be6ecb6cafd..96e03dfe598 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -46,7 +46,7 @@ export default Node.create({ title: node.attrs.title, 'data-unicode-version': node.attrs.unicodeVersion, }, - node.attrs.moji, + node.attrs.moji || '', ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index fd248709b5a..0c385481ac5 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -72,11 +72,20 @@ export default Node.create({ addInputRules() { const { editor } = this; const { assetResolver } = this.options; - const referenceInputRegex = /(?:^|\s)([\w/]*([!&#])\d+(\+?s?))(?:\s|\n)/m; + const referenceInputRegex = /(?:^|\s)([\w/]*([#!&%$@~]|\[vulnerability:)[\w.]+(\+?s?\]?))(?:\s|\n)/m; const referenceTypes = { '#': 'issue', '!': 'merge_request', '&': 'epic', + '%': 'milestone', + $: 'snippet', + '@': 'user', + '~': 'label', + '[vulnerability:': 'vulnerability', + }; + const nodeTypes = { + label: editor.schema.nodes.referenceLabel, + default: editor.schema.nodes.reference, }; return [ @@ -91,22 +100,26 @@ export default Node.create({ text, expandedText, fullyExpandedText, + backgroundColor, } = await assetResolver.resolveReference(referenceId); if (!text) return; let referenceText = text; - if (expansionType === '+') referenceText = expandedText; - if (expansionType === '+s') referenceText = fullyExpandedText; + if (expansionType === '+') referenceText = expandedText || text; + if (expansionType === '+s') referenceText = fullyExpandedText || text; const position = findReference(editor, referenceId); if (!position) return; + const nodeType = nodeTypes[referenceType] || nodeTypes.default; + editor.view.dispatch( editor.state.tr.replaceWith(position, position + referenceId.length, [ - this.type.create({ + nodeType.create({ referenceType, originalText: referenceId, + color: backgroundColor, href, text: referenceText, }), diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index f7ff2fd6647..d309210404a 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -3,29 +3,20 @@ import { VueRenderer } from '@tiptap/vue-2'; import tippy from 'tippy.js'; import Suggestion from '@tiptap/suggestion'; import { PluginKey } from '@tiptap/pm/state'; -import { isFunction, uniqueId, memoize } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; -import { initEmojiMap, getAllEmoji } from '~/emoji'; +import { uniqueId } from 'lodash'; import SuggestionsDropdown from '../components/suggestions_dropdown.vue'; -function find(haystack, needle) { - return String(haystack).toLocaleLowerCase().includes(String(needle).toLocaleLowerCase()); -} - function createSuggestionPlugin({ editor, char, - dataSource, - search, - limit = 15, + limit = 5, nodeType, - nodeProps = {}, + referenceType, + cache = true, insertionMap = {}, + serializer, + autocompleteHelper, }) { - const fetchData = memoize( - isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, - ); - return Suggestion({ editor, char, @@ -42,16 +33,17 @@ function createSuggestionPlugin({ .run(); }, - async items({ query }) { - if (!dataSource) return []; - - try { - const items = await fetchData(); - - return items.filter(search(query)).slice(0, limit); - } catch { - return []; - } + async items({ query, editor: tiptapEditor }) { + const slice = tiptapEditor.state.doc.slice(0, tiptapEditor.state.selection.to); + const markdownLine = serializer.serialize({ doc: slice.content }).split('\n').pop(); + + return autocompleteHelper + .getDataSource(referenceType, { + command: markdownLine.match(/\/\w+/)?.[0], + cache, + limit, + }) + .search(query); }, render: () => { @@ -76,7 +68,7 @@ function createSuggestionPlugin({ ...props, char, nodeType, - nodeProps, + nodeProps: { referenceType }, loading: true, }, editor: props.editor, @@ -132,101 +124,38 @@ export default Node.create({ addOptions() { return { - autocompleteDataSources: {}, + autocompleteHelper: {}, + serializer: null, }; }, addProseMirrorPlugins() { - return [ - createSuggestionPlugin({ - editor: this.editor, - char: '@', - dataSource: this.options.autocompleteDataSources.members, - nodeType: 'reference', - nodeProps: { - referenceType: 'user', - }, - search: (query) => ({ name, username }) => find(name, query) || find(username, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '#', - dataSource: this.options.autocompleteDataSources.issues, - nodeType: 'reference', - nodeProps: { - referenceType: 'issue', - }, - search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '$', - dataSource: this.options.autocompleteDataSources.snippets, - nodeType: 'reference', - nodeProps: { - referenceType: 'snippet', - }, - search: (query) => ({ id, title }) => find(id, query) || find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '~', - dataSource: this.options.autocompleteDataSources.labels, - nodeType: 'referenceLabel', - nodeProps: { - referenceType: 'label', - }, - search: (query) => ({ title }) => find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '&', - dataSource: this.options.autocompleteDataSources.epics, - nodeType: 'reference', - nodeProps: { - referenceType: 'epic', - }, - search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '[vulnerability:', - dataSource: this.options.autocompleteDataSources.vulnerabilities, - nodeType: 'reference', - nodeProps: { - referenceType: 'vulnerability', - }, - search: (query) => ({ id, title }) => find(id, query) || find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '!', - dataSource: this.options.autocompleteDataSources.mergeRequests, - nodeType: 'reference', - nodeProps: { - referenceType: 'merge_request', - }, - search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), - }), - createSuggestionPlugin({ - editor: this.editor, - char: '%', - dataSource: this.options.autocompleteDataSources.milestones, - nodeType: 'reference', - nodeProps: { - referenceType: 'milestone', - }, - search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), - }), + const { serializer, autocompleteHelper } = this.options; + + const createPlugin = (char, nodeType, referenceType, options = {}) => createSuggestionPlugin({ editor: this.editor, - char: '/', - dataSource: this.options.autocompleteDataSources.commands, - nodeType: 'reference', - nodeProps: { - referenceType: 'command', - }, - search: (query) => ({ name }) => find(name, query), + char, + nodeType, + referenceType, + serializer, + autocompleteHelper, + ...options, + }); + + return [ + createPlugin('@', 'reference', 'user', { limit: 10 }), + createPlugin('#', 'reference', 'issue'), + createPlugin('$', 'reference', 'snippet'), + createPlugin('~', 'referenceLabel', 'label', { limit: 20 }), + createPlugin('&', 'reference', 'epic'), + createPlugin('!', 'reference', 'merge_request'), + createPlugin('[vulnerability:', 'reference', 'vulnerability'), + createPlugin('%', 'reference', 'milestone'), + createPlugin(':', 'emoji', 'emoji'), + createPlugin('/', 'reference', 'command', { + cache: false, + limit: 100, insertionMap: { '/label': '~', '/unlabel': '~', @@ -241,18 +170,6 @@ export default Node.create({ '/milestone': '%', }, }), - createSuggestionPlugin({ - editor: this.editor, - char: ':', - dataSource: () => getAllEmoji(), - nodeType: 'emoji', - search: (query) => ({ d, name }) => find(d, query) || find(name, query), - limit: 10, - }), ]; }, - - onCreate() { - initEmojiMap(); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js index 01e5bddb97a..5ef9cf42f93 100644 --- a/app/assets/javascripts/content_editor/extensions/task_list.js +++ b/app/assets/javascripts/content_editor/extensions/task_list.js @@ -27,6 +27,13 @@ export default TaskList.extend({ default: false, parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), }, + bullet: { + default: '*', + parseHTML(element) { + const bullet = getMarkdownSource(element)?.charAt(0); + return '*+-'.includes(bullet) ? bullet : '*'; + }, + }, }; }, diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js index 0d4396fc176..07a69db7428 100644 --- a/app/assets/javascripts/content_editor/services/asset_resolver.js +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -27,10 +27,11 @@ export default class AssetResolver { if (!a.length) return {}; return { - href: a[0].getAttribute('href'), - text: a[0].textContent, - expandedText: a[1].textContent, - fullyExpandedText: a[2].textContent, + href: a[0]?.getAttribute('href'), + text: a[0]?.textContent, + expandedText: a[1]?.textContent, + fullyExpandedText: a[2]?.textContent, + backgroundColor: a[0]?.firstElementChild?.style.backgroundColor, }; }); 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 51e41ceefaf..5c48c0b1d43 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -70,6 +70,7 @@ import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; import AssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; +import DataSourceFactory from './data_source_factory'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ @@ -86,6 +87,7 @@ export const createContentEditor = ({ drawioEnabled = false, enableAutocomplete, autocompleteDataSources = {}, + sidebarMediator = {}, codeSuggestionsConfig = {}, } = {}) => { if (!isFunction(renderMarkdown)) { @@ -95,6 +97,10 @@ export const createContentEditor = ({ const eventHub = eventHubFactory(); const assetResolver = new AssetResolver({ renderMarkdown }); const serializer = new MarkdownSerializer({ serializerConfig }); + const autocompleteHelper = new DataSourceFactory({ + dataSourceUrls: autocompleteDataSources, + sidebarMediator, + }); const deserializer = window.gon?.features?.preserveUnchangedMarkdown ? createRemarkMarkdownDeserializer() : createGlApiMarkdownDeserializer({ @@ -166,7 +172,8 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; - if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); + if (enableAutocomplete) + allExtensions.push(Suggestions.configure({ autocompleteHelper, serializer })); if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, assetResolver })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); diff --git a/app/assets/javascripts/content_editor/services/data_source_factory.js b/app/assets/javascripts/content_editor/services/data_source_factory.js new file mode 100644 index 00000000000..a0f0e106f1d --- /dev/null +++ b/app/assets/javascripts/content_editor/services/data_source_factory.js @@ -0,0 +1,213 @@ +import { identity, memoize, throttle } from 'lodash'; +import { sprintf, __ } from '~/locale'; +import { initEmojiMap, getAllEmoji, searchEmoji } from '~/emoji'; +import { parsePikadayDate } from '~/lib/utils/datetime_utility'; +import axios from '~/lib/utils/axios_utils'; + +export function defaultSorter(searchFields) { + return (items, query) => { + if (!query) return items; + + const sortOrdersMap = new WeakMap(); + + items.forEach((item) => { + const sortOrders = searchFields.map((searchField) => { + const haystack = String(item[searchField]).toLocaleLowerCase(); + const needle = query.toLocaleLowerCase(); + + const i = haystack.indexOf(needle); + if (i < 0) return i; + return Number.MAX_SAFE_INTEGER - i; + }); + + sortOrdersMap.set(item, Math.max(...sortOrders)); + }); + + return items.sort((a, b) => sortOrdersMap.get(b) - sortOrdersMap.get(a)); + }; +} + +export function customSorter(sorter) { + return (items) => items.sort(sorter); +} + +const milestonesMap = new WeakMap(); + +function parseMilestone(milestone) { + if (!milestone.title) { + return milestone; + } + + const dueDate = milestone.due_date ? parsePikadayDate(milestone.due_date) : null; + const expired = dueDate ? Date.now() > dueDate.getTime() : false; + + return { + id: milestone.iid, + title: expired + ? sprintf(__('%{milestone} (expired)'), { + milestone: milestone.title, + }) + : milestone.title, + expired, + dueDate, + }; +} + +function mapMilestone(milestone) { + if (!milestonesMap.has(milestone)) { + milestonesMap.set(milestone, parseMilestone(milestone)); + } + + return milestonesMap.get(milestone); +} + +function sortMilestones(milestoneA, milestoneB) { + const mappedA = mapMilestone(milestoneA); + const mappedB = mapMilestone(milestoneB); + + // Move all expired milestones to the bottom. + if (milestoneA.expired) return 1; + if (milestoneB.expired) return -1; + + // Move milestones without due dates just above expired milestones. + if (!milestoneA.dueDate) return 1; + if (!milestoneB.dueDate) return -1; + + return mappedA.dueDate - mappedB.dueDate; +} + +export function createDataSource({ + source, + searchFields, + filter, + mapper = identity, + sorter = defaultSorter(searchFields), + cache = true, + limit = 15, +}) { + const fetchData = source ? async () => (await axios.get(source)).data : () => []; + let items = []; + + const sync = async function sync() { + try { + items = await fetchData(); + } catch { + items = []; + } + }; + + const init = memoize(sync); + const throttledSync = throttle(sync, 5000); + + return { + search: async (query) => { + await init(); + if (!cache) throttledSync(); + + let results = items.map(mapper); + if (filter) results = filter(items, query); + + if (query) { + results = results.filter((item) => { + if (!searchFields.length) return true; + return searchFields.some((field) => + String(item[field]).toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ); + }); + } + + return sorter(results, query).slice(0, limit); + }, + }; +} + +export default class DataSourceFactory { + constructor({ dataSourceUrls, sidebarMediator }) { + this.dataSourceUrls = dataSourceUrls; + this.sidebarMediator = sidebarMediator; + + initEmojiMap(); + } + + getDataSource = memoize( + (referenceType, config = {}) => { + const sources = { + user: this.dataSourceUrls.members, + issue: this.dataSourceUrls.issues, + snippet: this.dataSourceUrls.snippets, + label: this.dataSourceUrls.labels, + epic: this.dataSourceUrls.epics, + milestone: this.dataSourceUrls.milestones, + merge_request: this.dataSourceUrls.mergeRequests, + vulnerability: this.dataSourceUrls.vulnerabilities, + command: this.dataSourceUrls.commands, + }; + + const searchFields = { + user: ['username', 'name'], + issue: ['iid', 'title'], + snippet: ['id', 'title'], + label: ['title'], + epic: ['iid', 'title'], + vulnerability: ['id', 'title'], + merge_request: ['iid', 'title'], + milestone: ['title', 'iid'], + command: ['name'], + emoji: [], + }; + + const filters = { + label: (items) => + items.filter((item) => { + if (config.command === '/unlabel') return item.set; + if (config.command === '/label') return !item.set; + + return true; + }), + user: (items) => + items.filter((item) => { + const assigned = this.sidebarMediator?.store?.assignees.some( + (assignee) => assignee.username === item.username, + ); + const assignedReviewer = this.sidebarMediator?.store?.reviewers.some( + (reviewer) => reviewer.username === item.username, + ); + + if (config.command === '/assign') return !assigned; + if (config.command === '/assign_reviewer') return !assignedReviewer; + if (config.command === '/unassign') return assigned; + if (config.command === '/unassign_reviewer') return assignedReviewer; + + return true; + }), + emoji: (_, query) => + query + ? searchEmoji(query) + : getAllEmoji().map((emoji) => ({ emoji, fieldValue: emoji.name })), + }; + + const sorters = { + milestone: customSorter(sortMilestones), + default: defaultSorter(searchFields[referenceType]), + // do not sort emoji + emoji: customSorter(() => 0), + }; + + const mappers = { + milestone: mapMilestone, + default: identity, + }; + + return createDataSource({ + source: sources[referenceType], + searchFields: searchFields[referenceType], + mapper: mappers[referenceType] || mappers.default, + sorter: sorters[referenceType] || sorters.default, + filter: filters[referenceType], + cache: config.cache, + limit: config.limit, + }); + }, + (referenceType, config) => JSON.stringify({ referenceType, config }), + ); +} diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index 11a11ed43bd..a4abb8dcf38 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -16,8 +16,8 @@ const getRangeFromSourcePos = (sourcePos) => { const [endRow, endCol] = end.split(':'); return { - start: { row: Number(startRow) - 1, col: Number(startCol) - 1 }, - end: { row: Number(endRow) - 1, col: Number(endCol) - 1 }, + start: { row: Math.max(0, Number(startRow) - 1), col: Math.max(0, Number(startCol) - 1) }, + end: { row: Math.max(0, Number(endRow) - 1), col: Math.max(0, Number(endCol) - 1) }, }; }; @@ -33,8 +33,6 @@ export const getMarkdownSource = (element) => { for (let i = range.start.row; i <= range.end.row; i += 1) { if (i === range.start.row) { elSource += source[i].substring(range.start.col); - } else if (i === range.end.row) { - elSource += `\n${source[i]?.substring(0, range.start.col)}`; } else { elSource += `\n${source[i]}` || ''; } diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 0897232cf89..87959a44560 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -366,14 +366,16 @@ export function renderPlayable(state, node) { } export function renderCodeBlock(state, node) { + const numBackticks = Math.max(2, node.textContent.match(/```+/g)?.[0]?.length || 0) + 1; + const backticks = state.repeat('`', numBackticks); state.write( - `\`\`\`${ + `${backticks}${ (node.attrs.language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') }\n`, ); state.text(node.textContent, false); state.ensureNewLine(); - state.write('```'); + state.write(backticks); state.closeBlock(node); } @@ -478,6 +480,22 @@ export function renderReferenceLabel(state, node) { state.write(node.attrs.originalText || `~${state.quote(node.attrs.text)}`); } +const findChildWithMark = (mark, parent) => { + let child; + let offset; + let index; + + parent.forEach((_child, _offset, _index) => { + if (mark.isInSet(_child.marks)) { + child = _child; + offset = _offset; + index = _index; + } + }); + + return child ? { child, offset, index } : null; +}; + const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; @@ -529,13 +547,23 @@ export const italic = { }; const generateCodeTag = (wrapTagName = openTag) => { - return (_, mark) => { + const isOpen = wrapTagName === openTag; + + return (_, mark, parent) => { const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1]; if (type === '<code') { return wrapTagName(type.substring(1)); } + const childText = findChildWithMark(mark, parent).child?.text || ''; + if (childText.includes('`')) { + let tag = '``'; + if (childText.startsWith('`') || childText.endsWith('`')) + tag = isOpen ? `${tag} ` : ` ${tag}`; + return tag; + } + return '`'; }; }; @@ -579,22 +607,6 @@ const normalizeUrl = (url) => { const isValidAutolinkURL = (url) => /(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url); -const findChildWithMark = (mark, parent) => { - let child; - let offset; - let index; - - parent.forEach((_child, _offset, _index) => { - if (mark.isInSet(_child.marks)) { - child = _child; - offset = _offset; - index = _index; - } - }); - - return child ? { child, offset, index } : null; -}; - /** * This function detects whether a link should be serialized * as an autolink. diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js deleted file mode 100644 index ab5f01227fb..00000000000 --- a/app/assets/javascripts/contextual_sidebar.js +++ /dev/null @@ -1,112 +0,0 @@ -import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; -import $ from 'jquery'; -import { debounce } from 'lodash'; -import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; - -export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; - -export default class ContextualSidebar { - constructor() { - this.initDomElements(); - this.render(); - } - - initDomElements() { - this.$page = $('.layout-page'); - this.$sidebar = $('.nav-sidebar'); - - if (!this.$sidebar.length) return; - - this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar); - this.$overlay = $('.mobile-overlay'); - this.$openSidebar = $('.toggle-mobile-nav'); - this.$closeSidebar = $('.close-nav-button'); - this.$sidebarToggle = $('.js-toggle-sidebar'); - } - - bindEvents() { - if (!this.$sidebar.length) return; - - this.$openSidebar.on('click', () => this.toggleSidebarNav(true)); - this.$closeSidebar.on('click', () => this.toggleSidebarNav(false)); - this.$overlay.on('click', () => this.toggleSidebarNav(false)); - this.$sidebarToggle.on('click', () => { - if (!ContextualSidebar.isDesktopBreakpoint()) { - this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile')); - } else { - const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); - this.toggleCollapsedSidebar(value, true); - } - }); - - $(window).on( - 'resize', - debounce(() => this.render(), 100), - ); - } - - // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation - // NOTE: at 1200px nav sidebar should not overlap the content - // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 - static isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; - static setCollapsedCookie(value) { - if (!ContextualSidebar.isDesktopBreakpoint()) { - return; - } - setCookie('sidebar_collapsed', value, { expires: 365 * 10 }); - } - - toggleSidebarNav(show) { - const breakpoint = bp.getBreakpointSize(); - const dbp = ContextualSidebar.isDesktopBreakpoint(); - const supportedSizes = ['xs', 'sm', 'md']; - - this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); - this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); - this.$overlay.toggleClass( - 'mobile-nav-open', - supportedSizes.includes(breakpoint) ? show : false, - ); - this.$sidebar.removeClass('sidebar-collapsed-desktop'); - } - - toggleCollapsedSidebar(collapsed, saveCookie) { - const breakpoint = bp.getBreakpointSize(); - const dbp = ContextualSidebar.isDesktopBreakpoint(); - const supportedSizes = ['xs', 'sm', 'md']; - - if (this.$sidebar.length) { - this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed); - this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); - this.$page.toggleClass( - 'page-with-icon-sidebar', - supportedSizes.includes(breakpoint) ? true : collapsed, - ); - } - - if (saveCookie) { - ContextualSidebar.setCollapsedCookie(collapsed); - } - - requestIdleCallback(() => this.toggleSidebarOverflow()); - } - - toggleSidebarOverflow() { - if (this.$innerScroll.prop('scrollHeight') > this.$innerScroll.prop('offsetHeight')) { - this.$innerScroll.css('overflow-y', 'scroll'); - } else { - this.$innerScroll.css('overflow-y', ''); - } - } - - render() { - if (!this.$sidebar.length) return; - - if (!ContextualSidebar.isDesktopBreakpoint()) { - this.toggleSidebarNav(false); - } else { - const collapse = parseBoolean(getCookie('sidebar_collapsed')); - this.toggleCollapsedSidebar(collapse, true); - } - } -} diff --git a/app/assets/javascripts/contributors/components/contributor_area_chart.vue b/app/assets/javascripts/contributors/components/contributor_area_chart.vue new file mode 100644 index 00000000000..51d890078c1 --- /dev/null +++ b/app/assets/javascripts/contributors/components/contributor_area_chart.vue @@ -0,0 +1,68 @@ +<script> +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { __ } from '~/locale'; +import { dateFormats } from '~/analytics/shared/constants'; +import dateFormat from '~/lib/dateformat'; + +export default { + name: 'ContributorAreaChart', + components: { + GlAreaChart, + }, + props: { + data: { + type: Array, + required: true, + }, + option: { + type: Object, + required: true, + }, + height: { + type: Number, + required: true, + }, + }, + data() { + return { + tooltipTitle: '', + tooltipValue: [], + }; + }, + computed: { + tooltipLabel() { + return this.option.yAxis?.name || __('Value'); + }, + }, + methods: { + formatTooltipText({ seriesData }) { + const [dateTime, value] = seriesData[0].data; + this.tooltipTitle = dateFormat(dateTime, dateFormats.defaultDate); + this.tooltipValue = value; + }, + }, +}; +</script> + +<template> + <gl-area-chart + responsive + width="auto" + :data="data" + :option="option" + :height="height" + :format-tooltip-text="formatTooltipText" + @created="$emit('created', $event)" + > + <template #tooltip-title> + <div data-testid="tooltip-title">{{ tooltipTitle }}</div> + </template> + + <template #tooltip-content> + <div class="gl-display-flex gl-justify-content-space-between gl-gap-6"> + <span data-testid="tooltip-label">{{ tooltipLabel }}</span> + <span data-testid="tooltip-value">{{ tooltipValue }}</span> + </div> + </template> + </gl-area-chart> +</template> diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 21428ff9eca..9b834793428 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,7 +1,6 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { debounce, uniq } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; @@ -12,6 +11,7 @@ import { __ } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; +import ContributorAreaChart from './contributor_area_chart.vue'; const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; @@ -24,9 +24,9 @@ export default { }, }, components: { - GlAreaChart, GlButton, GlLoadingIcon, + ContributorAreaChart, RefSelector, }, props: { @@ -249,10 +249,8 @@ export default { <div data-testid="contributors-charts"> <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> - <gl-area-chart + <contributor-area-chart class="gl-mb-5" - responsive - width="auto" :data="masterChartData" :option="masterChartOptions" :height="masterChartHeight" @@ -269,9 +267,7 @@ export default { <p class="gl-mb-3"> {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) </p> - <gl-area-chart - responsive - width="auto" + <contributor-area-chart :data="contributor.dates" :option="individualChartOptions" :height="individualChartHeight" diff --git a/app/assets/javascripts/deploy_keys/graphql/client.js b/app/assets/javascripts/deploy_keys/graphql/client.js new file mode 100644 index 00000000000..3c183963683 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/client.js @@ -0,0 +1,47 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import typeDefs from './typedefs.graphql'; +import { resolvers } from './resolvers'; + +export const createApolloProvider = (endpoints) => { + const defaultClient = createDefaultClient(resolvers(endpoints), { + typeDefs, + cacheConfig: { + typePolicies: { + Query: { + fields: { + currentScope: { + read(data) { + return data || 'enabledKeys'; + }, + }, + currentPage: { + read(data) { + return data || 1; + }, + }, + pageInfo: { + read(data) { + return data || {}; + }, + }, + deployKeyToRemove: { + read(data) { + return data || null; + }, + }, + }, + }, + LocalDeployKey: { + deployKeysProjects: { + merge(_, incoming) { + return incoming; + }, + }, + }, + }, + }, + }); + + return new VueApollo({ defaultClient }); +}; diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql new file mode 100644 index 00000000000..adc78e6d2d2 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql @@ -0,0 +1,3 @@ +mutation confirmDisable($id: ID) { + confirmDisable(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql new file mode 100644 index 00000000000..923dd636785 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql @@ -0,0 +1,3 @@ +mutation disableKey($id: ID!) { + disableKey(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql new file mode 100644 index 00000000000..fb978679b7c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql @@ -0,0 +1,3 @@ +mutation enableKey($id: ID!) { + enableKey(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql new file mode 100644 index 00000000000..8e6438cdad0 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentDeployKeyPage($page: String) { + currentPage(page: $page) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql new file mode 100644 index 00000000000..3502eee5142 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentScope($scope: DeployKeysScope) { + currentScope(scope: $scope) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql new file mode 100644 index 00000000000..11d6a6ab83c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql @@ -0,0 +1,5 @@ +query confirmRemoveKey { + deployKeyToRemove @client { + id + } +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql new file mode 100644 index 00000000000..dc02d97531a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql @@ -0,0 +1,3 @@ +query getCurrentDeployKeyPage { + currentPage @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql new file mode 100644 index 00000000000..181f5c52254 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql @@ -0,0 +1,3 @@ +query getCurrentScope { + currentScope @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql new file mode 100644 index 00000000000..c98da2920cc --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql @@ -0,0 +1,26 @@ +query getDeployKeys($projectPath: ID!, $scope: DeployKeysScope, $page: Integer) { + project(fullPath: $projectPath) { + id + deployKeys(scope: $scope, page: $page) @client { + id + title + fingerprintSha256 + fingerprint + editPath + destroyedWhenOrphaned + almostOrphaned + expiresAt + createdAt + enablePath + disablePath + deployKeysProjects { + canPush + project { + id + fullPath + fullName + } + } + } + } +} diff --git a/app/assets/javascripts/deploy_keys/graphql/resolvers.js b/app/assets/javascripts/deploy_keys/graphql/resolvers.js new file mode 100644 index 00000000000..1993801636e --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/resolvers.js @@ -0,0 +1,106 @@ +import { gql } from '@apollo/client/core'; +import axios from '~/lib/utils/axios_utils'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; +import currentPageQuery from './queries/current_page.query.graphql'; +import currentScopeQuery from './queries/current_scope.query.graphql'; +import confirmRemoveKeyQuery from './queries/confirm_remove_key.query.graphql'; + +export const mapDeployKey = (deployKey) => ({ + ...convertObjectPropsToCamelCase(deployKey, { deep: true }), + __typename: 'LocalDeployKey', +}); + +export const resolvers = (endpoints) => ({ + Project: { + deployKeys(_, { scope, page }, { client }) { + const key = `${scope}Endpoint`; + let { [key]: endpoint } = endpoints; + + if (!endpoint) { + endpoint = endpoints.enabledKeysEndpoint; + } + + return axios.get(endpoint, { params: { page } }).then(({ headers, data }) => { + const normalizedHeaders = normalizeHeaders(headers); + const pageInfo = { + ...parseIntPagination(normalizedHeaders), + __typename: 'LocalPageInfo', + }; + client.writeQuery({ + query: pageInfoQuery, + variables: { input: { page, scope } }, + data: { pageInfo }, + }); + return data?.keys?.map(mapDeployKey) || []; + }); + }, + }, + Mutation: { + currentPage(_, { page }, { client }) { + client.writeQuery({ + query: currentPageQuery, + data: { currentPage: page }, + }); + }, + currentScope(_, { scope }, { client }) { + client.writeQuery({ + query: currentPageQuery, + data: { currentPage: 1 }, + }); + client.writeQuery({ + query: currentScopeQuery, + data: { currentScope: scope }, + }); + }, + disableKey(_, _variables, { client }) { + const { + deployKeyToRemove: { id }, + } = client.readQuery({ + query: confirmRemoveKeyQuery, + }); + + const fragment = gql` + fragment DisablePath on LocalDeployKey { + disablePath + } + `; + + const { disablePath } = client.readFragment({ fragment, id: `LocalDeployKey:${id}` }); + + return axios.put(disablePath).then(({ data }) => { + client.cache.evict({ fieldName: 'deployKeyToRemove' }); + client.cache.evict({ id: `LocalDeployKey:${id}` }); + client.cache.gc(); + + return data; + }); + }, + enableKey(_, { id }, { client }) { + const fragment = gql` + fragment EnablePath on LocalDeployKey { + enablePath + } + `; + + const { enablePath } = client.readFragment({ fragment, id: `LocalDeployKey:${id}` }); + + return axios.put(enablePath).then(({ data }) => { + client.cache.evict({ id: `LocalDeployKey:${id}` }); + client.cache.gc(); + + return data; + }); + }, + confirmDisable(_, { id }, { client }) { + client.writeQuery({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: id ? { id, __type: 'LocalDeployKey' } : null }, + }); + }, + }, +}); diff --git a/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql b/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql new file mode 100644 index 00000000000..a08dda3da92 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql @@ -0,0 +1,45 @@ +#import "~/graphql_shared/client/page_info.typedefs.graphql" + +enum DeployKeysScope { + enabledKeys + availableProjectKeys + availablePublicKeys +} + +enum LocalDeployKeyActions { + enable + disable +} + +type LocalProject { + id: ID! + fullPath: String + fullName: String +} + +type LocalDeployKeysProject { + canPush: Boolean + projects: [LocalProject] +} + +type LocalDeployKey { + id: ID! + title: String + fingerprintSha256: String + fingerprint: String + editPath: String + isEnabled: Boolean + destroyedWhenOrphaned: Boolean + almostOrphaned: Boolean + expiresAt: String + createdAt: String + deployKeysProjects: [LocalDeployKeysProject] +} + +extend type LocalPageInfoInput { + scope: DeployKeysScope +} + +extend type Project { + deployKeys: [LocalDeployKey] +} diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index f21086185fb..d0885fb8687 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -266,7 +266,6 @@ export default { class="gl-form-input-xl" name="deploy_token_expires_at" :value="formattedExpiryDate" - data-qa-selector="deploy_token_expires_at_field" /> </gl-form-group> <gl-form-group @@ -298,7 +297,6 @@ export default { :key="scope.id" v-model="scope.value" :name="scope.id" - :data-qa-selector="`${scope.id}_checkbox`" > {{ scope.scopeName }} <template #help>{{ scope.helpText }}</template> diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 9b5b4cef1b9..26500c37acf 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -844,7 +844,7 @@ export default class Notes { const selector = this.getEditFormSelector($target); const $editForm = $(selector); - $editForm.insertBefore('.diffs'); + $editForm.insertBefore('.js-snippets-note-edit-form-holder'); $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); } diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 54c276c36b1..00fd9f43a4f 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -115,6 +115,11 @@ export default { required: false, default: false, }, + codequalityReportAvailable: { + type: Boolean, + required: false, + default: false, + }, endpointCodequality: { type: String, required: false, @@ -147,6 +152,7 @@ export default { subscribedToVirtualScrollingEvents: false, autoScrolled: false, activeProject: undefined, + hasScannerError: false, }; }, apollo: { @@ -157,26 +163,31 @@ export default { return { fullPath: this.projectPath, iid: this.iid }; }, skip() { - const codeQualityBoolean = Boolean(this.endpointCodequality); + if (this.hasScannerError) { + return true; + } - return !this.sastReportsInInlineDiff || (!codeQualityBoolean && !this.sastReportAvailable); + return ( + !this.sastReportsInInlineDiff || + (!this.codequalityReportAvailable && !this.sastReportAvailable) + ); }, update(data) { - const codeQualityBoolean = Boolean(this.endpointCodequality); const { codequalityReportsComparer, sastReport } = data?.project?.mergeRequest || {}; this.activeProject = data?.project?.mergeRequest?.project; if ( (sastReport?.status === FINDINGS_STATUS_PARSED || !this.sastReportAvailable) && - (!codeQualityBoolean || codequalityReportsComparer.status === FINDINGS_STATUS_PARSED) + (!this.codequalityReportAvailable || + codequalityReportsComparer.status === FINDINGS_STATUS_PARSED) ) { - this.getMRCodequalityAndSecurityReportStopPolling( - this.$apollo.queries.getMRCodequalityAndSecurityReports, - ); + this.$apollo.queries.getMRCodequalityAndSecurityReports.stopPolling(); } if (sastReport?.status === FINDINGS_STATUS_ERROR && this.sastReportAvailable) { this.fetchScannerFindingsError(); + + this.$apollo.queries.getMRCodequalityAndSecurityReports.stopPolling(); } if (codequalityReportsComparer?.report?.newErrors) { @@ -192,6 +203,7 @@ export default { }, error() { this.fetchScannerFindingsError(); + this.$apollo.queries.getMRCodequalityAndSecurityReports.stopPolling(); }, }, }, @@ -432,8 +444,9 @@ export default { this.setDrawer({}); }, fetchScannerFindingsError() { + this.hasScannerError = true; createAlert({ - message: __('Something went wrong fetching the Scanner Findings. Please try again.'), + message: __('Something went wrong fetching the scanner findings. Please try again.'), }); }, subscribeToEvents() { @@ -445,9 +458,6 @@ export default { diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData); diffsEventHub.$on(EVT_DISCUSSIONS_ASSIGNED, this.handleHash); }, - getMRCodequalityAndSecurityReportStopPolling(query) { - query.stopPolling(); - }, unsubscribeFromEvents() { diffsEventHub.$off(EVT_DISCUSSIONS_ASSIGNED, this.handleHash); diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData); diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 8c1cab20ece..82b721da493 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -9,7 +9,7 @@ import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; import { createAlert } from '~/alert'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElement, isElementStuck } from '~/lib/utils/common_utils'; import { sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; @@ -170,6 +170,11 @@ export default { showWarning() { return this.isCollapsed && this.automaticallyCollapsed && !this.viewDiffsFileByFile; }, + expandableWarning() { + return this.file.viewer?.generated + ? this.$options.i18n.autoCollapsedGenerated + : this.$options.i18n.autoCollapsed; + }, showContent() { return !this.isCollapsed && !this.isFileTooLarge; }, @@ -295,8 +300,13 @@ export default { collapsed: collapsingNow, }); - if (collapsingNow && viaUserInteraction && contentElement) { - scrollToElement(contentElement, { duration: 1 }); + if ( + collapsingNow && + viaUserInteraction && + contentElement && + isElementStuck(this.$refs.header.$el) + ) { + scrollToElement(contentElement, { duration: 0 }); } if (!this.hasDiff && !collapsingNow) { @@ -381,6 +391,7 @@ export default { class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5" > <diff-file-header + ref="header" :can-current-user-fork="canCurrentUserFork" :diff-file="file" :collapsible="true" @@ -419,6 +430,7 @@ export default { <div :id="`diff-content-${file.file_hash}`" :class="hasBodyClasses.contentByHash" + class="diff-content" data-testid="content-area" > <gl-alert @@ -523,7 +535,7 @@ export default { class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" > <p class="gl-mb-5"> - {{ $options.i18n.autoCollapsed }} + {{ expandableWarning }} </p> <gl-button data-testid="expand-button" @click.prevent="handleToggle"> {{ $options.i18n.expand }} diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 20f82500a02..e45fd508a5b 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -211,19 +211,6 @@ export default { return this.getNoteableData.current_user.can_create_note; }, }, - watch: { - 'idState.moreActionsShown': { - handler(val) { - const el = this.$el.closest('.vue-recycle-scroller__item-view'); - - if (el) { - // We can't add a style with Vue because of the way the virtual - // scroller library renders the diff files - el.style.zIndex = val ? '1' : null; - } - }, - }, - }, methods: { ...mapActions('diffs', [ 'toggleFileDiscussions', diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue deleted file mode 100644 index 6cb1ed4cbcf..00000000000 --- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui'; - -export default { - components: { - GlAlert, - GlButton, - }, - directives: { - GlModalDirective, - }, - props: { - mergeable: { - type: Boolean, - required: true, - }, - resolutionPath: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div> - <gl-alert - :dismissible="false" - :title="__('There are merge conflicts')" - variant="warning" - class="gl-mb-5" - > - <p class="gl-mb-2"> - {{ __('The comparison view may be inaccurate due to merge conflicts.') }} - </p> - <p class="gl-mb-0"> - {{ - __( - 'Resolve these conflicts, or ask someone with write access to this repository to resolve them locally.', - ) - }} - </p> - <template #actions> - <gl-button - v-if="resolutionPath" - :href="resolutionPath" - variant="confirm" - class="gl-mr-3 gl-alert-action" - > - {{ __('Resolve conflicts') }} - </gl-button> - <gl-button - v-if="mergeable" - v-gl-modal-directive="'modal-merge-info'" - class="gl-alert-action" - > - {{ __('Resolve locally') }} - </gl-button> - </template> - </gl-alert> - </div> -</template> diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue index 2c1a8305935..854f6910fa1 100644 --- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -1,9 +1,10 @@ <script> -import { GlBadge, GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; +import { GlBadge, GlDrawer, GlLink, GlButton, GlIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getSeverity } from '~/ci/reports/utils'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { SAST_FINDING_DISMISSED } from '../../constants'; import DrawerItem from './findings_drawer_item.vue'; export const i18n = { @@ -26,7 +27,7 @@ export const codeQuality = 'codeQuality'; export default { i18n, codeQuality, - components: { GlBadge, GlDrawer, GlIcon, GlLink, DrawerItem }, + components: { GlBadge, GlDrawer, GlLink, GlButton, GlIcon, DrawerItem }, props: { drawer: { type: Object, @@ -38,19 +39,50 @@ export default { default: () => {}, }, }, + data() { + return { + activeIndex: 0, + }; + }, computed: { getDrawerHeaderHeight() { return getContentWrapperHeight(); }, isCodeQuality() { - return this.drawer.scale === this.$options.codeQuality; + return this.activeElement.scale === this.$options.codeQuality; + }, + activeElement() { + return this.drawer.findings[this.activeIndex]; + }, + findingsStatus() { + return this.activeElement.state === SAST_FINDING_DISMISSED ? 'muted' : 'warning'; }, }, DRAWER_Z_INDEX, + watch: { + drawer(newVal) { + this.activeIndex = newVal.index; + }, + }, methods: { getSeverity, + prev() { + if (this.activeIndex === 0) { + this.activeIndex = this.drawer.findings.length - 1; + } else { + this.activeIndex -= 1; + } + }, + next() { + if (this.activeIndex === this.drawer.findings.length - 1) { + this.activeIndex = 0; + } else { + this.activeIndex += 1; + } + }, + concatIdentifierName(name, index) { - return name + (index !== this.drawer.identifiers.length - 1 ? ', ' : ''); + return name + (index !== this.activeElement.identifiers.length - 1 ? ', ' : ''); }, }, }; @@ -64,36 +96,51 @@ export default { @close="$emit('close')" > <template #title> - <h2 class="drawer-heading gl-font-base gl-mt-0 gl-mb-0"> + <h2 class="drawer-heading gl-font-base gl-mt-0 gl-mb-0 gl-w-28"> <gl-icon :size="12" - :name="getSeverity(drawer).name" - :class="getSeverity(drawer).class" + :name="getSeverity(activeElement).name" + :class="getSeverity(activeElement).class" class="inline-findings-severity-icon gl-vertical-align-baseline!" /> - <span class="drawer-heading-severity">{{ drawer.severity }}</span> + <span class="drawer-heading-severity">{{ activeElement.severity }}</span> {{ isCodeQuality ? $options.i18n.codeQualityFinding : $options.i18n.sastFinding }} </h2> + <div v-if="drawer.findings.length > 1"> + <gl-button data-testid="findings-drawer-prev-button" class="gl-p-1!" @click="prev"> + <gl-icon :size="24" name="chevron-left" /> + </gl-button> + <gl-button class="gl-p-1!" @click="next"> + <gl-icon data-testid="findings-drawer-next-button" :size="24" name="chevron-right" /> + </gl-button> + </div> </template> <template #default> <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"> - <drawer-item v-if="drawer.title" :description="$options.i18n.name" :value="drawer.title" /> + <drawer-item + v-if="activeElement.title" + :description="$options.i18n.name" + :value="activeElement.title" + data-testid="findings-drawer-title" + /> - <drawer-item v-if="drawer.state" :description="$options.i18n.status"> + <drawer-item v-if="activeElement.state" :description="$options.i18n.status"> <template #value> - <gl-badge variant="warning" class="text-capitalize">{{ drawer.state }}</gl-badge> + <gl-badge :variant="findingsStatus" class="text-capitalize">{{ + activeElement.state + }}</gl-badge> </template> </drawer-item> <drawer-item - v-if="drawer.description" + v-if="activeElement.description" :description="$options.i18n.description" - :value="drawer.description" + :value="activeElement.description" /> <drawer-item - v-if="project && drawer.scale !== $options.codeQuality" + v-if="project && activeElement.scale !== $options.codeQuality" :description="$options.i18n.project" > <template #value> @@ -101,23 +148,31 @@ export default { </template> </drawer-item> - <drawer-item v-if="drawer.location || drawer.webUrl" :description="$options.i18n.file"> + <drawer-item + v-if="activeElement.location || activeElement.webUrl" + :description="$options.i18n.file" + > <template #value> - <span v-if="drawer.webUrl && drawer.filePath && drawer.line"> - <gl-link :href="drawer.webUrl">{{ drawer.filePath }}:{{ drawer.line }}</gl-link> + <span v-if="activeElement.webUrl && activeElement.filePath && activeElement.line"> + <gl-link :href="activeElement.webUrl" + >{{ activeElement.filePath }}:{{ activeElement.line }}</gl-link + > </span> - <span v-else-if="drawer.location"> - {{ drawer.location.file }}:{{ drawer.location.startLine }} + <span v-else-if="activeElement.location"> + {{ activeElement.location.file }}:{{ activeElement.location.startLine }} </span> </template> </drawer-item> <drawer-item - v-if="drawer.identifiers && drawer.identifiers.length" + v-if="activeElement.identifiers && activeElement.identifiers.length" :description="$options.i18n.identifiers" > <template #value> - <span v-for="(identifier, index) in drawer.identifiers" :key="identifier.externalId"> + <span + v-for="(identifier, index) in activeElement.identifiers" + :key="identifier.externalId" + > <gl-link v-if="identifier.url" :href="identifier.url"> {{ concatIdentifierName(identifier.name, index) }} </gl-link> @@ -129,15 +184,15 @@ export default { </drawer-item> <drawer-item - v-if="drawer.scale" + v-if="activeElement.scale" :description="$options.i18n.tool" :value="isCodeQuality ? $options.i18n.codeQuality : $options.i18n.sast" /> <drawer-item - v-if="drawer.engineName" + v-if="activeElement.engineName" :description="$options.i18n.engine" - :value="drawer.engineName" + :value="activeElement.engineName" /> </ul> </template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index e48eb10753c..351df1d1996 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -118,3 +118,6 @@ export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files'; // UI export const ZERO_CHANGES_ALT_DISPLAY = '-'; + +// SAST Findings +export const SAST_FINDING_DISMISSED = 'dismissed'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 15e3893d22a..651cae11c47 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -22,6 +22,9 @@ export const DIFF_FILE = { fork: __('Fork'), cancel: __('Cancel'), autoCollapsed: __('Files with large changes are collapsed by default.'), + autoCollapsedGenerated: __( + 'Generated files are collapsed by default. This behavior can be overriden via .gitattributes file if required.', + ), expand: __('Expand file'), }; export const START_THREAD = __('Start another thread'); diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 034dd4cf6d2..15e4225f062 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -36,7 +36,8 @@ export default function initDiffsApp(store = notesStore) { iid: dataset.iid || '', endpointCoverage: dataset.endpointCoverage || '', endpointCodequality: dataset.endpointCodequality || '', - sastReportAvailable: dataset.endpointSast, + codequalityReportAvailable: parseBoolean(dataset.codequalityReportAvailable), + sastReportAvailable: parseBoolean(dataset.sastReportAvailable), helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, @@ -86,6 +87,7 @@ export default function initDiffsApp(store = notesStore) { iid: this.iid, endpointCoverage: this.endpointCoverage, endpointCodequality: this.endpointCodequality, + codequalityReportAvailable: this.codequalityReportAvailable, sastReportAvailable: this.sastReportAvailable, currentUser: this.currentUser, helpPagePath: this.helpPagePath, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index fcaf8e99b2d..1c0e20183e2 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -15,6 +15,7 @@ import notesEventHub from '~/notes/event_hub'; import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; import { sortTree } from '~/ide/stores/utils'; import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; +import { isCollapsed } from '~/diffs/utils/diff_file'; import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, @@ -73,6 +74,7 @@ import { prepareLineForRenamedFile, parseUrlHashAsFileHash, isUrlHashNoteLink, + findDiffFile, } from './utils'; export const setBaseConfig = ({ commit }, options) => { @@ -658,18 +660,18 @@ export const goToFile = ({ state, commit, dispatch, getters }, { path }) => { const { fileHash } = state.treeEntries[path]; commit(types.SET_CURRENT_DIFF_FILE, fileHash); - document.location.hash = fileHash; + + const newUrl = new URL(window.location); + newUrl.hash = fileHash; + historyPushState(newUrl, { skipScrolling: true }); + scrollToElement('.diff-files-holder', { duration: 0 }); if (!getters.isTreePathLoaded(path)) { - dispatch('fetchFileByFile') - .then(() => { - dispatch('scrollToFile', { path }); - }) - .catch(() => { - createAlert({ - message: LOAD_SINGLE_DIFF_FAILED, - }); + dispatch('fetchFileByFile').catch(() => { + createAlert({ + message: LOAD_SINGLE_DIFF_FAILED, }); + }); } } }; @@ -1041,8 +1043,15 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) { export const disableVirtualScroller = ({ commit }) => commit(types.DISABLE_VIRTUAL_SCROLLING); -export const toggleFileCommentForm = ({ commit }, filePath) => - commit(types.TOGGLE_FILE_COMMENT_FORM, filePath); +export const toggleFileCommentForm = ({ state, commit }, filePath) => { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + if (isCollapsed(file)) { + commit(types.SET_FILE_COMMENT_FORM, { filePath, expanded: true }); + } else { + commit(types.TOGGLE_FILE_COMMENT_FORM, filePath); + } + commit(types.SET_FILE_COLLAPSED, { filePath, collapsed: false }); +}; export const addDraftToFile = ({ commit }, { filePath, draft }) => commit(types.ADD_DRAFT_TO_FILE, { filePath, draft }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index c2177bacbcc..b155804c70c 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -53,4 +53,5 @@ export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; export const DISABLE_VIRTUAL_SCROLLING = 'DISABLE_VIRTUAL_SCROLLING'; export const TOGGLE_FILE_COMMENT_FORM = 'TOGGLE_FILE_COMMENT_FORM'; +export const SET_FILE_COMMENT_FORM = 'SET_FILE_COMMENT_FORM'; export const ADD_DRAFT_TO_FILE = 'ADD_DRAFT_TO_FILE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 08c195469e3..bc5ed3c40df 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -394,6 +394,11 @@ export default { file.hasCommentForm = !file.hasCommentForm; }, + [types.SET_FILE_COMMENT_FORM](state, { filePath, expanded }) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.hasCommentForm = expanded; + }, [types.ADD_DRAFT_TO_FILE](state, { filePath, draft }) { const file = findDiffFile(state.diffFiles, filePath, 'file_path'); diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js index 37bfde0ed9f..63cc90b5db2 100644 --- a/app/assets/javascripts/drawio/drawio_editor.js +++ b/app/assets/javascripts/drawio/drawio_editor.js @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { isNil } from 'lodash'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { base64DecodeUnicode } from '~/lib/utils/text_utility'; @@ -181,7 +181,7 @@ function configureDrawIOEditor(drawIOEditorState) { } function onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt) { - if (_.isNil(evt) || evt.source !== drawIOEditorState.iframe.contentWindow) { + if (isNil(evt) || evt.source !== drawIOEditorState.iframe.contentWindow) { return; } diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f90d29c84b8..9ee4f7cf4aa 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -63,7 +63,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const dropzone = $formDropzone.dropzone({ url: uploadsPath, dictDefaultMessage: '', - clickable: true, + clickable: form.get(0).querySelector('[data-button-type="attach-file"]') ?? true, paramName: 'file', maxFilesize: maxFileSize, uploadMultiple: false, diff --git a/app/assets/javascripts/editor/extensions/source_editor_security_policy_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_security_policy_schema_ext.js new file mode 100644 index 00000000000..e3b9d273efd --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_security_policy_schema_ext.js @@ -0,0 +1,84 @@ +import { registerSchema } from '~/ide/utils'; +import axios from '~/lib/utils/axios_utils'; +import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; + +export const getSecurityPolicyListUrl = ({ namespacePath, namespaceType = 'group' }) => { + const isGroup = namespaceType === 'group'; + return joinPaths( + getBaseURL(), + isGroup ? 'groups' : '', + namespacePath, + '-', + 'security', + 'policies', + ); +}; + +export const getSecurityPolicySchemaUrl = ({ namespacePath, namespaceType }) => { + const policyListUrl = getSecurityPolicyListUrl({ namespacePath, namespaceType }); + return joinPaths(policyListUrl, 'schema'); +}; + +export const getSinglePolicySchema = async ({ namespacePath, namespaceType, policyType }) => { + try { + const { data: schemaForMultiplePolicies } = await axios.get( + getSecurityPolicySchemaUrl({ namespacePath, namespaceType }), + ); + return { + $id: schemaForMultiplePolicies.$id, + title: schemaForMultiplePolicies.title, + description: schemaForMultiplePolicies.description, + type: schemaForMultiplePolicies.type, + properties: { + type: { + type: 'string', + // eslint-disable-next-line @gitlab/require-i18n-strings + description: 'Specifies the type of policy to be enforced.', + enum: policyType, + }, + ...schemaForMultiplePolicies.properties[policyType].items.properties, + }, + }; + } catch { + return {}; + } +}; + +export class SecurityPolicySchemaExtension { + static get extensionName() { + return 'SecurityPolicySchema'; + } + // eslint-disable-next-line class-methods-use-this + provides() { + return { + registerSecurityPolicyEditorSchema: async (instance, options) => { + const { namespacePath, namespaceType, policyType } = options; + const singlePolicySchema = await getSinglePolicySchema({ + namespacePath, + namespaceType, + policyType, + }); + const modelFileName = instance.getModel().uri.path.split('/').pop(); + + registerSchema({ + uri: getSecurityPolicySchemaUrl({ namespacePath, namespaceType }), + schema: singlePolicySchema, + fileMatch: [modelFileName], + }); + }, + + registerSecurityPolicySchema: (instance, projectPath) => { + const uri = getSecurityPolicySchemaUrl({ + namespacePath: projectPath, + namespaceType: 'project', + }); + const modelFileName = instance.getModel().uri.path.split('/').pop(); + + registerSchema({ + uri, + fileMatch: [modelFileName], + }); + }, + }; + } +} diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 308a68544bc..1fb68394912 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -58,6 +58,9 @@ "interruptible": { "$ref": "#/definitions/interruptible" }, + "id_tokens": { + "$ref": "#/definitions/id_tokens" + }, "retry": { "$ref": "#/definitions/retry" }, @@ -114,6 +117,9 @@ "name": { "$ref": "#/definitions/workflowName" }, + "auto_cancel": { + "$ref": "#/definitions/workflowAutoCancel" + }, "rules": { "type": "array", "items": { @@ -327,6 +333,10 @@ "load_performance": { "$ref": "#/definitions/string_file_list", "markdownDescription": "Path to file or list of files with load performance testing report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsload_performance)." + }, + "repository_xray": { + "$ref": "#/definitions/string_file_list", + "description": "Path to file or list of files with Repository X-Ray report(s)." } } } @@ -509,6 +519,18 @@ "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.", "minItems": 1 }, + "docker": { + "type": "object", + "markdownDescription": "Options to pass to Runners Docker Executor. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#imagedocker)", + "additionalProperties": false, + "properties": { + "platform": { + "type": "string", + "minLength": 1, + "description": "Image architecture to pull." + } + } + }, "pull_policy": { "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#imagepull_policy).", "default": "always", @@ -540,13 +562,6 @@ "required": [ "name" ] - }, - { - "type": "array", - "minLength": 1, - "items": { - "type": "string" - } } ], "markdownDescription": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#image)." @@ -579,8 +594,20 @@ "type": "string" } }, + "docker": { + "type": "object", + "markdownDescription": "Options to pass to Runners Docker Executor. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#servicesdocker)", + "additionalProperties": false, + "properties": { + "platform": { + "type": "string", + "minLength": 1, + "description": "Image architecture to pull." + } + } + }, "pull_policy": { - "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#servicepull_policy).", + "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#servicespull_policy).", "default": "always", "oneOf": [ { @@ -915,6 +942,21 @@ "minLength": 1, "maxLength": 255 }, + "workflowAutoCancel": { + "type": "object", + "markdownDescription": "Define the rules for when pipeline should be automatically cancelled.", + "properties": { + "on_job_failure": { + "markdownDescription": "Define which jobs to stop after a job fails.", + "default": "none", + "type": "string", + "enum": [ + "none", + "all" + ] + } + } + }, "globalVariables": { "markdownDescription": "Defines default variables for all jobs. Job level property overrides global variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).", "type": "object", @@ -1480,7 +1522,7 @@ }, { "const": "data_integrity_failure", - "description": "Retry if there is a structural integrity problem detected." + "description": "Retry if there is an unknown job problem." } ] }, diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue index d8607cbc60b..80850475b96 100644 --- a/app/assets/javascripts/emoji/components/category.vue +++ b/app/assets/javascripts/emoji/components/category.vue @@ -52,7 +52,7 @@ export default { :key="index" :emojis="emojiGroup" :render-group="renderGroup" - :click-emoji="(emoji) => onClick(emoji)" + @emoji-click="onClick" /> </template> <p v-else> diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue index bbac6866636..bb0c3b0a694 100644 --- a/app/assets/javascripts/emoji/components/emoji_group.vue +++ b/app/assets/javascripts/emoji/components/emoji_group.vue @@ -1,10 +1,10 @@ <script> -import { compatFunctionalMixin } from '~/lib/utils/vue3compat/compat_functional_mixin'; +import { GlButton } from '@gitlab/ui'; export default { - // Temporary mixin for migration from Vue.js 2 to @vue/compat - mixins: [compatFunctionalMixin], - + components: { + GlButton, + }, props: { emojis: { type: Array, @@ -14,28 +14,33 @@ export default { type: Boolean, required: true, }, - clickEmoji: { - type: Function, - required: true, + }, + methods: { + clickEmoji(emoji) { + this.$emit('emoji-click', emoji); }, }, }; </script> -<!-- eslint-disable-next-line vue/no-deprecated-functional-template --> -<template functional> +<template> <div class="gl-display-flex gl-flex-wrap gl-mb-2"> - <template v-if="props.renderGroup"> - <button - v-for="emoji in props.emojis" + <template v-if="renderGroup"> + <gl-button + v-for="emoji in emojis" :key="emoji" type="button" - class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji" + category="tertiary" + class="emoji-picker-emoji" + :aria-label="emoji" data-testid="emoji-button" - @click="props.clickEmoji(emoji)" + button-text-classes="gl-display-none!" + @click="clickEmoji(emoji)" > - <gl-emoji :data-name="emoji" /> - </button> + <template #emoji> + <gl-emoji :data-name="emoji" class="gl-mr-0!" /> + </template> + </gl-button> </template> </div> </template> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 462420ba4e5..8b1784ae551 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -1,6 +1,6 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> -import { GlIcon, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlButton, GlIcon, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { findLastIndex } from 'lodash'; import VirtualList from 'vue-virtual-scroll-list'; import { getEmojiCategoryMap, state } from '~/emoji'; @@ -11,6 +11,7 @@ import { addToFrequentlyUsed, getEmojiCategories, hasFrequentlyUsedEmojis } from export default { components: { + GlButton, GlIcon, GlDropdown, GlDropdownItem, @@ -94,6 +95,11 @@ export default { this.currentCategory = findLastIndex(Object.values(categories), ({ top }) => offset >= top); }, + onHide() { + this.currentCategory = 0; + this.searchValue = ''; + this.$emit('hidden'); + }, }, }; </script> @@ -111,7 +117,7 @@ export default { :right="right" lazy @shown="$emit('shown')" - @hidden="$emit('hidden')" + @hidden="onHide" > <template #button-content> <slot name="button-content"> @@ -139,19 +145,16 @@ export default { v-show="!searchValue" class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1" > - <button + <gl-button v-for="(category, index) in categoryNames" :key="category.name" - :class="{ - 'gl-text-body! emoji-picker-category-active': index === currentCategory, - }" - type="button" - class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-grow-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" + category="tertiary" + :class="{ 'emoji-picker-category-active': index === currentCategory }" + class="gl-px-3! gl-rounded-0! gl-border-b-2! gl-border-b-solid! gl-flex-grow-1 emoji-picker-category-tab" + :icon="category.icon" :aria-label="category.name" @click="scrollToCategory(category.name)" - > - <gl-icon :name="category.icon" /> - </button> + /> </div> <emoji-list :search-value="searchValue"> <template #default="{ filteredCategories }"> diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index f98369c2fde..c4279e9d8e7 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -77,6 +77,8 @@ async function loadEmojiWithNames() { } export async function loadCustomEmojiWithNames() { + const emojiData = { emojis: {}, names: [] }; + if (document.body?.dataset?.groupFullPath && window.gon?.features?.customEmoji) { const client = createApolloClient(); const { data } = await client.query({ @@ -86,26 +88,21 @@ export async function loadCustomEmojiWithNames() { }, }); - return data?.group?.customEmoji?.nodes?.reduce( - (acc, e) => { - // Map the custom emoji into the format of the normal emojis - acc.emojis[e.name] = { - c: 'custom', - d: e.name, - e: undefined, - name: e.name, - src: e.url, - u: 'custom', - }; - acc.names.push(e.name); - - return acc; - }, - { emojis: {}, names: [] }, - ); + data?.group?.customEmoji?.nodes?.forEach((e) => { + // Map the custom emoji into the format of the normal emojis + emojiData.emojis[e.name] = { + c: 'custom', + d: e.name, + e: undefined, + name: e.name, + src: e.url, + u: 'custom', + }; + emojiData.names.push(e.name); + }); } - return { emojis: {}, names: [] }; + return emojiData; } async function prepareEmojiMap() { diff --git a/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql index 951027ec274..ba3911ab091 100644 --- a/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql +++ b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql @@ -1,7 +1,7 @@ query getCustomEmoji($groupPath: ID!) { group(fullPath: $groupPath) { id - customEmoji { + customEmoji(includeAncestorGroups: true) { nodes { id name diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js index ddb34e59144..1a4b57f0beb 100644 --- a/app/assets/javascripts/ensure_data.js +++ b/app/assets/javascripts/ensure_data.js @@ -1,4 +1,4 @@ -import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg?raw'; +import emptySvg from '@gitlab/svgs/dist/illustrations/empty-state/empty-dashboard-md.svg?raw'; import { GlEmptyState } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index c6cf6b7e24b..a4c2d4fcc51 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -7,7 +7,6 @@ import { GlCollapsibleListbox, GlLink, GlSprintf, - GlAlert, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { isAbsolute } from '~/lib/utils/url_utility'; @@ -19,9 +18,9 @@ import { import csrf from '~/lib/utils/csrf'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql'; import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql'; import EnvironmentFluxResourceSelector from './environment_flux_resource_selector.vue'; +import EnvironmentNamespaceSelector from './environment_namespace_selector.vue'; export default { components: { @@ -32,8 +31,8 @@ export default { GlCollapsibleListbox, GlLink, GlSprintf, - GlAlert, EnvironmentFluxResourceSelector, + EnvironmentNamespaceSelector, }, mixins: [glFeatureFlagsMixin()], inject: { @@ -72,8 +71,6 @@ export default { urlFeedback: __('The URL should start with http:// or https://'), agentLabel: s__('Environments|GitLab agent'), agentHelpText: s__('Environments|Select agent'), - namespaceLabel: s__('Environments|Kubernetes namespace (optional)'), - namespaceHelpText: s__('Environments|Select namespace'), save: __('Save'), cancel: __('Cancel'), reset: __('Reset'), @@ -93,35 +90,9 @@ export default { selectedAgentId: this.environment.clusterAgentId, agentSearchTerm: '', selectedNamespace: this.environment.kubernetesNamespace, - k8sNamespaces: [], - namespaceSearchTerm: '', kubernetesError: '', }; }, - apollo: { - k8sNamespaces: { - query: getNamespacesQuery, - skip() { - return !this.showNamespaceSelector; - }, - variables() { - return { - configuration: this.k8sAccessConfiguration, - }; - }, - update(data) { - return data?.k8sNamespaces || []; - }, - error(error) { - this.kubernetesError = error.message; - }, - result(result) { - if (!result?.error && !result.errors?.length) { - this.kubernetesError = null; - } - }, - }, - }, computed: { loadingNamespacesList() { return this.$apollo.queries.k8sNamespaces.loading; @@ -161,26 +132,9 @@ export default { item.text.toLowerCase().includes(lowerCasedSearchTerm), ); }, - namespacesList() { - return this.k8sNamespaces.map((item) => { - return { - value: item.metadata.name, - text: item.metadata.name, - }; - }); - }, - filteredNamespacesList() { - const lowerCasedSearchTerm = this.namespaceSearchTerm.toLowerCase(); - return this.namespacesList.filter((item) => - item.text.toLowerCase().includes(lowerCasedSearchTerm), - ); - }, showNamespaceSelector() { return Boolean(this.selectedAgentId); }, - namespaceDropdownToggleText() { - return this.selectedNamespace || this.$options.i18n.namespaceHelpText; - }, showFluxResourceSelector() { return Boolean(this.selectedNamespace && this.selectedAgentId); }, @@ -239,9 +193,6 @@ export default { fluxResourcePath: null, }); }, - onNamespaceSearch(search) { - this.namespaceSearchTerm = search; - }, }, }; </script> @@ -334,34 +285,14 @@ export default { /> </gl-form-group> - <gl-form-group + <environment-namespace-selector v-if="showNamespaceSelector" - :label="$options.i18n.namespaceLabel" - label-for="environment_namespace" - > - <gl-alert v-if="kubernetesError" variant="warning" :dismissible="false" class="gl-mb-5"> - {{ kubernetesError }} - </gl-alert> - <gl-collapsible-listbox - v-else - id="environment_namespace" - v-model="selectedNamespace" - class="gl-w-full" - data-testid="namespace-selector" - block - :items="filteredNamespacesList" - :loading="loadingNamespacesList" - :toggle-text="namespaceDropdownToggleText" - :header-text="$options.i18n.namespaceHelpText" - :reset-button-label="$options.i18n.reset" - :searchable="true" - @search="onNamespaceSearch" - @select=" - onChange({ ...environment, kubernetesNamespace: $event, fluxResourcePath: null }) - " - @reset="onChange({ ...environment, kubernetesNamespace: null })" - /> - </gl-form-group> + :namespace="selectedNamespace" + :configuration="k8sAccessConfiguration" + @change=" + onChange({ ...environment, kubernetesNamespace: $event, fluxResourcePath: null }) + " + /> <environment-flux-resource-selector v-if="showFluxResourceSelector" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 47edec8dcb0..18282bfd2ce 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -12,7 +12,7 @@ import { import { isEmpty } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -615,7 +615,7 @@ export default { :title="model.name" class="environment-name table-mobile-content" > - <a data-qa-selector="environment_link" :href="environmentPath"> + <a :href="environmentPath"> <span v-if="model.size === 1">{{ model.name }}</span> <span v-else>{{ model.name_without_type }}</span> </a> diff --git a/app/assets/javascripts/environments/components/environment_namespace_selector.vue b/app/assets/javascripts/environments/components/environment_namespace_selector.vue new file mode 100644 index 00000000000..101d70d36f3 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_namespace_selector.vue @@ -0,0 +1,136 @@ +<script> +import { GlFormGroup, GlCollapsibleListbox, GlAlert, GlButton, GlSprintf } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql'; + +export default { + components: { + GlFormGroup, + GlCollapsibleListbox, + GlAlert, + GlButton, + GlSprintf, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: false, + type: String, + default: '', + }, + }, + i18n: { + namespaceLabel: s__('Environments|Kubernetes namespace (optional)'), + namespaceHelpText: s__('Environments|Select namespace'), + selectButton: s__('Environments|Or select namespace: %{searchTerm}'), + reset: __('Reset'), + }, + data() { + return { + k8sNamespaces: [], + searchTerm: '', + kubernetesError: '', + }; + }, + apollo: { + k8sNamespaces: { + query: getNamespacesQuery, + + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sNamespaces?.map((item) => { + return { + value: item.metadata.name, + text: item.metadata.name, + }; + }) || [] + ); + }, + error(error) { + this.kubernetesError = error.message; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.kubernetesError = null; + } + }, + }, + }, + computed: { + loadingNamespacesList() { + return this.$apollo.queries.k8sNamespaces.loading; + }, + filteredNamespacesList() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.k8sNamespaces.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + namespaceDropdownToggleText() { + return this.namespace || this.$options.i18n.namespaceHelpText; + }, + shouldRenderSelectButton() { + const hasSearchedItem = this.k8sNamespaces.some( + (item) => item.text === this.searchTerm.toLowerCase(), + ); + return this.searchTerm && !hasSearchedItem; + }, + }, + methods: { + onChange(namespace) { + this.$emit('change', namespace); + }, + onNamespaceSearch(search) { + this.searchTerm = search; + }, + onSelect(namespace) { + this.onChange(namespace); + this.$refs.namespaceSelector.close(); + }, + }, +}; +</script> +<template> + <gl-form-group :label="$options.i18n.namespaceLabel" label-for="environment_namespace"> + <gl-alert v-if="kubernetesError" variant="warning" :dismissible="false" class="gl-mb-5"> + {{ kubernetesError }} + </gl-alert> + <gl-collapsible-listbox + id="environment_namespace" + ref="namespaceSelector" + :selected="namespace" + class="gl-w-full" + block + :items="filteredNamespacesList" + :loading="loadingNamespacesList" + :toggle-text="namespaceDropdownToggleText" + :header-text="$options.i18n.namespaceHelpText" + :reset-button-label="$options.i18n.reset" + :searchable="true" + @search="onNamespaceSearch" + @select="onChange" + @reset="onChange" + > + <template v-if="shouldRenderSelectButton" #footer> + <gl-button + category="tertiary" + class="gl-justify-content-start! gl-border-t-1! gl-border-t-solid gl-border-t-gray-200 gl-pl-7! gl-rounded-top-left-none! gl-rounded-top-right-none!" + :class="{ 'gl-mt-3': !filteredNamespacesList.length }" + @click="onSelect(searchTerm)" + > + <gl-sprintf :message="$options.i18n.selectButton"> + <template #searchTerm>{{ searchTerm }}</template> + </gl-sprintf> + </gl-button> + </template> + </gl-collapsible-listbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 4e8b75536a4..8de0e0266c5 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -4,9 +4,9 @@ import { debounce } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; 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 environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; @@ -56,6 +56,9 @@ export default { }, pageInfo: { query: pageInfoQuery, + variables() { + return { page: this.page }; + }, }, environmentToDelete: { query: environmentToDeleteQuery, diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index 36cce29d624..d5a7b43c953 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -43,7 +43,7 @@ export default { return { isVisible: false, error: '', - hasFailedState: false, + failedState: {}, podsLoading: false, workloadTypesLoading: false, }; @@ -78,6 +78,9 @@ export default { return this.hasFailedState ? 'error' : 'success'; }, + hasFailedState() { + return Object.values(this.failedState).some((item) => item); + }, }, methods: { toggleCollapse() { @@ -86,6 +89,12 @@ export default { onClusterError(message) { this.error = message; }, + onUpdateFailedState(event) { + this.failedState = { + ...this.failedState, + ...event, + }; + }, }, i18n: { collapse: __('Collapse'), @@ -126,14 +135,14 @@ export default { class="gl-mb-5" @cluster-error="onClusterError" @loading="podsLoading = $event" - @failed="hasFailedState = true" /> + @update-failed-state="onUpdateFailedState" /> <kubernetes-tabs :configuration="k8sAccessConfiguration" :namespace="namespace" class="gl-mb-5" @cluster-error="onClusterError" @loading="workloadTypesLoading = $event" - @failed="hasFailedState = true" + @update-failed-state="onUpdateFailedState" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue index 3f040f1f40a..cd21c4d65dc 100644 --- a/app/assets/javascripts/environments/components/kubernetes_pods.vue +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -1,14 +1,20 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { s__ } from '~/locale'; +import { + STATUS_RUNNING, + STATUS_PENDING, + STATUS_SUCCEEDED, + STATUS_FAILED, + STATUS_LABELS, +} from '~/kubernetes_dashboard/constants'; +import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql'; -import { PHASE_RUNNING, PHASE_PENDING, PHASE_SUCCEEDED, PHASE_FAILED } from '../constants'; export default { components: { GlLoadingIcon, - GlSingleStat, + WorkloadStats, }, apollo: { k8sPods: { @@ -52,20 +58,20 @@ export default { return [ { - value: this.countPodsByPhase(PHASE_RUNNING), - title: this.$options.i18n.runningPods, + value: this.countPodsByPhase(STATUS_RUNNING), + title: STATUS_LABELS[STATUS_RUNNING], }, { - value: this.countPodsByPhase(PHASE_PENDING), - title: this.$options.i18n.pendingPods, + value: this.countPodsByPhase(STATUS_PENDING), + title: STATUS_LABELS[STATUS_PENDING], }, { - value: this.countPodsByPhase(PHASE_SUCCEEDED), - title: this.$options.i18n.succeededPods, + value: this.countPodsByPhase(STATUS_SUCCEEDED), + title: STATUS_LABELS[STATUS_SUCCEEDED], }, { - value: this.countPodsByPhase(PHASE_FAILED), - title: this.$options.i18n.failedPods, + value: this.countPodsByPhase(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], }, ]; }, @@ -76,18 +82,15 @@ export default { methods: { countPodsByPhase(phase) { const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); - if (phase === PHASE_FAILED && filteredPods.length) { - this.$emit('failed'); - } + + const hasFailedState = Boolean(phase === STATUS_FAILED && filteredPods.length); + this.$emit('update-failed-state', { pods: hasFailedState }); + return filteredPods.length; }, }, i18n: { podsTitle: s__('Environment|Pods'), - runningPods: s__('Environment|Running'), - pendingPods: s__('Environment|Pending'), - succeededPods: s__('Environment|Succeeded'), - failedPods: s__('Environment|Failed'), }, }; </script> @@ -96,18 +99,6 @@ export default { <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p> <gl-loading-icon v-if="loading" /> - - <div - v-else-if="podStats && !error" - class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3" - > - <gl-single-stat - v-for="(stat, index) in podStats" - :key="index" - class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" - :value="stat.value" - :title="stat.title" - /> - </div> + <workload-stats v-else-if="podStats && !error" :stats="podStats" /> </div> </template> diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index 8ecb61711ce..20ed67f6bd9 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -153,7 +153,7 @@ export default { }, }, i18n: { - healthLabel: s__('Environment|Environment health'), + healthLabel: s__('Environment|Environment status'), syncStatusLabel: s__('Environment|Sync status'), }, badgeContainerClasses: 'gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-mr-3 gl-mb-2', diff --git a/app/assets/javascripts/environments/components/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue index e2fbc6fd2e7..2912fd8f4d8 100644 --- a/app/assets/javascripts/environments/components/kubernetes_summary.vue +++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue @@ -140,9 +140,7 @@ export default { return workloadType.items?.failed?.length > 0; }); - if (failed) { - this.$emit('failed'); - } + this.$emit('update-failed-state', { summary: failed }); }, }, i18n: { @@ -159,6 +157,7 @@ export default { completed: 'success', failed: 'danger', suspended: 'neutral', + pending: 'info', }, icons: { Active: { icon: 'status_success', class: 'gl-text-green-500' }, diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue index 7c699eec412..0d80b1fd797 100644 --- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue +++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue @@ -1,8 +1,9 @@ <script> import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { getAge } from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql'; -import { generateServicePortsString, getServiceAge } from '../helpers/k8s_integration_helper'; +import { generateServicePortsString } from '../helpers/k8s_integration_helper'; import { SERVICES_LIMIT_PER_PAGE } from '../constants'; import KubernetesSummary from './kubernetes_summary.vue'; @@ -62,7 +63,7 @@ export default { clusterIP: service?.spec?.clusterIP, externalIP: service?.spec?.externalIP, ports: generateServicePortsString(service?.spec?.ports), - age: getServiceAge(service?.metadata?.creationTimestamp), + age: getAge(service?.metadata?.creationTimestamp), }; }); }, @@ -139,7 +140,7 @@ export default { :namespace="namespace" :configuration="configuration" @loading="$emit('loading', $event)" - @failed="$emit('failed')" + @update-failed-state="$emit('update-failed-state', $event)" @cluster-error="$emit('cluster-error', $event)" /> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index e97720312b0..2fe9008c042 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -155,11 +155,6 @@ export const SYNC_STATUS_BADGES = { export const STATUS_TRUE = 'True'; export const STATUS_FALSE = 'False'; -export const PHASE_RUNNING = 'Running'; -export const PHASE_PENDING = 'Pending'; -export const PHASE_SUCCEEDED = 'Succeeded'; -export const PHASE_FAILED = 'Failed'; - const ERROR_UNAUTHORIZED = 'unauthorized'; const ERROR_FORBIDDEN = 'forbidden'; const ERROR_NOT_FOUND = 'not found'; @@ -167,7 +162,7 @@ const ERROR_OTHER = 'other'; export const CLUSTER_AGENT_ERROR_MESSAGES = { [ERROR_UNAUTHORIZED]: s__( - 'Environment|Unauthorized to access the cluster agent from this environment. Check your authentication and try again.', + "Environment|You don't have permission to view all the namespaces in the cluster. If a namespace is not shown, you can still enter its name to select it.", ), [ERROR_FORBIDDEN]: s__( 'Environment|Forbidden to access the cluster agent from this environment.', diff --git a/app/assets/javascripts/environments/folder/environments_folder_app.vue b/app/assets/javascripts/environments/folder/environments_folder_app.vue new file mode 100644 index 00000000000..720397c3089 --- /dev/null +++ b/app/assets/javascripts/environments/folder/environments_folder_app.vue @@ -0,0 +1,256 @@ +<script> +import { GlSkeletonLoader, GlTabs, GlTab, GlBadge, GlPagination } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import folderQuery from '../graphql/queries/folder.query.graphql'; +import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; +import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; +import environmentToChangeCanaryQuery from '../graphql/queries/environment_to_change_canary.query.graphql'; +import EnvironmentItem from '../components/new_environment_item.vue'; +import StopEnvironmentModal from '../components/stop_environment_modal.vue'; +import ConfirmRollbackModal from '../components/confirm_rollback_modal.vue'; +import DeleteEnvironmentModal from '../components/delete_environment_modal.vue'; +import CanaryUpdateModal from '../components/canary_update_modal.vue'; +import { ENVIRONMENTS_SCOPE } from '../constants'; + +export default { + components: { + GlPagination, + GlBadge, + GlTabs, + GlTab, + GlSkeletonLoader, + EnvironmentItem, + StopEnvironmentModal, + ConfirmRollbackModal, + DeleteEnvironmentModal, + CanaryUpdateModal, + }, + props: { + folderName: { + type: String, + required: true, + }, + folderPath: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + default: ENVIRONMENTS_SCOPE.ACTIVE, + }, + page: { + type: Number, + required: true, + }, + }, + data() { + return { + environmentToDelete: {}, + environmentToRollback: {}, + environmentToStop: {}, + environmentToChangeCanary: {}, + weight: 0, + lastRowCount: 3, + }; + }, + apollo: { + folder: { + query: folderQuery, + variables() { + return { + environment: this.environmentQueryData, + scope: this.scope, + search: '', + perPage: this.$options.perPage, + page: this.page, + }; + }, + pollInterval: 3000, + }, + environmentToDelete: { + query: environmentToDeleteQuery, + }, + environmentToRollback: { + query: environmentToRollbackQuery, + }, + environmentToStop: { + query: environmentToStopQuery, + }, + environmentToChangeCanary: { + query: environmentToChangeCanaryQuery, + }, + weight: { + query: environmentToChangeCanaryQuery, + }, + }, + computed: { + environmentQueryData() { + return { folderPath: this.folderPath }; + }, + environments() { + return this.folder?.environments; + }, + isLoading() { + return this.$apollo.queries.folder.loading; + }, + activeCount() { + return this.folder?.activeCount ?? '-'; + }, + stoppedCount() { + return this.folder?.stoppedCount ?? '-'; + }, + activeTab() { + return this.scope === ENVIRONMENTS_SCOPE.ACTIVE ? 0 : 1; + }, + totalItems() { + const environmentsCount = + this.scope === ENVIRONMENTS_SCOPE.ACTIVE + ? this.folder?.activeCount + : this.folder?.stoppedCount; + return Number(environmentsCount); + }, + totalPages() { + return Math.ceil(this.totalItems / this.$options.perPage); + }, + hasNextPage() { + return this.page !== this.totalPages; + }, + hasPreviousPage() { + return this.page > 1; + }, + pageNumber: { + get() { + return this.page; + }, + set(newPageNumber) { + if (newPageNumber !== this.page) { + const query = { ...this.$route.query, page: newPageNumber }; + this.$router.push({ query }); + } + }, + }, + }, + watch: { + environments(newEnvironments) { + if (newEnvironments?.length) { + this.lastRowCount = newEnvironments.length; + } + + // When we load a page, if there's next and/or previous pages existing, + // we'll load their data as well to improve percepted performance. + // The page data is cached by apollo client and is immediately accessible + // and won't trigger additional requests + if (this.hasNextPage) { + this.$apollo.query({ + query: folderQuery, + variables: { + environment: this.environmentQueryData, + scope: this.scope, + search: '', + perPage: this.$options.perPage, + page: this.page + 1, + }, + }); + } + + if (this.hasPreviousPage) { + this.$apollo.query({ + query: folderQuery, + variables: { + environment: this.environmentQueryData, + scope: this.scope, + search: '', + perPage: this.$options.perPage, + page: this.page - 1, + }, + }); + } + }, + }, + methods: { + setScope(scope) { + if (scope !== this.scope) { + this.$router.push({ query: { scope } }); + } + }, + }, + i18n: { + pageTitle: s__('Environments|Environments'), + active: __('Active'), + stopped: __('Stopped'), + }, + perPage: 20, + ENVIRONMENTS_SCOPE, +}; +</script> +<template> + <div> + <delete-environment-modal :environment="environmentToDelete" graphql /> + <stop-environment-modal :environment="environmentToStop" graphql /> + <confirm-rollback-modal :environment="environmentToRollback" graphql /> + <canary-update-modal :environment="environmentToChangeCanary" :weight="weight" /> + <h4 class="gl-font-weight-normal" data-testid="folder-name"> + {{ $options.i18n.pageTitle }} / + <b>{{ folderName }}</b> + </h4> + <gl-tabs :value="activeTab" query-param-name="scope"> + <gl-tab + :query-param-value="$options.ENVIRONMENTS_SCOPE.ACTIVE" + @click="setScope($options.ENVIRONMENTS_SCOPE.ACTIVE)" + > + <template #title> + <span>{{ $options.i18n.active }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ activeCount }} + </gl-badge> + </template> + </gl-tab> + <gl-tab + :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED" + @click="setScope($options.ENVIRONMENTS_SCOPE.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> + <div v-if="isLoading"> + <div + v-for="n in lastRowCount" + :key="`skeleton-box-${n}`" + class="gl-border-gray-100 gl-border-t-solid gl-border-1 gl-py-5 gl-md-pl-7" + > + <gl-skeleton-loader :lines="2" /> + </div> + </div> + <div v-else> + <!-- + We assign each element's key as index intentionally here. + Creation and destruction of "environments-item" component is quite taxing and leads + to noticeable blocking rendering times for lists of more than 10 items. + By assigning indexes we avoid destroying and re-creating the components when page changes, + thus getting a much better performance. + Correct component state is ensured by deep data-binding of "environment" prop + --> + <environment-item + v-for="(environment, index) in environments" + :id="environment.name" + :key="index" + :environment="environment" + class="gl-border-gray-100 gl-border-t-solid gl-border-1 gl-pt-3" + in-folder + /> + </div> + <gl-pagination + v-model="pageNumber" + :per-page="$options.perPage" + :total-items="totalItems" + align="center" + /> + </div> +</template> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 1a32de30de0..0201fb53f77 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,31 +1,78 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import Translate from '~/vue_shared/translate'; -import EnvironmentsFolderApp from './environments_folder_view.vue'; +import { apolloProvider } from '../graphql/client'; +import EnvironmentsFolderView from './environments_folder_view.vue'; +import EnvironmentsFolderApp from './environments_folder_app.vue'; Vue.use(Translate); Vue.use(VueApollo); -const apolloProvider = new VueApollo({ +const legacyApolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); export default () => { const el = document.getElementById('environments-folder-list-view'); + const environmentsData = el.dataset; + if (gon.features.environmentsFolderNewLook) { + Vue.use(VueRouter); + + const folderName = environmentsData.environmentsDataFolderName; + const folderPath = environmentsData.environmentsDataEndpoint.replace('.json', ''); + const projectPath = environmentsData.environmentsDataProjectPath; + const helpPagePath = environmentsData.environmentsDataHelpPagePath; + + const router = new VueRouter({ + mode: 'history', + base: window.location.pathname, + routes: [ + { + path: '/', + name: 'environments_folder', + component: EnvironmentsFolderApp, + props: (route) => ({ + scope: route.query.scope, + page: Number(route.query.page || '1'), + folderName, + folderPath, + }), + }, + ], + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition; + } + return { top: 0 }; + }, + }); + + return new Vue({ + el, + provide: { + projectPath, + helpPagePath, + }, + apolloProvider, + router, + render(createElement) { + return createElement('router-view'); + }, + }); + } return new Vue({ el, components: { - EnvironmentsFolderApp, + EnvironmentsFolderView, }, - apolloProvider, + apolloProvider: legacyApolloProvider, provide: { projectPath: el.dataset.projectPath, }, data() { - const environmentsData = el.dataset; - return { endpoint: environmentsData.environmentsDataEndpoint, folderName: environmentsData.environmentsDataFolderName, @@ -33,7 +80,7 @@ export default () => { }; }, render(createElement) { - return createElement('environments-folder-app', { + return createElement('environments-folder-view', { props: { endpoint: this.endpoint, folderName: this.folderName, diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index adb14ce3d6f..35a754c757b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -4,6 +4,7 @@ import DeleteEnvironmentModal from '../components/delete_environment_modal.vue'; import StopEnvironmentModal from '../components/stop_environment_modal.vue'; import environmentsMixin from '../mixins/environments_mixin'; import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin'; +import ConfirmRollbackModal from '../components/confirm_rollback_modal.vue'; export default { components: { @@ -12,6 +13,7 @@ export default { GlTab, GlTabs, StopEnvironmentModal, + ConfirmRollbackModal, }, mixins: [environmentsMixin, EnvironmentsPaginationApiMixin], @@ -42,6 +44,7 @@ export default { <div :class="cssContainerClass"> <stop-environment-modal :environment="environmentInStopModal" /> <delete-environment-modal :environment="environmentInDeleteModal" /> + <confirm-rollback-modal :environment="environmentInRollbackModal" /> <h4 class="gl-font-weight-normal" data-testid="folder-name"> {{ s__('Environments|Environments') }} / diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 8f57069d89d..0eb12427914 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -1,7 +1,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import environmentApp from './queries/environment_app.query.graphql'; -import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql index ac6a68e450c..d183f27a8b6 100644 --- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql @@ -1,5 +1,12 @@ -query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String, $search: String) { - folder(environment: $environment, scope: $scope, search: $search) @client { +query getEnvironmentFolder( + $environment: NestedLocalEnvironment + $scope: String + $search: String + $perPage: Int + $page: Int +) { + folder(environment: $environment, scope: $scope, search: $search, perPage: $perPage, page: $page) + @client { activeCount environments stoppedCount diff --git a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql deleted file mode 100644 index d77ca05d46f..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql +++ /dev/null @@ -1,8 +0,0 @@ -query getPageInfo { - pageInfo @client { - total - perPage - nextPage - previousPage - } -} diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js index 4427b8ff2ef..7d2a0689da2 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/base.js +++ b/app/assets/javascripts/environments/graphql/resolvers/base.js @@ -6,13 +6,13 @@ import { normalizeHeaders, } from '~/lib/utils/common_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import pollIntervalQuery from '../queries/poll_interval.query.graphql'; import environmentToRollbackQuery from '../queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from '../queries/environment_to_stop.query.graphql'; import environmentToDeleteQuery from '../queries/environment_to_delete.query.graphql'; import environmentToChangeCanaryQuery from '../queries/environment_to_change_canary.query.graphql'; import isEnvironmentStoppingQuery from '../queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from '../queries/page_info.query.graphql'; const buildErrors = (errors = []) => ({ errors, @@ -59,13 +59,18 @@ export const baseQueries = (endpoint) => ({ }; }); }, - folder(_, { environment: { folderPath }, scope, search }) { - return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ - activeCount: res.data.active_count, - environments: res.data.environments.map(mapEnvironment), - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentFolder', - })); + folder(_, { environment: { folderPath }, scope, search, perPage, page }) { + // eslint-disable-next-line camelcase + const per_page = perPage || 3; + const pageNumber = page || 1; + return axios + .get(folderPath, { params: { scope, search, per_page, page: pageNumber } }) + .then((res) => ({ + activeCount: res.data.active_count, + environments: res.data.environments.map(mapEnvironment), + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentFolder', + })); }, isLastDeployment(_, { environment }) { return environment?.lastDeployment?.isLast; @@ -73,7 +78,7 @@ export const baseQueries = (endpoint) => ({ }); export const baseMutations = { - stopEnvironmentREST(_, { environment }, { client }) { + stopEnvironmentREST(_, { environment }, { client, cache }) { client.writeQuery({ query: isEnvironmentStoppingQuery, variables: { environment }, @@ -82,6 +87,9 @@ export const baseMutations = { return axios .post(environment.stopPath) .then(() => buildErrors()) + .then(() => { + cache.evict({ fieldName: 'folder' }); + }) .catch(() => { client.writeQuery({ query: isEnvironmentStoppingQuery, @@ -93,10 +101,11 @@ export const baseMutations = { ]); }); }, - deleteEnvironment(_, { environment: { deletePath } }) { + deleteEnvironment(_, { environment: { deletePath } }, { cache }) { return axios .delete(deletePath) .then(() => buildErrors()) + .then(() => cache.evict({ fieldName: 'folder' })) .catch(() => buildErrors([ s__( @@ -105,10 +114,13 @@ export const baseMutations = { ]), ); }, - rollbackEnvironment(_, { environment, isLastDeployment }) { + rollbackEnvironment(_, { environment, isLastDeployment }, { cache }) { return axios .post(environment?.retryUrl) .then(() => buildErrors()) + .then(() => { + cache.evict({ fieldName: 'folder' }); + }) .catch(() => { buildErrors([ isLastDeployment diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js index 8375b8793d9..eab25298c36 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js +++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js @@ -6,8 +6,16 @@ import { WatchApi, EVENT_DATA, } from '@gitlab/cluster-client'; +import produce from 'immer'; +import { + getK8sPods, + handleClusterError, + buildWatchPath, +} from '~/kubernetes_dashboard/graphql/helpers/resolver_helpers'; import { humanizeClusterErrors } from '../../helpers/k8s_integration_helper'; import k8sPodsQuery from '../queries/k8s_pods.query.graphql'; +import k8sWorkloadsQuery from '../queries/k8s_workloads.query.graphql'; +import k8sServicesQuery from '../queries/k8s_services.query.graphql'; const mapWorkloadItems = (items, kind) => { return items.map((item) => { @@ -52,17 +60,10 @@ const mapWorkloadItems = (items, kind) => { }); }; -const handleClusterError = async (err) => { - if (!err.response) { - throw err; - } - - const errorData = await err.response.json(); - throw errorData; -}; +const watchWorkloadItems = ({ kind, apiVersion, configuration, namespace, client }) => { + const itemKind = kind.toLowerCase().replace('list', 's'); -const watchPods = ({ configuration, namespace, client }) => { - const path = namespace ? `/api/v1/namespaces/${namespace}/pods` : '/api/v1/pods'; + const path = buildWatchPath({ resource: itemKind, api: `apis/${apiVersion}`, namespace }); const config = new Configuration(configuration); const watcherApi = new WatchApi(config); @@ -72,14 +73,21 @@ const watchPods = ({ configuration, namespace, client }) => { let result = []; watcher.on(EVENT_DATA, (data) => { - result = data.map((item) => { - return { status: { phase: item.status.phase } }; + result = mapWorkloadItems(data, kind); + + const sourceData = client.readQuery({ + query: k8sWorkloadsQuery, + variables: { configuration, namespace }, + }); + + const updatedData = produce(sourceData, (draft) => { + draft.k8sWorkloads[kind] = result; }); client.writeQuery({ - query: k8sPodsQuery, + query: k8sWorkloadsQuery, variables: { configuration, namespace }, - data: { k8sPods: result }, + data: updatedData, }); }); }) @@ -88,32 +96,53 @@ const watchPods = ({ configuration, namespace, client }) => { }); }; -export default { - k8sPods(_, { configuration, namespace }, { client }) { - const config = new Configuration(configuration); +const mapServicesItems = (items) => { + return items.map((item) => { + const { type, clusterIP, externalIP, ports } = item.spec; + return { + metadata: item.metadata, + spec: { + type, + clusterIP: clusterIP || '-', + externalIP: externalIP || '-', + ports, + }, + }; + }); +}; - const coreV1Api = new CoreV1Api(config); - const podsApi = namespace - ? coreV1Api.listCoreV1NamespacedPod({ namespace }) - : coreV1Api.listCoreV1PodForAllNamespaces(); +const watchServices = ({ configuration, namespace, client }) => { + const path = buildWatchPath({ resource: 'services', namespace }); + const config = new Configuration(configuration); + const watcherApi = new WatchApi(config); - return podsApi - .then((res) => { - if (gon.features?.k8sWatchApi) { - watchPods({ configuration, namespace, client }); - } + watcherApi + .subscribeToStream(path, { watch: true }) + .then((watcher) => { + let result = []; - return res?.items || []; - }) - .catch(async (err) => { - try { - await handleClusterError(err); - } catch (error) { - throw new Error(error.message); - } + watcher.on(EVENT_DATA, (data) => { + result = mapServicesItems(data); + + client.writeQuery({ + query: k8sServicesQuery, + variables: { configuration, namespace }, + data: { k8sServices: result }, + }); }); + }) + .catch((err) => { + handleClusterError(err); + }); +}; + +export default { + k8sPods(_, { configuration, namespace }, { client }) { + const query = k8sPodsQuery; + const enableWatch = gon.features?.k8sWatchApi; + return getK8sPods({ client, query, configuration, namespace, enableWatch }); }, - k8sServices(_, { configuration, namespace }) { + k8sServices(_, { configuration, namespace }, { client }) { const coreV1Api = new CoreV1Api(new Configuration(configuration)); const servicesApi = namespace ? coreV1Api.listCoreV1NamespacedService({ namespace }) @@ -122,18 +151,12 @@ export default { return servicesApi .then((res) => { const items = res?.items || []; - return items.map((item) => { - const { type, clusterIP, externalIP, ports } = item.spec; - return { - metadata: item.metadata, - spec: { - type, - clusterIP: clusterIP || '-', - externalIP: externalIP || '-', - ports, - }, - }; - }); + + if (gon.features?.k8sWatchApi) { + watchServices({ configuration, namespace, client }); + } + + return mapServicesItems(items); }) .catch(async (err) => { try { @@ -143,7 +166,7 @@ export default { } }); }, - k8sWorkloads(_, { configuration, namespace }) { + k8sWorkloads(_, { configuration, namespace }, { client }) { const appsV1api = new AppsV1Api(new Configuration(configuration)); const batchV1api = new BatchV1Api(new Configuration(configuration)); @@ -189,10 +212,12 @@ export default { } for (const promiseResult of results) { if (promiseResult.status === 'fulfilled' && promiseResult?.value) { - const { kind, items } = promiseResult.value; + const { kind, items, apiVersion } = promiseResult.value; if (items?.length > 0) { summaryList[kind] = mapWorkloadItems(items, kind); + + watchWorkloadItems({ kind, apiVersion, configuration, namespace, client }); } } } diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 41f165ad1da..a235e387930 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/client/page_info.typedefs.graphql" + type LocalEnvironment { id: Int! globalId: ID! @@ -55,19 +57,19 @@ type LocalErrors { errors: [String!]! } -type LocalPageInfo { - total: Int! - perPage: Int! - nextPage: Int! - previousPage: Int! -} - type k8sPodStatus { phase: String } +type k8sPodMetadata { + name: String + namespace: String + creationTimestamp: String +} + type LocalK8sPods { status: k8sPodStatus + metadata: k8sPodMetadata } input LocalConfiguration { diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index 164a2d98e90..bb5cab7c279 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -1,4 +1,9 @@ -import { differenceInSeconds } from '~/lib/utils/datetime_utility'; +import { + calculateDeploymentStatus, + calculateStatefulSetStatus, + calculateDaemonSetStatus, +} from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; +import { STATUS_READY, STATUS_FAILED } from '~/kubernetes_dashboard/constants'; import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants'; export function generateServicePortsString(ports) { @@ -12,46 +17,29 @@ export function generateServicePortsString(ports) { .join(', '); } -export function getServiceAge(creationTimestamp) { - if (!creationTimestamp) return ''; - - const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date()); - - const seconds = Math.floor(timeDifference); - const minutes = Math.floor(seconds / 60) % 60; - const hours = Math.floor(seconds / 60 / 60) % 24; - const days = Math.floor(seconds / 60 / 60 / 24); - - let ageString; - if (days > 0) { - ageString = `${days}d`; - } else if (hours > 0) { - ageString = `${hours}h`; - } else if (minutes > 0) { - ageString = `${minutes}m`; - } else { - ageString = `${seconds}s`; - } - - return ageString; -} - export function getDeploymentsStatuses(items) { const failed = []; const ready = []; + const pending = []; items.forEach((item) => { - const [available, progressing] = item.status?.conditions ?? []; - // eslint-disable-next-line @gitlab/require-i18n-strings - if (available.status === 'True') { - ready.push(item); - // eslint-disable-next-line @gitlab/require-i18n-strings - } else if (available.status !== 'True' && progressing.status !== 'True') { - failed.push(item); + const status = calculateDeploymentStatus(item); + + switch (status) { + case STATUS_READY: + ready.push(item); + break; + case STATUS_FAILED: + failed.push(item); + break; + default: + pending.push(item); + break; } }); return { + ...(pending.length && { pending }), ...(failed.length && { failed }), ...(ready.length && { ready }), }; @@ -59,16 +47,10 @@ export function getDeploymentsStatuses(items) { export function getDaemonSetStatuses(items) { const failed = items.filter((item) => { - return ( - item.status?.numberMisscheduled > 0 || - item.status?.numberReady !== item.status?.desiredNumberScheduled - ); + return calculateDaemonSetStatus(item) === STATUS_FAILED; }); const ready = items.filter((item) => { - return ( - item.status?.numberReady === item.status?.desiredNumberScheduled && - !item.status?.numberMisscheduled - ); + return calculateDaemonSetStatus(item) === STATUS_READY; }); return { @@ -79,10 +61,10 @@ export function getDaemonSetStatuses(items) { export function getStatefulSetStatuses(items) { const failed = items.filter((item) => { - return item.status?.readyReplicas < item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_FAILED; }); const ready = items.filter((item) => { - return item.status?.readyReplicas === item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_READY; }); return { @@ -93,10 +75,10 @@ export function getStatefulSetStatuses(items) { export function getReplicaSetStatuses(items) { const failed = items.filter((item) => { - return item.status?.readyReplicas < item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_FAILED; }); const ready = items.filter((item) => { - return item.status?.readyReplicas === item.spec?.replicas; + return calculateStatefulSetStatus(item) === STATUS_READY; }); return { diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 01879a092ed..0fabe1779a8 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -169,16 +169,10 @@ export default { { text: this.ignoreBtnLabel, action: this.onIgnoreStatusUpdate, - extraAttrs: { - 'data-qa-selector': 'update_ignore_status_button', - }, }, { text: this.resolveBtnLabel, action: this.onResolveStatusUpdate, - extraAttrs: { - 'data-qa-selector': 'update_resolve_status_button', - }, }, ]; }, @@ -187,7 +181,7 @@ export default { text: __('View issue'), href: this.error.gitlabIssuePath, extraAttrs: { - 'data-qa-selector': 'view_issue_button', + 'data-testid': 'view-issue-button', }, }; }, @@ -342,7 +336,7 @@ export default { <gl-button v-if="error.gitlabIssuePath" class="gl-ml-3" - data-testid="view_issue_button" + data-testid="view-issue-button" :href="error.gitlabIssuePath" variant="confirm" > diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 4d4bae12570..95ae5e5a92c 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -22,7 +22,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import AccessorUtils from '~/lib/utils/accessor'; import { __ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import { sanitizeUrl } from '~/lib/utils/url_utility'; +import { sanitizeUrl, joinPaths } from '~/lib/utils/url_utility'; import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions, @@ -225,7 +225,7 @@ export default { if (!isValidErrorId(errorId)) { return 'about:blank'; } - return `error_tracking/${errorId}/details`; + return joinPaths(this.listPath, errorId, 'details'); }, goToNextPage() { this.pageValue = this.$options.NEXT_PAGE; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index e0a5e92564e..0cc136e79a5 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -50,12 +50,13 @@ export default { 'instanceId', 'isRotating', 'hasRotateError', + 'rotateEndpoint', ]), topAreaBaseClasses() { return ['gl-display-flex', 'gl-flex-direction-column']; }, canUserRotateToken() { - return this.rotateInstanceIdPath !== ''; + return this.rotateEndpoint !== ''; }, shouldRenderPagination() { return ( diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index 257c482cf1d..b952e0059bb 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -67,7 +67,7 @@ export default { return featureFlag.iid ? `^${featureFlag.iid}` : ''; }, canDeleteFlag(flag) { - return !this.permissions || (flag.scopes || []).every((scope) => scope.can_update); + return (flag.scopes || []).every((scope) => scope.can_update); }, setDeleteModalData(featureFlag) { this.deleteFeatureFlagUrl = featureFlag.destroy_path; diff --git a/app/assets/javascripts/feature_highlight/constants.js b/app/assets/javascripts/feature_highlight/constants.js deleted file mode 100644 index 3e4cd11f7d5..00000000000 --- a/app/assets/javascripts/feature_highlight/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const POPOVER_TARGET_ID = 'feature-highlight-trigger'; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js deleted file mode 100644 index e2218c1ba2e..00000000000 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; - -export const getSelector = (highlightId) => `.js-feature-highlight[data-highlight=${highlightId}]`; - -export function dismiss(endpoint, highlightId) { - return axios - .post(endpoint, { - feature_name: highlightId, - }) - .catch(() => - createAlert({ - message: __( - 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', - ), - }), - ); -} diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue deleted file mode 100644 index 24f7d567ea7..00000000000 --- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg?raw'; -import { GlPopover, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { __ } from '~/locale'; -import { POPOVER_TARGET_ID } from './constants'; -import { dismiss } from './feature_highlight_helper'; - -export default { - components: { - GlPopover, - GlSprintf, - GlLink, - GlButton, - }, - directives: { - SafeHtml, - }, - props: { - autoDevopsHelpPath: { - type: String, - required: true, - }, - highlightId: { - type: String, - required: true, - }, - dismissEndpoint: { - type: String, - required: true, - }, - }, - data() { - return { - dismissed: false, - triggerHidden: false, - }; - }, - methods: { - dismiss() { - dismiss(this.dismissEndpoint, this.highlightId); - this.$refs.popover.$emit('close'); - this.dismissed = true; - }, - hideTrigger() { - if (this.dismissed) { - this.triggerHidden = true; - } - }, - }, - clusterPopover, - targetId: POPOVER_TARGET_ID, - i18n: { - highlightMessage: __('Allows you to add and manage Kubernetes clusters.'), - autoDevopsProTipMessage: __( - 'Protip: %{linkStart}Auto DevOps%{linkEnd} uses Kubernetes clusters to deploy your code!', - ), - dismissButtonLabel: __('Got it!'), - }, -}; -</script> -<template> - <div class="gl-ml-3"> - <span v-if="!triggerHidden" :id="$options.targetId" class="feature-highlight"></span> - <gl-popover - ref="popover" - :target="$options.targetId" - :css-classes="['feature-highlight-popover']" - container="body" - placement="right" - boundary="viewport" - @hidden="hideTrigger" - > - <span - v-safe-html="$options.clusterPopover" - class="feature-highlight-illustration gl-display-flex gl-justify-content-center gl-py-4 gl-w-full" - ></span> - <div class="gl-px-4 gl-py-5"> - <p> - {{ $options.i18n.highlightMessage }} - </p> - <p> - <gl-sprintf :message="$options.i18n.autoDevopsProTipMessage"> - <template #link="{ content }"> - <gl-link class="gl-font-sm" :href="autoDevopsHelpPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <gl-button size="small" icon="thumb-up" variant="confirm" @click="dismiss"> - {{ $options.i18n.dismissButtonLabel }} - </gl-button> - </div> - </gl-popover> - </div> -</template> diff --git a/app/assets/javascripts/feature_highlight/index.js b/app/assets/javascripts/feature_highlight/index.js deleted file mode 100644 index 3a8b211b3c5..00000000000 --- a/app/assets/javascripts/feature_highlight/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import Vue from 'vue'; - -const init = async () => { - const el = document.querySelector('.js-feature-highlight'); - - if (!el) { - return null; - } - - const { autoDevopsHelpPath, highlight: highlightId, dismissEndpoint } = el.dataset; - const { default: FeatureHighlight } = await import( - /* webpackChunkName: 'feature_highlight' */ './feature_highlight_popover.vue' - ); - - return new Vue({ - el, - render: (h) => - h(FeatureHighlight, { - props: { - autoDevopsHelpPath, - highlightId, - dismissEndpoint, - }, - }), - }); -}; - -export default init; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 74d91734630..698302c5209 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -8,7 +8,10 @@ import { TOKEN_TYPE_SOURCE_BRANCH, } from '~/vue_shared/components/filtered_search_bar/constants'; -export default (IssuableTokenKeys, disableBranchFilter = false) => { +export default ( + IssuableTokenKeys, + { disableBranchFilter = false, disableEnvironmentFilter = false } = {}, +) => { const reviewerToken = { formattedKey: TOKEN_TITLE_REVIEWER, key: TOKEN_TYPE_REVIEWER, @@ -171,41 +174,43 @@ export default (IssuableTokenKeys, disableBranchFilter = false) => { ); IssuableTokenKeys.conditions.push(...approvedBy.condition); - const environmentToken = { - formattedKey: __('Environment'), - key: 'environment', - type: 'string', - param: '', - symbol: '', - icon: 'cloud-gear', - tag: 'environment', - }; + if (!disableEnvironmentFilter) { + const environmentToken = { + formattedKey: __('Environment'), + key: 'environment', + type: 'string', + param: '', + symbol: '', + icon: 'cloud-gear', + tag: 'environment', + }; - const deployedBeforeToken = { - formattedKey: __('Deployed-before'), - key: 'deployed-before', - type: 'string', - param: '', - symbol: '', - icon: 'clock', - tag: 'deployed_before', - }; + const deployedBeforeToken = { + formattedKey: __('Deployed-before'), + key: 'deployed-before', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_before', + }; - const deployedAfterToken = { - formattedKey: __('Deployed-after'), - key: 'deployed-after', - type: 'string', - param: '', - symbol: '', - icon: 'clock', - tag: 'deployed_after', - }; + const deployedAfterToken = { + formattedKey: __('Deployed-after'), + key: 'deployed-after', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_after', + }; - IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken); + IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken); - IssuableTokenKeys.tokenKeysWithAlternative.push( - environmentToken, - deployedBeforeToken, - deployedAfterToken, - ); + IssuableTokenKeys.tokenKeysWithAlternative.push( + environmentToken, + deployedBeforeToken, + deployedAfterToken, + ); + } }; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 8ccf7ba92a5..d00c98adc0d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,6 +1,6 @@ import { last } from 'lodash'; import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import { createFilteredSearchTokenKeys } from '~/filtered_search/issuable_filtered_search_token_keys'; import { createAlert } from '~/alert'; import { STATUS_ALL, @@ -40,7 +40,7 @@ export default class FilteredSearchManager { isGroupAncestor = true, isGroupDecendent = false, useDefaultState = false, - filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, + filteredSearchTokenKeys = createFilteredSearchTokenKeys(), stateFiltersSelector = '.issues-state-filters', placeholder = __('Search or filter results…'), anchor = null, diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 8aa99ec52f9..5a785de2e66 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -17,66 +17,75 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; -export const tokenKeys = [ - { - formattedKey: TOKEN_TITLE_AUTHOR, - key: TOKEN_TYPE_AUTHOR, - type: 'string', - param: 'username', - symbol: '@', - icon: 'pencil', - tag: '@author', - }, - { - formattedKey: TOKEN_TITLE_ASSIGNEE, - key: TOKEN_TYPE_ASSIGNEE, - type: 'string', - param: 'username', - symbol: '@', - icon: 'user', - tag: '@assignee', - }, - { - formattedKey: TOKEN_TITLE_MILESTONE, - key: TOKEN_TYPE_MILESTONE, - type: 'string', - param: 'title', - symbol: '%', - icon: 'clock', - tag: '%milestone', - }, - { - formattedKey: TOKEN_TITLE_RELEASE, - key: TOKEN_TYPE_RELEASE, - type: 'string', - param: 'tag', - symbol: '', - icon: 'rocket', - tag: __('tag name'), - }, - { - formattedKey: TOKEN_TITLE_LABEL, - key: TOKEN_TYPE_LABEL, - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', - }, -]; +export const createTokenKeys = ({ disableReleaseFilter = false } = {}) => { + const tokenKeys = [ + { + formattedKey: TOKEN_TITLE_AUTHOR, + key: TOKEN_TYPE_AUTHOR, + type: 'string', + param: 'username', + symbol: '@', + icon: 'pencil', + tag: '@author', + }, + { + formattedKey: TOKEN_TITLE_ASSIGNEE, + key: TOKEN_TYPE_ASSIGNEE, + type: 'string', + param: 'username', + symbol: '@', + icon: 'user', + tag: '@assignee', + }, + { + formattedKey: TOKEN_TITLE_MILESTONE, + key: TOKEN_TYPE_MILESTONE, + type: 'string', + param: 'title', + symbol: '%', + icon: 'clock', + tag: '%milestone', + }, + { + formattedKey: TOKEN_TITLE_LABEL, + key: TOKEN_TYPE_LABEL, + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'labels', + tag: '~label', + }, + ]; -if (gon.current_user_id) { - // Appending tokenkeys only logged-in - tokenKeys.push({ - formattedKey: TOKEN_TITLE_MY_REACTION, - key: TOKEN_TYPE_MY_REACTION, - type: 'string', - param: 'emoji', - symbol: '', - icon: 'thumb-up', - tag: 'emoji', - }); -} + if (!disableReleaseFilter) { + tokenKeys.push({ + formattedKey: TOKEN_TITLE_RELEASE, + key: TOKEN_TYPE_RELEASE, + type: 'string', + param: 'tag', + symbol: '', + icon: 'rocket', + tag: __('tag name'), + }); + } + + if (gon.current_user_id) { + // Appending tokenkeys only logged-in + tokenKeys.push({ + formattedKey: TOKEN_TITLE_MY_REACTION, + key: TOKEN_TYPE_MY_REACTION, + type: 'string', + param: 'emoji', + symbol: '', + icon: 'thumb-up', + tag: 'emoji', + }); + } + + return tokenKeys; +}; + +export const tokenKeys = createTokenKeys(); export const alternativeTokenKeys = [ { @@ -186,10 +195,7 @@ export const conditions = flattenDeep( }), ); -const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys( - tokenKeys, - alternativeTokenKeys, - conditions, -); +export const createFilteredSearchTokenKeys = (config = {}) => + new FilteredSearchTokenKeys(createTokenKeys(config), alternativeTokenKeys, conditions); -export default IssuableFilteredSearchTokenKeys; +export default createFilteredSearchTokenKeys(); diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js deleted file mode 100644 index 0fb70fb831e..00000000000 --- a/app/assets/javascripts/fly_out_nav.js +++ /dev/null @@ -1,205 +0,0 @@ -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; - -const HIDE_INTERVAL_TIMEOUT = 300; -const COLLAPSED_PANEL_WIDTH = 48; -const IS_OVER_CLASS = 'is-over'; -const IS_ABOVE_CLASS = 'is-above'; -const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; -let currentOpenMenu = null; -let menuCornerLocs; -let timeoutId; -let sidebar; - -export const mousePos = []; - -export const setSidebar = (el) => { - sidebar = el; -}; -export const getOpenMenu = () => currentOpenMenu; -export const setOpenMenu = (menu = null) => { - currentOpenMenu = menu; -}; - -export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); - -export const getHeaderHeight = () => sidebar?.offsetTop || 0; - -export const isSidebarCollapsed = () => - sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS); - -export const canShowActiveSubItems = (el) => { - if (el.classList.contains('active') && !isSidebarCollapsed()) { - return false; - } - - return true; -}; - -export const canShowSubItems = () => ['md', 'lg', 'xl'].includes(bp.getBreakpointSize()); - -export const getHideSubItemsInterval = () => { - if (!currentOpenMenu || !mousePos.length) return 0; - - const currentMousePos = mousePos[mousePos.length - 1]; - const prevMousePos = mousePos[0]; - const currentMousePosY = currentMousePos.y; - const [menuTop, menuBottom] = menuCornerLocs; - - if (currentMousePosY < menuTop.y || currentMousePosY > menuBottom.y) return 0; - - if ( - slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) && - slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop) - ) { - return HIDE_INTERVAL_TIMEOUT; - } - - return 0; -}; - -export const calculateTop = (boundingRect, outerHeight) => { - const windowHeight = window.innerHeight; - const bottomOverflow = windowHeight - (boundingRect.top + outerHeight); - - return bottomOverflow < 0 - ? boundingRect.top - outerHeight + boundingRect.height - : boundingRect.top; -}; - -export const hideMenu = (el) => { - if (!el) return; - - const parentEl = el.parentNode; - - el.style.display = ''; - el.style.transform = ''; - el.classList.remove(IS_ABOVE_CLASS); - el.classList.remove('fly-out-list'); - parentEl.classList.remove(IS_OVER_CLASS); - parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); - - setOpenMenu(); -}; - -export const moveSubItemsToPosition = (el, subItems) => { - const hasSubItems = subItems.parentNode.querySelector('.has-sub-items'); - const header = subItems.querySelector('.fly-out-top-item'); - const boundingRect = el.getBoundingClientRect(); - const left = sidebar ? sidebar.offsetWidth : COLLAPSED_PANEL_WIDTH; - let top = calculateTop(boundingRect, subItems.offsetHeight); - const isAbove = top < boundingRect.top; - if (hasSubItems) { - top = isAbove ? top : top - header.offsetHeight; - } else { - top = boundingRect.top; - } - - subItems.classList.add('fly-out-list'); - subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - getHeaderHeight()}px, 0)`; // eslint-disable-line no-param-reassign - const subItemsRect = subItems.getBoundingClientRect(); - - menuCornerLocs = [ - { - x: subItemsRect.left, // left position of the sub items - y: subItemsRect.top, // top position of the sub items - }, - { - x: subItemsRect.left, // left position of the sub items - y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items - }, - ]; - - if (isAbove) { - subItems.classList.add(IS_ABOVE_CLASS); - } -}; - -export const showSubLevelItems = (el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - const isIconOnly = subItems && subItems.classList.contains('is-fly-out-only'); - - if (!canShowSubItems() || !canShowActiveSubItems(el)) return; - - el.classList.add(IS_OVER_CLASS); - - if (!subItems || (!isSidebarCollapsed() && isIconOnly)) return; - - subItems.style.display = 'block'; - el.classList.add(IS_SHOWING_FLY_OUT_CLASS); - - setOpenMenu(subItems); - moveSubItemsToPosition(el, subItems); -}; - -export const mouseEnterTopItems = (el, timeout = getHideSubItemsInterval()) => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - - showSubLevelItems(el); - }, timeout); -}; - -export const mouseLeaveTopItem = (el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - - if ( - !canShowSubItems() || - !canShowActiveSubItems(el) || - (subItems && subItems === currentOpenMenu) - ) - return; - - el.classList.remove(IS_OVER_CLASS); -}; - -export const documentMouseMove = (e) => { - mousePos.push({ - x: e.clientX, - y: e.clientY, - }); - - if (mousePos.length > 6) mousePos.shift(); -}; - -export const subItemsMouseLeave = (relatedTarget) => { - clearTimeout(timeoutId); - - if (relatedTarget && !relatedTarget.closest(`.${IS_OVER_CLASS}`)) { - hideMenu(currentOpenMenu); - } -}; - -export default () => { - sidebar = document.querySelector('.nav-sidebar'); - - if (!sidebar) return; - - const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; - - const topItems = sidebar.querySelector('.sidebar-top-level-items'); - if (topItems) { - sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - }, getHideSubItemsInterval()); - }); - } - - items.forEach((el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - - if (subItems) { - subItems.addEventListener('mouseleave', (e) => subItemsMouseLeave(e.relatedTarget)); - } - - el.addEventListener('mouseenter', (e) => mouseEnterTopItems(e.currentTarget)); - el.addEventListener('mouseleave', (e) => mouseLeaveTopItem(e.currentTarget)); - }); - - document.addEventListener('mousemove', documentMouseMove); -}; diff --git a/app/assets/javascripts/forks/components/forks_button.vue b/app/assets/javascripts/forks/components/forks_button.vue index 40cf74ff4cc..fbea64d568b 100644 --- a/app/assets/javascripts/forks/components/forks_button.vue +++ b/app/assets/javascripts/forks/components/forks_button.vue @@ -77,7 +77,7 @@ export default { :href="forkButtonUrl" icon="fork" :title="forkButtonTooltip" - >{{ s__('ProjectOverview|Forks') }}</gl-button + >{{ s__('ProjectOverview|Fork') }}</gl-button > <gl-button data-testid="forks-count" :disabled="!canReadCode" :href="projectForksUrl">{{ forksCount diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue deleted file mode 100644 index 947d3053094..00000000000 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ /dev/null @@ -1,183 +0,0 @@ -<script> -import { GlLoadingIcon, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import AccessorUtilities from '~/lib/utils/accessor'; -import { - mapVuexModuleState, - mapVuexModuleActions, - mapVuexModuleGetters, -} from '~/lib/utils/vuex_module_mappers'; -import Tracking from '~/tracking'; -import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; -import eventHub from '../event_hub'; -import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; -import FrequentItemsList from './frequent_items_list.vue'; -import frequentItemsMixin from './frequent_items_mixin'; -import FrequentItemsSearchInput from './frequent_items_search_input.vue'; - -const trackingMixin = Tracking.mixin(); - -export default { - components: { - FrequentItemsSearchInput, - FrequentItemsList, - GlLoadingIcon, - GlButton, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [frequentItemsMixin, trackingMixin], - inject: ['vuexModule'], - props: { - currentUserName: { - type: String, - required: true, - }, - currentItem: { - type: Object, - required: true, - }, - searchClass: { - type: String, - required: false, - default: '', - }, - }, - computed: { - ...mapVuexModuleState((vm) => vm.vuexModule, [ - 'searchQuery', - 'isLoadingItems', - 'isItemsListEditable', - 'isFetchFailed', - 'isItemRemovalFailed', - 'items', - ]), - ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']), - translations() { - return this.getTranslations(['loadingMessage', 'header', 'headerEditToggle']); - }, - }, - created() { - const { namespace, currentUserName, currentItem } = this; - const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`; - - this.setNamespace(namespace); - this.setStorageKey(storageKey); - - if (currentItem.id) { - this.logItemAccess(storageKey, currentItem); - } - - eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); - - // As we init it through requestIdleCallback it could be that the dropdown is already open - const namespaceDropdown = document.getElementById(`nav-${this.namespace}-dropdown`); - if (namespaceDropdown && namespaceDropdown.classList.contains('show')) { - this.dropdownOpenHandler(); - } - }, - beforeDestroy() { - eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); - }, - methods: { - ...mapVuexModuleActions((vm) => vm.vuexModule, [ - 'setNamespace', - 'setStorageKey', - 'toggleItemsListEditablity', - 'fetchFrequentItems', - ]), - toggleItemsListEditablityTracked() { - this.track('click_button', { - label: 'toggle_edit_frequent_items', - property: 'navigation_top', - }); - this.toggleItemsListEditablity(); - }, - dropdownOpenHandler() { - if (this.searchQuery === '' || isMobile()) { - this.fetchFrequentItems(); - } - }, - logItemAccess(storageKey, unsanitizedItem) { - const item = sanitizeItem(unsanitizedItem); - - if (!AccessorUtilities.canUseLocalStorage()) { - return false; - } - - // Check if there's any frequent items list set - const storedRawItems = localStorage.getItem(storageKey); - const storedFrequentItems = storedRawItems - ? JSON.parse(storedRawItems) - : [{ ...item, frequency: 1 }]; // No frequent items list set, set one up. - - // Check if item already exists in list - const itemMatchIndex = storedFrequentItems.findIndex( - (frequentItem) => frequentItem.id === item.id, - ); - - if (itemMatchIndex > -1) { - storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem( - storedFrequentItems[itemMatchIndex], - item, - ); - } else { - if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) { - storedFrequentItems.shift(); - } - - storedFrequentItems.push({ ...item, frequency: 1 }); - } - - return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems)); - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full"> - <frequent-items-search-input - :namespace="namespace" - :class="searchClass" - data-testid="frequent-items-search-input" - /> - <gl-loading-icon - v-if="isLoadingItems" - :label="translations.loadingMessage" - size="lg" - class="loading-animation prepend-top-20" - data-testid="loading" - /> - <div - v-if="!isLoadingItems && !hasSearchQuery" - class="section-header gl-display-flex" - data-testid="header" - > - <span class="gl-flex-grow-1">{{ translations.header }}</span> - <gl-button - v-if="items.length" - v-gl-tooltip.left - size="small" - category="tertiary" - :aria-label="translations.headerEditToggle" - :title="translations.headerEditToggle" - :class="{ 'gl-bg-gray-100!': isItemsListEditable }" - class="gl-p-2!" - @click="toggleItemsListEditablityTracked" - > - <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> - </gl-button> - </div> - <frequent-items-list - v-if="!isLoadingItems" - :items="items" - :namespace="namespace" - :has-search-query="hasSearchQuery" - :is-fetch-failed="isFetchFailed" - :is-item-removal-failed="isItemRemovalFailed" - :matcher="searchQuery" - /> - </div> -</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue deleted file mode 100644 index da1d3bedaf4..00000000000 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue +++ /dev/null @@ -1,90 +0,0 @@ -<script> -import { sanitizeItem } from '../utils'; -import FrequentItemsListItem from './frequent_items_list_item.vue'; -import frequentItemsMixin from './frequent_items_mixin'; - -export default { - components: { - FrequentItemsListItem, - }, - mixins: [frequentItemsMixin], - props: { - items: { - type: Array, - required: true, - }, - hasSearchQuery: { - type: Boolean, - required: true, - }, - isFetchFailed: { - type: Boolean, - required: true, - }, - isItemRemovalFailed: { - type: Boolean, - required: true, - }, - matcher: { - type: String, - required: true, - }, - }, - computed: { - translations() { - return this.getTranslations([ - 'itemListEmptyMessage', - 'itemListErrorMessage', - 'searchListEmptyMessage', - 'searchListErrorMessage', - ]); - }, - isListEmpty() { - return this.items.length === 0; - }, - showListEmptyMessage() { - return this.isListEmpty || this.isItemRemovalFailed; - }, - listEmptyMessage() { - if (this.hasSearchQuery) { - return this.isFetchFailed - ? this.translations.searchListErrorMessage - : this.translations.searchListEmptyMessage; - } - - return this.isFetchFailed || this.isItemRemovalFailed - ? this.translations.itemListErrorMessage - : this.translations.itemListEmptyMessage; - }, - sanitizedItems() { - return this.items.map(sanitizeItem); - }, - }, -}; -</script> - -<template> - <div class="frequent-items-list-container"> - <ul data-testid="frequent-items-list" class="list-unstyled"> - <li - v-if="showListEmptyMessage" - :class="{ 'section-failure': isFetchFailed }" - class="section-empty gl-mb-3" - data-testid="frequent-items-list-empty" - > - {{ listEmptyMessage }} - </li> - <frequent-items-list-item - v-for="item in sanitizedItems" - v-else - :key="item.id" - :item-id="item.id" - :item-name="item.name" - :namespace="item.namespace" - :web-url="item.webUrl" - :avatar-url="item.avatarUrl" - :matcher="matcher" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue deleted file mode 100644 index 056dedf8757..00000000000 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ /dev/null @@ -1,132 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import highlight from '~/lib/utils/highlight'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { mapVuexModuleState, mapVuexModuleActions } from '~/lib/utils/vuex_module_mappers'; -import Tracking from '~/tracking'; -import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; - -const trackingMixin = Tracking.mixin(); - -export default { - components: { - GlIcon, - GlButton, - ProjectAvatar, - }, - directives: { - SafeHtml, - GlTooltip: GlTooltipDirective, - }, - mixins: [trackingMixin], - inject: ['vuexModule'], - props: { - matcher: { - type: String, - required: false, - default: '', - }, - itemId: { - type: Number, - required: true, - }, - itemName: { - type: String, - required: true, - }, - namespace: { - type: String, - required: false, - default: '', - }, - webUrl: { - type: String, - required: true, - }, - avatarUrl: { - required: true, - validator(value) { - return value === null || typeof value === 'string'; - }, - }, - }, - computed: { - ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType', 'isItemsListEditable']), - truncatedNamespace() { - return truncateNamespace(this.namespace); - }, - highlightedItemName() { - return highlight(this.itemName, this.matcher); - }, - itemTrackingLabel() { - return `${this.dropdownType}_dropdown_frequent_items_list_item`; - }, - }, - methods: { - removeFrequentItemTracked(item) { - this.track('click_button', { - label: `${this.dropdownType}_dropdown_remove_frequent_item`, - property: 'navigation_top', - }); - this.removeFrequentItem(item); - }, - ...mapVuexModuleActions((vm) => vm.vuexModule, ['removeFrequentItem']), - }, -}; -</script> - -<template> - <li class="frequent-items-list-item-container gl-relative"> - <gl-button - category="tertiary" - :href="webUrl" - class="gl-text-left gl-w-full" - button-text-classes="gl-display-flex gl-w-full" - data-testid="frequent-item-link" - @click="track('click_link', { label: itemTrackingLabel, property: 'navigation_top' })" - > - <div class="gl-flex-grow-1"> - <project-avatar - class="gl-float-left gl-mr-3" - :project-avatar-url="avatarUrl" - :project-id="itemId" - :project-name="itemName" - aria-hidden="true" - /> - <div - data-testid="frequent-items-item-metadata-container" - class="frequent-items-item-metadata-container" - > - <div - v-safe-html="highlightedItemName" - data-testid="frequent-items-item-title" - :title="itemName" - class="frequent-items-item-title" - ></div> - <div - v-if="namespace" - data-testid="frequent-items-item-namespace" - :title="namespace" - class="frequent-items-item-namespace" - > - {{ truncatedNamespace }} - </div> - </div> - </div> - </gl-button> - <gl-button - v-if="isItemsListEditable" - v-gl-tooltip.left - size="small" - category="tertiary" - :aria-label="__('Remove')" - :title="__('Remove')" - class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-right-4 gl-top-half gl-translate-y-n50" - data-testid="item-remove" - @click.stop.prevent="removeFrequentItemTracked(itemId)" - > - <gl-icon name="close" /> - </gl-button> - </li> -</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js deleted file mode 100644 index 704dc83ca8e..00000000000 --- a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js +++ /dev/null @@ -1,23 +0,0 @@ -import { TRANSLATION_KEYS } from '../constants'; - -export default { - props: { - namespace: { - type: String, - required: true, - }, - }, - methods: { - getTranslations(keys) { - const translationStrings = keys.reduce( - (acc, key) => ({ - ...acc, - [key]: TRANSLATION_KEYS[this.namespace][key], - }), - {}, - ); - - return translationStrings; - }, - }, -}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue deleted file mode 100644 index 023245f050b..00000000000 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { GlSearchBoxByType } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; -import Tracking from '~/tracking'; -import frequentItemsMixin from './frequent_items_mixin'; - -const trackingMixin = Tracking.mixin(); - -export default { - components: { - GlSearchBoxByType, - }, - mixins: [frequentItemsMixin, trackingMixin], - inject: ['vuexModule'], - data() { - return { - searchQuery: '', - }; - }, - computed: { - ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']), - translations() { - return this.getTranslations(['searchInputPlaceholder']); - }, - }, - watch: { - searchQuery: debounce(function debounceSearchQuery() { - this.track('type_search_query', { - label: `${this.dropdownType}_dropdown_frequent_items_search_input`, - property: 'navigation_top', - }); - this.setSearchQuery(this.searchQuery); - }, 500), - }, - methods: { - ...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']), - trackFocus() { - this.track('focus_input', { - label: `${this.dropdownType}_dropdown_frequent_items_search_input`, - property: 'navigation_top', - }); - }, - trackBlur() { - this.track('blur_input', { - label: `${this.dropdownType}_dropdown_frequent_items_search_input`, - property: 'navigation_top', - }); - }, - }, -}; -</script> - -<template> - <div class="search-input-container"> - <gl-search-box-by-type - v-model="searchQuery" - :placeholder="translations.searchInputPlaceholder" - @focus="trackFocus" - @blur="trackBlur" - /> - </div> -</template> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js deleted file mode 100644 index a7c27abf58e..00000000000 --- a/app/assets/javascripts/frequent_items/constants.js +++ /dev/null @@ -1,54 +0,0 @@ -import { s__ } from '~/locale'; - -export const FREQUENT_ITEMS = { - MAX_COUNT: 20, - LIST_COUNT_DESKTOP: 5, - LIST_COUNT_MOBILE: 3, - ELIGIBLE_FREQUENCY: 3, -}; - -export const FIFTEEN_MINUTES_IN_MS = 900000; - -export const STORAGE_KEY = { - projects: 'frequent-projects', - groups: 'frequent-groups', -}; - -export const TRANSLATION_KEYS = { - projects: { - loadingMessage: s__('ProjectsDropdown|Loading projects'), - header: s__('ProjectsDropdown|Frequently visited'), - headerEditToggle: s__('ProjectsDropdown|Toggle edit mode'), - itemListErrorMessage: s__( - 'ProjectsDropdown|This feature requires browser localStorage support', - ), - itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'), - searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'), - searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'), - searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'), - }, - groups: { - loadingMessage: s__('GroupsDropdown|Loading groups'), - header: s__('GroupsDropdown|Frequently visited'), - headerEditToggle: s__('GroupsDropdown|Toggle edit mode'), - itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'), - itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'), - searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'), - searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'), - searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), - }, -}; - -export const FREQUENT_ITEMS_PROJECTS = { - namespace: 'projects', - key: 'project', - vuexModule: 'frequentProjects', -}; - -export const FREQUENT_ITEMS_GROUPS = { - namespace: 'groups', - key: 'group', - vuexModule: 'frequentGroups', -}; - -export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS]; diff --git a/app/assets/javascripts/frequent_items/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/frequent_items/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js deleted file mode 100644 index e5ef49ec402..00000000000 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ /dev/null @@ -1,112 +0,0 @@ -import AccessorUtilities from '~/lib/utils/accessor'; -import { isLoggedIn } from '~/lib/utils/common_utils'; -import { getGroups, getProjects } from '~/rest_api'; -import { getTopFrequentItems } from '../utils'; -import * as types from './mutation_types'; - -export const setNamespace = ({ commit }, namespace) => { - commit(types.SET_NAMESPACE, namespace); -}; - -export const setStorageKey = ({ commit }, key) => { - commit(types.SET_STORAGE_KEY, key); -}; - -export const toggleItemsListEditablity = ({ commit }) => { - commit(types.TOGGLE_ITEMS_LIST_EDITABILITY); -}; - -export const requestFrequentItems = ({ commit }) => { - commit(types.REQUEST_FREQUENT_ITEMS); -}; -export const receiveFrequentItemsSuccess = ({ commit }, data) => { - commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data); -}; -export const receiveFrequentItemsError = ({ commit }) => { - commit(types.RECEIVE_FREQUENT_ITEMS_ERROR); -}; - -export const fetchFrequentItems = ({ state, dispatch }) => { - dispatch('requestFrequentItems'); - - if (AccessorUtilities.canUseLocalStorage()) { - const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey)); - - dispatch( - 'receiveFrequentItemsSuccess', - !storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems), - ); - } else { - dispatch('receiveFrequentItemsError'); - } -}; - -export const requestSearchedItems = ({ commit }) => { - commit(types.REQUEST_SEARCHED_ITEMS); -}; -export const receiveSearchedItemsSuccess = ({ commit }, data) => { - commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data); -}; -export const receiveSearchedItemsError = ({ commit }) => { - commit(types.RECEIVE_SEARCHED_ITEMS_ERROR); -}; -export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { - dispatch('requestSearchedItems'); - - const params = { - simple: true, - per_page: 20, - membership: isLoggedIn(), - }; - - let searchFunction; - if (state.namespace === 'projects') { - searchFunction = getProjects; - params.order_by = 'last_activity_at'; - } else { - searchFunction = getGroups; - } - - return searchFunction(searchQuery, params) - .then((results) => { - dispatch('receiveSearchedItemsSuccess', results); - }) - .catch(() => { - dispatch('receiveSearchedItemsError'); - }); -}; - -export const setSearchQuery = ({ commit, dispatch }, query) => { - commit(types.SET_SEARCH_QUERY, query); - - if (query) { - dispatch('fetchSearchedItems', query); - } else { - dispatch('fetchFrequentItems'); - } -}; - -export const removeFrequentItemSuccess = ({ commit }, itemId) => { - commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS, itemId); -}; - -export const removeFrequentItemError = ({ commit }) => { - commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR); -}; - -export const removeFrequentItem = ({ state, dispatch }, itemId) => { - if (AccessorUtilities.canUseLocalStorage()) { - try { - const storedRawItems = JSON.parse(localStorage.getItem(state.storageKey)); - localStorage.setItem( - state.storageKey, - JSON.stringify(storedRawItems.filter((item) => item.id !== itemId)), - ); - dispatch('removeFrequentItemSuccess', itemId); - } catch { - dispatch('removeFrequentItemError'); - } - } else { - dispatch('removeFrequentItemError'); - } -}; diff --git a/app/assets/javascripts/frequent_items/store/getters.js b/app/assets/javascripts/frequent_items/store/getters.js deleted file mode 100644 index e52678dbec2..00000000000 --- a/app/assets/javascripts/frequent_items/store/getters.js +++ /dev/null @@ -1 +0,0 @@ -export const hasSearchQuery = (state) => state.searchQuery !== ''; diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js deleted file mode 100644 index 3e5c9618805..00000000000 --- a/app/assets/javascripts/frequent_items/store/index.js +++ /dev/null @@ -1,29 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { FREQUENT_ITEMS_DROPDOWNS } from '../constants'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -export const createFrequentItemsModule = (initState = {}) => ({ - namespaced: true, - actions, - getters, - mutations, - state: state(initState), -}); - -export const createStoreOptions = () => ({ - modules: FREQUENT_ITEMS_DROPDOWNS.reduce( - (acc, { namespace, vuexModule }) => - Object.assign(acc, { - [vuexModule]: createFrequentItemsModule({ dropdownType: namespace }), - }), - {}, - ), -}); - -export const createStore = () => { - return new Vuex.Store(createStoreOptions()); -}; diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js deleted file mode 100644 index 9c9346081e9..00000000000 --- a/app/assets/javascripts/frequent_items/store/mutation_types.js +++ /dev/null @@ -1,12 +0,0 @@ -export const SET_NAMESPACE = 'SET_NAMESPACE'; -export const SET_STORAGE_KEY = 'SET_STORAGE_KEY'; -export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; -export const TOGGLE_ITEMS_LIST_EDITABILITY = 'TOGGLE_ITEMS_LIST_EDITABILITY'; -export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS'; -export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS'; -export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR'; -export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS'; -export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS'; -export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR'; -export const RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS = 'RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS'; -export const RECEIVE_REMOVE_FREQUENT_ITEM_ERROR = 'RECEIVE_REMOVE_FREQUENT_ITEM_ERROR'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js deleted file mode 100644 index 9882bef444a..00000000000 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ /dev/null @@ -1,88 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_NAMESPACE](state, namespace) { - Object.assign(state, { - namespace, - }); - }, - [types.SET_STORAGE_KEY](state, storageKey) { - Object.assign(state, { - storageKey, - }); - }, - [types.SET_SEARCH_QUERY](state, searchQuery) { - const hasSearchQuery = searchQuery !== ''; - - Object.assign(state, { - searchQuery, - isLoadingItems: true, - hasSearchQuery, - }); - }, - [types.TOGGLE_ITEMS_LIST_EDITABILITY](state) { - Object.assign(state, { - isItemsListEditable: !state.isItemsListEditable, - }); - }, - [types.REQUEST_FREQUENT_ITEMS](state) { - Object.assign(state, { - isLoadingItems: true, - hasSearchQuery: false, - }); - }, - [types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) { - Object.assign(state, { - items: rawItems, - isLoadingItems: false, - hasSearchQuery: false, - isFetchFailed: false, - }); - }, - [types.RECEIVE_FREQUENT_ITEMS_ERROR](state) { - Object.assign(state, { - isLoadingItems: false, - hasSearchQuery: false, - isFetchFailed: true, - }); - }, - [types.REQUEST_SEARCHED_ITEMS](state) { - Object.assign(state, { - isLoadingItems: true, - hasSearchQuery: true, - }); - }, - [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { - const rawItems = results.data; - Object.assign(state, { - items: rawItems.map((rawItem) => ({ - id: rawItem.id, - name: rawItem.name, - namespace: rawItem.name_with_namespace || rawItem.full_name, - webUrl: rawItem.web_url, - avatarUrl: rawItem.avatar_url, - })), - isLoadingItems: false, - hasSearchQuery: true, - isFetchFailed: false, - }); - }, - [types.RECEIVE_SEARCHED_ITEMS_ERROR](state) { - Object.assign(state, { - isLoadingItems: false, - hasSearchQuery: true, - isFetchFailed: true, - }); - }, - [types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](state, itemId) { - Object.assign(state, { - items: state.items.filter((item) => item.id !== itemId), - isItemRemovalFailed: false, - }); - }, - [types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](state) { - Object.assign(state, { - isItemRemovalFailed: true, - }); - }, -}; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js deleted file mode 100644 index ee94e9cd221..00000000000 --- a/app/assets/javascripts/frequent_items/store/state.js +++ /dev/null @@ -1,11 +0,0 @@ -export default ({ dropdownType = '' } = {}) => ({ - namespace: '', - dropdownType, - storageKey: '', - searchQuery: '', - isLoadingItems: false, - isFetchFailed: false, - isItemsListEditable: false, - isItemRemovalFailed: false, - items: [], -}); diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js deleted file mode 100644 index f71405a5bc4..00000000000 --- a/app/assets/javascripts/frequent_items/utils.js +++ /dev/null @@ -1,67 +0,0 @@ -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { take } from 'lodash'; -import { sanitize } from '~/lib/dompurify'; -import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from './constants'; - -export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); - -export const getTopFrequentItems = (items) => { - if (!items) { - return []; - } - const frequentItemsCount = isMobile() - ? FREQUENT_ITEMS.LIST_COUNT_MOBILE - : FREQUENT_ITEMS.LIST_COUNT_DESKTOP; - - const frequentItems = items.filter((item) => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY); - - if (!frequentItems || frequentItems.length === 0) { - return []; - } - - frequentItems.sort((itemA, itemB) => { - // Sort all frequent items in decending order of frequency - // and then by lastAccessedOn with recent most first - if (itemA.frequency !== itemB.frequency) { - return itemB.frequency - itemA.frequency; - } - if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { - return itemB.lastAccessedOn - itemA.lastAccessedOn; - } - - return 0; - }); - - return take(frequentItems, frequentItemsCount); -}; - -export const updateExistingFrequentItem = (frequentItem, item) => { - // `frequentItem` comes from localStorage and it's possible it doesn't have a `lastAccessedOn` - const neverAccessed = !frequentItem.lastAccessedOn; - const shouldUpdate = - neverAccessed || - Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; - - return { - ...item, - frequency: shouldUpdate ? frequentItem.frequency + 1 : frequentItem.frequency, - lastAccessedOn: shouldUpdate ? Date.now() : frequentItem.lastAccessedOn, - }; -}; - -export const sanitizeItem = (item) => { - // Only sanitize if the key exists on the item - const maybeSanitize = (key) => { - if (!Object.prototype.hasOwnProperty.call(item, key)) { - return {}; - } - - return { [key]: sanitize(item[key].toString(), { ALLOWED_TAGS: [] }) }; - }; - - return { - ...item, - ...maybeSanitize('name'), - ...maybeSanitize('namespace'), - }; -}; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 39a8b1d0a9c..b11f7b1ba76 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -11,7 +11,7 @@ import { state } from '~/sidebar/components/reviewers/sidebar_reviewers.vue'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; import { parsePikadayDate } from './lib/utils/datetime_utility'; -import glRegexp from './lib/utils/regexp'; +import { unicodeLetters } from './lib/utils/regexp'; const USERS_ALIAS = 'users'; const ISSUES_ALIAS = 'issues'; @@ -82,8 +82,8 @@ export function membersBeforeSave(members) { const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase(); const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : ''; - const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; - const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; + const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline s24 gl-mr-2"/>`; + const txtAvatar = `<div class="avatar ${rectAvatarClass} avatar-inline s24 gl-mr-2">${autoCompleteAvatar}</div>`; const avatarIcon = member.mentionsDisabled ? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2') : ''; @@ -262,6 +262,38 @@ class GfmAutoComplete { }); } + // eslint-disable-next-line class-methods-use-this + setSubmitReviewStates($input) { + if (!window.gon.features?.mrRequestChanges) return; + + const REVIEW_STATES = { + reviewed: { + header: __('Comment'), + description: __('Submit general feedback without explicit approval.'), + }, + approve: { + header: __('Approve'), + description: __('Submit feedback and approve these changes.'), + }, + requested_changes: { + header: __('Request changes'), + description: __('Submit feedback that should be addressed before merging.'), + }, + }; + + $input.filter('[data-supports-quick-actions="true"]').atwho({ + // Always keep the trailing space otherwise the command won't display correctly + at: '/submit_review ', + alias: 'submit_review', + data: Object.keys(REVIEW_STATES), + displayTpl({ name }) { + const reviewState = REVIEW_STATES[name]; + + return `<li><span class="name gl-font-weight-bold">${reviewState.header}</span><small class="description"><em>${reviewState.description}</em></small></li>`; + }, + }); + } + setupEmoji($input) { const fetchData = this.fetchData.bind(this); @@ -275,10 +307,7 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), matcher(flag, subtext) { - const regexp = new RegExp( - `(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^ :][^:]*)?$`, - 'gi', - ); + const regexp = new RegExp(`(?:[^${unicodeLetters}0-9:]|\n|^):([^ :][^:]*)?$`, 'gi'); const match = regexp.exec(subtext); if (match && match.length) { @@ -851,6 +880,9 @@ class GfmAutoComplete { } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then((data) => { + if (data.some((c) => c.name === 'submit_review')) { + this.setSubmitReviewStates($input); + } this.loadData($input, at, data); }) .catch(() => { diff --git a/app/assets/javascripts/graphql_shared/client/page_info.query.graphql b/app/assets/javascripts/graphql_shared/client/page_info.query.graphql new file mode 100644 index 00000000000..958d3eade68 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/page_info.query.graphql @@ -0,0 +1,8 @@ +query getPageInfo($input: LocalPageInfoInput) { + pageInfo(input: $input) @client { + total + perPage + nextPage + previousPage + } +} diff --git a/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql b/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql new file mode 100644 index 00000000000..2c74fa4cc34 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql @@ -0,0 +1,10 @@ +type LocalPageInfoInput { + page: Int +} + +type LocalPageInfo { + total: Int! + perPage: Int! + nextPage: Int! + previousPage: Int! +} diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 2863f52bea9..d8b97259730 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -15,6 +15,7 @@ export const TYPENAME_GROUP = 'Group'; export const TYPENAME_ISSUE = 'Issue'; export const TYPENAME_ITERATION = 'Iteration'; export const TYPENAME_ITERATIONS_CADENCE = 'Iterations::Cadence'; +export const TYPENAME_MEMBER_ROLE = 'MemberRole'; export const TYPENAME_MERGE_REQUEST = 'MergeRequest'; export const TYPENAME_MILESTONE = 'Milestone'; export const TYPENAME_NOTE = 'Note'; @@ -24,9 +25,9 @@ export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPENAME_SITE_PROFILE = 'DastSiteProfile'; export const TYPENAME_TODO = 'Todo'; export const TYPENAME_USER = 'User'; -export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; export const TYPENAME_VULNERABILITY = 'Vulnerability'; export const TYPENAME_WORK_ITEM = 'WorkItem'; -export const TYPENAME_ORGANIZATION = 'Organization'; +export const TYPE_ORGANIZATION = 'Organizations::Organization'; export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply'; export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace'; +export const TYPE_COMPLIANCE_FRAMEWORK = 'ComplianceManagement::Framework'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 9537c9ef8a6..d0ba34b6127 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -146,7 +146,7 @@ export const config = { }, IssueConnection: { merge(existing = { nodes: [] }, incoming, { args }) { - if (!args.after) { + if (!args?.after) { return incoming; } return { diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 1439a3181b0..4edad63cc79 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -4,7 +4,8 @@ "AlertManagementPrometheusIntegration" ], "AmazonS3ConfigurationInterface": [ - "AmazonS3ConfigurationType" + "AmazonS3ConfigurationType", + "InstanceAmazonS3ConfigurationType" ], "BaseHeaderInterface": [ "AuditEventStreamingHeader", @@ -150,6 +151,7 @@ "User": [ "AddOnUser", "AutocompletedUser", + "CurrentUser", "MergeRequestAssignee", "MergeRequestAuthor", "MergeRequestParticipant", @@ -201,5 +203,11 @@ "WorkItemWidgetStatus", "WorkItemWidgetTestReports", "WorkItemWidgetWeight" + ], + "WorkItemWidgetDefinition": [ + "WorkItemWidgetDefinitionAssignees", + "WorkItemWidgetDefinitionGeneric", + "WorkItemWidgetDefinitionHierarchy", + "WorkItemWidgetDefinitionLabels" ] } diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js index d4ac7d94bf4..3b595bac686 100644 --- a/app/assets/javascripts/group_settings/constants.js +++ b/app/assets/javascripts/group_settings/constants.js @@ -1,7 +1,7 @@ import { __, s__ } from '~/locale'; export const I18N_CONFIRM_MESSAGE = s__( - 'Runners|Shared runners will be disabled for all projects and subgroups in this group. If you proceed, you must manually re-enable shared runners in the settings of each project and subgroup.', + 'Runners|Shared runners will be disabled for all projects and subgroups in this group.', ); export const I18N_CONFIRM_OK = s__('Runners|Yes, disable shared runners'); export const I18N_CONFIRM_CANCEL = s__('Runners|No, keep shared runners enabled'); diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index af1af86d0c4..3a08e3e546f 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -243,7 +243,7 @@ export default { </div> </gl-popover> </template> - <user-access-role-badge v-if="group.permission" class="gl-mr-3"> + <user-access-role-badge v-if="group.permission" size="sm" class="gl-mr-3"> {{ group.permission }} </user-access-role-badge> <gl-label @@ -254,7 +254,7 @@ export default { size="sm" /> </div> - <div v-if="group.description" class="description"> + <div v-if="group.description" class="description gl-font-sm gl-mt-1"> <span v-safe-html:[$options.safeHtmlConfig]="group.description" :itemprop="microdata.descriptionItemprop" diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 969b41f4755..f654e349119 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -40,7 +40,7 @@ export default { </script> <template> - <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> + <div class="groups-list-tree-container" data-testid="groups-list-tree-container"> <group-folder :groups="groups" :action="action" /> <pagination-links :change="change" diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index d87190edfd2..55c5ef2ae80 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -68,7 +68,7 @@ export default { css-class="project-stars" icon-name="star" /> - <div v-if="isProject" class="last-updated"> + <div v-if="isProject" class="last-updated gl-font-sm"> <time-ago-tooltip :time="item.lastActivityAt" tooltip-placement="bottom" /> </div> </div> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 90a0582cc9f..8781f03a412 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -228,7 +228,7 @@ export default { <gl-search-box-by-type :value="search" :placeholder="$options.i18n.searchPlaceholder" - data-qa-selector="groups_filter_field" + data-testid="groups-filter-field" @input="handleSearchInput" /> </div> diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index b831ae7b9d6..80dd1d36734 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -21,7 +21,7 @@ export const createRouter = () => { const router = new VueRouter({ routes, mode: 'history', - base: '/', + base: gon.relative_url_root || '/', }); return router; diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js index b9d48cc660e..7558b8d6713 100644 --- a/app/assets/javascripts/groups/service/archived_projects_service.js +++ b/app/assets/javascripts/groups/service/archived_projects_service.js @@ -32,7 +32,7 @@ export default class ArchivedProjectsService { markdown_description: project.description_html, visibility: project.visibility, avatar_url: project.avatar_url, - relative_path: `/${project.path_with_namespace}`, + relative_path: `${gon.relative_url_root}/${project.path_with_namespace}`, edit_path: null, leave_path: null, can_edit: false, diff --git a/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue new file mode 100644 index 00000000000..76b0b819698 --- /dev/null +++ b/app/assets/javascripts/groups_projects/components/more_actions_dropdown.vue @@ -0,0 +1,154 @@ +<script> +import { + GlButton, + GlDisclosureDropdownItem, + GlDisclosureDropdown, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; + +export default { + components: { + GlButton, + GlDisclosureDropdownItem, + GlDisclosureDropdown, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: [ + 'isGroup', + 'id', + 'leavePath', + 'leaveConfirmMessage', + 'withdrawPath', + 'withdrawConfirmMessage', + 'requestAccessPath', + ], + computed: { + namespaceType() { + return this.isGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT; + }, + leaveTitle() { + return this.isGroup + ? this.$options.i18n.groupLeaveTitle + : this.$options.i18n.projectLeaveTitle; + }, + copyTitle() { + return this.isGroup ? this.$options.i18n.groupCopyTitle : this.$options.i18n.projectCopyTitle; + }, + copiedToClipboard() { + return this.isGroup + ? this.$options.i18n.groupCopiedToClipboard + : this.$options.i18n.projectCopiedToClipboard; + }, + leaveItem() { + return { + text: this.leaveTitle, + href: this.leavePath, + extraAttrs: { + 'aria-label': this.leaveTitle, + 'data-method': 'delete', + 'data-confirm': this.leaveConfirmMessage, + 'data-confirm-btn-variant': 'danger', + 'data-testid': `leave-${this.namespaceType}-link`, + rel: 'nofollow', + class: 'gl-text-red-500! js-leave-link', + }, + }; + }, + withdrawItem() { + return { + text: this.$options.i18n.withdrawAccessTitle, + href: this.withdrawPath, + extraAttrs: { + 'data-method': 'delete', + 'data-confirm': this.withdrawConfirmMessage, + 'data-testid': 'withdraw-access-link', + rel: 'nofollow', + }, + }; + }, + requestAccessItem() { + return { + text: this.$options.i18n.requestAccessTitle, + href: this.requestAccessPath, + extraAttrs: { + 'data-method': 'post', + 'data-testid': 'request-access-link', + rel: 'nofollow', + }, + }; + }, + copyIdItem() { + return { + text: sprintf(this.copyTitle, { id: this.id }), + action: () => { + this.$toast.show(this.copiedToClipboard); + }, + extraAttrs: { + 'data-testid': `copy-${this.namespaceType}-id`, + }, + }; + }, + }, + i18n: { + actionsLabel: __('Actions'), + groupCopiedToClipboard: s__('GroupPage|Group ID copied to clipboard.'), + projectCopiedToClipboard: s__('ProjectPage|Project ID copied to clipboard.'), + groupLeaveTitle: __('Leave group'), + projectLeaveTitle: __('Leave project'), + withdrawAccessTitle: __('Withdraw Access Request'), + requestAccessTitle: __('Request Access'), + groupCopyTitle: s__('GroupPage|Copy group ID: %{id}'), + projectCopyTitle: s__('ProjectPage|Copy project ID: %{id}'), + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + v-gl-tooltip.hover="$options.i18n.actionsLabel" + category="tertiary" + icon="ellipsis_v" + no-caret + :toggle-text="$options.i18n.actionsLabel" + text-sr-only + data-testid="groups-projects-more-actions-dropdown" + class="gl-relative gl-w-full gl-sm-w-auto" + > + <template #toggle> + <div class="gl-min-h-7"> + <gl-button + class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full gl-sm-w-auto" + button-text-classes="gl-w-full" + category="secondary" + :aria-label="$options.i18n.actionsLabel" + :title="$options.i18n.actionsLabel" + > + <span class="gl-new-dropdown-button-text">{{ $options.i18n.actionsLabel }}</span> + <gl-icon class="dropdown-chevron" name="chevron-down" /> + </gl-button> + <gl-button + ref="moreActionsDropdown" + class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret" + category="tertiary" + icon="ellipsis_v" + :aria-label="$options.i18n.actionsLabel" + :title="$options.i18n.actionsLabel" + /> + </div> + </template> + + <gl-disclosure-dropdown-item v-if="leavePath" ref="leaveItem" :item="leaveItem" /> + + <gl-disclosure-dropdown-item v-else-if="withdrawPath" :item="withdrawItem" /> + + <gl-disclosure-dropdown-item v-else-if="requestAccessPath" :item="requestAccessItem" /> + + <gl-disclosure-dropdown-item v-if="id" :item="copyIdItem" :data-clipboard-text="id" /> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index 5774065bff9..bce0a217deb 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -237,7 +237,6 @@ export default { <gl-form-group :label="label"> <gl-dropdown :text="selectedText" - data-qa-selector="namespaces_list" data-testid="transfer-locations-dropdown" block toggle-class="gl-mb-0" @@ -248,7 +247,7 @@ export default { <gl-search-box-by-type v-model.trim="searchTerm" :is-loading="isSearchLoading" - data-qa-selector="namespaces_list_search" + data-testid="transfer-locations-search" /> </template> <template v-if="showAdditionalDropdownItems"> @@ -265,23 +264,18 @@ export default { <gl-dropdown-item v-for="item in userTransferLocations" :key="item.id" - data-qa-selector="namespaces_list_item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > </div> - <div - v-if="hasGroupTransferLocations" - data-qa-selector="namespaces_list_groups" - data-testid="group-transfer-locations" - > + <div v-if="hasGroupTransferLocations" data-testid="group-transfer-locations"> <gl-dropdown-section-header v-if="showUserTransferLocations">{{ $options.i18n.GROUPS }}</gl-dropdown-section-header> <gl-dropdown-item v-for="item in groupTransferLocations" :key="item.id" - data-qa-selector="namespaces_list_item" + data-testid="group-transfer-item" @click="handleSelect(item)" >{{ item.humanName }}</gl-dropdown-item > diff --git a/app/assets/javascripts/groups_projects/init_more_actions_dropdown.js b/app/assets/javascripts/groups_projects/init_more_actions_dropdown.js new file mode 100644 index 00000000000..5d83f9ed3b2 --- /dev/null +++ b/app/assets/javascripts/groups_projects/init_more_actions_dropdown.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import MoreActionsDropdown from '~/groups_projects/components/more_actions_dropdown.vue'; + +export default function InitMoreActionsDropdown() { + const el = document.querySelector('.js-groups-projects-more-actions-dropdown'); + + if (!el) { + return false; + } + + const { + isGroup, + id, + leavePath, + leaveConfirmMessage, + withdrawPath, + withdrawConfirmMessage, + requestAccessPath, + } = el.dataset; + + return new Vue({ + el, + name: 'MoreActionsDropdownRoot', + provide: { + isGroup: parseBoolean(isGroup), + id, + leavePath, + leaveConfirmMessage, + withdrawPath, + withdrawConfirmMessage, + requestAccessPath, + }, + render: (createElement) => createElement(MoreActionsDropdown), + }); +} diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js deleted file mode 100644 index 095a2dc1324..00000000000 --- a/app/assets/javascripts/header.js +++ /dev/null @@ -1,145 +0,0 @@ -// TODO: Remove this with the removal of the old navigation. -// See https://gitlab.com/groups/gitlab-org/-/epics/11875. - -import Vue from 'vue'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; -import { highCountTrim } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; -import Translate from '~/vue_shared/translate'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -/** - * Updates todo counter when todos are toggled. - * When count is 0, we hide the badge. - * - * @param {jQuery.Event} e - * @param {String} count - */ -export default function initTodoToggle() { - document.addEventListener('todo:toggle', (e) => { - const updatedCount = e.detail.count || 0; - const todoPendingCount = document.querySelector('.js-todos-count'); - - if (todoPendingCount) { - todoPendingCount.textContent = highCountTrim(updatedCount); - if (updatedCount === 0) { - todoPendingCount.classList.add('hidden'); - } else { - todoPendingCount.classList.remove('hidden'); - } - } - }); -} - -export function initStatusTriggers() { - const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger'); - - if (setStatusModalTriggerEl) { - setStatusModalTriggerEl.addEventListener('click', () => { - const topNavbar = document.querySelector('.navbar-gitlab'); - const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl); - Tracking.event(undefined, 'click_button', { - label: 'user_edit_status', - property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu', - }); - - import( - /* webpackChunkName: 'statusModalBundle' */ './set_status_modal/set_status_modal_wrapper.vue' - ) - .then(({ default: SetStatusModalWrapper }) => { - const setStatusModalWrapperEl = document.querySelector('.js-set-status-modal-wrapper'); - const statusModalElement = document.createElement('div'); - setStatusModalWrapperEl.appendChild(statusModalElement); - - Vue.use(Translate); - - // eslint-disable-next-line no-new - new Vue({ - el: statusModalElement, - data() { - const { - currentEmoji, - defaultEmoji, - currentMessage, - currentAvailability, - currentClearStatusAfter, - } = setStatusModalWrapperEl.dataset; - - return { - currentEmoji, - defaultEmoji, - currentMessage, - currentAvailability, - currentClearStatusAfter, - }; - }, - render(createElement) { - const { - currentEmoji, - defaultEmoji, - currentMessage, - currentAvailability, - currentClearStatusAfter, - } = this; - - return createElement(SetStatusModalWrapper, { - props: { - currentEmoji, - defaultEmoji, - currentMessage, - currentAvailability, - currentClearStatusAfter, - }, - }); - }, - }); - }) - .catch(() => {}); - }); - - setStatusModalTriggerEl.classList.add('ready'); - } -} - -function trackShowUserDropdownLink(trackEvent, elToTrack, el) { - const { trackLabel, trackProperty } = elToTrack.dataset; - - el.addEventListener('shown.bs.dropdown', () => { - Tracking.event(document.body.dataset.page, trackEvent, { - label: trackLabel, - property: trackProperty, - }); - }); -} - -export function initNavUserDropdownTracking() { - const el = document.querySelector('.js-nav-user-dropdown'); - const buyEl = document.querySelector('.js-buy-pipeline-minutes-link'); - - if (el && buyEl) { - trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el); - } -} - -function initNewNavToggle() { - const el = document.querySelector('.js-new-nav-toggle'); - if (!el) return false; - - return new Vue({ - el, - render(h) { - return h(NewNavToggle, { - props: { - enabled: parseBoolean(el.dataset.enabled), - endpoint: el.dataset.endpoint, - }, - }); - }, - }); -} - -if (!gon?.use_new_navigation) { - requestIdleCallback(initStatusTriggers); -} -requestIdleCallback(initNavUserDropdownTracking); -requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue deleted file mode 100644 index 120b51f07cc..00000000000 --- a/app/assets/javascripts/header_search/components/app.vue +++ /dev/null @@ -1,306 +0,0 @@ -<script> -import { - GlSearchBoxByType, - GlIcon, - GlToken, - GlTooltipDirective, - GlResizeObserverDirective, -} from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapActions, mapGetters } from 'vuex'; -import { debounce } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; -import { truncate } from '~/lib/utils/text_utility'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; -import { - SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, - SEARCH_DESCRIBED_BY_DEFAULT, - SEARCH_DESCRIBED_BY_UPDATED, - SEARCH_RESULTS_LOADING, - SEARCH_RESULTS_SCOPE, - KBD_HELP, -} from '~/vue_shared/global_search/constants'; -import { - FIRST_DROPDOWN_INDEX, - SEARCH_BOX_INDEX, - SEARCH_INPUT_DESCRIPTION, - SEARCH_RESULTS_DESCRIPTION, - SEARCH_SHORTCUTS_MIN_CHARACTERS, - SCOPE_TOKEN_MAX_LENGTH, - INPUT_FIELD_PADDING, - IS_SEARCHING, - IS_FOCUSED, - IS_NOT_FOCUSED, - DROPDOWN_CLOSE_TIMEOUT, -} 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'; - -export default { - name: 'HeaderSearchApp', - i18n: { - SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, - SEARCH_DESCRIBED_BY_DEFAULT, - SEARCH_DESCRIBED_BY_UPDATED, - SEARCH_RESULTS_LOADING, - SEARCH_RESULTS_SCOPE, - KBD_HELP, - }, - directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, - components: { - GlSearchBoxByType, - HeaderSearchDefaultItems, - HeaderSearchScopedItems, - HeaderSearchAutocompleteItems, - DropdownKeyboardNavigation, - GlIcon, - GlToken, - }, - data() { - return { - isFocused: false, - currentFocusIndex: SEARCH_BOX_INDEX, - }; - }, - computed: { - ...mapState(['search', 'loading', 'searchContext']), - ...mapGetters(['searchQuery', 'searchOptions']), - searchText: { - get() { - return this.search; - }, - set(value) { - this.setSearch(value); - }, - }, - currentFocusedOption() { - return this.searchOptions[this.currentFocusIndex]; - }, - currentFocusedId() { - return this.currentFocusedOption?.html_id; - }, - isLoggedIn() { - return Boolean(gon?.current_username); - }, - showSearchDropdown() { - if (!this.isFocused || !this.isLoggedIn) { - return false; - } - return this.searchOptions?.length > 0; - }, - showDefaultItems() { - return !this.searchText; - }, - searchTermOverMin() { - return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; - }, - defaultIndex() { - if (this.showDefaultItems) { - return SEARCH_BOX_INDEX; - } - return FIRST_DROPDOWN_INDEX; - }, - searchInputDescribeBy() { - if (this.isLoggedIn) { - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; - } - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; - }, - 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.SEARCH_DESCRIBED_BY_DEFAULT, { - count: this.searchOptions.length, - }); - } - - return this.loading - ? this.$options.i18n.SEARCH_RESULTS_LOADING - : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { - count: this.searchOptions.length, - }); - }, - searchBarClasses() { - return { - [IS_SEARCHING]: this.searchTermOverMin, - [IS_FOCUSED]: this.isFocused, - [IS_NOT_FOCUSED]: !this.isFocused, - }; - }, - showScopeHelp() { - return this.searchTermOverMin && this.isFocused; - }, - searchBarItem() { - return this.searchOptions?.[0]; - }, - infieldHelpContent() { - return this.searchBarItem?.scope || this.searchBarItem?.description; - }, - infieldHelpIcon() { - return this.searchBarItem?.icon; - }, - scopeTokenTitle() { - return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, { - scope: this.infieldHelpContent, - }); - }, - }, - methods: { - ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), - openDropdown() { - this.isFocused = true; - this.$emit('expandSearchBar'); - - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, - collapseAndCloseSearchBar() { - // without timeout dropdown closes - // before click event is dispatched - setTimeout(() => { - this.isFocused = false; - this.$emit('collapseSearchBar'); - - Tracking.event(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, DROPDOWN_CLOSE_TIMEOUT); - }, - submitSearch() { - if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { - return null; - } - return visitUrl(this.currentFocusedOption?.url || this.searchQuery); - }, - getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { - this.openDropdown(); - if (!searchTerm) { - this.clearAutocomplete(); - } else { - this.fetchAutocompleteOptions(); - } - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getTruncatedScope(scope) { - return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); - }, - observeTokenWidth({ contentRect: { width } }) { - const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input'); - if (!inputField) { - return; - } - inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; - }, - }, - SEARCH_BOX_INDEX, - FIRST_DROPDOWN_INDEX, - SEARCH_INPUT_DESCRIPTION, - SEARCH_RESULTS_DESCRIPTION, -}; -</script> - -<template> - <form - role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search-form gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" - data-testid="header-search-form" - > - <gl-search-box-by-type - id="search" - ref="searchInputBox" - v-model="searchText" - role="searchbox" - class="gl-z-index-1" - data-testid="global-search-input" - autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" - :aria-activedescendant="currentFocusedId" - :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focusin="openDropdown" - @focusout="collapseAndCloseSearchBar" - @input="getAutocompleteOptions" - @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="collapseAndCloseSearchBar" - /> - <gl-token - v-if="showScopeHelp" - v-gl-resize-observer-directive="observeTokenWidth" - class="in-search-scope-help" - :view-only="true" - :title="scopeTokenTitle" - ><gl-icon - v-if="infieldHelpIcon" - class="gl-mr-2" - :aria-label="infieldHelpContent" - :name="infieldHelpIcon" - :size="16" - />{{ - getTruncatedScope( - sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { - scope: infieldHelpContent, - }), - ) - }} - </gl-token> - <kbd - v-show="!isFocused" - v-gl-tooltip.bottom.hover.html - class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" - :title="$options.i18n.KBD_HELP" - >/</kbd - > - <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-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3" - > - <div class="header-search-dropdown-content gl-py-2"> - <dropdown-keyboard-navigation - v-model="currentFocusIndex" - :max="searchOptions.length - 1" - :min="$options.FIRST_DROPDOWN_INDEX" - :default-index="defaultIndex" - :enable-cycle="true" - /> - <header-search-default-items - v-if="showDefaultItems" - :current-focused-option="currentFocusedOption" - /> - <template v-else> - <header-search-scoped-items - v-if="searchTermOverMin" - :current-focused-option="currentFocusedOption" - /> - <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> - </template> - </div> - </div> - </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 deleted file mode 100644 index a785ae2a859..00000000000 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ /dev/null @@ -1,168 +0,0 @@ -<script> -import { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, -} from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapGetters } from 'vuex'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import highlight from '~/lib/utils/highlight'; -import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { - GROUPS_CATEGORY, - PROJECTS_CATEGORY, - MERGE_REQUEST_CATEGORY, - ISSUES_CATEGORY, - RECENT_EPICS_CATEGORY, - AUTOCOMPLETE_ERROR_MESSAGE, -} from '~/vue_shared/global_search/constants'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; - -export default { - name: 'HeaderSearchAutocompleteItems', - i18n: { - AUTOCOMPLETE_ERROR_MESSAGE, - }, - components: { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, - }, - directives: { - SafeHtml, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, - computed: { - ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), - ...mapGetters(['autocompleteGroupedSearchOptions']), - }, - watch: { - currentFocusedOption() { - const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; - - if (focusedElement) { - focusedElement.scrollIntoView(false); - } - }, - }, - methods: { - truncateNamespace(string) { - if (string.split(' / ').length > 2) { - return truncateNamespace(string); - } - - return string; - }, - highlightedName(val) { - return highlight(val, this.search); - }, - avatarSize(data) { - if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { - return LARGE_AVATAR_PX; - } - - return SMALL_AVATAR_PX; - }, - isOptionFocused(data) { - return this.currentFocusedOption?.html_id === data.html_id; - }, - isProjectsCategory(data) { - return data.category === PROJECTS_CATEGORY; - }, - getEntityId(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_id || data.id || this.searchContext?.group?.id; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_id || data.id || this.searchContext?.project?.id; - default: - return data.id; - } - }, - getEntitytName(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_name || data.value || data.label || this.searchContext?.group?.name; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_name || data.value || data.label || this.searchContext?.project?.name; - default: - return data.label; - } - }, - }, - AVATAR_SHAPE_OPTION_RECT, -}; -</script> - -<template> - <div> - <template v-if="!loading"> - <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider v-if="index > 0" /> - <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> - <gl-dropdown-item - 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" aria-hidden="true"> - <gl-avatar - v-if="data.avatar_url !== undefined" - :src="data.avatar_url" - :entity-id="getEntityId(data)" - :entity-name="getEntitytName(data)" - :size="avatarSize(data)" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - /> - <span class="gl-display-flex gl-flex-direction-column"> - <span - v-safe-html="highlightedName(data.value || data.label)" - class="gl-text-gray-900" - ></span> - <span - v-if="data.value" - v-safe-html="truncateNamespace(data.label)" - class="gl-font-sm gl-text-gray-500" - ></span> - </span> - </div> - </gl-dropdown-item> - </div> - </template> - <gl-loading-icon v-else size="lg" class="my-4" /> - <gl-alert - v-if="autocompleteError" - class="gl-text-body gl-mt-2" - :dismissible="false" - variant="danger" - > - {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }} - </gl-alert> - </div> -</template> 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 deleted file mode 100644 index 6afee197c60..00000000000 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapGetters } from 'vuex'; -import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; - -export default { - name: 'HeaderSearchDefaultItems', - i18n: { - ALL_GITLAB, - }, - components: { - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, - computed: { - ...mapState(['searchContext']), - ...mapGetters(['defaultSearchOptions']), - sectionHeader() { - return ( - this.searchContext?.project?.name || - this.searchContext?.group?.name || - this.$options.i18n.ALL_GITLAB - ); - }, - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - }, -}; -</script> - -<template> - <div> - <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> - <gl-dropdown-item - 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" - > - <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 deleted file mode 100644 index 7faef5f9bd7..00000000000 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapGetters } from 'vuex'; -import { s__, sprintf } from '~/locale'; -import { truncate } from '~/lib/utils/text_utility'; -import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants'; -import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; - -export default { - name: 'HeaderSearchScopedItems', - i18n: { - SCOPED_SEARCH_ITEM_ARIA_LABEL, - }, - components: { - GlDropdownItem, - GlIcon, - GlToken, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, - computed: { - ...mapState(['search']), - ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - ariaLabel(option) { - return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, { - search: this.search, - description: option.description || option.icon, - scope: option.scope || '', - }); - }, - titleLabel(option) { - return sprintf(s__('GlobalSearch|in %{scope}'), { - search: this.search, - scope: option.scope || option.description, - }); - }, - getTruncatedScope(scope) { - return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); - }, - }, -}; -</script> - -<template> - <div> - <gl-dropdown-item - v-for="option in scopedSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - class="gl-max-w-full" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="ariaLabel(option)" - tabindex="-1" - :href="option.url" - :title="titleLabel(option)" - > - <span - ref="token-text-content" - class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full" - > - <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" /> - <span class="gl-flex-grow-1 gl-relative"> - <gl-token - class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!" - :view-only="true" - > - <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" /> - <span>{{ getTruncatedScope(titleLabel(option)) }}</span> - </gl-token> - {{ search }} - </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 deleted file mode 100644 index 47aeb2f9caa..00000000000 --- a/app/assets/javascripts/header_search/constants.js +++ /dev/null @@ -1,35 +0,0 @@ -export const ICON_PROJECT = 'project'; - -export const ICON_GROUP = 'group'; - -export const ICON_SUBGROUP = 'subgroup'; - -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_SHORTCUTS_MIN_CHARACTERS = 2; - -export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; - -export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; - -export const SCOPE_TOKEN_MAX_LENGTH = 36; - -export const INPUT_FIELD_PADDING = 52; - -export const HEADER_INIT_EVENTS = ['input', 'focus']; - -export const IS_SEARCHING = 'is-searching'; -export const IS_FOCUSED = 'is-focused'; -export const IS_NOT_FOCUSED = 'is-not-focused'; - -export const FETCH_TYPES = ['generic', 'search']; - -export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; - -export const DROPDOWN_CLOSE_TIMEOUT = 200; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js deleted file mode 100644 index 7b26dd183ad..00000000000 --- a/app/assets/javascripts/header_search/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import Vue from 'vue'; -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import Translate from '~/vue_shared/translate'; -import HeaderSearchApp from './components/app.vue'; -import createStore from './store'; -import { SEARCH_INPUT_FIELD_MAX_WIDTH } from './constants'; - -Vue.use(Translate); - -export const initHeaderSearchApp = (search = '') => { - const el = document.getElementById('js-header-search'); - const headerEl = document.querySelector('.header-content'); - - if (!el || !headerEl) { - return false; - } - - const searchContainer = headerEl.querySelector('.global-search-container'); - const newHeader = headerEl.querySelector('.header-search'); - - const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset; - let { searchContext } = el.dataset; - - try { - searchContext = JSON.parse(searchContext); - newHeader.style.maxWidth = SEARCH_INPUT_FIELD_MAX_WIDTH; - } catch (error) { - Sentry.captureException(error); - } - - return new Vue({ - el, - store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), - render(createElement) { - return createElement(HeaderSearchApp, { - on: { - expandSearchBar: () => { - searchContainer.style.flexGrow = '1'; - }, - collapseSearchBar: () => { - searchContainer.style.flexGrow = '0'; - }, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js deleted file mode 100644 index 1c582ace480..00000000000 --- a/app/assets/javascripts/header_search/init.js +++ /dev/null @@ -1,39 +0,0 @@ -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { HEADER_INIT_EVENTS } from './constants'; - -async function eventHandler(callback = () => {}) { - const { initHeaderSearchApp } = await import( - /* webpackChunkName: 'globalSearch' */ '~/header_search' - ).catch((error) => Sentry.captureException(error)); - - // In case the user started searching before we bootstrapped, - // let's pass the search along. - const initialSearchValue = this.searchInputBox.value; - initHeaderSearchApp(initialSearchValue); - - // this is new #search input element. We need to re-find it. - // And re-focus in it. - document.querySelector('#search').focus(); - callback(); -} - -function cleanEventListeners() { - HEADER_INIT_EVENTS.forEach((eventType) => { - document.querySelector('#search').removeEventListener(eventType, eventHandler); - }); -} - -function initHeaderSearch() { - const searchInputBox = document.querySelector('#search'); - - HEADER_INIT_EVENTS.forEach((eventType) => { - searchInputBox?.addEventListener( - eventType, - eventHandler.bind({ searchInputBox }, cleanEventListeners), - { once: true }, - ); - }); -} - -export default initHeaderSearch; -export { eventHandler, cleanEventListeners }; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js deleted file mode 100644 index a0f9e594506..00000000000 --- a/app/assets/javascripts/header_search/store/actions.js +++ /dev/null @@ -1,45 +0,0 @@ -import { omitBy, isNil } from 'lodash'; -import { objectToQuery } from '~/lib/utils/url_utility'; -import axios from '~/lib/utils/axios_utils'; -import { FETCH_TYPES } from '../constants'; -import * as types from './mutation_types'; - -export const autocompleteQuery = ({ state, fetchType }) => { - const query = omitBy( - { - term: state.search, - project_id: state.searchContext?.project?.id, - project_ref: state.searchContext?.ref, - filter: fetchType, - }, - isNil, - ); - - return `${state.autocompletePath}?${objectToQuery(query)}`; -}; - -const doFetch = ({ commit, state, fetchType }) => { - return axios - .get(autocompleteQuery({ state, fetchType })) - .then(({ data }) => { - commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data); - }) - .catch(() => { - commit(types.RECEIVE_AUTOCOMPLETE_ERROR); - }); -}; - -export const fetchAutocompleteOptions = ({ commit, state }) => { - commit(types.REQUEST_AUTOCOMPLETE); - const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType })); - - return Promise.all(promises); -}; - -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 deleted file mode 100644 index f86463b94d1..00000000000 --- a/app/assets/javascripts/header_search/store/getters.js +++ /dev/null @@ -1,220 +0,0 @@ -import { omitBy, isNil } from 'lodash'; -import { objectToQuery } from '~/lib/utils/url_utility'; - -import { - MSG_ISSUES_ASSIGNED_TO_ME, - MSG_ISSUES_IVE_CREATED, - MSG_MR_ASSIGNED_TO_ME, - MSG_MR_IM_REVIEWER, - MSG_MR_IVE_CREATED, - MSG_IN_ALL_GITLAB, - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - DROPDOWN_ORDER, -} from '~/vue_shared/global_search/constants'; -import { - ICON_GROUP, - ICON_SUBGROUP, - ICON_PROJECT, - SEARCH_SHORTCUTS_MIN_CHARACTERS, -} from '../constants'; - -export const searchQuery = (state) => { - 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, - snippets: state.searchContext?.for_snippets ? true : null, - search_code: state.searchContext?.code_search ? true : null, - repository_ref: state.searchContext?.ref, - }, - isNil, - ); - - return `${state.searchPath}?${objectToQuery(query)}`; -}; - -export const scopedIssuesPath = (state) => { - if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) { - return false; - } - - return ( - state.searchContext?.project_metadata?.issues_path || - state.searchContext?.group_metadata?.issues_path || - state.issuesPath - ); -}; - -export const scopedMRPath = (state) => { - return ( - state.searchContext?.project_metadata?.mr_path || - state.searchContext?.group_metadata?.mr_path || - state.mrPath - ); -}; - -export const defaultSearchOptions = (state, getters) => { - const userName = gon.current_username; - - const issues = [ - { - 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}`, - }, - ]; - - const mergeRequests = [ - { - 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}`, - }, - ]; - return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; -}; - -export const projectUrl = (state) => { - 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, - snippets: state.searchContext?.for_snippets ? true : null, - search_code: state.searchContext?.code_search ? true : null, - repository_ref: state.searchContext?.ref, - }, - isNil, - ); - - return `${state.searchPath}?${objectToQuery(query)}`; -}; - -export const groupUrl = (state) => { - const query = omitBy( - { - search: state.search, - nav_source: 'navbar', - group_id: state.searchContext?.group?.id, - scope: state.searchContext?.scope, - snippets: state.searchContext?.for_snippets ? true : null, - search_code: state.searchContext?.code_search ? true : null, - repository_ref: state.searchContext?.ref, - }, - isNil, - ); - - return `${state.searchPath}?${objectToQuery(query)}`; -}; - -export const allUrl = (state) => { - const query = omitBy( - { - search: state.search, - nav_source: 'navbar', - scope: state.searchContext?.scope, - snippets: state.searchContext?.for_snippets ? true : null, - search_code: state.searchContext?.code_search ? true : null, - repository_ref: state.searchContext?.ref, - }, - isNil, - ); - - return `${state.searchPath}?${objectToQuery(query)}`; -}; - -export const scopedSearchOptions = (state, getters) => { - const options = []; - - if (state.searchContext?.project) { - options.push({ - html_id: 'scoped-in-project', - scope: state.searchContext.project?.name || '', - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: getters.projectUrl, - }); - } - - if (state.searchContext?.group) { - options.push({ - html_id: 'scoped-in-group', - scope: state.searchContext.group?.name || '', - scopeCategory: GROUPS_CATEGORY, - icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, - url: getters.groupUrl, - }); - } - - options.push({ - html_id: 'scoped-in-all', - description: MSG_IN_ALL_GITLAB, - url: getters.allUrl, - }); - - return options; -}; - -export const autocompleteGroupedSearchOptions = (state) => { - const groupedOptions = {}; - const results = []; - - state.autocompleteOptions.forEach((option) => { - const category = groupedOptions[option.category]; - - if (category) { - category.data.push(option); - } else { - groupedOptions[option.category] = { - category: option.category, - data: [option], - }; - - results.push(groupedOptions[option.category]); - } - }); - - return results.sort( - (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), - ); -}; - -export const searchOptions = (state, getters) => { - if (!state.search) { - return getters.defaultSearchOptions; - } - - const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( - (options, group) => { - return [...options, ...group.data]; - }, - [], - ); - - if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { - return sortedAutocompleteOptions; - } - - return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); -}; diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js deleted file mode 100644 index ca5519f529c..00000000000 --- a/app/assets/javascripts/header_search/store/index.js +++ /dev/null @@ -1,26 +0,0 @@ -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import createState from './state'; - -Vue.use(Vuex); - -export const getStoreConfig = ({ - searchPath, - issuesPath, - mrPath, - autocompletePath, - searchContext, - search, -}) => ({ - actions, - getters, - mutations, - state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), -}); - -const createStore = (config) => new Vuex.Store(getStoreConfig(config)); -export default createStore; diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js deleted file mode 100644 index 6e65345757f..00000000000 --- a/app/assets/javascripts/header_search/store/mutation_types.js +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 19b4d4ec330..00000000000 --- a/app/assets/javascripts/header_search/store/mutations.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.REQUEST_AUTOCOMPLETE](state) { - state.loading = true; - state.autocompleteOptions = []; - state.autocompleteError = false; - }, - [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { - state.loading = false; - state.autocompleteOptions = [...state.autocompleteOptions].concat( - data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }), - ); - state.autocompleteError = false; - }, - [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { - state.loading = false; - state.autocompleteOptions = []; - state.autocompleteError = true; - }, - [types.CLEAR_AUTOCOMPLETE](state) { - state.autocompleteOptions = []; - state.autocompleteError = false; - }, - [types.SET_SEARCH](state, value) { - state.search = value; - }, -}; diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js deleted file mode 100644 index bebdbc7b92e..00000000000 --- a/app/assets/javascripts/header_search/store/state.js +++ /dev/null @@ -1,19 +0,0 @@ -const createState = ({ - searchPath, - issuesPath, - mrPath, - autocompletePath, - searchContext, - search, -}) => ({ - searchPath, - issuesPath, - mrPath, - autocompletePath, - searchContext, - search, - autocompleteOptions: [], - autocompleteError: false, - loading: false, -}); -export default createState; diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 44a94f5fefe..ef808c8218a 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -43,7 +43,6 @@ export default { :aria-label="s__('IDE|Edit')" data-container="body" data-placement="right" - data-qa-selector="edit_mode_tab" data-testid="edit-mode-button" type="button" class="ide-sidebar-link js-ide-edit-mode" @@ -80,7 +79,6 @@ export default { :aria-label="s__('IDE|Commit')" data-container="body" data-placement="right" - data-qa-selector="commit_mode_tab" data-testid="commit-mode-button" type="button" class="ide-sidebar-link js-ide-commit-mode" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index bc8496e359c..8765808cf0c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -78,7 +78,6 @@ export default { :value="$options.commitToCurrentBranch" :disabled="!canPushToBranch" :title="$options.currentBranchPermissionsTooltip" - data-qa-selector="commit_to_current_branch_radio_container" > <span class="ide-option-label"> <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')"> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 281a3054721..708b5d84e5b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -159,7 +159,6 @@ export default { category="primary" variant="confirm" block - data-qa-selector="begin_commit_button" data-testid="begin-commit-button" @click="beginCommit" > @@ -187,7 +186,6 @@ export default { :disabled="commitButtonDisabled" :loading="submitCommitLoading" data-testid="commit-button" - data-qa-selector="commit_button" category="primary" variant="confirm" type="submit" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 76d3acb8e1f..d7bf42ff559 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -3,7 +3,7 @@ import { GlButton, GlModal, GlTooltipDirective } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import ListItem from './list_item.vue'; export default { @@ -55,11 +55,6 @@ export default { }, }, computed: { - titleText() { - if (!this.title) return __('Changes'); - - return sprintf(__('%{title} changes'), { title: this.title }); - }, filesLength() { return this.fileList.length; }, @@ -84,7 +79,7 @@ export default { <div class="ide-commit-list-container"> <header class="multi-file-commit-panel-header gl-display-flex gl-mb-0"> <div class="gl-display-flex gl-align-items-center flex-fill"> - <strong> {{ titleText }} </strong> + <strong> {{ __('Changes') }} </strong> <div class="gl-display-flex gl-ml-auto"> <gl-button v-if="!stagedList" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 69d84bcc6aa..5ee28ae58bb 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -86,11 +86,7 @@ export default { role="button" @click="openFileInEditor" > - <span - class="multi-file-commit-list-file-path d-flex align-items-center" - data-qa-selector="file_to_commit_content" - :data-qa-file-name="file.name" - > + <span class="multi-file-commit-list-file-path d-flex align-items-center"> <file-icon :file-name="file.name" class="gl-mr-3" /> <template v-if="file.prevName && file.prevName !== file.name"> {{ file.prevName }} → diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index d05aa960f01..372ff9812ac 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -112,7 +112,6 @@ export default { :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" - data-qa-selector="ide_commit_message_field" dir="auto" name="commit-message" @scroll="handleScroll" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 38b71e3da73..0c1faad1573 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -76,7 +76,6 @@ export default { :value="value" :disabled="disabled" name="commit-action" - data-qa-selector="commit_type_radio" @change="updateCommitAction(value)" > <span v-if="label" class="ide-option-label"> diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index ce3d8f53fd2..0eb781e0ba2 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -53,7 +53,6 @@ export default { <template> <gl-alert - data-qa-selector="flash_alert" variant="danger" :dismissible="canDismiss" :primary-button-text="message.actionText" diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index 287ebc99662..e24e1468b6c 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -84,7 +84,6 @@ export default { <div class="gl-display-flex gl-align-items-center ide-file-templates gl-relative gl-z-index-1" data-testid="file-templates-bar" - data-qa-selector="file_templates_container" > <strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong> <gl-dropdown @@ -102,12 +101,11 @@ export default { <gl-dropdown v-if="showTemplatesDropdown" class="gl-mr-6" - data-qa-selector="file_template_dropdown" :text="$options.i18n.templateListDropdownLabel" @show="fetchTemplateTypes" > <template #header> - <gl-search-box-by-type v-model.trim="search" data-qa-selector="dropdown_filter_input" /> + <gl-search-box-by-type v-model.trim="search" /> </template> <div> <gl-loading-icon v-if="isLoading" /> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 6cb26643b66..a850d37c4c0 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -186,7 +186,6 @@ export default { category="primary" :title="__('New file')" :aria-label="__('New file')" - data-qa-selector="first_file_button" @click="createNewFile()" > {{ __('New file') }} diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue index 3296dc2060c..6e1a4cd8e99 100644 --- a/app/assets/javascripts/ide/components/ide_project_header.vue +++ b/app/assets/javascripts/ide/components/ide_project_header.vue @@ -25,11 +25,7 @@ export default { /> <span class="ide-sidebar-project-title"> <span class="sidebar-context-title"> {{ project.name }} </span> - <span - class="sidebar-context-title text-secondary" - data-qa-selector="project_path_content" - :data-qa-project-path="project.path_with_namespace" - > + <span class="sidebar-context-title text-secondary"> {{ project.path_with_namespace }} </span> </span> diff --git a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue index d8daf3b7ad6..172c622e195 100644 --- a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue +++ b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue @@ -71,7 +71,6 @@ export default { :title="tab.title" :aria-label="tab.title" :class="buttonClasses(tab)" - :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" class="ide-sidebar-link" type="button" @click="clickTab($event, tab)" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 984dc9edaf1..0c464606a98 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -4,7 +4,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { rightSidebarViews } from '../constants'; import IdeStatusList from './ide_status_list.vue'; @@ -106,7 +106,6 @@ export default { :href="getCommitPath(lastCommit.short_id)" class="commit-sha" data-testid="commit-sha-content" - data-qa-selector="commit_sha_content" >{{ lastCommit.short_id }}</a > by diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 427b3743961..5999d349ca8 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -61,7 +61,6 @@ export default { :show-label="false" class="gl-display-flex gl-border-0 gl-p-0 gl-mr-5" icon="doc-new" - data-qa-selector="new_file_button" @click="createNewFile()" /> <upload @@ -75,7 +74,6 @@ export default { :show-label="false" class="gl-display-flex gl-border-0 gl-p-0" icon="folder-new" - data-qa-selector="new_directory_button" @click="createNewFolder()" /> </div> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index f2a97e62190..7c3441b8cd0 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -53,7 +53,7 @@ export default { </script> <template> - <div class="ide-file-list" data-qa-selector="file_list_container"> + <div class="ide-file-list"> <template v-if="showLoading"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> <gl-skeleton-loader /> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index f5840661c17..21b12220b37 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlIcon } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 7cd415169cc..5e71470f383 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -66,7 +66,6 @@ export default { :aria-label="__('Create new file or directory')" type="button" class="rounded border-0 d-flex ide-entry-dropdown-toggle" - data-qa-selector="dropdown_button" @click.stop="openDropdown()" > <gl-icon name="ellipsis_v" /> @@ -100,7 +99,6 @@ export default { class="d-flex" icon="pencil" icon-classes="mr-2" - data-qa-selector="rename_move_button" @click="createNewItem($options.modalTypes.rename)" /> </li> @@ -110,7 +108,6 @@ export default { class="d-flex" icon="remove" icon-classes="mr-2" - data-qa-selector="delete_button" @click="deleteEntry(path)" /> </li> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index ba1258f8b50..1e4eca23cb5 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -157,7 +157,6 @@ export default { <gl-modal ref="modal" modal-id="ide-new-entry" - data-qa-selector="new_file_modal" data-testid="ide-new-entry" :title="modalTitle" size="lg" @@ -179,11 +178,7 @@ export default { :placeholder="placeholder" /> </form> - <ul - v-if="isCreatingNewFile" - class="file-templates gl-mt-3 list-inline" - data-qa-selector="template_list_content" - > + <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline"> <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <gl-button variant="dashed" diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 9664c5bc597..69cf5c9b252 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -82,7 +82,6 @@ export default { type="file" class="hidden" multiple - data-qa-selector="file_upload_field" @change="openFile" /> </li> diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index ce55d88437d..95d38da508e 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -74,11 +74,7 @@ export default { </script> <template> - <div - :class="`ide-${side}-sidebar`" - :data-qa-selector="`ide_${side}_sidebar`" - class="multi-file-commit-panel ide-sidebar" - > + <div :class="`ide-${side}-sidebar`" class="multi-file-commit-panel ide-sidebar"> <div v-show="isOpen" :class="`ide-${side}-sidebar-${currentView}`" diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 0e07cc34dd8..5f0030b0795 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -5,7 +5,7 @@ import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import IDEServices from '~/ide/services'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import JobsList from '../jobs/list.vue'; import EmptyState from './empty_state.vue'; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3b59fe86764..8f4f777d396 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -543,7 +543,6 @@ export default { 'is-added': file.tempFile, }" class="multi-file-editor-holder" - data-qa-selector="editor_container" data-testid="editor-container" :data-editor-loading="isEditorLoading" @focusout="triggerFilesChange" diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue index 428cf7f55ac..e7725054329 100644 --- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue +++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue @@ -121,7 +121,6 @@ export default { :placeholder="placeholder" :value="text" class="gl-absolute gl-w-full gl-h-full gl-z-index-2 gl-font-monospace p-0 gl-outline-0 gl-bg-transparent gl-border-0" - data-qa-selector="ide_commit_message_field" dir="auto" name="commit-message" @scroll="handleScroll" diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index fa93f6d42a5..e7dcf2ca6b6 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -53,13 +53,7 @@ export default { <template v-else> <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> <p> - <gl-button - :disabled="!isValid" - category="primary" - variant="confirm" - data-qa-selector="start_web_terminal_button" - @click="onStart" - > + <gl-button :disabled="!isValid" category="primary" variant="confirm" @click="onStart"> {{ __('Start Web Terminal') }} </gl-button> </p> diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue index b2e90a64758..b34febe5f1d 100644 --- a/app/assets/javascripts/ide/components/terminal/terminal.vue +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -95,7 +95,7 @@ export default { <template> <div class="d-flex flex-column flex-fill min-height-0 pr-3"> <div class="top-bar d-flex border-left-0 align-items-center"> - <div v-if="loadingText" data-qa-selector="loading_container"> + <div v-if="loadingText"> <gl-loading-icon size="sm" :inline="true" /> <span>{{ loadingText }}</span> </div> @@ -113,7 +113,6 @@ export default { ref="terminal" class="ide-terminal-trace flex-fill min-height-0 w-100" :data-project-path="terminalPath" - data-qa-selector="terminal_screen" ></div> </div> </div> diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 868830c953a..f5fb4c8be2f 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -6,10 +6,13 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_action'; import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; import csrf from '~/lib/utils/csrf'; import Tracking from '~/tracking'; -import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config'; -import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element'; +import { + getBaseConfig, + getOAuthConfig, + setupRootElement, + handleTracking, +} from './lib/gitlab_web_ide'; import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; -import { handleTracking } from './lib/gitlab_web_ide/handle_tracking_event'; const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { const remotePath = cleanLeadingSeparator(remotePathArg); @@ -51,15 +54,21 @@ export const initGitlabWebIDE = async (el) => { : null; const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; + const oauthConfig = getOAuthConfig(el.dataset); + const httpHeaders = oauthConfig + ? undefined + : // Use same headers as defined in axios_utils (not needed in oauth) + { + [csrf.headerKey]: csrf.token, + 'X-Requested-With': 'XMLHttpRequest', + }; + // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 start(rootEl, { ...getBaseConfig(), nonce, - // Use same headers as defined in axios_utils - httpHeaders: { - [csrf.headerKey]: csrf.token, - 'X-Requested-With': 'XMLHttpRequest', - }, + httpHeaders, + auth: oauthConfig, projectPath, ref, filePath, diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js new file mode 100644 index 00000000000..5493a9ba7c7 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js @@ -0,0 +1,12 @@ +export const getOAuthConfig = ({ clientId, callbackUrl }) => { + if (!clientId) { + return undefined; + } + + return { + type: 'oauth', + clientId, + callbackUrl, + protectRefreshToken: true, + }; +}; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js index 8311e11672e..87e0002c8c8 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js @@ -1,2 +1,4 @@ export * from './get_base_config'; +export * from './get_oauth_config'; +export * from './handle_tracking_event'; export * from './setup_root_element'; diff --git a/app/assets/javascripts/ide/mount_oauth_callback.js b/app/assets/javascripts/ide/mount_oauth_callback.js new file mode 100644 index 00000000000..79fffb24f8e --- /dev/null +++ b/app/assets/javascripts/ide/mount_oauth_callback.js @@ -0,0 +1,12 @@ +import { oauthCallback } from '@gitlab/web-ide'; +import { getBaseConfig, getOAuthConfig } from './lib/gitlab_web_ide'; + +export const mountOAuthCallback = () => { + const el = document.getElementById('ide'); + + return oauthCallback({ + ...getBaseConfig(), + username: gon.current_username, + auth: getOAuthConfig(el.dataset), + }); +}; diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js index b02eb3c4307..cbb01d0bbf1 100644 --- a/app/assets/javascripts/import/constants.js +++ b/app/assets/javascripts/import/constants.js @@ -7,9 +7,12 @@ export const BULK_IMPORT_STATIC_ITEMS = { epics: __('Epic'), issues: __('Issue'), labels: __('Label'), + iterations: __('Iteration'), + iterations_cadences: s__('Iterations|Iteration cadence'), members: __('Member'), merge_requests: __('Merge request'), milestones: __('Milestone'), + namespace_settings: s__('GroupSettings|Namespace setting'), project: __('Project'), }; diff --git a/app/assets/javascripts/import/details/components/bulk_import_details_app.vue b/app/assets/javascripts/import/details/components/bulk_import_details_app.vue index 5da16454032..a248dd3d2c4 100644 --- a/app/assets/javascripts/import/details/components/bulk_import_details_app.vue +++ b/app/assets/javascripts/import/details/components/bulk_import_details_app.vue @@ -1,10 +1,14 @@ <script> -import { __ } from '~/locale'; +import { sprintf, s__, __ } from '~/locale'; +import { getParameterValues } from '~/lib/utils/url_utility'; + import ImportDetailsTable from '~/import/details/components/import_details_table.vue'; export default { name: 'BulkImportDetailsApp', - components: { ImportDetailsTable }, + components: { + ImportDetailsTable, + }, fields: [ { @@ -28,12 +32,25 @@ export default { ], LOCAL_STORAGE_KEY: 'gl-bulk-import-details-page-size', + + gitlabLogo: window.gon.gitlab_logo, + + computed: { + title() { + const id = getParameterValues('entity_id')[0]; + + return sprintf(s__('BulkImport|Items that failed to be imported for %{id}'), { id }); + }, + }, }; </script> <template> <div> - <h1>{{ s__('Import|GitLab Migration details') }}</h1> + <h1 class="gl-font-size-h1 gl-my-0 gl-py-4 gl-display-flex gl-align-items-center gl-gap-3"> + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6" /> + <span>{{ title }}</span> + </h1> <import-details-table bulk-import diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue index 535ccb525ac..8b9bf14e3a3 100644 --- a/app/assets/javascripts/import/details/components/import_details_table.vue +++ b/app/assets/javascripts/import/details/components/import_details_table.vue @@ -21,7 +21,6 @@ export default { GlTable, PaginationBar, }, - STATISTIC_ITEMS, i18n: { fetchErrorMessage: s__('Import|An error occurred while fetching import details.'), @@ -141,7 +140,7 @@ export default { <template> <div> - <gl-table :fields="fields" :items="items" class="gl-mt-5" :busy="loading" show-empty> + <gl-table :fields="fields" :items="items" :busy="loading" show-empty> <template #table-busy> <gl-loading-icon size="lg" class="gl-my-5" /> </template> diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index 23604c7fb44..6f1ca92d80d 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -15,8 +15,12 @@ export const STATUSES = { export const PROVIDERS = { GITHUB: 'github', + BITBUCKET_SERVER: 'bitbucket_server', }; +// Retrieved from value of `PAGE_LENGTH` in lib/bitbucket_server/paginator.rb +export const BITBUCKET_SERVER_PAGE_LENGTH = 25; + const SCHEDULED_STATUS_ICON = { icon: 'status-scheduled', text: __('Pending'), diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue b/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue new file mode 100644 index 00000000000..218e7dee953 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue @@ -0,0 +1,31 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; + +export default { + components: { + GlLink, + }, + + props: { + id: { + type: Number, + required: false, + default: null, + }, + historyPath: { + type: String, + required: true, + }, + }, + + computed: { + historyPathWithId() { + return mergeUrlParams({ bulk_import_id: this.id }, this.historyPath); + }, + }, +}; +</script> +<template> + <gl-link :href="historyPathWithId">{{ __('View details') }}</gl-link> +</template> 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 df1e50cb433..db354a01899 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 @@ -13,8 +13,9 @@ import { GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, isNumber } from 'lodash'; import { createAlert } from '~/alert'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__, __, n__, sprintf } from '~/locale'; import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -28,7 +29,6 @@ import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/i import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { STATUSES } from '../../constants'; -import ImportStatusCell from '../../components/import_status.vue'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; @@ -36,7 +36,9 @@ import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants'; import { StatusPoller } from '../services/status_poller'; import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils'; import ImportActionsCell from './import_actions_cell.vue'; +import ImportHistoryLink from './import_history_link.vue'; import ImportSourceCell from './import_source_cell.vue'; +import ImportStatusCell from './import_status.vue'; import ImportTargetCell from './import_target_cell.vue'; const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; @@ -60,6 +62,7 @@ export default { ImportTargetCell, ImportStatusCell, ImportActionsCell, + ImportHistoryLink, PaginationBar, HelpPopover, }, @@ -143,7 +146,7 @@ export default { { key: 'progress', label: __('Status'), - tdAttr: { 'data-qa-selector': 'import_status_indicator' }, + tdAttr: { 'data-testid': 'import-status-indicator' }, }, { key: 'actions', @@ -283,10 +286,18 @@ export default { this.statusPoller = new StatusPoller({ pollPath: this.jobsPath, updateImportStatus: (update) => { - this.$apollo.mutate({ - mutation: updateImportStatusMutation, - variables: { id: update.id, status: update.status_name }, - }); + try { + this.$apollo.mutate({ + mutation: updateImportStatusMutation, + variables: { + id: update.id, + status: update.status_name, + hasFailures: update.has_failures, + }, + }); + } catch (error) { + Sentry.captureException(error); + } }, }); @@ -315,7 +326,7 @@ export default { qaRowAttributes(group, type) { if (type === 'row') { return { - 'data-qa-selector': 'import_item', + 'data-testid': 'import-item', 'data-qa-source-group': group.fullPath, }; } @@ -339,6 +350,16 @@ export default { return group.progress?.status || STATUSES.NONE; }, + hasFailures(group) { + return group.progress?.hasFailures; + }, + + showHistoryLink(group) { + // We need to check for `isNumber` to make sure `id` is passed from the backend + // and not "LOCAL-PROGRESS-${id}" as defined by client_factory.js + return group.progress?.id && isNumber(group.progress.id); + }, + updateImportTarget(group, changes) { const newImportTarget = { ...group.importTarget, @@ -570,11 +591,11 @@ export default { <template> <div> <div - class="gl-display-flex gl-align-items-center gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1" + class="gl-display-flex gl-align-items-center gl-border-solid gl-border-gray-100 gl-border-0 gl-border-b-1" > - <h1 class="gl-my-0 gl-py-4 gl-font-size-h1gl-display-flex"> - <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 class="gl-font-size-h1 gl-my-0 gl-py-4 gl-display-flex gl-align-items-center gl-gap-3"> + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6" /> + <span>{{ s__('BulkImport|Import groups by direct transfer') }}</span> </h1> <gl-link :href="historyPath" class="gl-ml-auto">{{ s__('BulkImport|History') }}</gl-link> </div> @@ -735,7 +756,6 @@ export default { <gl-table ref="table" class="gl-w-full import-table" - data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" thead-class="gl-sticky gl-z-index-2 gl-bg-gray-10" @@ -786,7 +806,13 @@ export default { /> </template> <template #cell(progress)="{ item: group }"> - <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" /> + <import-status-cell :status="group.visibleStatus" :has-failures="hasFailures(group)" /> + <import-history-link + v-if="showHistoryLink(group)" + :id="group.progress.id" + :history-path="historyPath" + class="gl-display-inline-block gl-mt-2" + /> </template> <template #cell(actions)="{ item: group, index }"> <import-actions-cell 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 de0595360bf..4046d25acc3 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 @@ -111,7 +111,11 @@ export function createResolvers({ endpoints }) { }, }, Mutation: { - async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) { + updateImportStatus( + _, + { id, status: newStatus, hasFailures = false }, + { client, getCacheKey }, + ) { const progressItem = client.readFragment({ fragment: bulkImportSourceGroupProgressFragment, fragmentName: 'BulkImportSourceGroupProgress', @@ -123,13 +127,14 @@ export function createResolvers({ endpoints }) { if (!progressItem) return null; - localStorageCache.updateStatusByJobId(id, newStatus); + localStorageCache.updateStatusByJobId(id, newStatus, hasFailures); return { __typename: clientTypenames.BulkImportProgress, ...progressItem, id, status: newStatus, + hasFailures, }; }, @@ -172,6 +177,7 @@ export function createResolvers({ endpoints }) { id: response.id || `local-${Date.now()}-${idx}`, status: response.success ? STATUSES.CREATED : STATUSES.FAILED, message: response.message || null, + hasFailures: !response.success, }; 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 33c564f36a8..43b512946bd 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,5 +1,6 @@ fragment BulkImportSourceGroupProgress on ClientBulkImportProgress { id status + hasFailures 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 39289887b75..b0909840f67 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 + hasFailures message } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql index 8c0233b2939..1eaffeb87a1 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql @@ -1,6 +1,7 @@ -mutation updateImportStatus($status: String!, $id: String!) { - updateImportStatus(status: $status, id: $id) @client { +mutation updateImportStatus($status: String!, $id: String!, $hasFailures: Boolean) { + updateImportStatus(status: $status, id: $id, hasFailures: $hasFailures) @client { id status + hasFailures } } 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 c2e35ce8270..a186b366ea9 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 @@ -1,8 +1,8 @@ import { debounce, merge } from 'lodash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -const OLD_KEY = 'gl-bulk-imports-import-state'; -export const KEY = 'gl-bulk-imports-import-state-v2'; +const OLD_KEY = 'gl-bulk-imports-import-state-v2'; +export const KEY = 'gl-bulk-imports-import-state-v3'; export const DEBOUNCE_INTERVAL = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export class LocalStorageCache { @@ -57,13 +57,14 @@ export class LocalStorageCache { return this.jobsLookupCache[jobId]; } - updateStatusByJobId(jobId, status) { + updateStatusByJobId(jobId, status, hasFailures) { this.getCacheKeysByJobId(jobId).forEach((webUrl) => this.set(webUrl, { ...this.get(webUrl), progress: { id: jobId, status, + hasFailures, }, }), ); 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 83d17a5baa7..7ea25de9a29 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -17,6 +17,7 @@ type ClientBulkImportSourceGroupConnection { type ClientBulkImportProgress { id: ID! status: String! + hasFailures: Boolean message: String } @@ -79,5 +80,5 @@ input ImportRequestInput { extend type Mutation { importGroups(importRequests: [ImportRequestInput!]!): [ClientBulkImportSourceGroup!]! - updateImportStatus(id: ID, status: String!): ClientBulkImportProgress + updateImportStatus(id: ID, status: String!, hasFailures: Boolean): ClientBulkImportProgress } diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index d98132382c6..bceded18a09 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -105,10 +105,7 @@ export default { mounted() { this.fetchJobs(); - - if (!this.paginatable) { - this.fetchRepos(); - } + this.fetchRepos(); }, beforeDestroy() { diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 6ee637b1ce8..cd2e8d690d6 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -21,7 +21,6 @@ export function initStoreFromElement(element) { importPath, cancelPath, defaultTargetNamespace, - paginatable, } = element.dataset; return createStore({ @@ -37,7 +36,6 @@ export function initStoreFromElement(element) { importPath, cancelPath, }, - hasPagination: parseBoolean(paginatable), }); } diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index e5cbac71ce0..96437736c82 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import _ from 'lodash'; +import { isEmpty } from 'lodash'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -9,7 +9,7 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import { isProjectImportable } from '../utils'; -import { PROVIDERS } from '../../constants'; +import { PROVIDERS, BITBUCKET_SERVER_PAGE_LENGTH } from '../../constants'; import * as types from './mutation_types'; let eTagPoll; @@ -25,7 +25,7 @@ const pathWithParams = ({ path, ...params }) => { return queryString ? `${path}?${queryString}` : path; }; const commitPaginationData = ({ state, commit, data }) => { - const cursorsGitHubResponse = !_.isEmpty(data.pageInfo || {}); + const cursorsGitHubResponse = !isEmpty(data.pageInfo || {}); if (state.provider === PROVIDERS.GITHUB && cursorsGitHubResponse) { commit(types.SET_PAGE_CURSORS, data.pageInfo); @@ -33,6 +33,16 @@ const commitPaginationData = ({ state, commit, data }) => { const nextPage = state.pageInfo.page + 1; commit(types.SET_PAGE, nextPage); } + + // Only BitBucket Server uses pagination with page length + if (state.provider === PROVIDERS.BITBUCKET_SERVER) { + const reposLength = data.providerRepos.length; + if (reposLength > 0 && reposLength % BITBUCKET_SERVER_PAGE_LENGTH === 0) { + commit(types.SET_HAS_NEXT_PAGE, true); + } else { + commit(types.SET_HAS_NEXT_PAGE, false); + } + } }; const paginationParams = ({ state }) => { if (state.provider === PROVIDERS.GITHUB && state.pageInfo.endCursor) { diff --git a/app/assets/javascripts/import_entities/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js index d3edb48e1db..058c04002d9 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/index.js +++ b/app/assets/javascripts/import_entities/import_projects/store/index.js @@ -8,10 +8,10 @@ import state from './state'; Vue.use(Vuex); -export default ({ initialState, endpoints, hasPagination }) => +export default ({ initialState, endpoints }) => new Vuex.Store({ state: { ...state(), ...initialState }, - actions: actionsFactory({ endpoints, hasPagination }), + actions: actionsFactory({ endpoints }), mutations, getters, }); diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js index 74832a03ac1..6adb8fb615b 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js @@ -17,3 +17,5 @@ export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET'; export const SET_PAGE = 'SET_PAGE'; export const SET_PAGE_CURSORS = 'SET_PAGE_CURSORS'; + +export const SET_HAS_NEXT_PAGE = 'SET_HAS_NEXT_PAGE'; diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index df529449f90..5ace3237e5d 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -143,4 +143,8 @@ export default { const { startCursor, endCursor, hasNextPage } = pageInfo; state.pageInfo = { ...state.pageInfo, startCursor, endCursor, hasNextPage }; }, + + [types.SET_HAS_NEXT_PAGE](state, hasNextPage) { + state.pageInfo.hasNextPage = hasNextPage; + }, }; diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js index 62dcefd3339..b90039c5f0b 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/state.js +++ b/app/assets/javascripts/import_entities/import_projects/store/state.js @@ -9,6 +9,6 @@ export default () => ({ page: 0, startCursor: null, endCursor: null, - hasNextPage: true, + hasNextPage: false, }, }); diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index bd45412a481..f3ebbd91a8b 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -106,7 +106,7 @@ export default { }); }, fieldId() { - return `service_${this.name}`; + return `service-${this.name}`; }, fieldName() { return `service[${this.name}]`; @@ -182,7 +182,7 @@ export default { autocomplete="new-password" :placeholder="placeholder" :required="passwordRequired" - :data-qa-selector="`${fieldId}_field`" + :data-testid="`${fieldId}-field`" /> <gl-form-input v-else @@ -191,7 +191,7 @@ export default { :type="type" :placeholder="placeholder" :required="required" - :data-qa-selector="`${fieldId}_field`" + :data-testid="`${fieldId}-field`" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 281666a021d..efd5f395f7f 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -256,7 +256,7 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" - :data-qa-selector="`${field.name}_div`" + :data-testid="`${field.name}-div`" /> </section> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue index a9d7c1ca378..d6eae8a7e23 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue @@ -79,8 +79,7 @@ export default { variant="confirm" :loading="isSaving" :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" + data-testid="save-changes-button" > {{ __('Save changes') }} </gl-button> @@ -93,8 +92,7 @@ export default { type="submit" :loading="isSaving" :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" + data-testid="save-changes-button" @click.prevent="onSaveClick" > {{ __('Save changes') }} 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 c1c09cfa3d6..8ee63ca4818 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -86,7 +86,7 @@ export default { <gl-form-checkbox v-model="enableJiraIssues" :disabled="checkboxDisabled" - data-qa-selector="service_jira_issues_enabled_checkbox" + data-testid="jira-issues-enabled-checkbox" > {{ $options.i18n.enableCheckboxLabel }} <template #help> @@ -107,7 +107,7 @@ export default { id="service_project_key" v-model="projectKey" name="service[project_key]" - data-qa-selector="service_jira_project_key_field" + data-testid="jira-project-key-field" :placeholder="$options.i18n.projectKeyPlaceholder" :required="enableJiraIssues" :state="validProjectKey" diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 034867f8b5f..e987cf49997 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -201,7 +201,7 @@ export default { <gl-form-checkbox v-model="issueTransitionEnabled" :disabled="isInheriting" - data-qa-selector="service_jira_issue_transition_enabled_checkbox" + data-testid="jira-issue-transition-enabled-checkbox" > {{ s__('JiraService|Enable Jira transitions') }} </gl-form-checkbox> @@ -219,7 +219,7 @@ export default { name="service[jira_issue_transition_automatic]" :value="issueTransitionOption.value" :disabled="isInheriting" - :data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`" + :data-testid="`jira-issue-transition-automatic-${issueTransitionOption.value}-radio`" > {{ issueTransitionOption.label }} @@ -229,7 +229,7 @@ export default { name="service[jira_issue_transition_id]" type="text" class="gl-my-3" - data-qa-selector="service_jira_issue_transition_id_field" + data-testid="jira-issue-transition-id-field" :placeholder="s__('JiraService|For example, 12, 24')" :disabled="isInheriting" :required="true" diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue index 052e8d8488d..bd407d4456b 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue @@ -34,7 +34,6 @@ export default { :key="`${currentKey}-${field.name}`" v-bind="field" :is-validated="isValidated" - :data-qa-selector="`${field.name}_div`" /> </div> </template> diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue index c94c509e811..8cfc3ea9098 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_table.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -117,11 +117,7 @@ export default { </template> <template #cell(title)="{ item }"> - <gl-avatar-link - :href="item.edit_path" - :title="item.title" - :data-qa-selector="`${item.name}_link`" - > + <gl-avatar-link :href="item.edit_path" :title="item.title" :data-testid="`${item.name}-link`"> <gl-avatar-labeled :label="item.title" :sub-label="item.description" diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue index 424a9d3fabd..b0cfe670edc 100644 --- a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -28,12 +28,7 @@ export default { </script> <template> - <gl-button - :class="classes" - data-qa-selector="invite_a_group_button" - data-test-id="invite-group-button" - @click="openModal" - > + <gl-button :class="classes" data-testid="invite-group-button" @click="openModal"> {{ displayText }} </gl-button> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index ceb9200dfad..9893572ae16 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -106,6 +106,9 @@ export default { inviteDisabled() { return Object.keys(this.groupToBeSharedWith).length === 0; }, + staticRoles() { + return { validRoles: this.accessLevels }; + }, }, mounted() { if (this.reloadPageOnSubmit) { @@ -182,7 +185,7 @@ export default { :modal-id="modalId" :modal-title="$options.labels.title" :name="name" - :access-levels="accessLevels" + :access-levels="staticRoles" :default-access-level="defaultAccessLevel" :help-link="helpLink" v-bind="$attrs" @@ -194,6 +197,7 @@ export default { :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" :full-path="fullPath" + is-group-invite @reset="resetFields" @submit="sendInvite" > 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 1a10130e969..dead90eeb71 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -85,6 +85,11 @@ export default { type: Number, required: true, }, + defaultMemberRoleId: { + type: Number, + required: false, + default: null, + }, helpLink: { type: String, required: true, @@ -128,8 +133,6 @@ export default { invalidMembers: {}, source: 'unknown', mode: 'default', - // Kept in sync with "base" - selectedAccessLevel: undefined, errorsLimit: 2, isErrorsSectionExpanded: false, shouldShowEmptyInvitesAlert: false, @@ -157,7 +160,7 @@ export default { labelSearchField() { return this.isEmailSignupEnabled ? this.$options.labels.searchField - : s__('InviteMembersModal|Username'); + : s__('InviteMembersModal|Username or name'); }, isEmptyInvites() { return Boolean(this.newUsersToInvite.length); @@ -183,6 +186,9 @@ export default { showUserLimitNotification() { return !isEmpty(this.usersLimitDataset.alertVariant); }, + staticRoles() { + return { validRoles: this.accessLevels }; + }, limitVariant() { return this.usersLimitDataset.alertVariant; }, @@ -269,7 +275,7 @@ export default { this.shouldShowEmptyInvitesAlert = true; this.$refs.alerts.focus(); }, - getInvitePayload({ accessLevel, expiresAt }) { + getInvitePayload({ accessLevel, expiresAt, memberRoleId }) { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {}; @@ -279,12 +285,13 @@ export default { format: 'json', expires_at: expiresAt, access_level: accessLevel, + member_role_id: memberRoleId, invite_source: this.source, ...email, ...userId, }; }, - async sendInvite({ accessLevel, expiresAt }) { + async sendInvite({ accessLevel, expiresAt, memberRoleId }) { this.isLoading = true; this.clearValidation(); @@ -298,7 +305,7 @@ export default { : Api.inviteGroupMembers.bind(Api); try { - const payload = this.getInvitePayload({ accessLevel, expiresAt }); + const payload = this.getInvitePayload({ accessLevel, expiresAt, memberRoleId }); const response = await apiAddByInvite(this.id, payload); const { error, message } = responseFromSuccess(response); @@ -355,9 +362,6 @@ export default { this.closeModal(); }, - onAccessLevelUpdate(val) { - this.selectedAccessLevel = val; - }, clearValidation() { this.invalidFeedbackMessage = ''; this.invalidMembers = {}; @@ -382,14 +386,16 @@ export default { :modal-id="modalId" :modal-title="modalTitle" :name="name" - :access-levels="accessLevels" + :access-levels="staticRoles" :default-access-level="defaultAccessLevel" + :default-member-role-id="defaultMemberRoleId" :help-link="helpLink" :label-intro-text="labelIntroText" :label-search-field="labelSearchField" :form-group-description="formGroupDescription" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" + :is-project="isProject" :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" :users-limit-dataset="usersLimitDataset" @@ -398,7 +404,6 @@ export default { @cancel="onCancel" @reset="resetFields" @submit="sendInvite" - @access-level="onAccessLevelUpdate" > <template #intro-text-before> <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"> @@ -511,6 +516,7 @@ export default { :exception-state="exceptionState" :users-filter="usersFilter" :filter-id="filterId" + :root-group-id="rootId" :invalid-members="invalidMembers" @clear="clearValidation" @token-remove="removeToken" 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 6efb7a6cdf1..7f76b7ca1ac 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -4,7 +4,6 @@ import { s__ } from '~/locale'; import eventHub from '../event_hub'; import { TRIGGER_ELEMENT_BUTTON, - TRIGGER_DEFAULT_QA_SELECTOR, TRIGGER_ELEMENT_WITH_EMOJI, TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI, TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, @@ -42,18 +41,12 @@ export default { required: false, default: 'button', }, - qaSelector: { - type: String, - required: false, - default: TRIGGER_DEFAULT_QA_SELECTOR, - }, }, computed: { componentAttributes() { return { class: this.classes, - 'data-qa-selector': this.qaSelector, - 'data-test-id': 'invite-members-button', + 'data-testid': 'invite-members-button', }; }, item() { diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index a14dcd38aa7..00b7c3f4bdd 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -1,7 +1,7 @@ <script> import { + GlCollapsibleListbox, GlFormGroup, - GlFormSelect, GlModal, GlDatepicker, GlLink, @@ -12,6 +12,7 @@ import { import Tracking from '~/tracking'; import { sprintf } from '~/locale'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; +import { initialSelectedRole, roleDropdownItems } from 'ee_else_ce/members/utils'; import { ACCESS_LEVEL, ACCESS_EXPIRE_DATE, @@ -36,8 +37,8 @@ const DEFAULT_SLOTS = [ export default { components: { + GlCollapsibleListbox, GlFormGroup, - GlFormSelect, GlDatepicker, GlLink, GlModal, @@ -68,6 +69,11 @@ export default { type: Number, required: true, }, + defaultMemberRoleId: { + type: Number, + required: false, + default: null, + }, helpLink: { type: String, required: true, @@ -95,6 +101,11 @@ export default { required: false, default: false, }, + isLoadingRoles: { + type: Boolean, + required: false, + default: false, + }, invalidFeedbackMessage: { type: String, required: false, @@ -134,14 +145,14 @@ export default { data() { // Be sure to check out reset! return { - selectedAccessLevel: this.defaultAccessLevel, + selectedAccessLevel: null, selectedDate: undefined, minDate: new Date(), }; }, computed: { - accessLevelsOptions() { - return Object.entries(this.accessLevels).map(([text, value]) => ({ text, value })); + accessLevelOptions() { + return roleDropdownItems(this.accessLevels); }, introText() { return sprintf(this.labelIntroText, { name: this.name }); @@ -158,11 +169,6 @@ export default { datepickerId() { return `${this.modalId}_expires_at`; }, - selectedRoleName() { - return Object.keys(this.accessLevels).find( - (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), - ); - }, contentSlots() { return [...DEFAULT_SLOTS, ...(this.extraSlots || [])]; }, @@ -173,7 +179,6 @@ export default { variant: 'confirm', disabled: this.submitDisabled, loading: this.isLoading, - 'data-qa-selector': 'invite_button', }, }; }, @@ -195,18 +200,16 @@ export default { }, }, watch: { - selectedAccessLevel: { + accessLevelOptions: { immediate: true, - handler(val) { - this.$emit('access-level', val); - }, + handler: 'resetSelectedAccessLevel', }, }, methods: { onReset() { // This component isn't necessarily disposed, // so we might need to reset it's state. - this.selectedAccessLevel = this.defaultAccessLevel; + this.resetSelectedAccessLevel(); this.selectedDate = undefined; this.$emit('reset'); @@ -230,14 +233,27 @@ export default { // We never want to hide when submitting e.preventDefault(); + const { accessLevel, memberRoleId } = this.accessLevelOptions.flatten.find( + (item) => item.value === this.selectedAccessLevel, + ); this.$emit('submit', { - accessLevel: this.selectedAccessLevel, + accessLevel, + memberRoleId, expiresAt: this.selectedDate, }); }, onClose() { this.$emit('close'); }, + resetSelectedAccessLevel() { + const accessLevel = { + integerValue: this.defaultAccessLevel, + memberRoleId: this.defaultMemberRoleId, + }; + this.selectedAccessLevel = initialSelectedRole(this.accessLevelOptions.flatten, { + accessLevel, + }); + }, }, HEADER_CLOSE_LABEL, ACCESS_EXPIRE_DATE, @@ -308,11 +324,13 @@ export default { </template> </gl-sprintf> </template> - <gl-form-select + <gl-collapsible-listbox :id="dropdownId" v-model="selectedAccessLevel" - data-qa-selector="access_level_dropdown" - :options="accessLevelsOptions" + data-testid="access-level-dropdown" + :items="accessLevelOptions.formatted" + :loading="isLoadingRoles" + block /> </gl-form-group> @@ -338,10 +356,10 @@ export default { <template #modal-footer> <div - class="gl-m-0 gl-w-full gl-display-flex gl-xs-flex-direction-column! gl-flex-direction-row-reverse" + class="gl-m-0 gl-w-full gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row-reverse" > <gl-button - class="gl-w-full gl-sm-w-auto gl-xs-mb-3! gl-sm-ml-3!" + class="gl-w-full gl-sm-w-auto gl-sm-ml-3!" data-testid="invite-modal-submit" v-bind="actionPrimary.attributes" @click="onSubmit" diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 0be04b7af35..0e3f2890b29 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -2,7 +2,9 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui'; import { debounce, isEmpty } from 'lodash'; import { __ } from '~/locale'; -import { getUsers } from '~/rest_api'; +import { getUsers, getGroupUsers } from '~/rest_api'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { memberName } from '../utils/member_utils'; import { SEARCH_DELAY, @@ -20,6 +22,7 @@ export default { GlIcon, GlSprintf, }, + mixins: [glFeatureFlagsMixin()], props: { canUseEmailToken: { type: Boolean, @@ -59,6 +62,10 @@ export default { required: false, default: '', }, + rootGroupId: { + type: String, + required: true, + }, }, data() { return { @@ -67,8 +74,6 @@ export default { originalInput: '', users: [], selectedTokens: [], - hasBeenFocused: false, - hideDropdownWithNoItems: true, }; }, computed: { @@ -89,9 +94,16 @@ export default { }, queryOptions() { if (this.usersFilter === USERS_FILTER_SAML_PROVIDER_ID) { + if (!this.glFeatures.groupUserSaml) { + return { + saml_provider_id: this.filterId, + ...this.$options.defaultQueryOptions, + }; + } return { - saml_provider_id: this.filterId, - ...this.$options.defaultQueryOptions, + active: true, + include_saml_users: true, + include_service_accounts: true, }; } return this.$options.defaultQueryOptions; @@ -102,7 +114,6 @@ export default { textInputAttrs() { return { 'data-testid': 'members-token-select-input', - 'data-qa-selector': 'members_token_select_input', id: this.inputId, }; }, @@ -125,7 +136,6 @@ export default { }, methods: { handleTextInput(inputQuery) { - this.hideDropdownWithNoItems = false; this.originalInput = inputQuery; this.query = inputQuery.trim(); this.loading = true; @@ -137,19 +147,27 @@ export default { class: this.tokenClass(token), })); }, - retrieveUsers: debounce(function debouncedRetrieveUsers() { - return getUsers(this.query, this.queryOptions) - .then((response) => { - this.users = response.data.map((token) => ({ - id: token.id, - name: token.name, - username: token.username, - avatar_url: token.avatar_url, - })); - }) - .finally(() => { - this.loading = false; - }); + retrieveUsersRequest() { + if (this.usersFilter === USERS_FILTER_SAML_PROVIDER_ID && this.glFeatures.groupUserSaml) { + return getGroupUsers(this.query, this.rootGroupId, this.queryOptions); + } + + return getUsers(this.query, this.queryOptions); + }, + retrieveUsers: debounce(async function debouncedRetrieveUsers() { + try { + const { data } = await this.retrieveUsersRequest(); + this.users = data.map((token) => ({ + id: token.id, + name: token.name, + username: token.username, + avatar_url: token.avatar_url, + })); + } catch (error) { + Sentry.captureException(error); + } + + this.loading = false; }, SEARCH_DELAY), tokenClass(token) { if (this.hasError(token)) { @@ -162,18 +180,10 @@ export default { handleInput() { this.$emit('input', this.selectedTokens); }, - handleBlur() { - this.hideDropdownWithNoItems = false; - }, handleFocus() { - // The modal auto-focuses on the input when opened. - // This prevents the dropdown from opening when the modal opens. - if (this.hasBeenFocused) { - this.loading = true; - this.retrieveUsers(); - } - - this.hasBeenFocused = true; + // Search for users when focused on the input + this.loading = true; + this.retrieveUsers(); }, handleTokenRemove(value) { if (this.selectedTokens.length) { @@ -184,6 +194,12 @@ export default { this.$emit('clear'); }, + handleTab(event) { + if (this.originalInput.length > 0) { + event.preventDefault(); + this.$refs.tokenSelector.handleEnter(); + } + }, hasError(token) { return Object.keys(this.invalidMembers).includes(memberName(token)); }, @@ -197,20 +213,20 @@ export default { <template> <gl-token-selector + ref="tokenSelector" v-model="selectedTokens" :state="exceptionState" :dropdown-items="users" :loading="loading" :allow-user-defined-tokens="emailIsValid" - :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" :text-input-attrs="textInputAttrs" - @blur="handleBlur" @text-input="handleTextInput" @input="handleInput" @focus="handleFocus" @token-remove="handleTokenRemove" + @keydown.tab="handleTab" > <template #token-content="{ token }"> <gl-icon diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 93386e5504b..f2a6cccbe35 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -20,7 +20,6 @@ export const TRIGGER_ELEMENT_WITH_EMOJI = 'text-emoji'; export const TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI = 'dropdown-text-emoji'; export const TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN = 'dropdown-text'; export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal'; -export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button'; export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY = 'invite_project_members_modal'; export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL = 'project-members-page'; export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); @@ -40,7 +39,7 @@ export const MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__( "InviteMembersModal|Congratulations on creating your project, you're almost there!", ); -export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|Username or email address'); +export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|Username, name or email address'); export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses'); export const GROUP_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite a group'); 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 8dfe697e2cb..bd291ecc90f 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -36,6 +36,7 @@ export default (function initInviteMembersModal() { isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), + defaultMemberRoleId: parseInt(el.dataset.defaultMemberRoleId, 10) || null, usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), usersLimitDataset: convertObjectPropsToCamelCase( diff --git a/app/assets/javascripts/issuable/components/locked_badge.vue b/app/assets/javascripts/issuable/components/locked_badge.vue index 652d02e8f9d..1bd399dc257 100644 --- a/app/assets/javascripts/issuable/components/locked_badge.vue +++ b/app/assets/javascripts/issuable/components/locked_badge.vue @@ -32,7 +32,7 @@ export default { </script> <template> - <gl-badge v-gl-tooltip :title="title" variant="warning"> + <gl-badge v-gl-tooltip :title="title" variant="warning" data-testid="locked-badge"> <gl-icon name="lock" /> <span class="gl-sr-only">{{ __('Locked') }}</span> </gl-badge> diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 126a3a84d66..09ecf9eb5bc 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -8,7 +8,7 @@ import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import relatedIssuableMixin from '../mixins/related_issuable_mixin'; diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js index 804f7384732..9065fc9c1fd 100644 --- a/app/assets/javascripts/issuable/issuable_label_selector.js +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue'; @@ -25,6 +26,7 @@ export default () => { issuableType, labelsFilterBasePath, labelsManagePath, + supportsLockOnMerge, } = el.dataset; return new Vue({ @@ -40,6 +42,7 @@ export default () => { fullPath, initialLabels: JSON.parse(initialLabels), issuableType, + issuableSupportsLockOnMerge: parseBoolean(supportsLockOnMerge), labelType: WORKSPACE_PROJECT, labelsFilterBasePath, labelsManagePath, diff --git a/app/assets/javascripts/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue index 80ae8ed8cf6..6c207d21ff2 100644 --- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue @@ -2,7 +2,7 @@ import { GlBadge, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import { __ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import query from '../queries/merge_request.query.graphql'; diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 3d8017e6e07..0a762b161ef 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -6,6 +6,7 @@ export const STATUS_MERGED = 'merged'; export const STATUS_OPEN = 'opened'; export const STATUS_REOPENED = 'reopened'; export const STATUS_LOCKED = 'locked'; +export const STATUS_EMPTY = 'empty'; export const TITLE_LENGTH_MAX = 255; diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index 74633b251b2..30cc1c5b822 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -23,6 +23,7 @@ export async function mountIssuesDashboardApp() { emptyStateWithoutFilterSvgPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, + hasIssueDateFilterFeature, hasIssueWeightsFeature, hasScopedLabelsFeature, initialSort, @@ -47,6 +48,7 @@ export async function mountIssuesDashboardApp() { emptyStateWithoutFilterSvgPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueDateFilterFeature: parseBoolean(hasIssueDateFilterFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), initialSort, diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index eea5207801c..b7b39d0ce08 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { initIssuableSidebar } from '~/issuable'; @@ -22,7 +23,7 @@ export function initForm() { new IssuableForm($('.issue-form')); // eslint-disable-line no-new IssuableLabelSelector(); new LabelsSelect(); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new + addShortcutsExtension(ShortcutsNavigation); initTitleSuggestions(); initTypePopover(); @@ -32,7 +33,7 @@ export function initForm() { export function initShow() { new Issue(); // eslint-disable-line no-new - new ShortcutsIssuable(); // eslint-disable-line no-new + addShortcutsExtension(ShortcutsIssuable); new ZenMode(); // eslint-disable-line no-new initAwardsApp(document.getElementById('js-vue-awards-block')); 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 72bb88ef1d5..adc789a205b 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -107,6 +107,7 @@ import { getInitialPageParams, getSortKey, getSortOptions, + groupMultiSelectFilterTokens, isSortKey, mapWorkItemWidgetsToIssueFields, updateUpvotesCount, @@ -384,6 +385,7 @@ export default { isProject: this.isProject, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, preloadedUsers, + multiSelect: this.glFeatures.groupMultiSelectTokens, }, { type: TOKEN_TYPE_ASSIGNEE, @@ -396,6 +398,7 @@ export default { isProject: this.isProject, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, preloadedUsers, + multiSelect: this.glFeatures.groupMultiSelectTokens, }, { type: TOKEN_TYPE_MILESTONE, @@ -803,7 +806,12 @@ export default { sortKey = defaultSortKey; } - this.filterTokens = getFilterTokens(window.location.search); + const tokens = getFilterTokens(window.location.search); + if (this.glFeatures.groupMultiSelectTokens) { + this.filterTokens = groupMultiSelectFilterTokens(tokens, this.searchTokens); + } else { + this.filterTokens = tokens; + } this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 682c7629962..d6ff5c952c2 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -164,6 +164,12 @@ export const specialFilterValues = [ FILTER_STARTED, ]; +export const TYPE_TOKEN_EPIC_OPTION = { + icon: 'epic', + title: __('Epic'), + value: 'epic', +}; + export const TYPE_TOKEN_OBJECTIVE_OPTION = { icon: 'issue-type-objective', title: s__('WorkItem|Objective'), diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 8c60ad6dc4e..5a836e3e40a 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -70,6 +70,7 @@ export async function mountIssuesListApp() { hasAnyIssues, hasAnyProjects, hasBlockedIssuesFeature, + hasEpicsFeature, hasIssuableHealthStatusFeature, hasIssueDateFilterFeature, hasIssueWeightsFeature, @@ -127,6 +128,7 @@ export async function mountIssuesListApp() { hasAnyIssues: parseBoolean(hasAnyIssues), hasAnyProjects: parseBoolean(hasAnyProjects), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), + hasEpicsFeature: parseBoolean(hasEpicsFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueDateFilterFeature: parseBoolean(hasIssueDateFilterFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 37df0c8f9ff..c1e10285a92 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -7,6 +7,7 @@ import { OPERATOR_NOT, OPERATOR_OR, OPERATOR_AFTER, + OPERATORS_TO_GROUP, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -233,6 +234,41 @@ export const getFilterTokens = (locationSearch) => }; }); +export function groupMultiSelectFilterTokens(filterTokensToGroup, tokenDefs) { + const groupedTokens = []; + + const multiSelectTokenTypes = tokenDefs.filter((t) => t.multiSelect).map((t) => t.type); + + filterTokensToGroup.forEach((token) => { + const shouldGroup = + OPERATORS_TO_GROUP.includes(token.value.operator) && + multiSelectTokenTypes.includes(token.type); + + if (!shouldGroup) { + groupedTokens.push(token); + return; + } + + const sameTypeAndOperator = (t) => + t.type === token.type && t.value.operator === token.value.operator; + const existingToken = groupedTokens.find(sameTypeAndOperator); + + if (!existingToken) { + groupedTokens.push({ + ...token, + value: { + ...token.value, + data: [token.value.data], + }, + }); + } else if (!existingToken.value.data.includes(token.value.data)) { + existingToken.value.data.push(token.value.data); + } + }); + + return groupedTokens; +} + export const isNotEmptySearchToken = (token) => !(token.type === FILTERED_SEARCH_TERM && !token.value.data); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 756585683c8..27646df506b 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -22,6 +22,8 @@ import PinnedLinks from './pinned_links.vue'; import StickyHeader from './sticky_header.vue'; import TitleComponent from './title.vue'; +const STICKY_HEADER_VISIBLE_CLASS = 'issuable-sticky-header-visible'; + export default { components: { HeaderActions, @@ -322,6 +324,7 @@ export default { eventHub.$off('close.form', this.closeForm); eventHub.$off('open.form', this.openForm); window.removeEventListener('beforeunload', this.handleBeforeUnloadEvent); + this.hideStickyHeader(); }, methods: { handleBeforeUnloadEvent(e) { @@ -472,6 +475,8 @@ export default { hideStickyHeader() { this.isStickyHeaderShowing = false; + + document.body.classList?.remove(STICKY_HEADER_VISIBLE_CLASS); }, showStickyHeader() { @@ -479,6 +484,8 @@ export default { if (this.$refs.title.$el.offsetTop < window.pageYOffset) { this.isStickyHeaderShowing = true; } + + document.body.classList?.add(STICKY_HEADER_VISIBLE_CLASS); }, handleSaveDescription(description) { @@ -555,7 +562,7 @@ export default { <slot name="header"> <issue-header - class="gl-p-0 gl-mt-2 gl-sm-mt-0" + class="gl-p-0 gl-mt-2" :class="headerClasses" :author="author" :confidential="isConfidential" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 32df19dfe44..dcdfd06fbf1 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -142,8 +142,8 @@ export default { deleteButtonText() { return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText }); }, - qaSelector() { - return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; + testId() { + return this.isClosed ? 'reopen-issue-button' : 'close-issue-button'; }, dropdownText() { return sprintf(__('%{issueType} actions'), { @@ -308,7 +308,7 @@ export default { <template> <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-sm-gap-3"> - <div class="gl-sm-display-none! w-100"> + <div class="gl-md-display-none! gl-w-full"> <gl-disclosure-dropdown v-if="hasMobileDropdown" ref="issuableActionsDropdownMobile" @@ -320,7 +320,7 @@ export default { :loading="isToggleStateButtonLoading" placement="right" > - <template v-if="showMovedSidebarOptions"> + <template v-if="showMovedSidebarOptions && !glFeatures.notificationsTodosButtons"> <sidebar-subscriptions-widget :iid="String(iid)" :full-path="fullPath" @@ -340,7 +340,7 @@ export default { </gl-disclosure-dropdown-item> <gl-disclosure-dropdown-item v-if="showToggleIssueStateButton" - :data-testid="`mobile_${qaSelector}`" + :data-testid="`mobile-${testId}`" @action="toggleIssueState" > <template #list-item>{{ buttonText }}</template> @@ -352,7 +352,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-disclosure-dropdown-item :data-clipboard-text="issuableReference" - button-class="js-copy-reference" + class="js-copy-reference" data-testid="copy-reference" @action="copyReference" ><template #list-item>{{ @@ -398,7 +398,7 @@ export default { v-gl-tooltip.bottom :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" - class="js-issuable-edit gl-display-none! gl-sm-display-block!" + class="js-issuable-edit gl-display-none! gl-md-display-block!" data-testid="edit-button" @click="edit" > @@ -410,7 +410,7 @@ export default { id="new-actions-header-dropdown" ref="issuableActionsDropdownDesktop" v-gl-tooltip.hover - class="gl-display-none gl-sm-display-inline-flex!" + class="gl-display-none gl-md-display-inline-flex!" icon="ellipsis_v" category="tertiary" placement="left" @@ -453,7 +453,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-disclosure-dropdown-item :data-clipboard-text="issuableReference" - button-class="js-copy-reference" + class="js-copy-reference" data-testid="copy-reference" @action="copyReference" ><template #list-item>{{ diff --git a/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue index d509f0dbc09..4b046b990aa 100644 --- a/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue @@ -39,7 +39,7 @@ export default { <template> <div v-show="showHighlightBar" - class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-flex-direction-column gl-sm-flex-direction-row" > <div v-if="alert" class="gl-mr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span> diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 2909a4d2666..c84fba23837 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -133,8 +133,7 @@ export default { item.classList.toggle('gl-display-none', !isSummaryTab); }); - editButton?.classList.toggle('gl-display-none', !isSummaryTab); - editButton?.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); + editButton?.classList.toggle('gl-md-display-block!', isSummaryTab); } }, }, diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 375180446d9..fef08ca22cf 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -54,7 +54,7 @@ export default { <template> <div - class="gl-display-flex gl-align-items-flex-start gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-pt-3" + class="gl-display-flex gl-align-items-flex-start gl-flex-direction-column gl-md-flex-direction-row gl-gap-3 gl-pt-3" > <h1 v-safe-html="titleHtml" diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue index 5d6117b836d..352d729794a 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue @@ -1,6 +1,6 @@ <script> import { GlBanner } from '@gitlab/ui'; -import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-bubble-sm.svg?url'; +import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-sm.svg?url'; import { s__, __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 85e250b14a0..b26f65616bb 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -46,6 +46,12 @@ export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('administration/settings export const SET_UP_INSTANCE_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { anchor: 'set-up-your-instance', }); +export const JIRA_USER_REQUIREMENTS_DOC_LINK = helpPagePath( + 'administration/settings/jira_cloud_app', + { + anchor: 'jira-user-requirements', + }, +); export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { anchor: 'failed-to-update-the-gitlab-instance', }); diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/dot_com_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/dot_com_alert.vue new file mode 100644 index 00000000000..4bb3c8b58e5 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/dot_com_alert.vue @@ -0,0 +1,34 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { JIRA_USER_REQUIREMENTS_DOC_LINK } from '~/jira_connect/subscriptions/constants'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + }, + i18n: { + title: s__('JiraConnect|Are you a Jira administrator?'), + body: s__( + 'JiraConnect|To complete the setup, you must meet %{linkStart}certain user requirements%{linkEnd} in Jira.', + ), + }, + JIRA_USER_REQUIREMENTS_DOC_LINK, +}; +</script> + +<template> + <gl-alert variant="warning" :title="$options.i18n.title" :dismissible="false"> + <div> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="$options.JIRA_USER_REQUIREMENTS_DOC_LINK" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </gl-alert> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue index 9f8fae5b476..75f3ff936bd 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue @@ -5,6 +5,7 @@ import { PREREQUISITES_DOC_LINK, OAUTH_SELF_MANAGED_DOC_LINK, SET_UP_INSTANCE_DOC_LINK, + JIRA_USER_REQUIREMENTS_DOC_LINK, } from '~/jira_connect/subscriptions/constants'; export default { @@ -31,6 +32,11 @@ export default { link: SET_UP_INSTANCE_DOC_LINK, checked: false, }, + { + name: s__('JiraConnect|Jira user requirements'), + link: JIRA_USER_REQUIREMENTS_DOC_LINK, + checked: false, + }, ], }; }, @@ -46,11 +52,7 @@ export default { <div class="gl-mt-5"> <h3>{{ s__('JiraConnect|Continue setup in GitLab') }}</h3> <p> - {{ - s__( - 'JiraConnect|In order to complete the set up, you’ll need to complete a few steps in GitLab:', - ) - }} + {{ s__('JiraConnect|To complete the setup, you must follow a few steps in GitLab:') }} </p> <div class="gl-mb-5"> <div v-for="step in requiredSteps" :key="step.name" class="gl-mb-2"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue index d3770cc310a..28bf974b8f1 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -11,6 +11,7 @@ import { __, s__ } from '~/locale'; import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; import SelfManagedAlert from './self_managed_alert.vue'; +import DotComAlert from './dot_com_alert.vue'; import SetupInstructions from './setup_instructions.vue'; const RADIO_OPTIONS = { @@ -30,6 +31,7 @@ export default { GlFormRadio, GlButton, SelfManagedAlert, + DotComAlert, SetupInstructions, }, props: { @@ -113,6 +115,7 @@ export default { </gl-form-radio> </gl-form-radio-group> <self-managed-alert v-if="isSelfManagedSelected" /> + <dot-com-alert v-else /> <div class="gl-display-flex gl-justify-content-end gl-mt-5"> <gl-button variant="confirm" type="submit" :loading="loading" data-testid="submit-button">{{ diff --git a/app/assets/javascripts/kubernetes_dashboard/components/page_title.vue b/app/assets/javascripts/kubernetes_dashboard/components/page_title.vue new file mode 100644 index 00000000000..0f0fbd61798 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/page_title.vue @@ -0,0 +1,28 @@ +<script> +import { GlIcon, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + agentTitle: s__('KubernetesDashboard|Agent %{name} ID #%{id}'), + }, + components: { + GlSprintf, + GlIcon, + }, + inject: ['agent'], +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <h1 class="gl-heading-2"><slot></slot></h1> + <div class="gl-ml-4 gl-mb-5"> + <gl-icon name="kubernetes-agent" class="gl-text-gray-500" /> + <gl-sprintf :message="$options.i18n.agentTitle"> + <template #name> {{ agent.name }} </template> + <template #id>{{ agent.id }}</template> + </gl-sprintf> + </div> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue new file mode 100644 index 00000000000..0d219f915c9 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_details.vue @@ -0,0 +1,81 @@ +<script> +import { GlBadge, GlTruncate } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { WORKLOAD_STATUS_BADGE_VARIANTS } from '../constants'; +import WorkloadDetailsItem from './workload_details_item.vue'; + +export default { + components: { + GlBadge, + GlTruncate, + WorkloadDetailsItem, + }, + props: { + item: { + type: Object, + required: true, + validator: (item) => + ['name', 'kind', 'labels', 'annotations', 'status'].every((key) => item[key]), + }, + }, + computed: { + itemLabels() { + const { labels } = this.item; + return Object.entries(labels).map(this.getLabelBadgeText); + }, + itemAnnotations() { + const { annotations } = this.item; + return Object.entries(annotations).map(this.getAnnotationsText); + }, + }, + methods: { + getLabelBadgeText([key, value]) { + return `${key}=${value}`; + }, + + getAnnotationsText([key, value]) { + return `${key}: ${value}`; + }, + }, + i18n: { + name: s__('KubernetesDashboard|Name'), + kind: s__('KubernetesDashboard|Kind'), + labels: s__('KubernetesDashboard|Labels'), + status: s__('KubernetesDashboard|Status'), + annotations: s__('KubernetesDashboard|Annotations'), + }, + WORKLOAD_STATUS_BADGE_VARIANTS, +}; +</script> + +<template> + <ul class="gl-list-style-none"> + <workload-details-item :label="$options.i18n.name"> + {{ item.name }} + </workload-details-item> + <workload-details-item :label="$options.i18n.kind"> + {{ item.kind }} + </workload-details-item> + <workload-details-item v-if="itemLabels.length" :label="$options.i18n.labels"> + <div class="gl-display-flex gl-flex-wrap gl-gap-2"> + <gl-badge v-for="label of itemLabels" :key="label" class="gl-max-w-full"> + <gl-truncate :text="label" with-tooltip /> + </gl-badge> + </div> + </workload-details-item> + <workload-details-item :label="$options.i18n.status"> + <gl-badge :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[item.status]">{{ + item.status + }}</gl-badge></workload-details-item + > + <workload-details-item v-if="itemAnnotations.length" :label="$options.i18n.annotations"> + <p + v-for="annotation of itemAnnotations" + :key="annotation" + class="gl-mb-2 gl-overflow-wrap-anywhere" + > + {{ annotation }} + </p> + </workload-details-item> + </ul> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_details_item.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_details_item.vue new file mode 100644 index 00000000000..2ac748418ff --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_details_item.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + label: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <li class="gl-line-height-20 gl-py-3 gl-border-b-solid gl-border-b-2 gl-border-b-gray-100"> + <label class="gl-font-weight-bold gl-mb-2"> {{ label }} </label> + <div class="gl-text-gray-500 gl-mb-0"> + <slot></slot> + </div> + </li> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue new file mode 100644 index 00000000000..8c6a08ad504 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue @@ -0,0 +1,78 @@ +<script> +import { GlLoadingIcon, GlAlert, GlDrawer } from '@gitlab/ui'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import WorkloadStats from './workload_stats.vue'; +import WorkloadTable from './workload_table.vue'; +import WorkloadDetails from './workload_details.vue'; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlDrawer, + WorkloadStats, + WorkloadTable, + WorkloadDetails, + }, + props: { + loading: { + type: Boolean, + default: false, + required: false, + }, + errorMessage: { + type: String, + default: '', + required: false, + }, + stats: { + type: Array, + required: true, + }, + items: { + type: Array, + required: true, + }, + }, + data() { + return { + showDetailsDrawer: false, + selectedItem: {}, + }; + }, + methods: { + closeDetailsDrawer() { + this.showDetailsDrawer = false; + }, + onItemSelect(item) { + this.selectedItem = item; + this.showDetailsDrawer = true; + }, + }, + DRAWER_Z_INDEX, +}; +</script> +<template> + <gl-loading-icon v-if="loading" /> + <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ errorMessage }} + </gl-alert> + <div v-else> + <workload-stats :stats="stats" /> + <workload-table :items="items" @select-item="onItemSelect" /> + + <gl-drawer + :open="showDetailsDrawer" + header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" + :z-index="$options.DRAWER_Z_INDEX" + @close="closeDetailsDrawer" + > + <template #title> + <h4 class="gl-font-weight-bold gl-font-size-h2 gl-m-0">{{ selectedItem.name }}</h4> + </template> + <template #default> + <workload-details :item="selectedItem" /> + </template> + </gl-drawer> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_stats.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_stats.vue new file mode 100644 index 00000000000..31b931e1855 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_stats.vue @@ -0,0 +1,27 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; + +export default { + components: { + GlSingleStat, + }, + props: { + stats: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3"> + <gl-single-stat + v-for="(stat, index) in stats" + :key="index" + class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" + :value="stat.value" + :title="stat.title" + /> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue new file mode 100644 index 00000000000..d3704863538 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue @@ -0,0 +1,85 @@ +<script> +import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +import { + WORKLOAD_STATUS_BADGE_VARIANTS, + PAGE_SIZE, + TABLE_HEADING_CLASSES, + DEFAULT_WORKLOAD_TABLE_FIELDS, +} from '../constants'; + +export default { + components: { + GlTable, + GlBadge, + GlPagination, + }, + props: { + items: { + type: Array, + required: true, + }, + fields: { + type: Array, + default: () => DEFAULT_WORKLOAD_TABLE_FIELDS, + required: false, + }, + }, + data() { + return { + currentPage: 1, + }; + }, + computed: { + tableFields() { + return this.fields.map((field) => { + return { + ...field, + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }; + }); + }, + }, + methods: { + selectItem(item) { + this.$emit('select-item', item); + }, + }, + PAGE_SIZE, + WORKLOAD_STATUS_BADGE_VARIANTS, + TABLE_CELL_CLASSES: 'gl-p-2', +}; +</script> + +<template> + <div class="gl-mt-8"> + <gl-table + :items="items" + :fields="tableFields" + :per-page="$options.PAGE_SIZE" + :current-page="currentPage" + tbody-tr-class="gl-hover-cursor-pointer" + stacked="md" + bordered + hover + @row-clicked="selectItem" + > + <template #cell(status)="{ item: { status } }"> + <gl-badge + :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[status]" + size="sm" + class="gl-ml-2" + >{{ status }}</gl-badge + > + </template> + </gl-table> + + <gl-pagination + v-model="currentPage" + :per-page="$options.PAGE_SIZE" + :total-items="items.length" + align="center" + class="gl-mt-6" + /> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/constants.js b/app/assets/javascripts/kubernetes_dashboard/constants.js new file mode 100644 index 00000000000..b93740aec90 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/constants.js @@ -0,0 +1,49 @@ +import { s__ } from '~/locale'; + +export const STATUS_RUNNING = 'Running'; +export const STATUS_PENDING = 'Pending'; +export const STATUS_SUCCEEDED = 'Succeeded'; +export const STATUS_FAILED = 'Failed'; +export const STATUS_READY = 'Ready'; + +export const STATUS_LABELS = { + [STATUS_RUNNING]: s__('KubernetesDashboard|Running'), + [STATUS_PENDING]: s__('KubernetesDashboard|Pending'), + [STATUS_SUCCEEDED]: s__('KubernetesDashboard|Succeeded'), + [STATUS_FAILED]: s__('KubernetesDashboard|Failed'), + [STATUS_READY]: s__('KubernetesDashboard|Ready'), +}; + +export const WORKLOAD_STATUS_BADGE_VARIANTS = { + [STATUS_RUNNING]: 'info', + [STATUS_PENDING]: 'warning', + [STATUS_SUCCEEDED]: 'success', + [STATUS_FAILED]: 'danger', + [STATUS_READY]: 'success', +}; + +export const PAGE_SIZE = 20; + +export const TABLE_HEADING_CLASSES = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap'; + +export const DEFAULT_WORKLOAD_TABLE_FIELDS = [ + { + key: 'name', + label: s__('KubernetesDashboard|Name'), + }, + { + key: 'status', + label: s__('KubernetesDashboard|Status'), + }, + { + key: 'namespace', + label: s__('KubernetesDashboard|Namespace'), + }, + { + key: 'age', + label: s__('KubernetesDashboard|Age'), + }, +]; + +export const STATUS_TRUE = 'True'; +export const STATUS_FALSE = 'False'; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js new file mode 100644 index 00000000000..5894472d83b --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js @@ -0,0 +1,108 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import typeDefs from '~/environments/graphql/typedefs.graphql'; +import k8sPodsQuery from './queries/k8s_dashboard_pods.query.graphql'; +import k8sDeploymentsQuery from './queries/k8s_dashboard_deployments.query.graphql'; +import k8sStatefulSetsQuery from './queries/k8s_dashboard_stateful_sets.query.graphql'; +import k8sReplicaSetsQuery from './queries/k8s_dashboard_replica_sets.query.graphql'; +import k8sDaemonSetsQuery from './queries/k8s_dashboard_daemon_sets.query.graphql'; +import { resolvers } from './resolvers'; + +export const apolloProvider = () => { + const defaultClient = createDefaultClient(resolvers, { + typeDefs, + }); + const { cache } = defaultClient; + + cache.writeQuery({ + query: k8sPodsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + phase: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sDeploymentsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + conditions: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sStatefulSetsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + readyReplicas: null, + }, + spec: { + replicas: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sReplicaSetsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + readyReplicas: null, + }, + spec: { + replicas: null, + }, + }, + }); + + cache.writeQuery({ + query: k8sDaemonSetsQuery, + data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + labels: null, + annotations: null, + }, + status: { + numberMisscheduled: null, + numberReady: null, + desiredNumberScheduled: null, + }, + }, + }); + + return new VueApollo({ + defaultClient, + }); +}; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js new file mode 100644 index 00000000000..47c2f543357 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/helpers/resolver_helpers.js @@ -0,0 +1,116 @@ +import { CoreV1Api, Configuration, WatchApi, EVENT_DATA } from '@gitlab/cluster-client'; + +export const handleClusterError = async (err) => { + if (!err.response) { + throw err; + } + + const errorData = await err.response.json(); + throw errorData; +}; + +export const buildWatchPath = ({ resource, api = 'api/v1', namespace = '' }) => { + return namespace ? `/${api}/namespaces/${namespace}/${resource}` : `/${api}/${resource}`; +}; + +export const mapWorkloadItem = (item) => { + if (item.metadata) { + const metadata = { + ...item.metadata, + annotations: item.metadata?.annotations || {}, + labels: item.metadata?.labels || {}, + }; + return { status: item.status, metadata }; + } + return { status: item.status }; +}; + +export const mapSetItem = (item) => { + const status = { + ...item.status, + readyReplicas: item.status?.readyReplicas || null, + }; + + const metadata = + { + ...item.metadata, + annotations: item.metadata?.annotations || {}, + labels: item.metadata?.labels || {}, + } || null; + + const spec = item.spec || null; + + return { status, metadata, spec }; +}; + +export const watchWorkloadItems = ({ + client, + query, + configuration, + namespace, + watchPath, + queryField, + mapFn = mapWorkloadItem, +}) => { + const config = new Configuration(configuration); + const watcherApi = new WatchApi(config); + + watcherApi + .subscribeToStream(watchPath, { watch: true }) + .then((watcher) => { + let result = []; + + watcher.on(EVENT_DATA, (data) => { + result = data.map(mapFn); + + client.writeQuery({ + query, + variables: { configuration, namespace }, + data: { [queryField]: result }, + }); + }); + }) + .catch((err) => { + handleClusterError(err); + }); +}; + +export const getK8sPods = ({ + client, + query, + configuration, + namespace = '', + enableWatch = false, +}) => { + const config = new Configuration(configuration); + + const coreV1Api = new CoreV1Api(config); + const podsApi = namespace + ? coreV1Api.listCoreV1NamespacedPod({ namespace }) + : coreV1Api.listCoreV1PodForAllNamespaces(); + + return podsApi + .then((res) => { + if (enableWatch) { + const watchPath = buildWatchPath({ resource: 'pods', namespace }); + watchWorkloadItems({ + client, + query, + configuration, + namespace, + watchPath, + queryField: 'k8sPods', + }); + } + + const data = res?.items || []; + return data.map(mapWorkloadItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); +}; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_daemon_sets.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_daemon_sets.query.graphql new file mode 100644 index 00000000000..4469c7a161a --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_daemon_sets.query.graphql @@ -0,0 +1,16 @@ +query getK8sDashboardDaemonSets($configuration: LocalConfiguration) { + k8sDaemonSets(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + numberMisscheduled + numberReady + desiredNumberScheduled + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql new file mode 100644 index 00000000000..21172bbbeb0 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql @@ -0,0 +1,14 @@ +query getK8sDashboardDeployments($configuration: LocalConfiguration) { + k8sDeployments(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + conditions + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql new file mode 100644 index 00000000000..3f8eabca03e --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql @@ -0,0 +1,14 @@ +query getK8sDashboardPods($configuration: LocalConfiguration) { + k8sPods(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + phase + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql new file mode 100644 index 00000000000..38aaa79853c --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql @@ -0,0 +1,17 @@ +query getK8sDashboardReplicaSets($configuration: LocalConfiguration) { + k8sReplicaSets(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + readyReplicas + } + spec { + replicas + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql new file mode 100644 index 00000000000..ab1b9e1e472 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql @@ -0,0 +1,17 @@ +query getK8sDashboardStatefulSets($configuration: LocalConfiguration) { + k8sStatefulSets(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + labels + annotations + } + status { + readyReplicas + } + spec { + replicas + } + } +} diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers.js b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers.js new file mode 100644 index 00000000000..b99ffff5bd1 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers.js @@ -0,0 +1,7 @@ +import kubernetesQueries from './resolvers/kubernetes'; + +export const resolvers = { + Query: { + ...kubernetesQueries, + }, +}; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js new file mode 100644 index 00000000000..e59bed5581b --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/resolvers/kubernetes.js @@ -0,0 +1,169 @@ +import { Configuration, AppsV1Api } from '@gitlab/cluster-client'; + +import { + getK8sPods, + handleClusterError, + mapWorkloadItem, + mapSetItem, + buildWatchPath, + watchWorkloadItems, +} from '../helpers/resolver_helpers'; +import k8sDashboardPodsQuery from '../queries/k8s_dashboard_pods.query.graphql'; +import k8sDashboardDeploymentsQuery from '../queries/k8s_dashboard_deployments.query.graphql'; +import k8sDashboardStatefulSetsQuery from '../queries/k8s_dashboard_stateful_sets.query.graphql'; +import k8sDashboardReplicaSetsQuery from '../queries/k8s_dashboard_replica_sets.query.graphql'; +import k8sDaemonSetsQuery from '../queries/k8s_dashboard_daemon_sets.query.graphql'; + +export default { + k8sPods(_, { configuration }, { client }) { + const query = k8sDashboardPodsQuery; + const enableWatch = true; + return getK8sPods({ client, query, configuration, enableWatch }); + }, + + k8sDeployments(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const appsV1api = new AppsV1Api(config); + const deploymentsApi = namespace + ? appsV1api.listAppsV1NamespacedDeployment({ namespace }) + : appsV1api.listAppsV1DeploymentForAllNamespaces(); + return deploymentsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'deployments', + api: 'apis/apps/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sDashboardDeploymentsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sDeployments', + }); + + const data = res?.items || []; + + return data.map(mapWorkloadItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, + + k8sStatefulSets(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const appsV1api = new AppsV1Api(config); + const deploymentsApi = namespace + ? appsV1api.listAppsV1NamespacedStatefulSet({ namespace }) + : appsV1api.listAppsV1StatefulSetForAllNamespaces(); + return deploymentsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'statefulsets', + api: 'apis/apps/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sDashboardStatefulSetsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sStatefulSets', + mapFn: mapSetItem, + }); + + const data = res?.items || []; + + return data.map(mapSetItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, + + k8sReplicaSets(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const appsV1api = new AppsV1Api(config); + const deploymentsApi = namespace + ? appsV1api.listAppsV1NamespacedReplicaSet({ namespace }) + : appsV1api.listAppsV1ReplicaSetForAllNamespaces(); + return deploymentsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'replicasets', + api: 'apis/apps/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sDashboardReplicaSetsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sReplicaSets', + mapFn: mapSetItem, + }); + + const data = res?.items || []; + + return data.map(mapSetItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, + + k8sDaemonSets(_, { configuration, namespace = '' }, { client }) { + const config = new Configuration(configuration); + + const appsV1api = new AppsV1Api(config); + const deploymentsApi = namespace + ? appsV1api.listAppsV1NamespacedDaemonSet({ namespace }) + : appsV1api.listAppsV1DaemonSetForAllNamespaces(); + return deploymentsApi + .then((res) => { + const watchPath = buildWatchPath({ + resource: 'daemonsets', + api: 'apis/apps/v1', + namespace, + }); + watchWorkloadItems({ + client, + query: k8sDaemonSetsQuery, + configuration, + namespace, + watchPath, + queryField: 'k8sDaemonSets', + }); + + const data = res?.items || []; + + return data.map(mapWorkloadItem); + }) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } + }); + }, +}; diff --git a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js new file mode 100644 index 00000000000..24f43e21506 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js @@ -0,0 +1,60 @@ +import { differenceInSeconds } from '~/lib/utils/datetime_utility'; +import { + STATUS_TRUE, + STATUS_FALSE, + STATUS_PENDING, + STATUS_READY, + STATUS_FAILED, +} from '../constants'; + +export function getAge(creationTimestamp) { + if (!creationTimestamp) return ''; + + const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date()); + + const seconds = Math.floor(timeDifference); + const minutes = Math.floor(seconds / 60) % 60; + const hours = Math.floor(seconds / 60 / 60) % 24; + const days = Math.floor(seconds / 60 / 60 / 24); + + let ageString; + if (days > 0) { + ageString = `${days}d`; + } else if (hours > 0) { + ageString = `${hours}h`; + } else if (minutes > 0) { + ageString = `${minutes}m`; + } else { + ageString = `${seconds}s`; + } + + return ageString; +} + +export function calculateDeploymentStatus(item) { + const [available, progressing] = item.status?.conditions ?? []; + if (available?.status === STATUS_TRUE) { + return STATUS_READY; + } + if (available?.status === STATUS_FALSE && progressing?.status !== STATUS_TRUE) { + return STATUS_FAILED; + } + return STATUS_PENDING; +} + +export function calculateStatefulSetStatus(item) { + if (item.status?.readyReplicas === item.spec?.replicas) { + return STATUS_READY; + } + return STATUS_FAILED; +} + +export function calculateDaemonSetStatus(item) { + if ( + item.status?.numberReady === item.status?.desiredNumberScheduled && + !item.status?.numberMisscheduled + ) { + return STATUS_READY; + } + return STATUS_FAILED; +} diff --git a/app/assets/javascripts/kubernetes_dashboard/init_kubernetes_dashboard.js b/app/assets/javascripts/kubernetes_dashboard/init_kubernetes_dashboard.js new file mode 100644 index 00000000000..6c8e8a2eb31 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/init_kubernetes_dashboard.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; +import csrf from '~/lib/utils/csrf'; +import { apolloProvider as createApolloProvider } from './graphql/client'; +import App from './pages/app.vue'; +import createRouter from './router/index'; + +Vue.use(VueApollo); + +const initKubernetesDashboard = () => { + const el = document.querySelector('.js-kubernetes-app'); + + if (!el) { + return null; + } + + const { basePath, agent, kasTunnelUrl } = el.dataset; + const agentObject = JSON.parse(agent); + + const configuration = { + basePath: removeLastSlashInUrlPath(kasTunnelUrl), + headers: { + 'GitLab-Agent-Id': agentObject.id, + 'Content-Type': 'application/json', + ...csrf.headers, + }, + credentials: 'include', + }; + + const router = createRouter({ + base: basePath, + }); + + return new Vue({ + el, + name: 'KubernetesDashboardRoot', + router, + apolloProvider: createApolloProvider(), + provide: { + agent: agentObject, + configuration, + }, + render: (createElement) => createElement(App), + }); +}; + +export { initKubernetesDashboard }; diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/app.vue b/app/assets/javascripts/kubernetes_dashboard/pages/app.vue new file mode 100644 index 00000000000..e135afed9fe --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/app.vue @@ -0,0 +1,13 @@ +<script> +import PageTitle from '../components/page_title.vue'; + +export default { + components: { PageTitle }, +}; +</script> +<template> + <div class="gl-mt-5"> + <page-title> {{ $route.meta.title }} </page-title> + <router-view /> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/daemon_sets_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/daemon_sets_page.vue new file mode 100644 index 00000000000..bdde4e89f34 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/daemon_sets_page.vue @@ -0,0 +1,80 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateDaemonSetStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sDaemonSetsQuery from '../graphql/queries/k8s_dashboard_daemon_sets.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sDaemonSets: { + query: k8sDaemonSetsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sDaemonSets?.map((daemonSet) => { + return { + name: daemonSet.metadata?.name, + namespace: daemonSet.metadata?.namespace, + status: calculateDaemonSetStatus(daemonSet), + age: getAge(daemonSet.metadata?.creationTimestamp), + labels: daemonSet.metadata?.labels, + annotations: daemonSet.metadata?.annotations, + kind: s__('KubernetesDashboard|DaemonSet'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sDaemonSets: [], + errorMessage: '', + }; + }, + computed: { + daemonSetsStats() { + return [ + { + value: this.countDaemonSetsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countDaemonSetsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sDaemonSets.loading; + }, + }, + methods: { + countDaemonSetsByStatus(status) { + const filteredDaemonSets = this.k8sDaemonSets.filter((item) => item.status === status) || []; + + return filteredDaemonSets.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="daemonSetsStats" + :items="k8sDaemonSets" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/deployments_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/deployments_page.vue new file mode 100644 index 00000000000..c5472966539 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/deployments_page.vue @@ -0,0 +1,84 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateDeploymentStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sDeploymentsQuery from '../graphql/queries/k8s_dashboard_deployments.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_PENDING, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sDeployments: { + query: k8sDeploymentsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sDeployments?.map((deployment) => { + return { + name: deployment.metadata?.name, + namespace: deployment.metadata?.namespace, + status: calculateDeploymentStatus(deployment), + age: getAge(deployment.metadata?.creationTimestamp), + labels: deployment.metadata?.labels, + annotations: deployment.metadata?.annotations, + kind: s__('KubernetesDashboard|Deployment'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sDeployments: [], + errorMessage: '', + }; + }, + computed: { + deploymentsStats() { + return [ + { + value: this.countDeploymentsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countDeploymentsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + { + value: this.countDeploymentsByStatus(STATUS_PENDING), + title: STATUS_LABELS[STATUS_PENDING], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sDeployments.loading; + }, + }, + methods: { + countDeploymentsByStatus(phase) { + const filteredDeployments = this.k8sDeployments.filter((item) => item.status === phase) || []; + + return filteredDeployments.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="deploymentsStats" + :items="k8sDeployments" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue new file mode 100644 index 00000000000..4be40fdde62 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue @@ -0,0 +1,94 @@ +<script> +import { s__ } from '~/locale'; +import { getAge } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sPodsQuery from '../graphql/queries/k8s_dashboard_pods.query.graphql'; +import { + STATUS_RUNNING, + STATUS_PENDING, + STATUS_SUCCEEDED, + STATUS_FAILED, + STATUS_LABELS, +} from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sPods: { + query: k8sPodsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sPods?.map((pod) => { + return { + name: pod.metadata?.name, + namespace: pod.metadata?.namespace, + status: pod.status.phase, + age: getAge(pod.metadata?.creationTimestamp), + labels: pod.metadata?.labels, + annotations: pod.metadata?.annotations, + kind: s__('KubernetesDashboard|Pod'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sPods: [], + errorMessage: '', + }; + }, + computed: { + podStats() { + return [ + { + value: this.countPodsByPhase(STATUS_RUNNING), + title: STATUS_LABELS[STATUS_RUNNING], + }, + { + value: this.countPodsByPhase(STATUS_PENDING), + title: STATUS_LABELS[STATUS_PENDING], + }, + { + value: this.countPodsByPhase(STATUS_SUCCEEDED), + title: STATUS_LABELS[STATUS_SUCCEEDED], + }, + { + value: this.countPodsByPhase(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo?.queries?.k8sPods?.loading; + }, + }, + methods: { + countPodsByPhase(phase) { + const filteredPods = this.k8sPods?.filter((item) => item.status === phase) || []; + + return filteredPods.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="podStats" + :items="k8sPods" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue new file mode 100644 index 00000000000..212cc0dbaf7 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/replica_sets_page.vue @@ -0,0 +1,80 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateStatefulSetStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sReplicaSetsQuery from '../graphql/queries/k8s_dashboard_replica_sets.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sReplicaSets: { + query: k8sReplicaSetsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sReplicaSets?.map((replicaSet) => { + return { + name: replicaSet.metadata?.name, + namespace: replicaSet.metadata?.namespace, + status: calculateStatefulSetStatus(replicaSet), + age: getAge(replicaSet.metadata?.creationTimestamp), + labels: replicaSet.metadata?.labels, + annotations: replicaSet.metadata?.annotations, + kind: s__('KubernetesDashboard|ReplicaSet'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sReplicaSets: [], + errorMessage: '', + }; + }, + computed: { + replicaSetsStats() { + return [ + { + value: this.countReplicaSetsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countReplicaSetsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sReplicaSets.loading; + }, + }, + methods: { + countReplicaSetsByStatus(phase) { + const filteredReplicaSets = this.k8sReplicaSets.filter((item) => item.status === phase) || []; + + return filteredReplicaSets.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="replicaSetsStats" + :items="k8sReplicaSets" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/stateful_sets_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/stateful_sets_page.vue new file mode 100644 index 00000000000..bcdce41b433 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/pages/stateful_sets_page.vue @@ -0,0 +1,81 @@ +<script> +import { s__ } from '~/locale'; +import { getAge, calculateStatefulSetStatus } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; +import k8sStatefulSetsQuery from '../graphql/queries/k8s_dashboard_stateful_sets.query.graphql'; +import { STATUS_FAILED, STATUS_READY, STATUS_LABELS } from '../constants'; + +export default { + components: { + WorkloadLayout, + }, + inject: ['configuration'], + apollo: { + k8sStatefulSets: { + query: k8sStatefulSetsQuery, + variables() { + return { + configuration: this.configuration, + }; + }, + update(data) { + return ( + data?.k8sStatefulSets?.map((statefulSet) => { + return { + name: statefulSet.metadata?.name, + namespace: statefulSet.metadata?.namespace, + status: calculateStatefulSetStatus(statefulSet), + age: getAge(statefulSet.metadata?.creationTimestamp), + labels: statefulSet.metadata?.labels, + annotations: statefulSet.metadata?.annotations, + kind: s__('KubernetesDashboard|StatefulSet'), + }; + }) || [] + ); + }, + error(err) { + this.errorMessage = err?.message; + }, + }, + }, + data() { + return { + k8sStatefulSets: [], + errorMessage: '', + }; + }, + computed: { + statefulSetsStats() { + return [ + { + value: this.countStatefulSetsByStatus(STATUS_READY), + title: STATUS_LABELS[STATUS_READY], + }, + { + value: this.countStatefulSetsByStatus(STATUS_FAILED), + title: STATUS_LABELS[STATUS_FAILED], + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sStatefulSets.loading; + }, + }, + methods: { + countStatefulSetsByStatus(phase) { + const filteredStatefulSets = + this.k8sStatefulSets.filter((item) => item.status === phase) || []; + + return filteredStatefulSets.length; + }, + }, +}; +</script> +<template> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="statefulSetsStats" + :items="k8sStatefulSets" + /> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/router/constants.js b/app/assets/javascripts/kubernetes_dashboard/router/constants.js new file mode 100644 index 00000000000..700f501ade4 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/router/constants.js @@ -0,0 +1,11 @@ +export const PODS_ROUTE_NAME = 'pods'; +export const DEPLOYMENTS_ROUTE_NAME = 'deployments'; +export const STATEFUL_SETS_ROUTE_NAME = 'statefulSets'; +export const REPLICA_SETS_ROUTE_NAME = 'replicaSets'; +export const DAEMON_SETS_ROUTE_NAME = 'daemonSets'; + +export const PODS_ROUTE_PATH = '/pods'; +export const DEPLOYMENTS_ROUTE_PATH = '/deployments'; +export const STATEFUL_SETS_ROUTE_PATH = '/statefulsets'; +export const REPLICA_SETS_ROUTE_PATH = '/replicasets'; +export const DAEMON_SETS_ROUTE_PATH = '/daemonsets'; diff --git a/app/assets/javascripts/kubernetes_dashboard/router/index.js b/app/assets/javascripts/kubernetes_dashboard/router/index.js new file mode 100644 index 00000000000..7f59f850f3f --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/router/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import routes from './routes'; + +Vue.use(VueRouter); + +export default function createRouter({ base }) { + const router = new VueRouter({ + base, + mode: 'history', + routes, + }); + + return router; +} diff --git a/app/assets/javascripts/kubernetes_dashboard/router/routes.js b/app/assets/javascripts/kubernetes_dashboard/router/routes.js new file mode 100644 index 00000000000..a1684a62ca4 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/router/routes.js @@ -0,0 +1,61 @@ +import { s__ } from '~/locale'; +import PodsPage from '../pages/pods_page.vue'; +import DeploymentsPage from '../pages/deployments_page.vue'; +import StatefulSetsPage from '../pages/stateful_sets_page.vue'; +import ReplicaSetsPage from '../pages/replica_sets_page.vue'; +import DaemonSetsPage from '../pages/daemon_sets_page.vue'; +import { + PODS_ROUTE_NAME, + PODS_ROUTE_PATH, + DEPLOYMENTS_ROUTE_NAME, + DEPLOYMENTS_ROUTE_PATH, + STATEFUL_SETS_ROUTE_NAME, + STATEFUL_SETS_ROUTE_PATH, + REPLICA_SETS_ROUTE_NAME, + REPLICA_SETS_ROUTE_PATH, + DAEMON_SETS_ROUTE_NAME, + DAEMON_SETS_ROUTE_PATH, +} from './constants'; + +export default [ + { + name: PODS_ROUTE_NAME, + path: PODS_ROUTE_PATH, + component: PodsPage, + meta: { + title: s__('KubernetesDashboard|Pods'), + }, + }, + { + name: DEPLOYMENTS_ROUTE_NAME, + path: DEPLOYMENTS_ROUTE_PATH, + component: DeploymentsPage, + meta: { + title: s__('KubernetesDashboard|Deployments'), + }, + }, + { + name: STATEFUL_SETS_ROUTE_NAME, + path: STATEFUL_SETS_ROUTE_PATH, + component: StatefulSetsPage, + meta: { + title: s__('KubernetesDashboard|StatefulSets'), + }, + }, + { + name: REPLICA_SETS_ROUTE_NAME, + path: REPLICA_SETS_ROUTE_PATH, + component: ReplicaSetsPage, + meta: { + title: s__('KubernetesDashboard|ReplicaSets'), + }, + }, + { + name: DAEMON_SETS_ROUTE_NAME, + path: DAEMON_SETS_ROUTE_PATH, + component: DaemonSetsPage, + meta: { + title: s__('KubernetesDashboard|DaemonSets'), + }, + }, +]; diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue index a2012f95fd6..f00290d0e46 100644 --- a/app/assets/javascripts/language_switcher/components/app.vue +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -52,10 +52,7 @@ export default { @select="onLanguageSelected" > <template #list-item="{ item: locale }"> - <span - :data-testid="itemTestSelector(locale.value)" - :data-qa-selector="itemTestSelector(locale.value)" - > + <span :data-testid="itemTestSelector(locale.value)"> {{ locale.text }} </span> </template> diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 670170ec9b9..1f58065a505 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,7 +1,4 @@ import $ from 'jquery'; -import ContextualSidebar from './contextual_sidebar'; -import initFlyOutNav from './fly_out_nav'; -import { setNotification } from './whats_new/utils/notification'; function hideEndFade($scrollingTabs) { $scrollingTabs.each(function scrollTabsLoop() { @@ -88,36 +85,11 @@ function initInviteMembers() { .catch(() => {}); } -function initWhatsNewComponent() { - const appEl = document.getElementById('whats-new-app'); - if (!appEl) return; - - setNotification(appEl); - - const triggerEl = document.querySelector('.js-whats-new-trigger'); - if (!triggerEl) return; - - triggerEl.addEventListener('click', () => { - import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') - .then(({ default: initWhatsNew }) => { - initWhatsNew(appEl); - }) - .catch(() => {}); - }); -} - function initDeferred() { initScrollingTabs(); - initWhatsNewComponent(); initInviteMembers(); } export default function initLayoutNav() { - if (!gon.use_new_navigation) { - const contextualSidebar = new ContextualSidebar(); - contextualSidebar.bindEvents(); - initFlyOutNav(); - } - requestIdleCallback(initDeferred); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 5285fa363a5..7ae78eb72c9 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,4 +1,5 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; +import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; import { persistCache } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; @@ -64,8 +65,7 @@ export const typePolicies = { }; export const stripWhitespaceFromQuery = (url, path) => { - /* eslint-disable-next-line no-unused-vars */ - const [_, params] = url.split(path); + const [, params] = url.split(path); if (!params) { return url; @@ -159,7 +159,15 @@ function createApolloClient(resolvers = {}, config = {}) { return fetch(stripWhitespaceFromQuery(url, uri), options); }; - const requestLink = new HttpLink({ ...httpOptions, fetch: fetchIntervention }); + const requestLink = ApolloLink.split( + (operation) => operation.getContext().batchKey, + new BatchHttpLink({ + ...httpOptions, + batchKey: (operation) => operation.getContext().batchKey, + fetch: fetchIntervention, + }), + new HttpLink({ ...httpOptions, fetch: fetchIntervention }), + ); const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 27da2ac6ce1..674a901aebc 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -82,7 +82,7 @@ export const handleLocationHash = () => { const fixedTabs = document.querySelector('.js-tabs-affix'); const fixedDiffStats = document.querySelector('.js-diff-files-changed'); - const fixedNav = document.querySelector('.navbar-gitlab'); + const headerLoggedOut = document.querySelector('.header-logged-out'); const fixedTopBar = document.querySelector('.top-bar-fixed'); const performanceBar = document.querySelector('#js-peek'); const topPadding = 8; @@ -91,7 +91,7 @@ export const handleLocationHash = () => { let adjustment = 0; - adjustment -= getElementOffsetHeight(fixedNav); + adjustment -= getElementOffsetHeight(headerLoggedOut); adjustment -= getElementOffsetHeight(fixedTabs); adjustment -= getElementOffsetHeight(fixedDiffStats); adjustment -= getElementOffsetHeight(fixedTopBar); @@ -153,7 +153,7 @@ export const contentTop = () => { const isDesktop = breakpointInstance.isDesktop(); const heightCalculators = [ () => getOuterHeight('#js-peek'), - () => getOuterHeight('.navbar-gitlab'), + () => getOuterHeight('.header-logged-out'), () => getOuterHeight('.top-bar-fixed'), ({ desktop }) => { const mrStickyHeader = document.querySelector('.merge-request-sticky-header'); @@ -176,25 +176,13 @@ export const contentTop = () => { () => getOuterHeight('.js-diff-files-changed'), ({ desktop }) => { const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; - let size; - - if (desktop && diffsTabIsActive) { - size = getOuterHeight( - '.diffs .diff-file .file-title-flex-parent:not([style="display:none"])', - ); - } - - return size; - }, - ({ desktop }) => { - let size; - - if (desktop) { - size = getOuterHeight('.mr-version-controls'); - } - - return size; + const isDiscussionScroll = + desktop && diffsTabIsActive && window.location.hash.startsWith('#note'); + return isDiscussionScroll + ? getOuterHeight('.diffs .diff-file .file-title-flex-parent:not([style="display:none"])') + : 0; }, + ({ desktop }) => (desktop ? getOuterHeight('.mr-version-controls') : 0), ]; return heightCalculators.reduce((totalHeight, calculator) => { @@ -385,8 +373,8 @@ export const buildUrlWithCurrentLocation = (param) => { * * @param {String} param */ -export const historyPushState = (newUrl) => { - window.history.pushState({}, document.title, newUrl); +export const historyPushState = (newUrl, state = {}) => { + window.history.pushState(state, document.title, newUrl); }; /** @@ -752,3 +740,12 @@ export const isDefaultCiConfig = (path) => { export const hasCiConfigExtension = (path) => { return CI_CONFIG_PATH_EXTENSION.test(path); }; + +/** + * Checks if an element with position:sticky is stuck + * + * @param el + * @returns {boolean} + */ +export const isElementStuck = (el) => + el.getBoundingClientRect().top <= parseInt(getComputedStyle(el).top, 10); diff --git a/app/assets/javascripts/lib/utils/datetime/constants.js b/app/assets/javascripts/lib/utils/datetime/constants.js deleted file mode 100644 index 869ade45ebd..00000000000 --- a/app/assets/javascripts/lib/utils/datetime/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -// Keys for the memoized Intl dateTime formatters -export const DATE_WITH_TIME_FORMAT = 'DATE_WITH_TIME_FORMAT'; -export const DATE_ONLY_FORMAT = 'DATE_ONLY_FORMAT'; - -export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT; - -export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT]; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 4e0d19f2c2a..6484fcff769 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -413,6 +413,15 @@ export const nYearsAfter = (date, numberOfYears) => { }; /** + * Returns the date `n` years before the date provided. + * + * @param {Date} date the initial date + * @param {Number} numberOfYears number of years before + * @return {Date} A `Date` object `n` years before the provided `Date` + */ +export const nYearsBefore = (date, numberOfYears) => nYearsAfter(date, -numberOfYears); + +/** * Returns the date after the date provided * * @param {Date} date the initial date diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index c4b8f95e99f..7eb9c0f4518 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -377,28 +377,50 @@ export const dateToTimeInputValue = (date) => { export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, months }) => { if (months) { - return sprintf(s__('ValueStreamAnalytics|%{value}M'), { - value: roundToNearestHalf(months), - }); + const value = roundToNearestHalf(months); + return sprintf( + n__('ValueStreamAnalytics|%{value} month', 'ValueStreamAnalytics|%{value} months', value), + { + value, + }, + ); } if (weeks) { - return sprintf(s__('ValueStreamAnalytics|%{value}w'), { - value: roundToNearestHalf(weeks), - }); + const value = roundToNearestHalf(weeks); + return sprintf( + n__('ValueStreamAnalytics|%{value} week', 'ValueStreamAnalytics|%{value} weeks', value), + { + value, + }, + ); } if (days) { - return sprintf(s__('ValueStreamAnalytics|%{value}d'), { - value: roundToNearestHalf(days), - }); + const value = roundToNearestHalf(days); + return sprintf( + n__('ValueStreamAnalytics|%{value} day', 'ValueStreamAnalytics|%{value} days', value), + { + value, + }, + ); } if (hours) { - return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); + return sprintf( + n__('ValueStreamAnalytics|%{value} hour', 'ValueStreamAnalytics|%{value} hours', hours), + { + value: hours, + }, + ); } if (minutes) { - return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); + return sprintf( + n__('ValueStreamAnalytics|%{value} minute', 'ValueStreamAnalytics|%{value} minutes', minutes), + { + value: minutes, + }, + ); } if (seconds) { - return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); + return unescape(sanitize(s__('ValueStreamAnalytics|<1 minute'), { ALLOWED_TAGS: [] })); } return '-'; }; diff --git a/app/assets/javascripts/lib/utils/datetime/locale_dateformat.js b/app/assets/javascripts/lib/utils/datetime/locale_dateformat.js new file mode 100644 index 00000000000..a4d911a6699 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/locale_dateformat.js @@ -0,0 +1,273 @@ +import { createDateTimeFormat } from '~/locale'; + +/** + * Format a Date with the help of {@link DateTimeFormat.asDateTime} + * + * Note: In case you can use localeDateFormat.asDateTime directly, please do that. + * + * @example + * localeDateFormat[DATE_WITH_TIME_FORMAT].format(date) // returns 'Jul 6, 2020, 2:43 PM' + * localeDateFormat[DATE_WITH_TIME_FORMAT].formatRange(date, date) // returns 'Jul 6, 2020, 2:45PM – 8:43 PM' + */ +export const DATE_WITH_TIME_FORMAT = 'asDateTime'; + +/** + * Format a Date with the help of {@link DateTimeFormat.asDateTimeFull} + * + * Note: In case you can use localeDateFormat.asDateTimeFull directly, please do that. + * + * @example + * localeDateFormat[DATE_TIME_FULL_FORMAT].format(date) // returns 'July 6, 2020 at 2:43:12 PM GMT' + */ +export const DATE_TIME_FULL_FORMAT = 'asDateTimeFull'; + +/** + * Format a Date with the help of {@link DateTimeFormat.asDate} + * + * Note: In case you can use localeDateFormat.asDate directly, please do that. + * + * @example + * localeDateFormat[DATE_ONLY_FORMAT].format(date) // returns 'Jul 05, 2023' + * localeDateFormat[DATE_ONLY_FORMAT].formatRange(date, date) // returns 'Jul 05 - Jul 07, 2023' + */ +export const DATE_ONLY_FORMAT = 'asDate'; + +/** + * Format a Date with the help of {@link DateTimeFormat.asTime} + * + * Note: In case you can use localeDateFormat.asTime directly, please do that. + * + * @example + * localeDateFormat[TIME_ONLY_FORMAT].format(date) // returns '2:43' + * localeDateFormat[TIME_ONLY_FORMAT].formatRange(date, date) // returns '2:43 - 6:27 PM' + */ +export const TIME_ONLY_FORMAT = 'asTime'; +export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT; +export const DATE_TIME_FORMATS = [ + DATE_WITH_TIME_FORMAT, + DATE_TIME_FULL_FORMAT, + DATE_ONLY_FORMAT, + TIME_ONLY_FORMAT, +]; + +/** + * The DateTimeFormat utilities support formatting a number of types, + * essentially anything you might use in the `Date` constructor. + * + * The reason for this is mostly backwards compatibility, as dateformat did the same + * https://github.com/felixge/node-dateformat/blob/c53e475891130a1fecd3b0d9bc5ebf3820b31b44/src/dateformat.js#L37-L41 + * + * @typedef {Date|number|string|null} Dateish + * + */ +/** + * @typedef {Object} DateTimeFormatter + * @property {function(Dateish): string} format + * Formats a single {@link Dateish} + * with {@link Intl.DateTimeFormat.format} + * @property {function(Dateish, Dateish): string} formatRange + * Formats two {@link Dateish} as a range + * with {@link Intl.DateTimeFormat.formatRange} + */ + +class DateTimeFormat { + #formatters = {}; + + /** + * Locale aware formatter to display date _and_ time. + * + * Use this formatter when in doubt. + * + * @example + * // en-US: returns something like Jul 6, 2020, 2:43 PM + * // en-GB: returns something like 6 Jul 2020, 14:43 + * localeDateFormat.asDateTime.format(date) + * + * @returns {DateTimeFormatter} + */ + get asDateTime() { + return ( + this.#formatters[DATE_WITH_TIME_FORMAT] || + this.#createFormatter(DATE_WITH_TIME_FORMAT, { + dateStyle: 'medium', + timeStyle: 'short', + hourCycle: DateTimeFormat.#hourCycle, + }) + ); + } + /** + * Locale aware formatter to a complete date time. + * + * This is needed if you need to convey a full timestamp including timezone and seconds. + * + * This is mainly used in tooltips. Use {@link DateTimeFormat.asDateTime} + * if you don't need to show all the information. + * + * + * @example + * // en-US: returns something like July 6, 2020 at 2:43:12 PM GMT + * // en-GB: returns something like 6 July 2020 at 14:43:12 GMT + * localeDateFormat.asDateTimeFull.format(date) + * + * @returns {DateTimeFormatter} + */ + get asDateTimeFull() { + return ( + this.#formatters[DATE_TIME_FULL_FORMAT] || + this.#createFormatter(DATE_TIME_FULL_FORMAT, { + dateStyle: 'long', + timeStyle: 'long', + hourCycle: DateTimeFormat.#hourCycle, + }) + ); + } + + /** + * Locale aware formatter to display a only the date. + * + * Use {@link DateTimeFormat.asDateTime} if you also need to display the time. + * + * @example + * // en-US: returns something like Jul 6, 2020 + * // en-GB: returns something like 6 Jul 2020 + * localeDateFormat.asDate.format(date) + * + * @example + * // en-US: returns something like Jul 6 – 7, 2020 + * // en-GB: returns something like 6-7 Jul 2020 + * localeDateFormat.asDate.formatRange(date, date2) + * + * @returns {DateTimeFormatter} + */ + get asDate() { + return ( + this.#formatters[DATE_ONLY_FORMAT] || + this.#createFormatter(DATE_ONLY_FORMAT, { + dateStyle: 'medium', + }) + ); + } + + /** + * Locale aware formatter to display only the time. + * + * Use {@link DateTimeFormat.asDateTime} if you also need to display the date. + * + * + * @example + * // en-US: returns something like 2:43 PM + * // en-GB: returns something like 14:43 + * localeDateFormat.asTime.format(date) + * + * Note: If formatting a _range_ and the dates are not on the same day, + * the formatter will do something sensible like: + * 7/9/1983, 2:43 PM – 7/12/1983, 12:36 PM + * + * @example + * // en-US: returns something like 2:43 – 6:27 PM + * // en-GB: returns something like 14:43 – 18:27 + * localeDateFormat.asTime.formatRange(date, date2) + * + * @returns {DateTimeFormatter} + */ + get asTime() { + return ( + this.#formatters[TIME_ONLY_FORMAT] || + this.#createFormatter(TIME_ONLY_FORMAT, { + timeStyle: 'short', + hourCycle: DateTimeFormat.#hourCycle, + }) + ); + } + + /** + * Resets the memoized formatters + * + * While this method only seems to be useful for testing right now, + * it could also be used in the future to live-preview the formatting + * to the user on their settings page. + */ + reset() { + this.#formatters = {}; + } + + /** + * This helper function creates formatters in a memoized fashion. + * + * The first time a getter is called, it will use this helper + * to create an {@link Intl.DateTimeFormat} which is used internally. + * + * We memoize the creation of the formatter, because using one of them + * is about 300 faster than creating them. + * + * @param {string} name (one of {@link DATE_TIME_FORMATS}) + * @param {Intl.DateTimeFormatOptions} format + * @returns {DateTimeFormatter} + */ + #createFormatter(name, format) { + const intlFormatter = createDateTimeFormat(format); + + this.#formatters[name] = { + format: (date) => intlFormatter.format(DateTimeFormat.castToDate(date)), + formatRange: (date1, date2) => { + return intlFormatter.formatRange( + DateTimeFormat.castToDate(date1), + DateTimeFormat.castToDate(date2), + ); + }, + }; + + return this.#formatters[name]; + } + + /** + * Casts a Dateish to a Date. + * @param dateish {Dateish} + * @returns {Date} + */ + static castToDate(dateish) { + const date = dateish instanceof Date ? dateish : new Date(dateish); + if (Number.isNaN(date)) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Invalid date provided'); + } + return date; + } + + /** + * Internal method to determine the {@link Intl.Locale.hourCycle} a user prefers. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle + * @returns {undefined|'h12'|'h23'} + */ + static get #hourCycle() { + switch (window.gon?.time_display_format) { + case 1: + return 'h12'; + case 2: + return 'h23'; + default: + return undefined; + } + } +} + +/** + * A singleton instance of {@link DateTimeFormat}. + * This formatting helper respects the user preferences (locale and 12h/24h preference) + * and gives an efficient way to format dates and times. + * + * Each of the supported formatters has support to format a simple date, but also a range. + * + * + * DateTime (showing both date and times): + * - {@link DateTimeFormat.asDateTime localeDateFormat.asDateTime} - the default format for date times + * - {@link DateTimeFormat.asDateTimeFull localeDateFormat.asDateTimeFull} - full format, including timezone and seconds + * + * Date (showing date only): + * - {@link DateTimeFormat.asDate localeDateFormat.asDate} - the default format for a date + * + * Time (showing time only): + * - {@link DateTimeFormat.asTime localeDateFormat.asTime} - the default format for a time + */ +export const localeDateFormat = new DateTimeFormat(); diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index 89170ecc55d..3a94b26ee35 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -1,7 +1,6 @@ import * as timeago from 'timeago.js'; -import { languageCode, s__, createDateTimeFormat } from '~/locale'; -import { formatDate } from './date_format_utility'; -import { DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT, DEFAULT_DATE_TIME_FORMAT } from './constants'; +import { languageCode, s__ } from '~/locale'; +import { DEFAULT_DATE_TIME_FORMAT, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; /** * Timeago uses underscores instead of dashes to separate language from country code. @@ -107,51 +106,10 @@ timeago.register(timeagoLanguageCode, memoizedLocale()); timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration()); -const setupAbsoluteFormatters = () => { - let cache = {}; - - // Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options) - // For hourCycle please check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle - const hourCycle = [undefined, 'h12', 'h23']; - const formats = { - [DATE_WITH_TIME_FORMAT]: () => ({ - dateStyle: 'medium', - timeStyle: 'short', - hourCycle: hourCycle[window.gon?.time_display_format || 0], - }), - [DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }), - }; - - return (formatName = DEFAULT_DATE_TIME_FORMAT) => { - if (cache.time_display_format !== window.gon?.time_display_format) { - cache = { - time_display_format: window.gon?.time_display_format, - }; - } - - if (cache[formatName]) { - return cache[formatName]; - } - - let format = formats[formatName] && formats[formatName](); - if (!format) { - format = formats[DEFAULT_DATE_TIME_FORMAT](); - } - - const formatter = createDateTimeFormat(format); - - cache[formatName] = { - format(date) { - return formatter.format(date instanceof Date ? date : new Date(date)); - }, - }; - return cache[formatName]; - }; -}; -const memoizedFormatters = setupAbsoluteFormatters(); - export const getTimeago = (formatName) => - window.gon?.time_display_relative === false ? memoizedFormatters(formatName) : timeago; + window.gon?.time_display_relative === false + ? localeDateFormat[formatName] ?? localeDateFormat[DEFAULT_DATE_TIME_FORMAT] + : timeago; /** * For the given elements, sets a tooltip with a formatted date. @@ -171,7 +129,7 @@ export const localTimeAgo = (elements, updateTooltip = true) => { function addTimeAgoTooltip() { elements.forEach((el) => { // Recreate with custom template - el.setAttribute('title', formatDate(el.dateTime)); + el.setAttribute('title', localeDateFormat.asDateTimeFull.format(el.dateTime)); }); } diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index a6331bc6551..061ce96407e 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,6 +1,6 @@ -export * from './datetime/constants'; export * from './datetime/timeago_utility'; export * from './datetime/date_format_utility'; export * from './datetime/date_calculation_utility'; export * from './datetime/pikaday_utility'; export * from './datetime/time_spent_utility'; +export * from './datetime/locale_dateformat'; diff --git a/app/assets/javascripts/lib/utils/regexp.js b/app/assets/javascripts/lib/utils/regexp.js index f212bf80bd7..8f3c3fccc97 100644 --- a/app/assets/javascripts/lib/utils/regexp.js +++ b/app/assets/javascripts/lib/utils/regexp.js @@ -4,12 +4,5 @@ // Inspired by https://github.com/mishoo/UglifyJS/blob/2bc1d02363db3798d5df41fb5059a19edca9b7eb/lib/parse-js.js#L203 // Unicode 6.1 -const unicodeLetters = +export const unicodeLetters = '\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F0\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC'; - -/** - * A regex that matches all single quotes in a string - */ -export const allSingleQuotes = /'/g; - -export default { unicodeLetters, allSingleQuotes }; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js index 49de7b3a081..4d8612aeeff 100644 --- a/app/assets/javascripts/lib/utils/secret_detection.js +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -28,6 +28,10 @@ export const containsSensitiveToken = (message) => { name: 'GitLab OAuth Application Secret', regex: `gloas-[0-9a-zA-Z_-]{64}`, }, + { + name: 'GitLab Deploy Token', + regex: `gldt-[0-9a-zA-Z_-]{20}`, + }, ]; for (const rule of sensitiveDataPatterns) { diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 638ee1f7e5a..6c30294cbbb 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -5,7 +5,6 @@ import { TRUNCATE_WIDTH_DEFAULT_WIDTH, TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, } from '~/lib/utils/constants'; -import { allSingleQuotes } from '~/lib/utils/regexp'; export const COLON = ':'; export const HYPHEN = '-'; @@ -446,6 +445,11 @@ export const markdownConfig = { }; /** + * A regex that matches all single quotes in a string + */ +const allSingleQuotes = /'/g; + +/** * Escapes a string into a shell string, for example * when you want to give a user the command to checkout * a branch. diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js deleted file mode 100644 index 5298eb67c2b..00000000000 --- a/app/assets/javascripts/lib/utils/vuex_module_mappers.js +++ /dev/null @@ -1,92 +0,0 @@ -import { mapValues, isString } from 'lodash'; -// eslint-disable-next-line no-restricted-imports -import { mapState, mapActions } from 'vuex'; - -export const REQUIRE_STRING_ERROR_MESSAGE = - '`vuex_module_mappers` can only be used with an array of strings, or an object with string values. Consider using the regular `vuex` map helpers instead.'; - -const normalizeFieldsToObject = (fields) => { - return Array.isArray(fields) - ? fields.reduce((acc, key) => Object.assign(acc, { [key]: key }), {}) - : fields; -}; - -const mapVuexModuleFields = ({ namespaceSelector, fields, vuexHelper, selector } = {}) => { - // The `vuexHelper` needs an object which maps keys to field selector functions. - const map = mapValues(normalizeFieldsToObject(fields), (value) => { - if (!isString(value)) { - throw new Error(REQUIRE_STRING_ERROR_MESSAGE); - } - - // We need to use a good ol' function to capture the right "this". - return function mappedFieldSelector(...args) { - const namespace = namespaceSelector(this); - - return selector(namespace, value, ...args); - }; - }); - - return vuexHelper(map); -}; - -/** - * Like `mapState`, but takes a function in the first param for selecting a namespace. - * - * ``` - * computed: { - * ...mapVuexModuleState(vm => vm.vuexModule, ['foo']), - * } - * ``` - * - * @param {Function} namespaceSelector - * @param {Array|Object} fields - */ -export const mapVuexModuleState = (namespaceSelector, fields) => - mapVuexModuleFields({ - namespaceSelector, - fields, - vuexHelper: mapState, - selector: (namespace, value, state) => state[namespace][value], - }); - -/** - * Like `mapActions`, but takes a function in the first param for selecting a namespace. - * - * ``` - * methods: { - * ...mapVuexModuleActions(vm => vm.vuexModule, ['fetchFoos']), - * } - * ``` - * - * @param {Function} namespaceSelector - * @param {Array|Object} fields - */ -export const mapVuexModuleActions = (namespaceSelector, fields) => - mapVuexModuleFields({ - namespaceSelector, - fields, - vuexHelper: mapActions, - selector: (namespace, value, dispatch, ...args) => dispatch(`${namespace}/${value}`, ...args), - }); - -/** - * Like `mapGetters`, but takes a function in the first param for selecting a namespace. - * - * ``` - * computed: { - * ...mapGetters(vm => vm.vuexModule, ['hasSearchInfo']), - * } - * ``` - * - * @param {Function} namespaceSelector - * @param {Array|Object} fields - */ -export const mapVuexModuleGetters = (namespaceSelector, fields) => - mapVuexModuleFields({ - namespaceSelector, - fields, - // `mapGetters` does not let us pass an object which maps to functions. Thankfully `mapState` does - // and gives us access to the getters. - vuexHelper: mapState, - selector: (namespace, value, state, getters) => getters[`${namespace}/${value}`], - }); diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js index ca3f1caec67..c76e44a196d 100644 --- a/app/assets/javascripts/logo.js +++ b/app/assets/javascripts/logo.js @@ -3,3 +3,20 @@ export default function initLogoAnimation() { document.querySelector('.tanuki-logo')?.classList.add('animate'); }); } + +export function initPortraitLogoDetection() { + const image = document.querySelector('.js-portrait-logo-detection'); + + image?.addEventListener( + 'load', + ({ currentTarget: img }) => { + const isPortrait = img.height > img.width; + if (isPortrait) { + // Limit the width when the logo has portrait format + img.classList.replace('gl-h-9', 'gl-w-10'); + } + img.classList.remove('gl-visibility-hidden'); + }, + { once: true }, + ); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 29189e3ac2f..c3914391a49 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -15,26 +15,21 @@ import * as tooltips from '~/tooltips'; import { initPrefetchLinks } from '~/lib/utils/navigation_utility'; import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred'; import initAlertHandler from './alert_handler'; -import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime/timeago_utility'; import { getLocationHash, visitUrl, mergeUrlParams } from './lib/utils/url_utility'; // everything else -import initFeatureHighlight from './feature_highlight'; import LazyLoader from './lazy_loader'; -import initLogoAnimation from './logo'; +import initLogoAnimation, { initPortraitLogoDetection } from './logo'; import initBreadcrumbs from './breadcrumb'; import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; -import { initSidebarTracking } from './pages/shared/nav/sidebar_tracking'; 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 initHeaderSearch from './header_search/init'; import initGitlabVersionCheck from './gitlab_version_check'; import 'ee_else_ce/main_ee'; @@ -85,19 +80,14 @@ initRails(); function deferredInitialisation() { const $body = $('body'); - if (!gon.use_new_navigation) { - initTopNav(); - initTodoToggle(); - } initBreadcrumbs(); initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); + initPortraitLogoDetection(); initUserPopovers(); initBroadcastNotifications(); initPersistentUserCallouts(); initDefaultTrackers(); - initSidebarTracking(); - initFeatureHighlight(); initCopyCodeButton(); initGitlabVersionCheck(); @@ -121,11 +111,6 @@ function deferredInitialisation() { setTimeout(() => $body.addClass('page-initialised'), 1000); } -// header search vue component bootstrap -// loading this inside requestIdleCallback is causing issues -// see https://gitlab.com/gitlab-org/gitlab/-/issues/365746 -initHeaderSearch(); - const $body = $('body'); const $document = $(document); const bootstrapBreakpoint = bp.getBreakpointSize(); @@ -198,10 +183,6 @@ $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxComplete } }); -$('.navbar-toggler').on('click', () => { - document.body.classList.toggle('top-nav-responsive-open'); -}); - /** * Show suppressed commit diff * diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 2f10a333bf4..c76b928ad3d 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -36,7 +36,7 @@ export default { :title="$options.i18n.buttonTitle" :aria-label="$options.i18n.buttonTitle" icon="remove" - data-qa-selector="remove_group_link_button" + data-testid="remove-group-link-button" @click="showRemoveGroupLinkModal(groupLink)" /> </template> diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index 18db8fe9cfb..55f75bc819c 100644 --- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -15,7 +15,7 @@ export default { text: s__('Members|Remove group'), attributes: { variant: 'danger', - 'data-qa-selector': 'remove_group_button', + 'data-testid': 'remove-group-button', }, }, csrf, @@ -69,7 +69,7 @@ export default { :action-primary="$options.actionPrimary" :action-cancel="$options.actionCancel" size="sm" - data-qa-selector="remove_group_link_modal_content" + data-testid="remove-group-link-modal-content" @primary="handlePrimary" @hide="hideRemoveGroupLinkModal" > diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index ecc769174f4..d3079dc7d0a 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -72,7 +72,7 @@ export default { text: this.actionText, attributes: { variant: 'danger', - 'data-qa-selector': 'remove_member_button', + 'data-testid': 'remove-member-button', }, }; }, @@ -104,7 +104,7 @@ export default { :action-primary="actionPrimary" :title="actionText" :visible="removeMemberModalVisible" - data-qa-selector="remove_member_modal" + data-testid="remove-member-modal" @primary="submitForm" @hide="hideRemoveMemberModal" > diff --git a/app/assets/javascripts/members/components/table/max_role.vue b/app/assets/javascripts/members/components/table/max_role.vue new file mode 100644 index 00000000000..89780108518 --- /dev/null +++ b/app/assets/javascripts/members/components/table/max_role.vue @@ -0,0 +1,134 @@ +<script> +import { GlBadge, GlCollapsibleListbox } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +// eslint-disable-next-line no-restricted-imports +import { mapActions } from 'vuex'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; +import { roleDropdownItems, initialSelectedRole } from 'ee_else_ce/members/utils'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { s__ } from '~/locale'; + +export default { + components: { + GlCollapsibleListbox, + GlBadge, + LdapDropdownFooter: () => + import('ee_component/members/components/action_dropdowns/ldap_dropdown_footer.vue'), + CustomPermissions: () => import('ee_component/members/components/table/custom_permissions.vue'), + }, + inject: ['namespace', 'group'], + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + data() { + const accessLevelOptions = roleDropdownItems(this.member); + return { + accessLevelOptions, + busy: false, + customPermissions: this.member.customPermissions ?? [], + isDesktop: false, + memberRoleId: this.member.accessLevel.memberRoleId ?? null, + selectedRole: initialSelectedRole(accessLevelOptions.flatten, this.member), + }; + }, + computed: { + disabled() { + return this.permissions.canOverride && !this.member.isOverridden; + }, + }, + mounted() { + this.isDesktop = bp.isDesktop(); + }, + methods: { + ...mapActions({ + updateMemberRole(dispatch, { memberId, accessLevel, memberRoleId }) { + return dispatch(`${this.namespace}/updateMemberRole`, { + memberId, + accessLevel, + memberRoleId, + }); + }, + }), + async handleSelect(value) { + this.busy = true; + + const newRole = this.accessLevelOptions.flatten.find((item) => item.value === value); + const previousRole = this.selectedRole; + const previousMemberRoleId = this.memberRoleId; + + try { + const confirmed = await guestOverageConfirmAction({ + oldAccessLevel: this.member.accessLevel.integerValue, + newRoleName: ACCESS_LEVEL_LABELS[newRole.accessLevel], + newMemberRoleId: newRole.memberRoleId, + group: this.group, + memberId: this.member.id, + memberType: this.namespace, + }); + if (!confirmed) { + return; + } + + this.selectedRole = value; + this.memberRoleId = newRole.memberRoleId; + + await this.updateMemberRole({ + memberId: this.member.id, + accessLevel: newRole.accessLevel, + memberRoleId: newRole.memberRoleId, + }); + + this.$toast.show(s__('Members|Role updated successfully.')); + } catch (error) { + this.selectedRole = previousRole; + this.memberRoleId = previousMemberRoleId; + Sentry.captureException(error); + } finally { + this.busy = false; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-collapsible-listbox + v-if="permissions.canUpdate" + :placement="isDesktop ? 'left' : 'right'" + :header-text="__('Change role')" + :disabled="disabled" + :loading="busy" + data-testid="access-level-dropdown" + :items="accessLevelOptions.formatted" + :selected="selectedRole" + @select="handleSelect" + > + <template #list-item="{ item }"> + <span data-testid="access-level-link">{{ item.text }}</span> + </template> + <template #footer> + <ldap-dropdown-footer + v-if="permissions.canOverride && member.isOverridden" + :member-id="member.id" + /> + </template> + </gl-collapsible-listbox> + + <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + + <custom-permissions + v-if="memberRoleId !== null" + :member-role-id="memberRoleId" + :custom-permissions="customPermissions" + /> + </div> +</template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 2b3294c1c79..1bccb8a0c4b 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -31,7 +31,7 @@ import MemberActions from './member_actions.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; import MemberActivity from './member_activity.vue'; -import RoleDropdown from './role_dropdown.vue'; +import MaxRole from './max_role.vue'; export default { name: 'MembersTable', @@ -44,7 +44,7 @@ export default { MembersTableCell, MemberSource, MemberActions, - RoleDropdown, + MaxRole, RemoveGroupLinkModal, RemoveMemberModal, ExpirationDatepicker, @@ -143,7 +143,6 @@ export default { ...this.tableAttrs.tr, ...(member?.id && { 'data-testid': `members-table-row-${member.id}`, - 'data-qa-selector': 'member_row', }), }; }, @@ -292,8 +291,7 @@ export default { <template #cell(maxRole)="{ item: member }"> <members-table-cell #default="{ permissions }" :member="member"> - <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" /> - <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + <max-role :permissions="permissions" :member="member" /> </members-table-cell> </template> diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue deleted file mode 100644 index 2b72a3fe6e8..00000000000 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ /dev/null @@ -1,120 +0,0 @@ -<script> -import { GlCollapsibleListbox } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -// eslint-disable-next-line no-restricted-imports -import { mapActions } from 'vuex'; -import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; -import { roleDropdownItems, initialSelectedRole } from 'ee_else_ce/members/utils'; -import { s__ } from '~/locale'; - -export default { - name: 'RoleDropdown', - components: { - GlCollapsibleListbox, - LdapDropdownFooter: () => - import('ee_component/members/components/action_dropdowns/ldap_dropdown_footer.vue'), - }, - inject: ['namespace', 'group'], - props: { - member: { - type: Object, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - data() { - return { - isDesktop: false, - busy: false, - selectedRole: null, - }; - }, - computed: { - disabled() { - return this.permissions.canOverride && !this.member.isOverridden; - }, - dropdownItems() { - return roleDropdownItems(this.member); - }, - }, - created() { - this.selectedRole = initialSelectedRole(this.dropdownItems.flatten, this.member); - }, - mounted() { - this.isDesktop = bp.isDesktop(); - }, - methods: { - ...mapActions({ - updateMemberRole(dispatch, payload) { - return dispatch(`${this.namespace}/updateMemberRole`, payload); - }, - }), - async handleSelect(value) { - this.busy = true; - - const newRole = this.dropdownItems.flatten.find((item) => item.value === value); - const previousRole = this.selectedRole; - - try { - const confirmed = await guestOverageConfirmAction({ - currentRoleValue: this.member.accessLevel.integerValue, - newRoleValue: newRole.accessLevel, - newRoleName: newRole.text, - newMemberRoleId: newRole.memberRoleId, - group: this.group, - memberId: this.member.id, - memberType: this.namespace, - }); - if (!confirmed) { - return; - } - - this.selectedRole = value; - - await this.updateMemberRole({ - memberId: this.member.id, - accessLevel: { - integerValue: newRole.accessLevel, - memberRoleId: newRole.memberRoleId, - }, - }); - - this.$toast.show(s__('Members|Role updated successfully.')); - } catch (error) { - this.selectedRole = previousRole; - Sentry.captureException(error); - } finally { - this.busy = false; - } - }, - }, -}; -</script> - -<template> - <gl-collapsible-listbox - :placement="isDesktop ? 'left' : 'right'" - :toggle-text="member.accessLevel.stringValue" - :header-text="__('Change role')" - :disabled="disabled" - :loading="busy" - data-qa-selector="access_level_dropdown" - :items="dropdownItems.formatted" - :selected="selectedRole" - @select="handleSelect" - > - <template #list-item="{ item }"> - <span data-qa-selector="access_level_link">{{ item.text }}</span> - </template> - <template #footer> - <ldap-dropdown-footer - v-if="permissions.canOverride && member.isOverridden" - :member-id="member.id" - /> - </template> - </gl-collapsible-listbox> -</template> diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 87ae670c146..ad477d8b4b6 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -2,6 +2,8 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseDataAttributes } from '~/members/utils'; import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import MembersTabs from './components/members_tabs.vue'; @@ -13,6 +15,7 @@ export const initMembersApp = (el, options) => { } Vue.use(Vuex); + Vue.use(VueApollo); Vue.use(GlToast); const { @@ -61,6 +64,7 @@ export const initMembersApp = (el, options) => { el, components: { MembersTabs }, store, + apolloProvider: new VueApollo({ defaultClient: createDefaultClient() }), provide: { currentUserId: gon.current_user_id || null, sourceId, diff --git a/app/assets/javascripts/members/store/actions.js b/app/assets/javascripts/members/store/actions.js index d696f618a3c..54a53e7c0a9 100644 --- a/app/assets/javascripts/members/store/actions.js +++ b/app/assets/javascripts/members/store/actions.js @@ -2,17 +2,15 @@ import axios from '~/lib/utils/axios_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import * as types from './mutation_types'; -export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => { +export const updateMemberRole = async ( + { state, commit }, + { memberId, accessLevel, memberRoleId }, +) => { try { await axios.put( state.memberPath.replace(/:id$/, memberId), - state.requestFormatter({ - accessLevel: accessLevel.integerValue, - memberRoleId: accessLevel.memberRoleId, - }), + state.requestFormatter({ accessLevel, memberRoleId }), ); - - commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel }); } catch (error) { commit(types.RECEIVE_MEMBER_ROLE_ERROR, { error }); diff --git a/app/assets/javascripts/members/store/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js index 5fa75725552..c1cdbf6146f 100644 --- a/app/assets/javascripts/members/store/mutation_types.js +++ b/app/assets/javascripts/members/store/mutation_types.js @@ -1,4 +1,3 @@ -export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS'; export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR'; export const RECEIVE_MEMBER_EXPIRATION_SUCCESS = 'RECEIVE_MEMBER_EXPIRATION_SUCCESS'; diff --git a/app/assets/javascripts/members/store/mutations.js b/app/assets/javascripts/members/store/mutations.js index b4cf9f3480f..edc400aef7d 100644 --- a/app/assets/javascripts/members/store/mutations.js +++ b/app/assets/javascripts/members/store/mutations.js @@ -4,15 +4,6 @@ import * as types from './mutation_types'; import { findMember } from './utils'; export default { - [types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { memberId, accessLevel }) { - const member = findMember(state, memberId); - - if (!member) { - return; - } - - Vue.set(member, 'accessLevel', accessLevel); - }, [types.RECEIVE_MEMBER_ROLE_ERROR](state, { error }) { state.errorMessage = error.response?.data?.message || diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index d80517c1c1f..5f8f0e2b96c 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -2,6 +2,7 @@ import { GlSprintf, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import DiffFileEditor from './components/diff_file_editor.vue'; @@ -23,6 +24,7 @@ export default { components: { GlButton, GlButtonGroup, + ClipboardButton, GlSprintf, GlLoadingIcon, FileIcon, @@ -122,6 +124,12 @@ export default { <div class="file-header-content" data-testid="file-name"> <file-icon :file-name="file.filePath" :size="16" css-classes="gl-mr-2" /> <strong class="file-title-name">{{ file.filePath }}</strong> + <clipboard-button + :title="__('Copy file path')" + :text="file.filePath" + size="small" + category="tertiary" + /> </div> <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"> <gl-button-group v-if="file.type === 'text'" class="gl-mr-3"> diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 8ea995b8b4e..1290a5a17b9 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -237,7 +237,8 @@ export default class MergeRequestTabs { bindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); - window.addEventListener('popstate', () => { + window.addEventListener('popstate', (event) => { + if (event?.state?.skipScrolling) return; const action = getActionFromHref(location.href); this.tabShown(action, location.href); @@ -603,11 +604,7 @@ export default class MergeRequestTabs { if (!isInVueNoteablePage() || this.cachedPageLayoutClasses) return; this.cachedPageLayoutClasses = this.pageLayout.className; - this.pageLayout.classList.remove( - 'right-sidebar-collapsed', - 'right-sidebar-expanded', - 'page-with-icon-sidebar', - ); + this.pageLayout.classList.remove('right-sidebar-collapsed', 'right-sidebar-expanded'); this.sidebar.style.width = '0px'; } diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index 43d28e3d699..ea942012af3 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -1,45 +1,15 @@ <script> -import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; -import { isEmpty, maxBy, range } from 'lodash'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; -import { __, sprintf } from '~/locale'; -import DetailRow from './components/candidate_detail_row.vue'; - -import { - TITLE_LABEL, - INFO_LABEL, - ID_LABEL, - STATUS_LABEL, - EXPERIMENT_LABEL, - ARTIFACTS_LABEL, - PARAMETERS_LABEL, - METRICS_LABEL, - METADATA_LABEL, - DELETE_CANDIDATE_CONFIRMATION_MESSAGE, - DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, - DELETE_CANDIDATE_MODAL_TITLE, - MLFLOW_ID_LABEL, - CI_SECTION_LABEL, - JOB_LABEL, - CI_USER_LABEL, - CI_MR_LABEL, - PERFORMANCE_LABEL, - NO_PARAMETERS_MESSAGE, - NO_METRICS_MESSAGE, - NO_METADATA_MESSAGE, - NO_CI_MESSAGE, -} from './translations'; +import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; +import { s__ } from '~/locale'; export default { name: 'MlCandidatesShow', components: { ModelExperimentsHeader, DeleteButton, - DetailRow, - GlAvatarLabeled, - GlLink, - GlTableLite, + CandidateDetail, }, props: { candidate: { @@ -47,70 +17,18 @@ export default { required: true, }, }, - i18n: { - TITLE_LABEL, - INFO_LABEL, - ID_LABEL, - STATUS_LABEL, - EXPERIMENT_LABEL, - ARTIFACTS_LABEL, - DELETE_CANDIDATE_CONFIRMATION_MESSAGE, - DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, - DELETE_CANDIDATE_MODAL_TITLE, - MLFLOW_ID_LABEL, - CI_SECTION_LABEL, - JOB_LABEL, - CI_USER_LABEL, - CI_MR_LABEL, - PARAMETERS_LABEL, - METRICS_LABEL, - METADATA_LABEL, - PERFORMANCE_LABEL, - NO_PARAMETERS_MESSAGE, - NO_METRICS_MESSAGE, - NO_METADATA_MESSAGE, - NO_CI_MESSAGE, - }, computed: { info() { return Object.freeze(this.candidate.info); }, - ciJob() { - return Object.freeze(this.info.ci_job); - }, - hasMetadata() { - return !isEmpty(this.candidate.metadata); - }, - hasParameters() { - return !isEmpty(this.candidate.params); - }, - hasMetrics() { - return !isEmpty(this.candidate.metrics); - }, - metricsTableFields() { - const maxStep = maxBy(this.candidate.metrics, 'step').step; - const rowClass = 'gl-p-3!'; - - const cssClasses = { thClass: rowClass, tdClass: rowClass }; - - const fields = range(maxStep + 1).map((step) => ({ - key: step.toString(), - label: sprintf(__('Step %{step}'), { step }), - ...cssClasses, - })); - - return [{ key: 'name', label: __('Metric'), ...cssClasses }, ...fields]; - }, - metricsTableItems() { - const items = {}; - this.candidate.metrics.forEach((metric) => { - const metricRow = items[metric.name] || { name: metric.name }; - metricRow[metric.step] = metric.value; - items[metric.name] = metricRow; - }); - - return Object.values(items); - }, + }, + i18n: { + TITLE_LABEL: s__('MlExperimentTracking|Model candidate details'), + DELETE_CANDIDATE_CONFIRMATION_MESSAGE: s__( + 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', + ), + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL: s__('MlExperimentTracking|Delete candidate'), + DELETE_CANDIDATE_MODAL_TITLE: s__('MlExperimentTracking|Delete candidate?'), }, }; </script> @@ -126,106 +44,6 @@ export default { /> </model-experiments-header> - <section class="gl-mb-6"> - <table class="candidate-details"> - <tbody> - <detail-row :label="$options.i18n.ID_LABEL"> - {{ info.iid }} - </detail-row> - - <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> - - <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> - - <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> - <gl-link :href="info.path_to_experiment"> - {{ info.experiment_name }} - </gl-link> - </detail-row> - - <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> - <gl-link :href="info.path_to_artifact"> - {{ $options.i18n.ARTIFACTS_LABEL }} - </gl-link> - </detail-row> - </tbody> - </table> - </section> - - <section class="gl-mb-6"> - <h4>{{ $options.i18n.CI_SECTION_LABEL }}</h4> - - <table v-if="ciJob" class="candidate-details"> - <tbody> - <detail-row - :label="$options.i18n.JOB_LABEL" - :section-label="$options.i18n.CI_SECTION_LABEL" - > - <gl-link :href="ciJob.path"> - {{ ciJob.name }} - </gl-link> - </detail-row> - - <detail-row v-if="ciJob.user" :label="$options.i18n.CI_USER_LABEL"> - <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar"> - <gl-link :href="ciJob.user.path"> - {{ ciJob.user.name }} - </gl-link> - </gl-avatar-labeled> - </detail-row> - - <detail-row v-if="ciJob.merge_request" :label="$options.i18n.CI_MR_LABEL"> - <gl-link :href="ciJob.merge_request.path"> - !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }} - </gl-link> - </detail-row> - </tbody> - </table> - - <div v-else class="gl-text-secondary">{{ $options.i18n.NO_CI_MESSAGE }}</div> - </section> - - <section class="gl-mb-6"> - <h4>{{ $options.i18n.PARAMETERS_LABEL }}</h4> - - <table v-if="hasParameters" class="candidate-details"> - <tbody> - <detail-row v-for="item in candidate.params" :key="item.name" :label="item.name"> - {{ item.value }} - </detail-row> - </tbody> - </table> - - <div v-else class="gl-text-secondary">{{ $options.i18n.NO_PARAMETERS_MESSAGE }}</div> - </section> - - <section class="gl-mb-6"> - <h4>{{ $options.i18n.METADATA_LABEL }}</h4> - - <table v-if="hasMetadata" class="candidate-details"> - <tbody> - <detail-row v-for="item in candidate.metadata" :key="item.name" :label="item.name"> - {{ item.value }} - </detail-row> - </tbody> - </table> - - <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METADATA_MESSAGE }}</div> - </section> - - <section class="gl-mb-6"> - <h4>{{ $options.i18n.PERFORMANCE_LABEL }}</h4> - - <div v-if="hasMetrics" class="gl-overflow-x-auto"> - <gl-table-lite - :items="metricsTableItems" - :fields="metricsTableFields" - class="gl-w-auto" - hover - /> - </div> - - <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METRICS_MESSAGE }}</div> - </section> + <candidate-detail :candidate="candidate" /> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js deleted file mode 100644 index 98988e1db35..00000000000 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ /dev/null @@ -1,26 +0,0 @@ -import { __, s__ } from '~/locale'; - -export const TITLE_LABEL = s__('MlExperimentTracking|Model candidate details'); -export const INFO_LABEL = s__('MlExperimentTracking|Info'); -export const ID_LABEL = s__('MlExperimentTracking|ID'); -export const MLFLOW_ID_LABEL = s__('MlExperimentTracking|MLflow run ID'); -export const STATUS_LABEL = s__('MlExperimentTracking|Status'); -export const EXPERIMENT_LABEL = s__('MlExperimentTracking|Experiment'); -export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); -export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); -export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); -export const PERFORMANCE_LABEL = s__('MlExperimentTracking|Model performance'); -export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); -export const NO_PARAMETERS_MESSAGE = s__('MlExperimentTracking|No logged parameters'); -export const NO_METRICS_MESSAGE = s__('MlExperimentTracking|No logged metrics'); -export const NO_METADATA_MESSAGE = s__('MlExperimentTracking|No logged metadata'); -export const NO_CI_MESSAGE = s__('MlExperimentTracking|Candidate not linked to a CI build'); -export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( - 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', -); -export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); -export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); -export const CI_SECTION_LABEL = s__('MLExperimentTracking|CI Info'); -export const JOB_LABEL = __('Job'); -export const CI_USER_LABEL = s__('MlExperimentTracking|Triggered by'); -export const CI_MR_LABEL = __('Merge request'); diff --git a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue index 5a55d5669a8..e5e093db5ca 100644 --- a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue +++ b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue @@ -1,10 +1,13 @@ <script> import { isEmpty } from 'lodash'; +import { GlBadge } from '@gitlab/ui'; import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import EmptyState from '../components/empty_state.vue'; import * as i18n from '../translations'; -import { BASE_SORT_FIELDS } from '../constants'; +import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '../constants'; import SearchBar from '../components/search_bar.vue'; import ModelRow from '../components/model_row.vue'; @@ -16,6 +19,8 @@ export default { SearchBar, MetadataItem, TitleArea, + GlBadge, + EmptyState, }, props: { models: { @@ -39,23 +44,32 @@ export default { }, i18n, sortableFields: BASE_SORT_FIELDS, + docHref: helpPagePath('user/project/ml/model_registry/index.md'), + modelEntity: MODEL_ENTITIES.model, }; </script> <template> <div> - <title-area :title="$options.i18n.TITLE_LABEL"> + <title-area> + <template #title> + <div class="gl-flex-grow-1 gl-display-flex gl-align-items-center"> + <span>{{ $options.i18n.TITLE_LABEL }}</span> + <gl-badge variant="neutral" class="gl-mx-4" size="lg" :href="$options.docHref"> + {{ __('Experiment') }} + </gl-badge> + </div> + </template> <template #metadata-models-count> <metadata-item icon="machine-learning" :text="$options.i18n.modelsCountLabel(modelCount)" /> </template> </title-area> - <template v-if="hasModels"> <search-bar :sortable-fields="$options.sortableFields" /> <model-row v-for="model in models" :key="model.name" :model="model" /> <pagination v-bind="pageInfo" /> </template> - <p v-else class="gl-text-secondary">{{ $options.i18n.NO_MODELS_LABEL }}</p> + <empty-state v-else :entity-type="$options.modelEntity" /> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue index e8ec8f157ef..51b8fca6511 100644 --- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue @@ -2,16 +2,23 @@ import { GlTab, GlTabs, GlBadge } from '@gitlab/ui'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue'; +import { MODEL_ENTITIES } from '~/ml/model_registry/constants'; +import EmptyState from '../components/empty_state.vue'; import * as i18n from '../translations'; export default { name: 'ShowMlModelApp', components: { + ModelVersionList: () => import('../components/model_version_list.vue'), + CandidateList: () => import('../components/candidate_list.vue'), + EmptyState, TitleArea, GlTabs, GlTab, GlBadge, MetadataItem, + ModelVersionDetail, }, props: { model: { @@ -26,8 +33,12 @@ export default { candidateCount() { return this.model.candidateCount || 0; }, + latestVersionTitle() { + return `${i18n.LATEST_VERSION_LABEL}: ${this.model.latestVersion.version}`; + }, }, i18n, + modelVersionEntity: MODEL_ENTITIES.modelVersion, }; </script> @@ -48,23 +59,28 @@ export default { <gl-tabs class="gl-mt-4"> <gl-tab :title="$options.i18n.MODEL_DETAILS_TAB_LABEL"> - <h3 class="gl-font-lg">{{ $options.i18n.LATEST_VERSION_LABEL }}</h3> <template v-if="model.latestVersion"> - {{ model.latestVersion.version }} + <h3 class="gl-font-lg">{{ latestVersionTitle }}</h3> + <model-version-detail :model-version="model.latestVersion" /> </template> - <div v-else class="gl-text-secondary">{{ $options.i18n.NO_VERSIONS_LABEL }}</div> + + <empty-state v-else :entity-type="$options.modelVersionEntity" /> </gl-tab> <gl-tab> <template #title> {{ $options.i18n.MODEL_OTHER_VERSIONS_TAB_LABEL }} <gl-badge size="sm" class="gl-tab-counter-badge">{{ versionCount }}</gl-badge> </template> + + <model-version-list :model-id="model.id" /> </gl-tab> <gl-tab> <template #title> {{ $options.i18n.MODEL_CANDIDATES_TAB_LABEL }} <gl-badge size="sm" class="gl-tab-counter-badge">{{ candidateCount }}</gl-badge> </template> + + <candidate-list :model-id="model.id" /> </gl-tab> </gl-tabs> </div> diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue index a9440aff1ce..6608f44ecf7 100644 --- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue @@ -1,16 +1,30 @@ <script> +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import ModelVersionDetail from '../components/model_version_detail.vue'; + export default { name: 'ShowMlModelVersionApp', - components: {}, + components: { + ModelVersionDetail, + TitleArea, + }, props: { modelVersion: { type: Object, required: true, }, }, + computed: { + title() { + return `${this.modelVersion.model.name} / ${this.modelVersion.version}`; + }, + }, }; </script> <template> - <div>{{ modelVersion.model.name }} - {{ modelVersion.version }}</div> + <div> + <title-area :title="title" /> + <model-version-detail :model-version="modelVersion" /> + </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue b/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue new file mode 100644 index 00000000000..58216a77e9e --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue @@ -0,0 +1,213 @@ +<script> +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { isEmpty, maxBy, range } from 'lodash'; +import { __, sprintf } from '~/locale'; +import { + INFO_LABEL, + ID_LABEL, + STATUS_LABEL, + EXPERIMENT_LABEL, + ARTIFACTS_LABEL, + PARAMETERS_LABEL, + METADATA_LABEL, + MLFLOW_ID_LABEL, + CI_SECTION_LABEL, + JOB_LABEL, + CI_USER_LABEL, + CI_MR_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, +} from '../translations'; +import DetailRow from './candidate_detail_row.vue'; + +export default { + HEADER_CLASSES: ['gl-font-lg', 'gl-mt-5'], + name: 'MlCandidateDetail', + components: { + DetailRow, + GlAvatarLabeled, + GlLink, + GlTableLite, + }, + props: { + candidate: { + type: Object, + required: true, + }, + showInfoSection: { + type: Boolean, + required: false, + default: true, + }, + }, + i18n: { + INFO_LABEL, + ID_LABEL, + STATUS_LABEL, + EXPERIMENT_LABEL, + ARTIFACTS_LABEL, + MLFLOW_ID_LABEL, + CI_SECTION_LABEL, + JOB_LABEL, + CI_USER_LABEL, + CI_MR_LABEL, + PARAMETERS_LABEL, + METADATA_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, + }, + computed: { + info() { + return Object.freeze(this.candidate.info); + }, + ciJob() { + return Object.freeze(this.info.ciJob); + }, + hasMetadata() { + return !isEmpty(this.candidate.metadata); + }, + hasParameters() { + return !isEmpty(this.candidate.params); + }, + hasMetrics() { + return !isEmpty(this.candidate.metrics); + }, + metricsTableFields() { + const maxStep = maxBy(this.candidate.metrics, 'step').step; + const rowClass = 'gl-p-3!'; + + const cssClasses = { thClass: rowClass, tdClass: rowClass }; + + const fields = range(maxStep + 1).map((step) => ({ + key: step.toString(), + label: sprintf(__('Step %{step}'), { step }), + ...cssClasses, + })); + + return [{ key: 'name', label: __('Metric'), ...cssClasses }, ...fields]; + }, + metricsTableItems() { + const items = {}; + this.candidate.metrics.forEach((metric) => { + const metricRow = items[metric.name] || { name: metric.name }; + metricRow[metric.step] = metric.value; + items[metric.name] = metricRow; + }); + + return Object.values(items); + }, + }, +}; +</script> + +<template> + <div> + <section v-if="showInfoSection" class="gl-mb-6"> + <table class="candidate-details"> + <tbody> + <detail-row :label="$options.i18n.ID_LABEL"> + {{ info.iid }} + </detail-row> + + <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> + + <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> + + <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> + <gl-link :href="info.pathToExperiment"> + {{ info.experimentName }} + </gl-link> + </detail-row> + + <detail-row v-if="info.pathToArtifact" :label="$options.i18n.ARTIFACTS_LABEL"> + <gl-link :href="info.pathToArtifact"> + {{ $options.i18n.ARTIFACTS_LABEL }} + </gl-link> + </detail-row> + </tbody> + </table> + </section> + + <section class="gl-mb-6"> + <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.CI_SECTION_LABEL }}</h3> + + <table v-if="ciJob" class="candidate-details"> + <tbody> + <detail-row + :label="$options.i18n.JOB_LABEL" + :section-label="$options.i18n.CI_SECTION_LABEL" + > + <gl-link :href="ciJob.path"> + {{ ciJob.name }} + </gl-link> + </detail-row> + + <detail-row v-if="ciJob.user" :label="$options.i18n.CI_USER_LABEL"> + <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar"> + <gl-link :href="ciJob.user.path"> + {{ ciJob.user.name }} + </gl-link> + </gl-avatar-labeled> + </detail-row> + + <detail-row v-if="ciJob.mergeRequest" :label="$options.i18n.CI_MR_LABEL"> + <gl-link :href="ciJob.mergeRequest.path"> + !{{ ciJob.mergeRequest.iid }} {{ ciJob.mergeRequest.title }} + </gl-link> + </detail-row> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_CI_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.PARAMETERS_LABEL }}</h3> + + <table v-if="hasParameters" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.params" :key="item.name" :label="item.name"> + {{ item.value }} + </detail-row> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_PARAMETERS_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.METADATA_LABEL }}</h3> + + <table v-if="hasMetadata" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.metadata" :key="item.name" :label="item.name"> + {{ item.value }} + </detail-row> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METADATA_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.PERFORMANCE_LABEL }}</h3> + + <div v-if="hasMetrics" class="gl-overflow-x-auto"> + <gl-table-lite + :items="metricsTableItems" + :fields="metricsTableFields" + class="gl-w-auto" + hover + /> + </div> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METRICS_MESSAGE }}</div> + </section> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/model_registry/components/candidate_detail_row.vue index 8c7460940a0..8c7460940a0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue +++ b/app/assets/javascripts/ml/model_registry/components/candidate_detail_row.vue diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_list.vue b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue new file mode 100644 index 00000000000..fc24a538293 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/candidate_list.vue @@ -0,0 +1,139 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue'; +import { makeLoadCandidatesErrorMessage, NO_CANDIDATES_LABEL } from '../translations'; +import getModelCandidatesQuery from '../graphql/queries/get_model_candidates.query.graphql'; +import { GRAPHQL_PAGE_SIZE } from '../constants'; + +export default { + name: 'MlCandidateList', + components: { + GlAlert, + CandidateListRow, + PackagesListLoader, + RegistryList, + }, + props: { + modelId: { + type: Number, + required: true, + }, + }, + data() { + return { + modelVersions: {}, + errorMessage: undefined, + }; + }, + apollo: { + candidates: { + query: getModelCandidatesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.mlModel?.candidates ?? {}; + }, + error(error) { + this.errorMessage = makeLoadCandidatesErrorMessage(error.message); + Sentry.captureException(error); + }, + }, + }, + computed: { + gid() { + return convertToGraphQLId('Ml::Model', this.modelId); + }, + isListEmpty() { + return this.count === 0; + }, + isLoading() { + return this.$apollo.queries.candidates.loading; + }, + pageInfo() { + return this.candidates?.pageInfo ?? {}; + }, + listTitle() { + return n__('%d candidate', '%d candidates', this.count); + }, + queryVariables() { + return { + id: this.gid, + first: GRAPHQL_PAGE_SIZE, + }; + }, + items() { + return this.candidates?.nodes ?? []; + }, + count() { + return this.candidates?.count ?? 0; + }, + }, + methods: { + fetchPage({ first = null, last = null, before = null, after = null } = {}) { + const variables = { + ...this.queryVariables, + first, + last, + before, + after, + }; + + this.$apollo.queries.candidates.fetchMore({ + variables, + updateQuery: (previousResult, { fetchMoreResult }) => { + return fetchMoreResult; + }, + }); + }, + fetchPreviousCandidatesPage() { + this.fetchPage({ + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }); + }, + fetchNextCandidatesPage() { + this.fetchPage({ + first: GRAPHQL_PAGE_SIZE, + after: this.pageInfo?.endCursor, + }); + }, + }, + i18n: { + NO_CANDIDATES_LABEL, + }, +}; +</script> +<template> + <div> + <div v-if="isLoading"> + <packages-list-loader /> + </div> + <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{ + errorMessage + }}</gl-alert> + <div v-else-if="isListEmpty" class="gl-text-secondary"> + {{ $options.i18n.NO_CANDIDATES_LABEL }} + </div> + <div v-else> + <registry-list + :hidden-delete="true" + :is-loading="isLoading" + :items="items" + :pagination="pageInfo" + :title="listTitle" + @prev-page="fetchPreviousCandidatesPage" + @next-page="fetchNextCandidatesPage" + > + <template #default="{ item }"> + <candidate-list-row :candidate="item" /> + </template> + </registry-list> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_list_row.vue b/app/assets/javascripts/ml/model_registry/components/candidate_list_row.vue new file mode 100644 index 00000000000..24248c0981b --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/candidate_list_row.vue @@ -0,0 +1,49 @@ +<script> +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'MlCandidateListRow', + components: { + ListItem, + GlLink, + GlTruncate, + GlSprintf, + TimeAgoTooltip, + }, + props: { + candidate: { + type: Object, + required: true, + }, + }, + computed: { + pathToDetails() { + return this.candidate._links?.showPath; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <gl-link class="gl-text-body" :href="pathToDetails"> + <gl-truncate :text="candidate.name" /> + </gl-link> + </div> + </template> + + <template #left-secondary> + <span> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <time-ago-tooltip :time="candidate.createdAt" /> + </template> + </gl-sprintf> + </span> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/empty_state.vue b/app/assets/javascripts/ml/model_registry/components/empty_state.vue new file mode 100644 index 00000000000..017ddba78f1 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/empty_state.vue @@ -0,0 +1,58 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import emptySvgUrl from '@gitlab/svgs/dist/illustrations/empty-state/empty-dag-md.svg?url'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; +import { MODEL_ENTITIES } from '../constants'; + +const emptyStateTranslations = { + [MODEL_ENTITIES.model]: { + title: s__('MlModelRegistry|Start tracking your machine learning models'), + description: s__('MlModelRegistry|Store and manage your machine learning models and versions'), + createNew: s__('MlModelRegistry|Add a model'), + }, + [MODEL_ENTITIES.modelVersion]: { + title: s__('MlModelRegistry|Manage versions of your machine learning model'), + description: s__('MlModelRegistry|Use versions to track performance, parameters, and metadata'), + createNew: s__('MlModelRegistry|Create a model version'), + }, +}; + +export default { + components: { + GlEmptyState, + }, + props: { + entityType: { + type: String, + required: true, + validator(value) { + return MODEL_ENTITIES[value] !== undefined; + }, + }, + }, + computed: { + emptyStateValues() { + return { + ...emptyStateTranslations[this.entityType], + helpPath: helpPagePath('user/project/ml/model_registry/index', { + anchor: 'creating-machine-learning-models-and-model-versions', + }), + emptySvgPath: emptySvgUrl, + }; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="emptyStateValues.title" + :primary-button-text="emptyStateValues.createNew" + :primary-button-link="emptyStateValues.helpPath" + :svg-path="emptyStateValues.emptySvgPath" + :svg-height="null" + :description="emptyStateValues.description" + class="gl-py-8" + /> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_detail.vue b/app/assets/javascripts/ml/model_registry/components/model_version_detail.vue new file mode 100644 index 00000000000..8d3e8cf2023 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/model_version_detail.vue @@ -0,0 +1,61 @@ +<script> +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants'; +import * as i18n from '../translations'; +import CandidateDetail from './candidate_detail.vue'; + +export default { + name: 'ModelVersionDetail', + components: { + PackageFiles: () => + import('~/packages_and_registries/package_registry/components/details/package_files.vue'), + CandidateDetail, + }, + props: { + modelVersion: { + type: Object, + required: true, + }, + }, + computed: { + packageId() { + return convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.modelVersion.packageId); + }, + projectPath() { + return this.modelVersion.projectPath; + }, + packageType() { + return 'ml_model'; + }, + }, + i18n, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg gl-mt-5">{{ $options.i18n.DESCRIPTION_LABEL }}</h3> + + <div v-if="modelVersion.description"> + {{ modelVersion.description }} + </div> + <div v-else class="gl-text-secondary"> + {{ $options.i18n.NO_DESCRIPTION_PROVIDED_LABEL }} + </div> + + <template v-if="modelVersion.packageId"> + <package-files + :package-id="packageId" + :project-path="projectPath" + :package-type="packageType" + /> + </template> + + <div class="gl-mt-5"> + <span class="gl-font-weight-bold">{{ $options.i18n.MLFLOW_ID_LABEL }}:</span> + {{ modelVersion.candidate.info.eid }} + </div> + + <candidate-detail :candidate="modelVersion.candidate" :show-info-section="false" /> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_list.vue b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue new file mode 100644 index 00000000000..6b44cb2f613 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/model_version_list.vue @@ -0,0 +1,137 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { makeLoadVersionsErrorMessage } from '~/ml/model_registry/translations'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import getModelVersionsQuery from '../graphql/queries/get_model_versions.query.graphql'; +import { GRAPHQL_PAGE_SIZE, MODEL_ENTITIES } from '../constants'; +import EmptyState from './empty_state.vue'; +import ModelVersionRow from './model_version_row.vue'; + +export default { + components: { + EmptyState, + GlAlert, + ModelVersionRow, + PackagesListLoader, + RegistryList, + }, + props: { + modelId: { + type: Number, + required: true, + }, + }, + data() { + return { + modelVersions: {}, + errorMessage: undefined, + }; + }, + apollo: { + modelVersions: { + query: getModelVersionsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.mlModel?.versions ?? {}; + }, + error(error) { + this.errorMessage = makeLoadVersionsErrorMessage(error.message); + Sentry.captureException(error); + }, + }, + }, + computed: { + gid() { + return convertToGraphQLId('Ml::Model', this.modelId); + }, + isListEmpty() { + return this.count === 0; + }, + isLoading() { + return this.$apollo.queries.modelVersions.loading; + }, + pageInfo() { + return this.modelVersions?.pageInfo ?? {}; + }, + listTitle() { + return n__('%d version', '%d versions', this.versions.length); + }, + queryVariables() { + return { + id: this.gid, + first: GRAPHQL_PAGE_SIZE, + }; + }, + versions() { + return this.modelVersions?.nodes ?? []; + }, + count() { + return this.modelVersions?.count ?? 0; + }, + }, + methods: { + fetchPreviousVersionsPage() { + const variables = { + ...this.queryVariables, + first: null, + last: GRAPHQL_PAGE_SIZE, + before: this.pageInfo?.startCursor, + }; + this.$apollo.queries.modelVersions.fetchMore({ + variables, + updateQuery: (previousResult, { fetchMoreResult }) => { + return fetchMoreResult; + }, + }); + }, + fetchNextVersionsPage() { + const variables = { + ...this.queryVariables, + first: GRAPHQL_PAGE_SIZE, + last: null, + after: this.pageInfo?.endCursor, + }; + + this.$apollo.queries.modelVersions.fetchMore({ + variables, + updateQuery: (previousResult, { fetchMoreResult }) => { + return fetchMoreResult; + }, + }); + }, + }, + modelVersionEntity: MODEL_ENTITIES.modelVersion, +}; +</script> +<template> + <div> + <div v-if="isLoading"> + <packages-list-loader /> + </div> + <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">{{ + errorMessage + }}</gl-alert> + <empty-state v-else-if="isListEmpty" :entity-type="$options.modelVersionEntity" /> + <div v-else> + <registry-list + :hidden-delete="true" + :is-loading="isLoading" + :items="versions" + :pagination="pageInfo" + :title="listTitle" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" + > + <template #default="{ item }"> + <model-version-row :model-version="item" /> + </template> + </registry-list> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_row.vue b/app/assets/javascripts/ml/model_registry/components/model_version_row.vue new file mode 100644 index 00000000000..7e024ff546d --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/model_version_row.vue @@ -0,0 +1,49 @@ +<script> +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'MlModelVersionRow', + components: { + ListItem, + GlLink, + GlTruncate, + GlSprintf, + TimeAgoTooltip, + }, + props: { + modelVersion: { + type: Object, + required: true, + }, + }, + computed: { + pathToDetails() { + return this.modelVersion._links?.showPath; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <gl-link class="gl-text-body" :href="pathToDetails"> + <gl-truncate :text="modelVersion.version" /> + </gl-link> + </div> + </template> + + <template #left-secondary> + <span> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <time-ago-tooltip :time="modelVersion.createdAt" /> + </template> + </gl-sprintf> + </span> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/ml/model_registry/constants.js b/app/assets/javascripts/ml/model_registry/constants.js index 10c21ec4f12..02f9508b4c8 100644 --- a/app/assets/javascripts/ml/model_registry/constants.js +++ b/app/assets/javascripts/ml/model_registry/constants.js @@ -11,3 +11,10 @@ export const BASE_SORT_FIELDS = Object.freeze([ label: s__('MlExperimentTracking|Created at'), }, ]); + +export const GRAPHQL_PAGE_SIZE = 30; + +export const MODEL_ENTITIES = { + model: 'model', + modelVersion: 'modelVersion', +}; diff --git a/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_candidates.query.graphql b/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_candidates.query.graphql new file mode 100644 index 00000000000..81875cf509e --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_candidates.query.graphql @@ -0,0 +1,28 @@ +query getModelCandidates( + $id: MlModelID! + $first: Int + $last: Int + $after: String + $before: String +) { + mlModel(id: $id) { + id + candidates(after: $after, before: $before, first: $first, last: $last) { + count + nodes { + id + name + createdAt + _links { + showPath + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_versions.query.graphql b/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_versions.query.graphql new file mode 100644 index 00000000000..1b48a67a0bd --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/graphql/queries/get_model_versions.query.graphql @@ -0,0 +1,22 @@ +query getModelVersions($id: MlModelID!, $first: Int, $last: Int, $after: String, $before: String) { + mlModel(id: $id) { + id + versions(after: $after, before: $before, first: $first, last: $last) { + count + nodes { + id + version + createdAt + _links { + showPath + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/ml/model_registry/translations.js b/app/assets/javascripts/ml/model_registry/translations.js index 89b3f45ed94..968ec83434d 100644 --- a/app/assets/javascripts/ml/model_registry/translations.js +++ b/app/assets/javascripts/ml/model_registry/translations.js @@ -1,16 +1,45 @@ -import { s__, n__ } from '~/locale'; +import { __, s__, n__, sprintf } from '~/locale'; export const MODEL_DETAILS_TAB_LABEL = s__('MlModelRegistry|Details'); export const MODEL_OTHER_VERSIONS_TAB_LABEL = s__('MlModelRegistry|Versions'); export const MODEL_CANDIDATES_TAB_LABEL = s__('MlModelRegistry|Version candidates'); export const LATEST_VERSION_LABEL = s__('MlModelRegistry|Latest version'); -export const NO_VERSIONS_LABEL = s__('MlModelRegistry|This model has no versions'); export const versionsCountLabel = (versionCount) => n__('MlModelRegistry|%d version', 'MlModelRegistry|%d versions', versionCount); export const TITLE_LABEL = s__('MlModelRegistry|Model registry'); -export const NO_MODELS_LABEL = s__('MlModelRegistry|No models registered in this project'); export const modelsCountLabel = (modelCount) => n__('MlModelRegistry|%d model', 'MlModelRegistry|%d models', modelCount); + +export const DESCRIPTION_LABEL = __('Description'); +export const NO_DESCRIPTION_PROVIDED_LABEL = s__('MlModelRegistry|No description provided'); +export const INFO_LABEL = s__('MlModelRegistry|Info'); +export const ID_LABEL = s__('MlModelRegistry|ID'); +export const MLFLOW_ID_LABEL = s__('MlModelRegistry|MLflow run ID'); +export const STATUS_LABEL = s__('MlModelRegistry|Status'); +export const EXPERIMENT_LABEL = s__('MlModelRegistry|Experiment'); +export const ARTIFACTS_LABEL = s__('MlModelRegistry|Artifacts'); +export const PARAMETERS_LABEL = s__('MlModelRegistry|Parameters'); +export const PERFORMANCE_LABEL = s__('MlModelRegistry|Model performance'); +export const METADATA_LABEL = s__('MlModelRegistry|Metadata'); +export const NO_PARAMETERS_MESSAGE = s__('MlModelRegistry|No logged parameters'); +export const NO_METRICS_MESSAGE = s__('MlModelRegistry|No logged metrics'); +export const NO_METADATA_MESSAGE = s__('MlModelRegistry|No logged metadata'); +export const NO_CI_MESSAGE = s__('MlModelRegistry|Candidate not linked to a CI build'); +export const CI_SECTION_LABEL = s__('MlModelRegistry|CI Info'); +export const JOB_LABEL = __('Job'); +export const CI_USER_LABEL = s__('MlModelRegistry|Triggered by'); +export const CI_MR_LABEL = __('Merge request'); + +export const makeLoadVersionsErrorMessage = (message) => + sprintf(s__('MlModelRegistry|Failed to load model versions with error: %{message}'), { + message, + }); + +export const NO_CANDIDATES_LABEL = s__('MlModelRegistry|This model has no candidates'); +export const makeLoadCandidatesErrorMessage = (message) => + sprintf(s__('MlModelRegistry|Failed to load model candidates with error: %{message}'), { + message, + }); diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue deleted file mode 100644 index 4e5d6b0ce6c..00000000000 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ /dev/null @@ -1,105 +0,0 @@ -<script> -import { GlToggle, GlDisclosureDropdownItem } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import Tracking from '~/tracking'; - -export default { - i18n: { - sectionTitle: s__('NorthstarNavigation|Navigation redesign'), - toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'), - toggleLabel: s__('NorthstarNavigation|Toggle new navigation'), - updateError: s__( - 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', - ), - }, - components: { - GlToggle, - GlDisclosureDropdownItem, - }, - props: { - enabled: { - type: Boolean, - required: true, - }, - endpoint: { - type: String, - required: true, - }, - newNavigation: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isEnabled: this.enabled, - }; - }, - methods: { - toggleNav() { - this.isEnabled = !this.isEnabled; - this.updateAndReload(); - }, - async updateAndReload() { - try { - await axios.put(this.endpoint, { user: { use_new_navigation: this.isEnabled } }); - - Tracking.event(undefined, 'click_toggle', { - label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', - property: this.enabled ? 'nav_user_menu' : 'navigation_top', - }); - - window.location.reload(); - } catch (error) { - createAlert({ - message: this.$options.i18n.updateError, - error, - }); - } - }, - }, -}; -</script> - -<template> - <gl-disclosure-dropdown-item v-if="newNavigation" @action="toggleNav"> - <div class="gl-new-dropdown-item-content"> - <div - class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2! gl-gap-3" - > - {{ $options.i18n.toggleMenuItemLabel }} - <gl-toggle - class="gl-flex-grow-0!" - :value="isEnabled" - :label="$options.i18n.toggleLabel" - label-position="hidden" - /> - </div> - </div> - </gl-disclosure-dropdown-item> - - <li v-else> - <div - class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center" - > - <b>{{ $options.i18n.sectionTitle }}</b> - </div> - - <div - class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-gap-3" - @click.prevent.stop="toggleNav" - > - {{ $options.i18n.toggleMenuItemLabel }} - <gl-toggle - class="gl-flex-grow-0!" - :value="isEnabled" - :label="$options.i18n.toggleLabel" - label-position="hidden" - data-testid="new_navigation_toggle" - /> - </div> - </li> -</template> diff --git a/app/assets/javascripts/nav/components/responsive_app.vue b/app/assets/javascripts/nav/components/responsive_app.vue deleted file mode 100644 index 68a39f862fc..00000000000 --- a/app/assets/javascripts/nav/components/responsive_app.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; -import { BV_DROPDOWN_SHOW, BV_DROPDOWN_HIDE } from '~/lib/utils/constants'; -import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; -import { resetMenuItemsActive } from '../utils'; -import ResponsiveHeader from './responsive_header.vue'; -import ResponsiveHome from './responsive_home.vue'; -import TopNavContainerView from './top_nav_container_view.vue'; - -export default { - components: { - KeepAliveSlots, - ResponsiveHeader, - ResponsiveHome, - TopNavContainerView, - }, - props: { - navData: { - type: Object, - required: true, - }, - }, - data() { - return { - activeView: 'home', - hasMobileOverlay: false, - }; - }, - computed: { - nav() { - return resetMenuItemsActive(this.navData); - }, - }, - created() { - this.$root.$on(BV_DROPDOWN_SHOW, this.showMobileOverlay); - this.$root.$on(BV_DROPDOWN_HIDE, this.hideMobileOverlay); - }, - beforeDestroy() { - this.$root.$off(BV_DROPDOWN_SHOW, this.showMobileOverlay); - this.$root.$off(BV_DROPDOWN_HIDE, this.hideMobileOverlay); - }, - methods: { - onMenuItemClick({ view }) { - if (view) { - this.activeView = view; - } - }, - showMobileOverlay() { - this.hasMobileOverlay = true; - }, - hideMobileOverlay() { - this.hasMobileOverlay = false; - }, - }, - FREQUENT_ITEMS_PROJECTS, - FREQUENT_ITEMS_GROUPS, -}; -</script> - -<template> - <div> - <div - class="mobile-overlay" - :class="{ 'mobile-nav-open': hasMobileOverlay }" - data-testid="mobile-overlay" - ></div> - <keep-alive-slots :slot-key="activeView"> - <template #home> - <responsive-home :nav-data="nav" @menu-item-click="onMenuItemClick" /> - </template> - <template #projects> - <responsive-header @menu-item-click="onMenuItemClick"> - {{ __('Projects') }} - </responsive-header> - <top-nav-container-view - :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace" - :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule" - container-class="gl-px-3" - v-bind="nav.views.projects" - /> - </template> - <template #groups> - <responsive-header @menu-item-click="onMenuItemClick"> - {{ __('Groups') }} - </responsive-header> - <top-nav-container-view - :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace" - :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule" - container-class="gl-px-3" - v-bind="nav.views.groups" - /> - </template> - </keep-alive-slots> - </div> -</template> diff --git a/app/assets/javascripts/nav/components/responsive_header.vue b/app/assets/javascripts/nav/components/responsive_header.vue deleted file mode 100644 index e29b4a67383..00000000000 --- a/app/assets/javascripts/nav/components/responsive_header.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import TopNavMenuItem from './top_nav_menu_item.vue'; - -export default { - components: { - TopNavMenuItem, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - computed: { - menuItem() { - return { - id: 'home', - view: 'home', - icon: 'chevron-lg-left', - }; - }, - }, -}; -</script> - -<template> - <header class="gl-py-4 gl-display-flex gl-align-items-center"> - <top-nav-menu-item - v-gl-tooltip="{ title: s__('TopNav|Go back') }" - class="gl-p-3!" - :menu-item="menuItem" - icon-only - @click="$emit('menu-item-click', menuItem)" - /> - <span class="gl-font-size-h2 gl-font-weight-bold gl-ml-2"> - <slot></slot> - </span> - </header> -</template> diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue deleted file mode 100644 index 371b252a6ba..00000000000 --- a/app/assets/javascripts/nav/components/responsive_home.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import TopNavMenuItem from './top_nav_menu_item.vue'; -import TopNavMenuSections from './top_nav_menu_sections.vue'; -import TopNavNewDropdown from './top_nav_new_dropdown.vue'; - -const NEW_VIEW = 'new'; -const SEARCH_VIEW = 'search'; - -export default { - components: { - TopNavMenuItem, - TopNavMenuSections, - TopNavNewDropdown, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - navData: { - type: Object, - required: true, - }, - }, - computed: { - menuSections() { - return [ - { id: 'primary', menuItems: this.navData.primary }, - { id: 'secondary', menuItems: this.navData.secondary }, - ].filter((x) => x.menuItems?.length); - }, - newDropdownViewModel() { - return this.navData.views[NEW_VIEW]; - }, - searchMenuItem() { - return this.navData.views[SEARCH_VIEW]; - }, - }, -}; -</script> - -<template> - <div> - <header class="gl-display-flex gl-align-items-center gl-py-4 gl-pl-4"> - <h1 class="gl-m-0 gl-font-size-h2 gl-reset-color gl-mr-auto">{{ __('Menu') }}</h1> - <top-nav-menu-item - v-if="searchMenuItem" - v-gl-tooltip="{ title: searchMenuItem.title }" - class="gl-ml-3" - :menu-item="searchMenuItem" - icon-only - /> - <top-nav-new-dropdown - v-if="newDropdownViewModel" - v-gl-tooltip="{ title: newDropdownViewModel.title }" - :view-model="newDropdownViewModel" - class="gl-ml-3" - data-testid="mobile_new_dropdown" - /> - </header> - <top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" /> - </div> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue deleted file mode 100644 index 22c77e9ae32..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui'; -import Tracking from '~/tracking'; -import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; - -export default { - components: { - GlIcon, - GlNav, - GlNavItemDropdown, - GlDropdownForm, - TopNavDropdownMenu, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - navData: { - type: Object, - required: true, - }, - }, - methods: { - trackToggleEvent() { - Tracking.event(undefined, 'click_nav', { - label: 'hamburger_menu', - property: 'navigation_top', - }); - }, - }, -}; -</script> - -<template> - <gl-nav class="navbar-sub-nav"> - <gl-nav-item-dropdown - v-gl-tooltip.bottom="navData.menuTooltip" - data-testid="navbar_dropdown" - data-qa-title="Menu" - menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu" - toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" - no-flip - no-caret - @toggle="trackToggleEvent" - > - <template #button-content> - <gl-icon name="hamburger" /> - <span v-if="navData.menuTitle" class="gl-ml-3"> - {{ navData.menuTitle }} - </span> - </template> - <gl-dropdown-form> - <top-nav-dropdown-menu - :primary="navData.primary" - :secondary="navData.secondary" - :views="navData.views" - /> - </gl-dropdown-form> - </gl-nav-item-dropdown> - </gl-nav> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue deleted file mode 100644 index 36e4a278da9..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_container_view.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import FrequentItemsApp from '~/frequent_items/components/app.vue'; -import eventHub from '~/frequent_items/event_hub'; -import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; -import TopNavMenuSections from './top_nav_menu_sections.vue'; - -export default { - components: { - FrequentItemsApp, - TopNavMenuSections, - VuexModuleProvider, - }, - inheritAttrs: false, - props: { - frequentItemsVuexModule: { - type: String, - required: true, - }, - frequentItemsDropdownType: { - type: String, - required: true, - }, - currentItem: { - type: Object, - required: true, - }, - containerClass: { - type: String, - required: false, - default: '', - }, - linksPrimary: { - type: Array, - required: false, - default: () => [], - }, - linksSecondary: { - type: Array, - required: false, - default: () => [], - }, - }, - computed: { - menuSections() { - return [ - { id: 'primary', menuItems: this.linksPrimary }, - { id: 'secondary', menuItems: this.linksSecondary }, - ].filter((x) => x.menuItems?.length); - }, - currentItemTimestamped() { - return { - ...this.currentItem, - lastAccessedOn: Date.now(), - }; - }, - }, - mounted() { - // For historic reasons, the frequent-items-app component requires this too start up. - this.$nextTick(() => { - eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`); - }); - }, -}; -</script> - -<template> - <div class="top-nav-container-view gl-display-flex gl-flex-direction-column"> - <div - class="frequent-items-dropdown-container gl-w-auto" - :class="containerClass" - data-testid="frequent-items-container" - > - <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!"> - <vuex-module-provider :vuex-module="frequentItemsVuexModule"> - <frequent-items-app :current-item="currentItemTimestamped" v-bind="$attrs" /> - </vuex-module-provider> - </div> - </div> - <top-nav-menu-sections class="gl-mt-auto" :sections="menuSections" with-top-border /> - </div> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue deleted file mode 100644 index fa202a0574d..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ /dev/null @@ -1,107 +0,0 @@ -<script> -import { cloneDeep } from 'lodash'; -import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; -import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; -import TopNavContainerView from './top_nav_container_view.vue'; -import TopNavMenuSections from './top_nav_menu_sections.vue'; - -export default { - components: { - KeepAliveSlots, - TopNavContainerView, - TopNavMenuSections, - }, - props: { - primary: { - type: Array, - required: false, - default: () => [], - }, - secondary: { - type: Array, - required: false, - default: () => [], - }, - views: { - type: Object, - required: false, - default: () => ({}), - }, - }, - data() { - // It's expected that primary & secondary never change, so these are treated as "init" props. - // We need to clone so that we can mutate the data without mutating the props - const menuSections = [ - { id: 'primary', menuItems: cloneDeep(this.primary) }, - { id: 'secondary', menuItems: cloneDeep(this.secondary) }, - ].filter((x) => x.menuItems?.length); - - return { - menuSections, - }; - }, - computed: { - allMenuItems() { - return this.menuSections.flatMap((x) => x.menuItems); - }, - activeView() { - const active = this.allMenuItems.find((x) => x.active); - - return active?.view; - }, - menuClass() { - if (!this.activeView) { - return 'gl-w-full'; - } - - return ''; - }, - }, - methods: { - onMenuItemClick({ id }) { - this.allMenuItems.forEach((menuItem) => { - this.$set(menuItem, 'active', id === menuItem.id); - }); - }, - }, - FREQUENT_ITEMS_PROJECTS, - FREQUENT_ITEMS_GROUPS, -}; -</script> - -<template> - <div class="gl-display-flex gl-align-items-stretch"> - <div - class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-p-3" - :class="menuClass" - data-testid="menu-sidebar" - > - <top-nav-menu-sections - :sections="menuSections" - :is-primary-section="true" - @menu-item-click="onMenuItemClick" - /> - </div> - <keep-alive-slots - v-show="activeView" - :slot-key="activeView" - class="gl-w-grid-size-40 gl-overflow-hidden gl-p-3" - data-testid="menu-subview" - > - <template #projects> - <top-nav-container-view - :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace" - :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule" - v-bind="views.projects" - /> - </template> - <template #groups> - <top-nav-container-view - :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace" - :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule" - v-bind="views.groups" - /> - </template> - </keep-alive-slots> - </div> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue deleted file mode 100644 index bf1fd691ca8..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import { GlButton, GlIcon } from '@gitlab/ui'; -import { kebabCase, mapKeys } from 'lodash'; - -const getDataKey = (key) => `data-${kebabCase(key)}`; - -const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active'; - -export default { - components: { - GlButton, - GlIcon, - }, - props: { - menuItem: { - type: Object, - required: true, - }, - iconOnly: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - dataAttrs() { - return mapKeys(this.menuItem.data || {}, (value, key) => getDataKey(key)); - }, - }, - ACTIVE_CLASS, -}; -</script> - -<template> - <gl-button - category="tertiary" - :href="menuItem.href" - class="top-nav-menu-item gl-display-block gl-pr-3!" - :class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]" - :aria-label="menuItem.title" - v-bind="dataAttrs" - v-on="$listeners" - > - <span class="gl-display-flex"> - <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-3!': !iconOnly }" /> - <template v-if="!iconOnly"> - {{ menuItem.title }} - <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> - </template> - </span> - </gl-button> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue deleted file mode 100644 index 1f3f11dc624..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue +++ /dev/null @@ -1,82 +0,0 @@ -<script> -import TopNavMenuItem from './top_nav_menu_item.vue'; - -const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid'; - -export default { - components: { - TopNavMenuItem, - }, - props: { - sections: { - type: Array, - required: true, - }, - withTopBorder: { - type: Boolean, - required: false, - default: false, - }, - isPrimarySection: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - onClick(menuItem) { - // If we're a link, let's just do the default behavior so the view won't change - if (menuItem.href) { - return; - } - - this.$emit('menu-item-click', menuItem); - }, - getMenuSectionClasses(index) { - // This is a method instead of a computed so we don't have to incur the cost of - // creating a whole new array/object. - const hasBorder = this.withTopBorder || index > 0; - return { - [BORDER_CLASSES]: hasBorder, - 'gl-border-gray-100': hasBorder && this.isPrimarySection, - 'gl-border-gray-50': hasBorder && !this.isPrimarySection, - 'gl-mt-3': index > 0, - }; - }, - }, - // Expose for unit tests - BORDER_CLASSES, -}; -</script> - -<template> - <div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column"> - <div - v-for="({ id, menuItems }, sectionIndex) in sections" - :key="id" - :class="getMenuSectionClasses(sectionIndex)" - data-testid="menu-section" - > - <template v-for="(menuItem, menuItemIndex) in menuItems"> - <strong - v-if="menuItem.type == 'header'" - :key="menuItem.title" - class="gl-px-4 gl-py-2 gl-text-gray-900 gl-display-block" - :class="{ 'gl-pt-3!': menuItemIndex > 0 }" - data-testid="menu-header" - > - {{ menuItem.title }} - </strong> - <top-nav-menu-item - v-else - :key="menuItem.id" - :menu-item="menuItem" - data-testid="menu-item" - class="gl-w-full" - :class="{ 'gl-mt-1': menuItemIndex > 0 }" - @click="onClick(menuItem)" - /> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue deleted file mode 100644 index 2dfd77bc02e..00000000000 --- a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue +++ /dev/null @@ -1,73 +0,0 @@ -<script> -import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants'; - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - InviteMembersTrigger, - }, - props: { - viewModel: { - type: Object, - required: true, - }, - }, - computed: { - sections() { - return this.viewModel.menu_sections || []; - }, - showHeaders() { - return this.sections.length > 1; - }, - }, - methods: { - isInvitedMembers(menuItem) { - return menuItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT; - }, - }, -}; -</script> - -<template> - <gl-dropdown - toggle-class="top-nav-menu-item" - icon="plus" - :text="viewModel.title" - category="tertiary" - text-sr-only - no-caret - right - > - <template v-for="({ title, menu_items }, index) in sections"> - <gl-dropdown-divider v-if="index > 0" :key="`${index}_divider`" data-testid="divider" /> - <gl-dropdown-section-header v-if="showHeaders" :key="`${index}_header`" data-testid="header"> - {{ title }} - </gl-dropdown-section-header> - <template v-for="menuItem in menu_items"> - <invite-members-trigger - v-if="isInvitedMembers(menuItem)" - :key="`${index}_item_${menuItem.id}`" - :trigger-element="`dropdown-${menuItem.data.trigger_element}`" - :display-text="menuItem.title" - :icon="menuItem.icon" - :trigger-source="menuItem.data.trigger_source" - /> - <gl-dropdown-item - v-else - :key="`${index}_item_${menuItem.id}`" - link-class="top-nav-menu-item" - :href="menuItem.href" - data-testid="item" - :data-qa-selector="`${menuItem.title.toLowerCase().replace(' ', '_')}_mobile_button`" - > - {{ menuItem.title }} - </gl-dropdown-item> - </template> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js deleted file mode 100644 index abd537d2c9a..00000000000 --- a/app/assets/javascripts/nav/index.js +++ /dev/null @@ -1,31 +0,0 @@ -// TODO: With the combined_menu feature flag removed, there's likely a better -// way to slice up the async import (i.e., include trigger in main bundle, but -// async import subviews. Don't do this at the cost of UX). -// See https://gitlab.com/gitlab-org/gitlab/-/issues/336042 -const importModule = () => import(/* webpackChunkName: 'top_nav' */ './mount'); - -const tryMountTopNav = async () => { - const el = document.getElementById('js-top-nav'); - - if (!el) { - return; - } - - const { mountTopNav } = await importModule(); - - mountTopNav(el); -}; - -const tryMountTopNavResponsive = async () => { - const el = document.getElementById('js-top-nav-responsive'); - - if (!el) { - return; - } - - const { mountTopNavResponsive } = await importModule(); - - mountTopNavResponsive(el); -}; - -export const initTopNav = async () => Promise.all([tryMountTopNav(), tryMountTopNavResponsive()]); diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js deleted file mode 100644 index 0fc946bea76..00000000000 --- a/app/assets/javascripts/nav/mount.js +++ /dev/null @@ -1,30 +0,0 @@ -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import ResponsiveApp from './components/responsive_app.vue'; -import App from './components/top_nav_app.vue'; -import { createStore } from './stores'; - -Vue.use(Vuex); - -const mount = (el, Component) => { - const viewModel = JSON.parse(el.dataset.viewModel); - const store = createStore(); - - return new Vue({ - el, - name: 'TopNavRoot', - store, - render(h) { - return h(Component, { - props: { - navData: viewModel, - }, - }); - }, - }); -}; - -export const mountTopNav = (el) => mount(el, App); - -export const mountTopNavResponsive = (el) => mount(el, ResponsiveApp); diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js deleted file mode 100644 index 7c8f93f042c..00000000000 --- a/app/assets/javascripts/nav/stores/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { createStoreOptions } from '~/frequent_items/store'; - -export const createStore = () => new Vuex.Store(createStoreOptions()); diff --git a/app/assets/javascripts/nav/utils/index.js b/app/assets/javascripts/nav/utils/index.js deleted file mode 100644 index 6d93818f0d3..00000000000 --- a/app/assets/javascripts/nav/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './reset_menu_items_active'; diff --git a/app/assets/javascripts/nav/utils/reset_menu_items_active.js b/app/assets/javascripts/nav/utils/reset_menu_items_active.js deleted file mode 100644 index 9b5d8e97c9c..00000000000 --- a/app/assets/javascripts/nav/utils/reset_menu_items_active.js +++ /dev/null @@ -1,14 +0,0 @@ -const resetActiveInArray = (arr) => arr?.map((menuItem) => ({ ...menuItem, active: false })); - -/** - * This method sets `active: false` for the menu items within the given nav data. - * - * @returns navData with the menu items updated with `active: false` - */ -export const resetMenuItemsActive = ({ primary, secondary, ...navData }) => { - return { - ...navData, - primary: resetActiveInArray(primary), - secondary: resetActiveInArray(secondary), - }; -}; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 329d6cfec00..87b55b19c08 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,7 +3,6 @@ import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@ import $ from 'jquery'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; @@ -17,6 +16,7 @@ import { badgeState } from '~/merge_requests/components/merge_request_header.vue import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; import * as constants from '../constants'; import eventHub from '../event_hub'; @@ -297,8 +297,10 @@ export default { const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable; toggleState() - .then(() => badgeState.updateStatus && badgeState.updateStatus()) - .then(refreshUserMergeRequestCounts) + .then(() => { + fetchUserCounts(); + return badgeState?.updateStatus(); + }) .catch(() => createAlert({ message: constants.toggleStateErrorMessage[this.noteableType][this.openState], diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index a999b633f64..88fec0dfb9b 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -35,7 +35,7 @@ export default { </script> <template> - <div class="disabled-comments gl-mt-3"> + <div class="gl-mt-3" data-testid="disabled-comments"> <span class="issuable-note-warning gl-display-inline-block gl-w-full gl-px-5 gl-py-4 gl-rounded-base" > diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index f8a0db93e37..9aaae960b6f 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -435,7 +435,7 @@ export default { category="primary" variant="confirm" data-testid="reply-comment-button" - class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button" + class="gl-sm-mr-3 gl-mb-3 gl-sm-mb-0 js-vue-issue-save js-comment-button" @click="handleUpdate()" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index b4eeea8db02..be9c768ae60 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -38,11 +38,7 @@ export default { }, computed: { showAiActions() { - return ( - this.resourceGlobalId && - (this.glFeatures.openaiExperimentation || this.glFeatures.aiGlobalSwitch) && - this.glFeatures.summarizeNotes - ); + return this.resourceGlobalId && this.glFeatures.summarizeNotes; }, }, }; diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js index f5891c9acb5..8d004188c39 100644 --- a/app/assets/javascripts/notifications/constants.js +++ b/app/assets/javascripts/notifications/constants.js @@ -55,5 +55,6 @@ export const i18n = { reopen_merge_request: s__('NotificationEvent|Reopen merge request'), merge_when_pipeline_succeeds: s__('NotificationEvent|Merge when pipeline succeeds'), success_pipeline: s__('NotificationEvent|Successful pipeline'), + approver: s__('NotificationEvent|Added as approver'), }, }; diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index 32ff7fff128..3a793c9dc14 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -147,7 +147,7 @@ function filterObjToQueryParams(filterObj) { const filterParams = new URLSearchParams(); Object.keys(SUPPORTED_FILTERS).forEach((filterName) => { - const filterValues = filterObj[filterName] || []; + const filterValues = Array.isArray(filterObj[filterName]) ? filterObj[filterName] : []; const validFilters = filterValues.filter((f) => SUPPORTED_FILTERS[filterName].includes(f.operator), ); @@ -251,10 +251,26 @@ async function fetchOperations(operationsUrl, serviceName) { } } -async function fetchMetrics(metricsUrl) { +async function fetchMetrics(metricsUrl, { filters = {}, limit } = {}) { try { + const params = new URLSearchParams(); + + if (Array.isArray(filters.search)) { + const searchPrefix = filters.search + .map((f) => f.value) + .join(' ') + .trim(); + + if (searchPrefix) { + params.append('starts_with', searchPrefix); + if (limit) { + params.append('limit', limit); + } + } + } const { data } = await axios.get(metricsUrl, { withCredentials: true, + params, }); if (!Array.isArray(data.metrics)) { throw new Error('metrics are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings @@ -265,12 +281,46 @@ async function fetchMetrics(metricsUrl) { } } +async function fetchMetric(searchUrl, name, type) { + try { + if (!name) { + throw new Error('fetchMetric() - metric name is required.'); + } + if (!type) { + throw new Error('fetchMetric() - metric type is required.'); + } + + const params = new URLSearchParams({ + mname: name, + mtype: type, + }); + + const { data } = await axios.get(searchUrl, { + params, + withCredentials: true, + }); + if (!Array.isArray(data.results)) { + throw new Error('metrics are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings + } + return data.results; + } catch (e) { + return reportErrorAndThrow(e); + } +} + export function buildClient(config) { if (!config) { throw new Error('No options object provided'); // eslint-disable-line @gitlab/require-i18n-strings } - const { provisioningUrl, tracingUrl, servicesUrl, operationsUrl, metricsUrl } = config; + const { + provisioningUrl, + tracingUrl, + servicesUrl, + operationsUrl, + metricsUrl, + metricsSearchUrl, + } = config; if (typeof provisioningUrl !== 'string') { throw new Error('provisioningUrl param must be a string'); @@ -292,6 +342,10 @@ export function buildClient(config) { throw new Error('metricsUrl param must be a string'); } + if (typeof metricsSearchUrl !== 'string') { + throw new Error('metricsSearchUrl param must be a string'); + } + return { enableObservability: () => enableObservability(provisioningUrl), isObservabilityEnabled: () => isObservabilityEnabled(provisioningUrl), @@ -299,6 +353,7 @@ export function buildClient(config) { fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId), fetchServices: () => fetchServices(servicesUrl), fetchOperations: (serviceName) => fetchOperations(operationsUrl, serviceName), - fetchMetrics: () => fetchMetrics(metricsUrl), + fetchMetrics: (options) => fetchMetrics(metricsUrl, options), + fetchMetric: (metricName, metricType) => fetchMetric(metricsSearchUrl, metricName, metricType), }; } diff --git a/app/assets/javascripts/organizations/constants.js b/app/assets/javascripts/organizations/constants.js index 8ade37b169e..d3da072b38d 100644 --- a/app/assets/javascripts/organizations/constants.js +++ b/app/assets/javascripts/organizations/constants.js @@ -2,3 +2,5 @@ export const RESOURCE_TYPE_GROUPS = 'groups'; export const RESOURCE_TYPE_PROJECTS = 'projects'; export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; + +export const ORGANIZATION_USERS_PER_PAGE = 20; diff --git a/app/assets/javascripts/organizations/index/components/app.vue b/app/assets/javascripts/organizations/index/components/app.vue index c47f4ed52c5..71a6aae4e93 100644 --- a/app/assets/javascripts/organizations/index/components/app.vue +++ b/app/assets/javascripts/organizations/index/components/app.vue @@ -2,7 +2,8 @@ import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; -import organizationsQuery from '../graphql/organizations.query.graphql'; +import { DEFAULT_PER_PAGE } from '~/api'; +import organizationsQuery from '../../shared/graphql/queries/organizations.query.graphql'; import OrganizationsView from './organizations_view.vue'; export default { @@ -21,14 +22,23 @@ export default { inject: ['newOrganizationUrl'], data() { return { - organizations: [], + organizations: {}, + pagination: { + first: DEFAULT_PER_PAGE, + after: null, + last: null, + before: null, + }, }; }, apollo: { organizations: { query: organizationsQuery, + variables() { + return this.pagination; + }, update(data) { - return data.currentUser.organizations.nodes; + return data.currentUser.organizations; }, error(error) { createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); @@ -37,12 +47,30 @@ export default { }, computed: { showHeader() { - return this.loading || this.organizations.length; + return this.loading || this.organizations.nodes?.length; }, loading() { return this.$apollo.queries.organizations.loading; }, }, + methods: { + onNext(endCursor) { + this.pagination = { + first: DEFAULT_PER_PAGE, + after: endCursor, + last: null, + before: null, + }; + }, + onPrev(startCursor) { + this.pagination = { + first: null, + after: null, + last: DEFAULT_PER_PAGE, + before: startCursor, + }; + }, + }, }; </script> @@ -56,6 +84,11 @@ export default { }}</gl-button> </div> </div> - <organizations-view :organizations="organizations" :loading="loading" /> + <organizations-view + :organizations="organizations" + :loading="loading" + @next="onNext" + @prev="onPrev" + /> </section> </template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list.vue b/app/assets/javascripts/organizations/index/components/organizations_list.vue index 539a4fcfe29..971d4710be2 100644 --- a/app/assets/javascripts/organizations/index/components/organizations_list.vue +++ b/app/assets/javascripts/organizations/index/components/organizations_list.vue @@ -1,26 +1,52 @@ <script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { __ } from '~/locale'; import OrganizationsListItem from './organizations_list_item.vue'; export default { name: 'OrganizationsList', components: { OrganizationsListItem, + GlKeysetPagination, + }, + i18n: { + prev: __('Prev'), + next: __('Next'), }, props: { organizations: { - type: Array, + type: Object, required: true, }, }, + computed: { + nodes() { + return this.organizations.nodes || []; + }, + pageInfo() { + return this.organizations.pageInfo || {}; + }, + }, }; </script> <template> - <ul class="gl-p-0 gl-list-style-none"> - <organizations-list-item - v-for="organization in organizations" - :key="organization.id" - :organization="organization" - /> - </ul> + <div> + <ul class="gl-p-0 gl-list-style-none"> + <organizations-list-item + v-for="organization in nodes" + :key="organization.id" + :organization="organization" + /> + </ul> + <div v-if="pageInfo.hasNextPage || pageInfo.hasPreviousPage" class="gl-text-center gl-mt-5"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="$options.i18n.prev" + :next-text="$options.i18n.next" + @prev="$emit('prev', $event)" + @next="$emit('next', $event)" + /> + </div> + </div> </template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_view.vue b/app/assets/javascripts/organizations/index/components/organizations_view.vue index 9720646bca3..59e94670826 100644 --- a/app/assets/javascripts/organizations/index/components/organizations_view.vue +++ b/app/assets/javascripts/organizations/index/components/organizations_view.vue @@ -20,9 +20,9 @@ export default { inject: ['newOrganizationUrl', 'organizationsEmptyStateSvgPath'], props: { organizations: { - type: Array, + type: Object, required: false, - default: () => [], + default: () => {}, }, loading: { type: Boolean, @@ -30,15 +30,22 @@ export default { default: false, }, }, + computed: { + nodes() { + return this.organizations.nodes || []; + }, + }, }; </script> <template> <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> <organizations-list - v-else-if="organizations.length" + v-else-if="nodes.length" :organizations="organizations" class="gl-border-t" + @prev="$emit('prev', $event)" + @next="$emit('next', $event)" /> <gl-empty-state v-else diff --git a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql deleted file mode 100644 index 6090e2ec789..00000000000 --- a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query getCurrentUserOrganizations { - currentUser { - id - organizations @client { - nodes { - id - name - descriptionHtml - avatarUrl - webUrl - } - } - } -} diff --git a/app/assets/javascripts/organizations/index/index.js b/app/assets/javascripts/organizations/index/index.js index 7cbb9c9165d..df9ed2a4cce 100644 --- a/app/assets/javascripts/organizations/index/index.js +++ b/app/assets/javascripts/organizations/index/index.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import resolvers from '../shared/graphql/resolvers'; import OrganizationsIndexApp from './components/app.vue'; export const initOrganizationsIndex = () => { @@ -11,7 +10,7 @@ export const initOrganizationsIndex = () => { if (!el) return false; const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(), }); const { newOrganizationUrl, organizationsEmptyStateSvgPath } = convertObjectPropsToCamelCase( diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 725b6ac1ad8..0c363cf7c7f 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -6,7 +6,7 @@ export const organizations = [ { - id: 'gid://gitlab/Organization/1', + id: 'gid://gitlab/Organizations::Organization/1', name: 'My First Organization', descriptionHtml: '<p>This is where an organization can be explained in <strong>detail</strong></p>', @@ -15,7 +15,7 @@ export const organizations = [ __typename: 'Organization', }, { - id: 'gid://gitlab/Organization/2', + id: 'gid://gitlab/Organizations::Organization/2', name: 'Vegetation Co.', descriptionHtml: '<p> Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt<script>alert(1)</script></p>', @@ -24,7 +24,7 @@ export const organizations = [ __typename: 'Organization', }, { - id: 'gid://gitlab/Organization/3', + id: 'gid://gitlab/Organizations::Organization/3', name: 'Dude where is my car?', descriptionHtml: null, avatarUrl: null, @@ -302,10 +302,48 @@ export const organizationCreateResponseWithErrors = { }, }; -export const updateOrganizationResponse = { - organization: { - id: 'gid://gitlab/Organizations/1', - name: 'Default updated', +export const organizationUpdateResponse = { + data: { + organizationUpdate: { + organization: { + id: 'gid://gitlab/Organizations::Organization/1', + name: 'Default updated', + webUrl: 'http://127.0.0.1:3000/-/organizations/default', + }, + errors: [], + }, + }, +}; + +export const organizationUpdateResponseWithErrors = { + data: { + organizationUpdate: { + organization: null, + errors: ['Path is too short (minimum is 2 characters)'], + }, }, - errors: [], +}; + +export const pageInfo = { + endCursor: 'eyJpZCI6IjEwNTMifQ', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'eyJpZCI6IjEwNzIifQ', + __typename: 'PageInfo', +}; + +export const pageInfoOnePage = { + endCursor: 'eyJpZCI6IjEwNTMifQ', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjEwNzIifQ', + __typename: 'PageInfo', +}; + +export const pageInfoEmpty = { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + __typename: 'PageInfo', }; diff --git a/app/assets/javascripts/organizations/profile/preferences/index.js b/app/assets/javascripts/organizations/profile/preferences/index.js index 0b0dd313cd8..11686b62eca 100644 --- a/app/assets/javascripts/organizations/profile/preferences/index.js +++ b/app/assets/javascripts/organizations/profile/preferences/index.js @@ -30,8 +30,8 @@ export const initHomeOrganizationSetting = () => { block: true, label: s__('Organization|Home organization'), description: s__('Organization|Choose what organization you want to see by default.'), - inputName: 'home_organization', - inputId: 'home_organization', + inputName: 'user[home_organization_id]', + inputId: 'user_home_organization_id', initialSelection, toggleClass: 'gl-form-input-xl', }, diff --git a/app/assets/javascripts/organizations/settings/general/components/advanced_settings.vue b/app/assets/javascripts/organizations/settings/general/components/advanced_settings.vue new file mode 100644 index 00000000000..879e7b230a1 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/components/advanced_settings.vue @@ -0,0 +1,26 @@ +<script> +import { s__, __ } from '~/locale'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import ChangeUrl from './change_url.vue'; + +export default { + name: 'AdvancedSettings', + components: { SettingsBlock, ChangeUrl }, + i18n: { + settingsBlock: { + title: __('Advanced'), + description: s__('Organization|Perform advanced options such as deleting the organization.'), + }, + }, +}; +</script> + +<template> + <settings-block slide-animated> + <template #title>{{ $options.i18n.settingsBlock.title }}</template> + <template #description>{{ $options.i18n.settingsBlock.description }}</template> + <template #default> + <change-url /> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/organizations/settings/general/components/app.vue b/app/assets/javascripts/organizations/settings/general/components/app.vue index 134fcc17b54..ba8ab5a09fd 100644 --- a/app/assets/javascripts/organizations/settings/general/components/app.vue +++ b/app/assets/javascripts/organizations/settings/general/components/app.vue @@ -1,14 +1,16 @@ <script> import OrganizationSettings from './organization_settings.vue'; +import AdvancedSettings from './advanced_settings.vue'; export default { name: 'OrganizationSettingsGeneralApp', - components: { OrganizationSettings }, + components: { OrganizationSettings, AdvancedSettings }, }; </script> <template> <div> <organization-settings /> + <advanced-settings /> </div> </template> diff --git a/app/assets/javascripts/organizations/settings/general/components/change_url.vue b/app/assets/javascripts/organizations/settings/general/components/change_url.vue new file mode 100644 index 00000000000..8b65947ab2f --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/components/change_url.vue @@ -0,0 +1,139 @@ +<script> +import { GlFormFields, GlButton, GlForm, GlCard } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { visitUrlWithAlerts, joinPaths } from '~/lib/utils/url_utility'; +import { createAlert } from '~/alert'; +import OrganizationUrlField from '~/organizations/shared/components/organization_url_field.vue'; +import { FORM_FIELD_PATH, FORM_FIELD_PATH_VALIDATORS } from '~/organizations/shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ORGANIZATION } from '~/graphql_shared/constants'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; +import organizationUpdateMutation from '../graphql/mutations/organization_update.mutation.graphql'; + +export default { + name: 'OrganizationSettings', + components: { OrganizationUrlField, GlFormFields, GlButton, GlForm, GlCard, FormErrorsAlert }, + inject: ['organization'], + i18n: { + cardHeaderTitle: s__('Organization|Change organization URL'), + cardHeaderDescription: s__( + "Organization|Changing an organization's URL can have unintended side effects.", + ), + submitButtonText: s__('Organization|Change organization URL'), + errorMessage: s__( + 'Organization|An error occurred changing your organization URL. Please try again.', + ), + successAlertMessage: s__('Organization|Organization URL successfully changed.'), + }, + formId: 'change-organization-url-form', + fields: { + [FORM_FIELD_PATH]: { + label: s__('Organization|Organization URL'), + validators: FORM_FIELD_PATH_VALIDATORS, + groupAttrs: { + class: 'gl-w-full', + labelSrOnly: true, + }, + }, + }, + data() { + return { + formValues: { + path: this.organization.path, + }, + loading: false, + errors: [], + }; + }, + computed: { + isSubmitButtonDisabled() { + return this.formValues.path === this.organization.path; + }, + }, + methods: { + async onSubmit() { + this.errors = []; + this.loading = true; + try { + const { + data: { + organizationUpdate: { errors, organization }, + }, + } = await this.$apollo.mutate({ + mutation: organizationUpdateMutation, + variables: { + input: { + id: convertToGraphQLId(TYPE_ORGANIZATION, this.organization.id), + path: this.formValues.path, + }, + }, + }); + + if (errors.length) { + this.errors = errors; + + return; + } + + visitUrlWithAlerts(joinPaths(organization.webUrl, '/settings/general'), [ + { + id: 'organization-url-successfully-changed', + message: this.$options.i18n.successAlertMessage, + variant: 'info', + }, + ]); + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div> + <form-errors-alert v-model="errors" /> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header gl-flex-direction-column" + body-class="gl-new-card-body gl-px-5 gl-py-4" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h4 class="gl-new-card-title">{{ $options.i18n.cardHeaderTitle }}</h4> + </div> + <p class="gl-new-card-description">{{ $options.i18n.cardHeaderDescription }}</p> + </template> + <gl-form :id="$options.formId"> + <gl-form-fields + v-model="formValues" + :form-id="$options.formId" + :fields="$options.fields" + @submit="onSubmit" + > + <template #input(path)="{ id, value, validation, input, blur }"> + <organization-url-field + :id="id" + :value="value" + :validation="validation" + @input="input" + @blur="blur" + /> + </template> + </gl-form-fields> + <div class="gl-display-flex gl-gap-3"> + <gl-button + type="submit" + variant="danger" + class="js-no-auto-disable" + :loading="loading" + :disabled="isSubmitButtonDisabled" + >{{ $options.i18n.submitButtonText }}</gl-button + > + </div> + </gl-form> + </gl-card> + </div> +</template> diff --git a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue index 14826825cd6..1acc4c54f75 100644 --- a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue +++ b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue @@ -1,14 +1,18 @@ <script> import { s__, __ } from '~/locale'; -import { createAlert, VARIANT_INFO } from '~/alert'; +import { createAlert } from '~/alert'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import updateOrganizationMutation from '../graphql/mutations/update_organization.mutation.graphql'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ORGANIZATION } from '~/graphql_shared/constants'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; +import organizationUpdateMutation from '../graphql/mutations/organization_update.mutation.graphql'; export default { name: 'OrganizationSettings', - components: { NewEditForm, SettingsBlock }, + components: { NewEditForm, SettingsBlock, FormErrorsAlert }, inject: ['organization'], i18n: { submitButtonText: __('Save changes'), @@ -25,30 +29,41 @@ export default { data() { return { loading: false, + errors: [], }; }, methods: { async onSubmit(formValues) { + this.errors = []; this.loading = true; try { const { data: { - updateOrganization: { errors }, + organizationUpdate: { errors }, }, } = await this.$apollo.mutate({ - mutation: updateOrganizationMutation, + mutation: organizationUpdateMutation, variables: { - id: this.organization.id, - name: formValues.name, + input: { + id: convertToGraphQLId(TYPE_ORGANIZATION, this.organization.id), + name: formValues.name, + }, }, }); if (errors.length) { - // TODO: handle errors when using real API after https://gitlab.com/gitlab-org/gitlab/-/issues/419608 is complete. + this.errors = errors; + return; } - createAlert({ message: this.$options.i18n.successMessage, variant: VARIANT_INFO }); + visitUrlWithAlerts(window.location.href, [ + { + id: 'organization-successfully-updated', + message: this.$options.i18n.successMessage, + variant: 'info', + }, + ]); } catch (error) { createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); } finally { @@ -64,6 +79,7 @@ export default { <template #title>{{ $options.i18n.settingsBlock.title }}</template> <template #description>{{ $options.i18n.settingsBlock.description }}</template> <template #default> + <form-errors-alert v-model="errors" /> <new-edit-form :loading="loading" :initial-form-values="organization" diff --git a/app/assets/javascripts/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql b/app/assets/javascripts/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql new file mode 100644 index 00000000000..566db101ab4 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql @@ -0,0 +1,10 @@ +mutation organizationUpdate($input: OrganizationUpdateInput!) { + organizationUpdate(input: $input) { + organization { + id + name + webUrl + } + errors + } +} diff --git a/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql b/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql deleted file mode 100644 index b571a523260..00000000000 --- a/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation updateOrganization($input: LocalUpdateOrganizationInput!) { - updateOrganization(input: $input) @client { - organization { - id - name - } - errors - } -} diff --git a/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql b/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql deleted file mode 100644 index eb81a7b0321..00000000000 --- a/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql +++ /dev/null @@ -1,5 +0,0 @@ -# TODO: Use real input type when https://gitlab.com/gitlab-org/gitlab/-/issues/419608 is complete. -input LocalUpdateOrganizationInput { - id: ID! - name: String -} diff --git a/app/assets/javascripts/organizations/settings/general/index.js b/app/assets/javascripts/organizations/settings/general/index.js index 36303c32b94..138606a0aab 100644 --- a/app/assets/javascripts/organizations/settings/general/index.js +++ b/app/assets/javascripts/organizations/settings/general/index.js @@ -3,7 +3,6 @@ import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; -import resolvers from '../../shared/graphql/resolvers'; import App from './components/app.vue'; export const initOrganizationsSettingsGeneral = () => { @@ -19,7 +18,7 @@ export const initOrganizationsSettingsGeneral = () => { ); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue index 8aaa680036f..c5bb16b944a 100644 --- a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue +++ b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue @@ -1,18 +1,15 @@ <script> -import { - GlForm, - GlFormFields, - GlButton, - GlFormInputGroup, - GlFormInput, - GlInputGroupText, - GlTruncate, -} from '@gitlab/ui'; +import { GlForm, GlFormFields, GlButton } from '@gitlab/ui'; import { formValidators } from '@gitlab/ui/dist/utils'; import { s__, __ } from '~/locale'; import { slugify } from '~/lib/utils/text_utility'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH } from '../constants'; +import { + FORM_FIELD_NAME, + FORM_FIELD_ID, + FORM_FIELD_PATH, + FORM_FIELD_PATH_VALIDATORS, +} from '../constants'; +import OrganizationUrlField from './organization_url_field.vue'; export default { name: 'NewEditForm', @@ -20,17 +17,13 @@ export default { GlForm, GlFormFields, GlButton, - GlFormInputGroup, - GlFormInput, - GlInputGroupText, - GlTruncate, + OrganizationUrlField, }, i18n: { cancel: __('Cancel'), - pathPlaceholder: s__('Organization|my-organization'), }, formId: 'new-organization-form', - inject: ['organizationsPath', 'rootUrl'], + inject: ['organizationsPath'], props: { loading: { type: Boolean, @@ -71,9 +64,6 @@ export default { }; }, computed: { - baseUrl() { - return joinPaths(this.rootUrl, this.organizationsPath, '/'); - }, fields() { const fields = { [FORM_FIELD_NAME]: { @@ -103,13 +93,7 @@ export default { }, [FORM_FIELD_PATH]: { label: s__('Organization|Organization URL'), - validators: [ - formValidators.required(s__('Organization|Organization URL is required.')), - formValidators.factory( - s__('Organization|Organization URL must be a minimum of two characters.'), - (val) => val.length >= 2, - ), - ], + validators: FORM_FIELD_PATH_VALIDATORS, groupAttrs: { class: 'gl-w-full', }, @@ -156,22 +140,13 @@ export default { @submit="$emit('submit', formValues)" > <template #input(path)="{ id, value, validation, input, blur }"> - <gl-form-input-group> - <template #prepend> - <gl-input-group-text class="organization-root-path"> - <gl-truncate :text="baseUrl" position="middle" /> - </gl-input-group-text> - </template> - <gl-form-input - v-bind="validation" - :id="id" - :value="value" - :placeholder="$options.i18n.pathPlaceholder" - class="gl-h-auto! gl-md-form-input-lg" - @input="onPathInput($event, input)" - @blur="blur" - /> - </gl-form-input-group> + <organization-url-field + :id="id" + :value="value" + :validation="validation" + @input="onPathInput($event, input)" + @blur="blur" + /> </template> </gl-form-fields> <div class="gl-display-flex gl-gap-3"> diff --git a/app/assets/javascripts/organizations/shared/components/organization_url_field.vue b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue new file mode 100644 index 00000000000..d36f62477e6 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/organization_url_field.vue @@ -0,0 +1,58 @@ +<script> +import { GlFormInputGroup, GlFormInput, GlInputGroupText, GlTruncate } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; + +export default { + name: 'OrganizationUrlField', + components: { + GlFormInputGroup, + GlFormInput, + GlInputGroupText, + GlTruncate, + }, + i18n: { + pathPlaceholder: s__('Organization|my-organization'), + }, + formId: 'new-organization-form', + inject: ['organizationsPath', 'rootUrl'], + props: { + id: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + validation: { + type: Object, + required: true, + }, + }, + computed: { + baseUrl() { + return joinPaths(this.rootUrl, this.organizationsPath, '/'); + }, + }, +}; +</script> + +<template> + <gl-form-input-group> + <template #prepend> + <gl-input-group-text class="organization-root-path"> + <gl-truncate :text="baseUrl" position="middle" /> + </gl-input-group-text> + </template> + <gl-form-input + v-bind="validation" + :id="id" + :value="value" + :placeholder="$options.i18n.pathPlaceholder" + class="gl-h-auto! gl-md-form-input-lg" + @input="$emit('input', $event)" + @blur="$emit('blur', $event)" + /> + </gl-form-input-group> +</template> diff --git a/app/assets/javascripts/organizations/shared/constants.js b/app/assets/javascripts/organizations/shared/constants.js index 010613bc9fd..7287d84f99f 100644 --- a/app/assets/javascripts/organizations/shared/constants.js +++ b/app/assets/javascripts/organizations/shared/constants.js @@ -1,3 +1,14 @@ +import { formValidators } from '@gitlab/ui/dist/utils'; +import { s__ } from '~/locale'; + export const FORM_FIELD_NAME = 'name'; export const FORM_FIELD_ID = 'id'; export const FORM_FIELD_PATH = 'path'; + +export const FORM_FIELD_PATH_VALIDATORS = [ + formValidators.required(s__('Organization|Organization URL is required.')), + formValidators.factory( + s__('Organization|Organization URL is too short (minimum is 2 characters).'), + (val) => val.length >= 2, + ), +]; diff --git a/app/assets/javascripts/organizations/shared/graphql/fragments/organization.fragment.graphql b/app/assets/javascripts/organizations/shared/graphql/fragments/organization.fragment.graphql new file mode 100644 index 00000000000..c0bccdcc120 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/graphql/fragments/organization.fragment.graphql @@ -0,0 +1,7 @@ +fragment Organization on Organization { + id + name + descriptionHtml + avatarUrl + webUrl +} diff --git a/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql index 1d95786fcb0..a8d8d63c27a 100644 --- a/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql +++ b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql @@ -1,9 +1,7 @@ -query getOrganization($id: ID!) { - organization(id: $id) @client { - id - name - descriptionHtml - avatarUrl - webUrl +#import "../fragments/organization.fragment.graphql" + +query getOrganization($id: OrganizationsOrganizationID!) { + organization(id: $id) { + ...Organization } } diff --git a/app/assets/javascripts/organizations/shared/graphql/queries/organizations.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/organizations.query.graphql new file mode 100644 index 00000000000..d69e7916512 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/graphql/queries/organizations.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "../fragments/organization.fragment.graphql" + +query getCurrentUserOrganizations($first: Int, $last: Int, $before: String, $after: String) { + currentUser { + id + organizations(first: $first, last: $last, before: $before, after: $after) { + nodes { + ...Organization + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/organizations/shared/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index 9ed1be62352..efde13852d8 100644 --- a/app/assets/javascripts/organizations/shared/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -1,9 +1,4 @@ -import { - organizations, - organizationProjects, - organizationGroups, - updateOrganizationResponse, -} from '../../mock_data'; +import { organizations, organizationProjects, organizationGroups } from '../../mock_data'; const simulateLoading = () => { return new Promise((resolve) => { @@ -24,21 +19,4 @@ export default { }; }, }, - UserCore: { - organizations: async () => { - await simulateLoading(); - - return { - nodes: organizations, - }; - }, - }, - Mutation: { - updateOrganization: async () => { - // Simulate API loading - await simulateLoading(); - - return updateOrganizationResponse; - }, - }, }; diff --git a/app/assets/javascripts/organizations/users/components/app.vue b/app/assets/javascripts/organizations/users/components/app.vue index ae22bedd69a..065a1e004f2 100644 --- a/app/assets/javascripts/organizations/users/components/app.vue +++ b/app/assets/javascripts/organizations/users/components/app.vue @@ -1,10 +1,22 @@ <script> import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; +import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants'; import organizationUsersQuery from '../graphql/organization_users.query.graphql'; +import UsersView from './users_view.vue'; + +const defaultPagination = { + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + before: '', + after: '', +}; export default { name: 'OrganizationsUsersApp', + components: { + UsersView, + }, i18n: { users: __('Users'), loadingPlaceholder: __('Loading'), @@ -16,16 +28,31 @@ export default { data() { return { users: [], + pagination: { + ...defaultPagination, + }, + pageInfo: {}, }; }, apollo: { users: { query: organizationUsersQuery, variables() { - return { id: this.organizationGid }; + return { + id: this.organizationGid, + first: this.pagination.first, + last: this.pagination.last, + before: this.pagination.before, + after: this.pagination.after, + }; }, update(data) { - return data.organization.organizationUsers.nodes; + const { nodes, pageInfo } = data.organization.organizationUsers; + this.pageInfo = pageInfo; + + return nodes.map(({ badges, user }) => { + return { ...user, badges, email: user.publicEmail }; + }); }, error(error) { createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); @@ -37,15 +64,28 @@ export default { return this.$apollo.queries.users.loading; }, }, + methods: { + handlePrevPage() { + this.pagination.before = this.pageInfo.startCursor; + this.pagination.after = ''; + }, + handleNextPage() { + this.pagination.before = ''; + this.pagination.after = this.pageInfo.endCursor; + }, + }, }; </script> <template> <section> <h1 class="gl-my-4 gl-font-size-h-display">{{ $options.i18n.users }}</h1> - <template v-if="loading"> - {{ $options.i18n.loadingPlaceholder }} - </template> - <div data-testid="organization-users">{{ users }}</div> + <users-view + :users="users" + :loading="loading" + :page-info="pageInfo" + @prev="handlePrevPage" + @next="handleNextPage" + /> </section> </template> diff --git a/app/assets/javascripts/organizations/users/components/users_view.vue b/app/assets/javascripts/organizations/users/components/users_view.vue new file mode 100644 index 00000000000..c1f411fb958 --- /dev/null +++ b/app/assets/javascripts/organizations/users/components/users_view.vue @@ -0,0 +1,48 @@ +<script> +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; + +export default { + name: 'UsersView', + components: { + GlLoadingIcon, + GlKeysetPagination, + UsersTable, + }, + inject: ['paths'], + props: { + users: { + type: Array, + required: false, + default: () => [], + }, + pageInfo: { + type: Object, + required: true, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <template v-else> + <users-table :users="users" :admin-user-path="paths.adminUser" /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="__('Previous')" + :next-text="__('Next')" + @prev="$emit('prev')" + @next="$emit('next')" + /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql index a0b2a639401..0b9b1314fa2 100644 --- a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql +++ b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql @@ -1,7 +1,15 @@ -query getOrganizationUsers($id: OrganizationsOrganizationID!) { +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getOrganizationUsers( + $id: OrganizationsOrganizationID! + $first: Int + $last: Int + $before: String! + $after: String! +) { organization(id: $id) { id - organizationUsers { + organizationUsers(first: $first, last: $last, before: $before, after: $after) { nodes { badges { text @@ -10,8 +18,17 @@ query getOrganizationUsers($id: OrganizationsOrganizationID!) { id user { id + username + avatarUrl + name + publicEmail + createdAt + lastActivityOn } } + pageInfo { + ...PageInfo + } } } } diff --git a/app/assets/javascripts/organizations/users/index.js b/app/assets/javascripts/organizations/users/index.js index 76656243075..794ae9e70a6 100644 --- a/app/assets/javascripts/organizations/users/index.js +++ b/app/assets/javascripts/organizations/users/index.js @@ -13,7 +13,9 @@ export const initOrganizationsUsers = () => { defaultClient: createDefaultClient(), }); - const { organizationGid } = convertObjectPropsToCamelCase(el.dataset); + const { organizationGid, paths } = convertObjectPropsToCamelCase(JSON.parse(el.dataset.appData), { + deep: true, + }); return new Vue({ el, @@ -21,6 +23,7 @@ export const initOrganizationsUsers = () => { apolloProvider, provide: { organizationGid, + paths, }, render(createElement) { return createElement(OrganizationsUsersApp); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue index c8a4f32d5a7..3796c5440f7 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue @@ -119,9 +119,15 @@ export default { tagLocation() { return this.tag.path?.replace(`:${this.tag.name}`, ''); }, + isEmptyRevision() { + return this.tag.revision === ''; + }, isInvalidTag() { return !this.tag.digest; }, + showConfigDigest() { + return !this.isInvalidTag && !this.isEmptyRevision; + }, }, }; </script> @@ -235,7 +241,7 @@ export default { /> </details-row> </template> - <template v-if="!isInvalidTag" #details-configuration-digest> + <template v-if="showConfigDigest" #details-configuration-digest> <details-row icon="cloud-gear" data-testid="configuration-detail"> <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> <template #digest> 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 a821a2483cd..b1729f07861 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 @@ -126,7 +126,6 @@ export default { attributes: { variant: 'danger', category: 'primary', - 'data-qa-selector': 'delete_modal_button', }, }, fileDeletePrimaryAction: { @@ -158,7 +157,6 @@ export default { class="js-delete-button" variant="danger" category="primary" - data-qa-selector="delete_button" > {{ __('Delete') }} </gl-button> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue index 498ddbae7b1..d71773adb9d 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue @@ -8,6 +8,9 @@ import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; export default { i18n: { requiredPython: s__('PackageRegistry|Required Python: %{pythonVersion}'), + summary: s__('PackageRegistry|Summary: %{summary}'), + authorEmail: s__('PackageRegistry|Author email: %{authorEmail}'), + keywords: s__('PackageRegistry|Keywords: %{keywords}'), }, components: { DetailsRow, @@ -24,12 +27,33 @@ export default { <template> <div> - <details-row icon="information-o" padding="gl-p-4" data-testid="pypi-required-python"> + <details-row dashed icon="information-o" padding="gl-p-4" data-testid="pypi-required-python"> <gl-sprintf :message="$options.i18n.requiredPython"> <template #pythonVersion> <strong>{{ packageMetadata.requiredPython }}</strong> </template> </gl-sprintf> </details-row> + <details-row dashed icon="doc-text" padding="gl-p-4" data-testid="pypi-summary"> + <gl-sprintf :message="$options.i18n.summary"> + <template #summary> + <strong>{{ packageMetadata.summary }}</strong> + </template> + </gl-sprintf> + </details-row> + <details-row dashed icon="doc-text" padding="gl-p-4" data-testid="pypi-keywords"> + <gl-sprintf :message="$options.i18n.keywords"> + <template #keywords> + <strong>{{ packageMetadata.keywords }}</strong> + </template> + </gl-sprintf> + </details-row> + <details-row dashed icon="mail" padding="gl-p-4" data-testid="pypi-author-email"> + <gl-sprintf :message="$options.i18n.authorEmail"> + <template #authorEmail> + <strong>{{ packageMetadata.authorEmail }}</strong> + </template> + </gl-sprintf> + </details-row> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index 1020cd0c533..df50f5a52b4 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -1,10 +1,13 @@ <script> +import { GlFilteredSearchToken } from '@gitlab/ui'; import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import { FILTERED_SEARCH_TERM, OPERATORS_IS, TOKEN_TITLE_TYPE, TOKEN_TYPE_TYPE, + TOKEN_TITLE_VERSION, + TOKEN_TYPE_VERSION, } from '~/vue_shared/components/filtered_search_bar/constants'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; @@ -21,6 +24,14 @@ export default { token: PackageTypeToken, operators: OPERATORS_IS, }, + { + type: TOKEN_TYPE_VERSION, + icon: 'doc-versions', + title: TOKEN_TITLE_VERSION, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATORS_IS, + }, ], components: { LocalStorageSync, @@ -57,6 +68,7 @@ export default { const parsed = { packageName: '', packageType: undefined, + packageVersion: '', }; return filters.reduce((acc, filter) => { @@ -67,6 +79,13 @@ export default { }; } + if (filter.type === TOKEN_TYPE_VERSION && filter.value?.data) { + return { + ...acc, + packageVersion: filter.value.data.trim(), + }; + } + if (filter.type === FILTERED_SEARCH_TERM) { return { ...acc, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql index fc8b39b37ab..b95b5c2bc74 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql @@ -12,7 +12,10 @@ query getPackageMetadata($id: PackagesPackageID!) { } ... on PypiMetadata { id + authorEmail + keywords requiredPython + summary } ... on ConanMetadata { id 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 f25f24cbc5f..77f09e7b76b 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 @@ -9,6 +9,7 @@ query getPackages( $groupSort: PackageGroupSort $packageName: String $packageType: PackageTypeEnum + $packageVersion: String $first: Int $last: Int $after: String @@ -20,6 +21,7 @@ query getPackages( sort: $sort packageName: $packageName packageType: $packageType + packageVersion: $packageVersion after: $after before: $before first: $first @@ -43,6 +45,7 @@ query getPackages( sort: $groupSort packageName: $packageName packageType: $packageType + packageVersion: $packageVersion after: $after before: $before first: $first diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 294c6baad1b..eb33c020f7d 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -82,6 +82,7 @@ export default { groupSort: this.isGroupPage ? this.sort : undefined, packageName: this.filters?.packageName, packageType: this.filters?.packageType, + packageVersion: this.filters?.packageVersion, first: GRAPHQL_PAGE_SIZE, ...this.pageParams, }; @@ -96,10 +97,10 @@ export default { return this.packages?.count; }, hasFilters() { - return this.filters.packageName && this.filters.packageType; + return this.filters.packageName || this.filters.packageType || this.filters.packageVersion; }, emptySearch() { - return !this.filters.packageName && !this.filters.packageType; + return !this.filters.packageName && !this.filters.packageType && !this.filters.packageVersion; }, emptyStateTitle() { return this.emptySearch 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 59d4f5e24d0..9e6d55d71d3 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 @@ -129,7 +129,7 @@ export default { </script> <template> - <settings-block data-qa-selector="dependency_proxy_settings_content"> + <settings-block data-testid="dependency-proxy-settings-content"> <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template> <template #description> {{ $options.i18n.DEPENDENCY_PROXY_DESCRIPTION }} </template> <template #default> @@ -138,7 +138,6 @@ export default { v-model="enabled" :disabled="isLoading" :label="$options.i18n.enabledProxyLabel" - data-qa-selector="dependency_proxy_setting_toggle" data-testid="dependency-proxy-setting-toggle" > <template v-if="enabled" #help> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue index e15f204dc6e..a773a64c4fc 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue @@ -78,7 +78,6 @@ export default { exception: 'mavenDuplicateExceptionRegex', }, testid: 'maven-settings', - dataQaSelector: 'allow_duplicates_toggle', }, { id: 'generic-duplicated-settings-regex-input', @@ -154,7 +153,7 @@ export default { </script> <template> - <settings-block data-qa-selector="package_registry_settings_content"> + <settings-block data-testid="package-registry-settings-content"> <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> <template #description> <span data-testid="description"> @@ -174,7 +173,7 @@ export default { </template> <template #cell(allowDuplicates)="{ item }"> <gl-toggle - :data-qa-selector="item.dataQaSelector" + :data-testid="item.dataTestid" :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" :value="item.duplicatesAllowed" :disabled="isLoading" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 4cc9cc190e8..06af69ff250 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -6,16 +6,24 @@ import { SHOW_SETUP_SUCCESS_ALERT, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/packages_and_registries/settings/project/constants'; -import ContainerExpirationPolicy from './container_expiration_policy.vue'; -import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; +import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + DependencyProxyPackagesSettings: () => + import( + 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue' + ), GlAlert, PackagesCleanupPolicy, }, - inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], + inject: [ + 'showContainerRegistrySettings', + 'showPackageRegistrySettings', + 'showDependencyProxySettings', + ], i18n: { UPDATE_SETTINGS_SUCCESS_MESSAGE, }, @@ -54,5 +62,6 @@ export default { </gl-alert> <packages-cleanup-policy v-if="showPackageRegistrySettings" /> <container-expiration-policy v-if="showContainerRegistrySettings" /> + <dependency-proxy-packages-settings v-if="showDependencyProxySettings" /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index 57c8d07e620..326265430d9 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -23,6 +23,7 @@ export default () => { helpPagePath, showContainerRegistrySettings, showPackageRegistrySettings, + showDependencyProxySettings, } = el.dataset; return new Vue({ el, @@ -40,6 +41,7 @@ export default () => { helpPagePath, showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings), + showDependencyProxySettings: parseBoolean(showDependencyProxySettings), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index a19c8ed5866..e7606936e6b 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -10,13 +10,16 @@ export const searchArrayToFilterTokens = (search) => search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s)); export const extractFilterAndSorting = (queryObject) => { - const { type, search, sort, orderBy } = queryObject; + const { type, search, version, sort, orderBy } = queryObject; const filters = []; const sorting = {}; if (type) { filters.push(keyValueToFilterToken('type', type)); } + if (version) { + filters.push(keyValueToFilterToken('version', version)); + } if (search) { filters.push(...searchArrayToFilterTokens(search)); } diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue index 2217792d7f3..9dfad16ae82 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue @@ -23,7 +23,7 @@ export default { type: Boolean, required: true, }, - dataQaSelector: { + dataTestId: { type: String, required: false, default: '', @@ -36,11 +36,7 @@ export default { <div> <input :name="name" type="hidden" :value="value ? '1' : '0'" data-testid="input" /> - <gl-form-checkbox - :checked="value" - :data-qa-selector="dataQaSelector" - @input="$emit('input', $event)" - > + <gl-form-checkbox :checked="value" :data-testid="dataTestId" @input="$emit('input', $event)"> <span data-testid="label">{{ label }}</span> <template v-if="helpText" #help> <span data-testid="helpText">{{ helpText }}</span> diff --git a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js new file mode 100644 index 00000000000..8a12e753847 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js @@ -0,0 +1,3 @@ +import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data'; + +initServiceUsageData(); diff --git a/app/assets/javascripts/pages/admin/deploy_keys/new/index.js b/app/assets/javascripts/pages/admin/deploy_keys/new/index.js new file mode 100644 index 00000000000..a79542ee6e0 --- /dev/null +++ b/app/assets/javascripts/pages/admin/deploy_keys/new/index.js @@ -0,0 +1,3 @@ +import initDatePickers from '~/behaviors/date_picker'; + +initDatePickers(); diff --git a/app/assets/javascripts/pages/clusters/agents/dashboard/index.js b/app/assets/javascripts/pages/clusters/agents/dashboard/index.js new file mode 100644 index 00000000000..eebb674515b --- /dev/null +++ b/app/assets/javascripts/pages/clusters/agents/dashboard/index.js @@ -0,0 +1,3 @@ +import { initKubernetesDashboard } from '~/kubernetes_dashboard/init_kubernetes_dashboard'; + +initKubernetesDashboard(); diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 2ca11e96f69..963dc0c57da 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -1,10 +1,14 @@ -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import { createFilteredSearchTokenKeys } from '~/filtered_search/issuable_filtered_search_token_keys'; import { mountIssuesDashboardApp } from '~/issues/dashboard'; import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; +const IssuableFilteredSearchTokenKeys = createFilteredSearchTokenKeys({ + disableReleaseFilter: true, +}); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index a8c59ea6f3d..774e234a358 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,12 +1,20 @@ 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 { createFilteredSearchTokenKeys } from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants'; import searchUserProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql'; -addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true); +const IssuableFilteredSearchTokenKeys = createFilteredSearchTokenKeys({ + disableReleaseFilter: true, +}); + +addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, { + disableBranchFilter: true, + disableReleaseFilter: true, + disableEnvironmentFilter: true, +}); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js index 23f5b083589..a591fed3d9b 100644 --- a/app/assets/javascripts/pages/groups/boards/index.js +++ b/app/assets/javascripts/pages/groups/boards/index.js @@ -1,5 +1,6 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initBoards from '~/boards'; -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); initBoards(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 5d9eafe5672..46040cd6706 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -1,10 +1,11 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; import initNotificationsDropdown from '~/notifications'; import ProjectsList from '~/projects_list'; export default function initGroupDetails() { - new ShortcutsNavigation(); // eslint-disable-line no-new + addShortcutsExtension(ShortcutsNavigation); initNotificationsDropdown(); diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index f6a4ca0f360..1c6c3c0c518 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -2,10 +2,12 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; import { initGroupReadme } from '~/groups/init_group_readme'; import initReadMore from '~/read_more'; +import InitMoreActionsDropdown from '~/groups_projects/init_more_actions_dropdown'; import initGroupDetails from '../shared/group_details'; -leaveByUrl('group'); initGroupDetails(); initGroupOverviewTabs(); initReadMore(); initGroupReadme(); +InitMoreActionsDropdown(); +leaveByUrl('group'); diff --git a/app/assets/javascripts/pages/ide/index.js b/app/assets/javascripts/pages/ide/index/index.js index 15933256e75..15933256e75 100644 --- a/app/assets/javascripts/pages/ide/index.js +++ b/app/assets/javascripts/pages/ide/index/index.js diff --git a/app/assets/javascripts/pages/ide/oauth_redirect/index.js b/app/assets/javascripts/pages/ide/oauth_redirect/index.js new file mode 100644 index 00000000000..ee9233fab38 --- /dev/null +++ b/app/assets/javascripts/pages/ide/oauth_redirect/index.js @@ -0,0 +1,3 @@ +import { mountOAuthCallback } from '~/ide/mount_oauth_callback'; + +mountOAuthCallback(); 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 e912bfa4f92..1d54dad43a9 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 @@ -8,12 +8,13 @@ import { GlTableLite, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; +import { isEqual } from 'lodash'; import { s__, __ } from '~/locale'; import { createAlert } from '~/alert'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { getBulkImportsHistory } from '~/rest_api'; +import { joinPaths, getParameterValues } from '~/lib/utils/url_utility'; +import { getBulkImportHistory, getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/import_groups/components/import_status.vue'; import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; @@ -92,7 +93,6 @@ export default { tableCell({ key: 'status', label: __('Status'), - tdAttr: { 'data-qa-selector': 'import_status_indicator' }, }), ], @@ -110,12 +110,18 @@ export default { showDetailsLink() { return this.glFeatures.bulkImportDetailsPage; }, + + paginationConfigCopy() { + return { ...this.paginationConfig }; + }, }, watch: { - paginationConfig: { - handler() { - this.loadHistoryItems(); + paginationConfigCopy: { + handler(newValue, oldValue) { + if (!isEqual(newValue, oldValue)) { + this.loadHistoryItems(); + } }, deep: true, }, @@ -159,10 +165,19 @@ export default { }, methods: { + fetchFn(params) { + const bulkImportId = getParameterValues('bulk_import_id')[0]; + + return bulkImportId + ? getBulkImportHistory(bulkImportId, params) + : getBulkImportsHistory(params); + }, + async loadHistoryItems() { try { this.loading = true; - const { data: historyItems, headers } = await getBulkImportsHistory({ + + const { data: historyItems, headers } = await this.fetchFn({ page: this.paginationConfig.page, per_page: this.paginationConfig.perPage, }); @@ -217,14 +232,11 @@ export default { <template> <div> - <div - class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" - > - <h1 class="gl-my-0 gl-py-4 gl-font-size-h1"> - <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> - {{ s__('BulkImport|GitLab Migration history') }} - </h1> - </div> + <h1 class="gl-font-size-h1 gl-my-0 gl-py-4 gl-display-flex gl-align-items-center gl-gap-3"> + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6" /> + <span>{{ s__('BulkImport|Direct transfer history') }}</span> + </h1> + <gl-loading-icon v-if="loading" size="lg" class="gl-mt-5" /> <gl-empty-state v-else-if="!hasHistoryItems" diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue index 9c0f937fe0e..a0ff3ded3f5 100644 --- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue +++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue @@ -65,10 +65,10 @@ export default { key: 'created_at', label: __('Date'), }), + tableCell({ key: 'status', label: __('Status'), - tdAttr: { 'data-qa-selector': 'import_status_indicator' }, }), ], @@ -154,9 +154,7 @@ export default { </gl-link> <span v-else>{{ item.import_url }}</span> </template> - <span v-else>{{ - s__('BulkImport|Template / File-based import / GitLab Migration') - }}</span> + <span v-else>{{ s__('BulkImport|Template / File-based import / Direct transfer') }}</span> </template> <template #cell(destination)="{ item }"> <gl-link :href="item.http_url_to_repo"> diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index 8fe822e4639..41952a33c05 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -8,7 +8,7 @@ const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSk if (skippable) { const button = `<div class="gl-alert-actions"> - <a class="btn gl-button btn-md btn-confirm gl-alert-action" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a> + <a class="btn gl-button btn-md btn-confirm gl-alert-action" data-testid="configure-it-later-button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a> </div>`; const flashAlert = document.querySelector('.flash-alert'); if (flashAlert) { diff --git a/app/assets/javascripts/pages/projects/activity/index.js b/app/assets/javascripts/pages/projects/activity/index.js index 03fbad0f1ec..3138026e1db 100644 --- a/app/assets/javascripts/pages/projects/activity/index.js +++ b/app/assets/javascripts/pages/projects/activity/index.js @@ -1,5 +1,6 @@ import Activities from '~/activities'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; new Activities(); // eslint-disable-line no-new -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); diff --git a/app/assets/javascripts/pages/projects/artifacts/browse/index.js b/app/assets/javascripts/pages/projects/artifacts/browse/index.js index 60680ec7d1d..47cf348eb4d 100644 --- a/app/assets/javascripts/pages/projects/artifacts/browse/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/browse/index.js @@ -1,5 +1,6 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BuildArtifacts from '~/build_artifacts'; -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); new BuildArtifacts(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js index 07ee4d686cc..3bc3b9dabbc 100644 --- a/app/assets/javascripts/pages/projects/artifacts/file/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js @@ -1,5 +1,6 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { BlobViewer } from '~/blob/viewer/index'; -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js index 23f5b083589..a591fed3d9b 100644 --- a/app/assets/javascripts/pages/projects/boards/index.js +++ b/app/assets/javascripts/pages/projects/boards/index.js @@ -1,5 +1,6 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initBoards from '~/boards'; -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); initBoards(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index c9f5895c7a3..d875f28433e 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Vue from 'vue'; import loadAwardsHandler from '~/awards_handler'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; import { createAlert } from '~/alert'; @@ -20,7 +21,7 @@ import { initReportAbuse } from '~/projects/report_abuse'; initDiffStatsDropdown(); new ZenMode(); -new ShortcutsNavigation(); +addShortcutsExtension(ShortcutsNavigation); initCommitBoxInfo(); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index f5ecf9be591..e3b22bbfee0 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -1,10 +1,11 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import CommitsList from '~/commits'; import GpgBadges from '~/gpg_badges'; import { mountCommits, initCommitsRefSwitcher } from '~/projects/commits'; new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); GpgBadges.fetch(); mountCommits(document.getElementById('js-author-dropdown')); initCommitsRefSwitcher(); diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index 22c21430e8b..4df84ac167c 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file'; import ProjectFindFile from '~/projects/project_find_file'; import InitBlobRefSwitcher from '../ref_switcher'; @@ -11,4 +12,4 @@ const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { refType: findElement.dataset.refType, }); projectFindFile.load(findElement.dataset.fileFindUrl); -new ShortcutsFindFile(projectFindFile); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsFindFile, projectFindFile); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index e3d50e900ca..bfa2f2cc14f 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -423,7 +423,7 @@ export default { > <div> <gl-icon - data-qa-selector="fork_privacy_button" + data-testid="fork-privacy-button" :name="icon" :data-qa-privacy-level="`${value}`" /> @@ -440,8 +440,7 @@ export default { category="primary" variant="confirm" class="js-no-auto-disable" - data-testid="submit-button" - data-qa-selector="fork_project_button" + data-testid="fork-project-button" :loading="isSaving" > {{ s__('ForkProject|Fork project') }} diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue index 84796954cf1..b4bb2176e26 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue @@ -90,8 +90,7 @@ export default { }}</gl-button> <gl-collapsible-listbox class="gl-flex-grow-1" - data-qa-selector="select_namespace_dropdown" - data-testid="select_namespace_dropdown" + data-testid="select-namespace-dropdown" :items="namespaceItems" :header-text="__('Namespaces')" :no-results-text="__('No matches found')" diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 1075241e172..dc00036864f 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,6 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Project from './project'; new Project(); // eslint-disable-line no-new -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js index 244d1d5590e..6e3e1a35bd2 100644 --- a/app/assets/javascripts/pages/projects/init_blob.js +++ b/app/assets/javascripts/pages/projects/init_blob.js @@ -1,3 +1,4 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BlobForkSuggestion from '~/blob/blob_fork_suggestion'; @@ -18,10 +19,8 @@ export default () => { const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); - new ShortcutsNavigation(); // eslint-disable-line no-new - - // eslint-disable-next-line no-new - new ShortcutsBlob({ + addShortcutsExtension(ShortcutsNavigation); + addShortcutsExtension(ShortcutsBlob, { fileBlobPermalinkUrl, fileBlobPermalinkUrlElement, }); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index b320d8a61c2..322eaa845ec 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,6 +1,7 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues/list'; mountIssuesListApp(); mountJiraIssuesListApp(); -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); 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 3ae8018714a..a37c18e41ab 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,4 +1,5 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; @@ -16,7 +17,7 @@ initFilteredSearch({ useDefaultState: true, }); -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); initIssuableByEmail(); initCsvImportExportButtons(); 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 599fd225de9..0e66c3521dd 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 @@ -3,12 +3,13 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import LabelsSelect from '~/labels/labels_select'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; export default () => { - new ShortcutsNavigation(); + addShortcutsExtension(ShortcutsNavigation); new IssuableForm($('.merge-request-form')); IssuableLabelSelector(); new LabelsSelect(); 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 af1635221ab..1cac330520f 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 @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { s__ } from '~/locale'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; import { initIssuableSidebar } from '~/issuable'; @@ -18,7 +19,7 @@ import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow(store) { new ZenMode(); // eslint-disable-line no-new initPipelineCountListener(document.querySelector('#commit-pipeline-table-view')); - new ShortcutsIssuable(true); // eslint-disable-line no-new + addShortcutsExtension(ShortcutsIssuable); initSourcegraph(); initIssuableSidebar(); initAwardsApp(document.getElementById('js-vue-awards-block')); diff --git a/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js b/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js index 1a2b85d7e16..7202dcccd31 100644 --- a/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js @@ -1,4 +1,4 @@ import { initSimpleApp } from '~/helpers/init_simple_app_helper'; import { ShowMlModelVersion } from '~/ml/model_registry/apps'; -initSimpleApp('#js-mount-show-ml-model-version', ShowMlModelVersion); +initSimpleApp('#js-mount-show-ml-model-version', ShowMlModelVersion, { withApolloProvider: true }); diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index a669ea5baaf..58b703bdfda 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNetwork from '~/behaviors/shortcuts/shortcuts_network'; import RefSelector from '~/ref/components/ref_selector.vue'; import Network from '../network'; @@ -44,6 +45,5 @@ initRefSwitcher(); commit_id: $('.network-graph').attr('data-commit-id'), }); - // eslint-disable-next-line no-new - new ShortcutsNetwork(networkGraph.branch_graph); + addShortcutsExtension(ShortcutsNetwork, networkGraph.branch_graph); })(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 9c4582ece21..ff2ece99f87 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -2,7 +2,6 @@ import { GlFormRadio, GlFormRadioGroup, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; const KEY_EVERY_DAY = 'everyDay'; @@ -10,6 +9,12 @@ const KEY_EVERY_WEEK = 'everyWeek'; const KEY_EVERY_MONTH = 'everyMonth'; const KEY_CUSTOM = 'custom'; +const MINUTE = 60; // minute between 0-59 +const HOUR = 24; // hour between 0-23 +const WEEKDAY_INDEX = 7; // week index Sun-Sat +const DAY = 29; // day between 0-28 +const getRandomCronValue = (max) => Math.floor(Math.random() * max); + export default { components: { GlFormRadio, @@ -20,7 +25,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { initialCronInterval: { type: String, @@ -41,9 +45,10 @@ export default { data() { return { isEditingCustom: false, - randomHour: this.generateRandomHour(), - randomWeekDayIndex: this.generateRandomWeekDayIndex(), - randomDay: this.generateRandomDay(), + randomMinute: getRandomCronValue(MINUTE), + randomHour: getRandomCronValue(HOUR), + randomWeekDayIndex: getRandomCronValue(WEEKDAY_INDEX), + randomDay: getRandomCronValue(DAY), inputNameAttribute: 'schedule[cron]', radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, @@ -53,19 +58,22 @@ export default { computed: { cronIntervalPresets() { return { - [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`, - [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, - [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`, + [KEY_EVERY_DAY]: `${this.randomMinute} ${this.randomHour} * * *`, + [KEY_EVERY_WEEK]: `${this.randomMinute} ${this.randomHour} * * ${this.randomWeekDayIndex}`, + [KEY_EVERY_MONTH]: `${this.randomMinute} ${this.randomHour} ${this.randomDay} * *`, }; }, + formattedMinutes() { + return String(this.randomMinute).padStart(2, '0'); + }, formattedTime() { if (this.randomHour > 12) { - return `${this.randomHour - 12}:00pm`; + return `${this.randomHour - 12}:${this.formattedMinutes}pm`; } if (this.randomHour === 12) { - return `12:00pm`; + return `12:${this.formattedMinutes}pm`; } - return `${this.randomHour}:00am`; + return `${this.randomHour}:${this.formattedMinutes}am`; }, radioOptions() { return [ @@ -133,15 +141,6 @@ export default { onCustomInput() { this.radioValue = KEY_CUSTOM; }, - generateRandomHour() { - return Math.floor(Math.random() * 23); - }, - generateRandomWeekDayIndex() { - return Math.floor(Math.random() * 6); - }, - generateRandomDay() { - return Math.floor(Math.random() * 28); - }, showDailyLimitMessage({ value }) { return value === KEY_CUSTOM && this.dailyLimit; }, diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue index ed5ba3c2653..32775ac553c 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue @@ -1,50 +1,58 @@ <script> -import { GlBadge, GlLink, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui'; -import { createAlert, VARIANT_INFO } from '~/alert'; +import { GlLink, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui'; +import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; +import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue'; import getCiCatalogSettingsQuery from '../graphql/queries/get_ci_catalog_settings.query.graphql'; import catalogResourcesCreate from '../graphql/mutations/catalog_resources_create.mutation.graphql'; +import catalogResourcesDestroy from '../graphql/mutations/catalog_resources_destroy.mutation.graphql'; -export const i18n = { - badgeText: __('Experiment'), +const i18n = { catalogResourceQueryError: s__( 'CiCatalog|There was a problem fetching the CI/CD Catalog setting.', ), - catalogResourceMutationError: s__( - 'CiCatalog|There was a problem marking the project as a CI/CD Catalog resource.', + setCatalogResourceMutationError: s__( + 'CiCatalog|Unable to set project as a CI/CD Catalog resource.', + ), + removeCatalogResourceMutationError: s__( + 'CiCatalog|Unable to remove project as a CI/CD Catalog resource.', + ), + setCatalogResourceMutationSuccess: s__('CiCatalog|This project is now a CI/CD Catalog resource.'), + removeCatalogResourceMutationSuccess: s__( + 'CiCatalog|This project is no longer a CI/CD Catalog resource.', ), - catalogResourceMutationSuccess: s__('CiCatalog|This project is now a CI/CD Catalog resource.'), ciCatalogLabel: s__('CiCatalog|CI/CD Catalog resource'), ciCatalogHelpText: s__( - 'CiCatalog|Mark project as a CI/CD Catalog resource. %{linkStart}What is the CI/CD Catalog?%{linkEnd}', + 'CiCatalog|Set project as a CI/CD Catalog resource. %{linkStart}What is the CI/CD Catalog?%{linkEnd}', ), modal: { actionPrimary: { - text: s__('CiCatalog|Mark project as a CI/CD Catalog resource'), + text: s__('CiCatalog|Remove from the CI/CD catalog'), }, actionCancel: { text: __('Cancel'), }, body: s__( - 'CiCatalog|This project will be marked as a CI/CD Catalog resource and will be visible in the CI/CD Catalog. This action is not reversible.', + "CiCatalog|The project and any released versions will be removed from the CI/CD Catalog. If you re-enable this toggle, the project's existing releases are not re-added to the catalog. You must %{linkStart}create a new release%{linkEnd}.", ), - title: s__('CiCatalog|Mark project as a CI/CD Catalog resource'), + title: s__('CiCatalog|Remove project from the CI/CD Catalog?'), }, readMeHelpText: s__( - 'CiCatalog|The project must contain a README.md file and a template.yml file. When enabled, the repository is available in the CI/CD Catalog.', + 'CiCatalog|The project will be findable in the CI/CD Catalog after the project has at least one release.', ), }; -export const ciCatalogHelpPath = helpPagePath('ci/components/index', { +const ciCatalogHelpPath = helpPagePath('ci/components/index', { anchor: 'components-catalog', }); +const releasesHelpPath = helpPagePath('user/project/releases/release_cicd_examples'); + export default { - i18n, components: { - GlBadge, + BetaBadge, GlLink, GlLoadingIcon, GlModal, @@ -59,7 +67,6 @@ export default { }, data() { return { - ciCatalogHelpPath, isCatalogResource: false, showCatalogResourceModal: false, }; @@ -81,19 +88,34 @@ export default { }, }, computed: { + successMessage() { + return this.isCatalogResource + ? this.$options.i18n.setCatalogResourceMutationSuccess + : this.$options.i18n.removeCatalogResourceMutationSuccess; + }, + errorMessage() { + return this.isCatalogResource + ? this.$options.i18n.removeCatalogResourceMutationError + : this.$options.i18n.setCatalogResourceMutationError; + }, isLoading() { return this.$apollo.queries.isCatalogResource.loading; }, }, methods: { - async markProjectAsCatalogResource() { + async toggleCatalogResourceMutation({ isCreating }) { + this.showCatalogResourceModal = false; + + const mutation = isCreating ? catalogResourcesCreate : catalogResourcesDestroy; + const mutationInput = isCreating ? 'catalogResourcesCreate' : 'catalogResourcesDestroy'; + try { const { data: { - catalogResourcesCreate: { errors }, + [mutationInput]: { errors }, }, } = await this.$apollo.mutate({ - mutation: catalogResourcesCreate, + mutation, variables: { input: { projectPath: this.fullPath } }, }); @@ -101,23 +123,30 @@ export default { throw new Error(errors[0]); } - this.isCatalogResource = true; - createAlert({ - message: this.$options.i18n.catalogResourceMutationSuccess, - variant: VARIANT_INFO, - }); + this.isCatalogResource = !this.isCatalogResource; + this.$toast.show(this.successMessage); } catch (error) { - const message = error.message || this.$options.i18n.catalogResourceMutationError; + const message = error.message || this.errorMessage; createAlert({ message }); } }, - onCatalogResourceEnabledToggled() { - this.showCatalogResourceModal = true; - }, onModalCanceled() { this.showCatalogResourceModal = false; }, + onToggleCatalogResource() { + if (this.isCatalogResource) { + this.showCatalogResourceModal = true; + } else { + this.toggleCatalogResourceMutation({ isCreating: true }); + } + }, + unlistCatalogResource() { + this.toggleCatalogResourceMutation({ isCreating: false }); + }, }, + i18n, + ciCatalogHelpPath, + releasesHelpPath, }; </script> @@ -125,40 +154,44 @@ export default { <div> <gl-loading-icon v-if="isLoading" /> <div v-else data-testid="ci-catalog-settings"> - <div> - <label class="gl-mb-1 gl-mr-2"> + <div class="gl-display-flex"> + <label class="gl-mb-1 gl-mr-3"> {{ $options.i18n.ciCatalogLabel }} </label> - <gl-badge size="sm" variant="info"> {{ $options.i18n.badgeText }} </gl-badge> + <beta-badge size="sm" /> </div> <gl-sprintf :message="$options.i18n.ciCatalogHelpText"> <template #link="{ content }"> - <gl-link :href="ciCatalogHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.ciCatalogHelpPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> <gl-toggle class="gl-my-2" - :disabled="isCatalogResource" :value="isCatalogResource" :label="$options.i18n.ciCatalogLabel" label-position="hidden" name="ci_resource_enabled" - @change="onCatalogResourceEnabledToggled" + data-testid="catalog-resource-toggle" + @change="onToggleCatalogResource" /> <div class="gl-text-secondary"> {{ $options.i18n.readMeHelpText }} </div> <gl-modal :visible="showCatalogResourceModal" - modal-id="mark-as-catalog-resource" + modal-id="unlist-catalog-resource" size="sm" :title="$options.i18n.modal.title" :action-cancel="$options.i18n.modal.actionCancel" :action-primary="$options.i18n.modal.actionPrimary" @canceled="onModalCanceled" - @primary="markProjectAsCatalogResource" + @primary="unlistCatalogResource" > - {{ $options.i18n.modal.body }} + <gl-sprintf :message="$options.i18n.modal.body"> + <template #link="{ content }"> + <gl-link :href="$options.releasesHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> </gl-modal> </div> </div> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 6ff48b7de95..a7f685ea8a8 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -16,6 +16,7 @@ import { CVE_ID_REQUEST_BUTTON_I18N, featureAccessLevelDescriptions, modelExperimentsHelpPath, + modelRegistryHelpPath, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; import ProjectFeatureSetting from './project_feature_setting.vue'; @@ -63,6 +64,8 @@ export default { modelExperimentsHelpText: s__( 'ProjectSettings|Track machine learning model experiments and artifacts.', ), + modelRegistryLabel: s__('ProjectSettings|Model registry'), + modelRegistryHelpText: s__('ProjectSettings|Manage machine learning models.'), pagesLabel: s__('ProjectSettings|Pages'), repositoryLabel: s__('ProjectSettings|Repository'), requirementsLabel: s__('ProjectSettings|Requirements'), @@ -83,7 +86,7 @@ export default { VISIBILITY_LEVEL_INTERNAL_INTEGER, VISIBILITY_LEVEL_PUBLIC_INTEGER, modelExperimentsHelpPath, - + modelRegistryHelpPath, components: { CiCatalogSettings, ProjectFeatureSetting, @@ -259,6 +262,7 @@ export default { mergeRequestsAccessLevel: featureAccessLevel.EVERYONE, packageRegistryAccessLevel: featureAccessLevel.EVERYONE, modelExperimentsAccessLevel: featureAccessLevel.EVERYONE, + modelRegistryAccessLevel: featureAccessLevel.EVERYONE, buildsAccessLevel: featureAccessLevel.EVERYONE, wikiAccessLevel: featureAccessLevel.EVERYONE, snippetsAccessLevel: featureAccessLevel.EVERYONE, @@ -411,6 +415,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.modelExperimentsAccessLevel, ); + this.modelRegistryAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.modelRegistryAccessLevel, + ); this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel); this.snippetsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, @@ -475,6 +483,8 @@ export default { this.wikiAccessLevel = featureAccessLevel.EVERYONE; if (this.modelExperimentsAccessLevel > featureAccessLevel.NOT_ENABLED) this.modelExperimentsAccessLevel = featureAccessLevel.EVERYONE; + if (this.modelRegistryAccessLevel > featureAccessLevel.NOT_ENABLED) + this.modelRegistryAccessLevel = featureAccessLevel.EVERYONE; if (this.snippetsAccessLevel > featureAccessLevel.NOT_ENABLED) this.snippetsAccessLevel = featureAccessLevel.EVERYONE; if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS) @@ -571,7 +581,7 @@ export default { :disabled="!canChangeVisibilityLevel" name="project[visibility_level]" class="form-control select-control" - data-qa-selector="project_visibility_dropdown" + data-testid="project-visibility-dropdown" > <option :value="$options.VISIBILITY_LEVEL_PRIVATE_INTEGER" @@ -914,6 +924,19 @@ export default { /> </project-setting-row> <project-setting-row + ref="model-registry-settings" + :label="$options.i18n.modelRegistryLabel" + :help-text="$options.i18n.modelRegistryHelpText" + :help-path="$options.modelRegistryHelpPath" + > + <project-feature-setting + v-model="modelRegistryAccessLevel" + :label="$options.i18n.modelRegistryLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][model_registry_access_level]" + /> + </project-setting-row> + <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" ref="pages-settings" :help-path="pagesHelpPath" @@ -1060,13 +1083,7 @@ export default { data-testid="project-features-save-button" @confirm="$emit('confirm')" /> - <gl-button - v-else - type="submit" - variant="confirm" - data-testid="project-features-save-button" - data-qa-selector="visibility_features_permissions_save_button" - > + <gl-button v-else type="submit" variant="confirm" data-testid="project-features-save-button"> {{ $options.i18n.confirmButtonText }} </gl-button> </div> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index 522cc7cfc1a..125fc279240 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -48,3 +48,5 @@ export const CVE_ID_REQUEST_BUTTON_I18N = { export const modelExperimentsHelpPath = helpPagePath( 'user/project/ml/experiment_tracking/index.md', ); + +export const modelRegistryHelpPath = helpPagePath('user/project/ml/model_registry/index.md'); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql new file mode 100644 index 00000000000..fa42b081a5f --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.mutation.graphql @@ -0,0 +1,5 @@ +mutation catalogResourcesDestroy($input: CatalogResourcesDestroyInput!) { + catalogResourcesDestroy(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js index 8ceea37b701..e4a4e2c00eb 100644 --- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -18,7 +18,7 @@ export default ({ el, router }) => { const { projectPath, ref, isBlob, webIdeUrl, ...options } = convertObjectPropsToCamelCase( JSON.parse(el.dataset.options), ); - const { webIdePromoPopoverImg } = el.dataset; + const { webIdePromoPopoverImg, cssClasses } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -40,6 +40,7 @@ export default ({ el, router }) => { joinPaths('/', projectPath, 'edit', ref, '-', this.$route?.params.path || '', '/'), ), projectPath, + cssClasses, ...options, }, }); diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 98c58515d24..150c702f1fe 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,3 +1,4 @@ +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert'; import leaveByUrl from '~/namespaces/leave_by_url'; @@ -8,6 +9,7 @@ import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; import initForksButton from '~/forks/init_forks_button'; import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; +import InitMoreActionsDropdown from '~/groups_projects/init_more_actions_dropdown'; // Project show page loads different overview content based on user preferences if (document.getElementById('js-tree-list')) { @@ -34,11 +36,9 @@ if (document.querySelector('.project-show-activity')) { .catch(() => {}); } -leaveByUrl('project'); - initVueNotificationsDropdown(); -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); initUploadFileTrigger(); initClustersDeprecationAlert(); @@ -61,3 +61,5 @@ if (document.querySelector('.js-autodevops-banner')) { } initForksButton(); +InitMoreActionsDropdown(); +leaveByUrl('project'); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index d87f8898c63..edecb798686 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import initTree from 'ee_else_ce/repository'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NewCommitForm from '~/new_commit_form'; import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; @@ -7,4 +8,4 @@ import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new initTree(); initAmbiguousRefModal(); -new ShortcutsNavigation(); // eslint-disable-line no-new +addShortcutsExtension(ShortcutsNavigation); diff --git a/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js b/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js deleted file mode 100644 index 47aae36ecbb..00000000000 --- a/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js +++ /dev/null @@ -1,44 +0,0 @@ -function onSidebarLinkClick() { - const setDataTrackAction = (element, action) => { - element.dataset.trackAction = action; - }; - - const setDataTrackExtra = (element, value) => { - const SIDEBAR_COLLAPSED = 'Collapsed'; - const SIDEBAR_EXPANDED = 'Expanded'; - const sidebarCollapsed = document - .querySelector('.nav-sidebar') - .classList.contains('js-sidebar-collapsed') - ? SIDEBAR_COLLAPSED - : SIDEBAR_EXPANDED; - - element.dataset.trackExtra = JSON.stringify({ - sidebar_display: sidebarCollapsed, - menu_display: value, - }); - }; - - const EXPANDED = 'Expanded'; - const FLY_OUT = 'Fly out'; - const CLICK_MENU_ACTION = 'click_menu'; - const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; - const parentElement = this.parentNode; - const subMenuList = parentElement.closest('.sidebar-sub-level-items'); - - if (subMenuList) { - const isFlyOut = subMenuList.classList.contains('fly-out-list') ? FLY_OUT : EXPANDED; - - setDataTrackExtra(parentElement, isFlyOut); - setDataTrackAction(parentElement, CLICK_MENU_ITEM_ACTION); - } else { - const isFlyOut = parentElement.classList.contains('is-showing-fly-out') ? FLY_OUT : EXPANDED; - - setDataTrackExtra(parentElement, isFlyOut); - setDataTrackAction(parentElement, CLICK_MENU_ACTION); - } -} -export const initSidebarTracking = () => { - document.querySelectorAll('.nav-sidebar li[data-track-label] > a').forEach((link) => { - link.addEventListener('click', onSidebarLinkClick); - }); -}; diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 3c070d2708d..de85420a976 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue @@ -43,8 +43,7 @@ export default { text: this.$options.i18n.deletePageText, attributes: { variant: 'danger', - 'data-qa-selector': 'confirm_deletion_button', - 'data-testid': 'confirm_deletion_button', + 'data-testid': 'confirm-deletion-button', }, }; }, @@ -77,7 +76,7 @@ export default { v-gl-modal="$options.modal.modalId" category="secondary" variant="danger" - data-qa-selector="delete-button" + data-testid="delete-button" > {{ $options.i18n.deletePageText }} </gl-button> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index 8491d667213..0b31c9b0e16 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -88,7 +88,6 @@ export default { v-else-if="!loadingContentFailed && !isLoadingContent" ref="content" v-safe-html="content" - data-qa-selector="wiki_page_content" data-testid="wiki-page-content" class="js-wiki-page-content md" ></div> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue deleted file mode 100644 index 4d13f25c4cb..00000000000 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlDisclosureDropdown } from '@gitlab/ui'; -import { __ } from '~/locale'; -import printMarkdownDom from '~/lib/print_markdown_dom'; - -export default { - components: { - GlDisclosureDropdown, - }, - inject: ['target', 'title', 'stylesheet'], - computed: { - dropdownItems() { - return [ - { - text: __('Print as PDF'), - action: this.print, - }, - ]; - }, - }, - methods: { - print() { - printMarkdownDom({ - target: document.querySelector(this.target), - title: this.title, - stylesheet: this.stylesheet, - }); - }, - }, -}; -</script> -<template> - <gl-disclosure-dropdown - :items="dropdownItems" - icon="ellipsis_v" - category="tertiary" - placement="right" - no-caret - /> -</template> 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 eaa99556994..855c7c4105f 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -187,6 +187,11 @@ export default { return typeof this.drawioUrl === 'string' && this.drawioUrl.length > 0; }, }, + watch: { + title() { + this.updateCommitMessage(); + }, + }, mounted() { if (!this.commitMessage) this.updateCommitMessage(); @@ -302,7 +307,7 @@ export default { /> <div class="row"> - <div class="col-sm-9"> + <div class="col-12"> <gl-form-group :label="$options.i18n.title.label" label-for="wiki_title"> <template #description> <gl-icon class="gl-mr-n1" name="bulb" /> @@ -321,7 +326,6 @@ export default { :required="true" :autofocus="!pageInfo.persisted" :placeholder="$options.i18n.title.placeholder" - @input="updateCommitMessage" /> </gl-form-group> </div> @@ -361,8 +365,8 @@ export default { :drawio-enabled="drawioEnabled" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" - @keydown.ctrl.enter="submitFormShortcut" - @keydown.meta.enter="submitFormShortcut" + @keydown.ctrl.enter="submitFormWithShortcut" + @keydown.meta.enter="submitFormWithShortcut" /> <div class="form-text gl-text-gray-600"> <gl-sprintf @@ -404,7 +408,7 @@ export default { </div> </div> - <div class="form-actions"> + <div class="gl-display-flex gl-gap-3" data-testid="wiki-form-actions"> <gl-button category="primary" variant="confirm" @@ -416,7 +420,6 @@ export default { <gl-button data-testid="wiki-cancel-button" :href="cancelFormPath" - class="float-right" @click="isFormDirty = false" > {{ $options.i18n.cancel }}</gl-button diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_more_dropdown.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_more_dropdown.vue new file mode 100644 index 00000000000..18b11d46bca --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_more_dropdown.vue @@ -0,0 +1,58 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +export default { + components: { + GlDisclosureDropdown, + }, + inject: ['print', 'history'], + computed: { + dropdownItems() { + const items = []; + + if (this.history) { + items.push({ + text: s__('Wiki|Page history'), + href: this.history, + extraAttrs: { + 'data-testid': 'page-history-button', + }, + }); + } + + if (this.print) { + items.push({ + text: __('Print as PDF'), + action: this.printPage, + extraAttrs: { + 'data-testid': 'page-print-button', + }, + }); + } + + return items; + }, + }, + methods: { + printPage() { + printMarkdownDom({ + target: document.querySelector(this.print.target), + title: this.print.title, + stylesheet: this.print.stylesheet, + }); + }, + }, +}; +</script> +<template> + <gl-disclosure-dropdown + :items="dropdownItems" + icon="ellipsis_v" + category="tertiary" + placement="right" + no-caret + data-testid="wiki-more-dropdown" + /> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js index 9bc399d07b3..89ed395f742 100644 --- a/app/assets/javascripts/pages/shared/wikis/show.js +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import Wikis from './wikis'; import WikiContent from './components/wiki_content.vue'; -import WikiExport from './components/wiki_export.vue'; +import WikiMoreDropdown from './components/wiki_more_dropdown.vue'; const mountWikiContentApp = () => { const el = document.querySelector('.js-async-wiki-page-content'); @@ -25,17 +25,16 @@ const mountWikiExportApp = () => { const el = document.querySelector('#js-export-actions'); if (!el) return false; - const { target, title, stylesheet } = JSON.parse(el.dataset.options); + const { history, print } = JSON.parse(el.dataset.options); return new Vue({ el, provide: { - target, - title, - stylesheet, + history, + print, }, render(createElement) { - return createElement(WikiExport); + return createElement(WikiMoreDropdown); }, }); }; diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index b32cc700e16..c98cda92a94 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,5 +1,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; const TRACKING_EVENT_NAME = 'view_wiki_page'; @@ -72,6 +73,6 @@ export default class Wikis { } static initShortcuts() { - new ShortcutsWiki(); // eslint-disable-line no-new + addShortcutsExtension(ShortcutsWiki); } } diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/user_settings/personal_access_tokens/index.js index c520042c172..c520042c172 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/user_settings/personal_access_tokens/index.js diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index ab10283b3c4..f23b6d4596a 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -141,7 +141,7 @@ export default { v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" class="gl-display-flex gl-align-items-baseline view" - data-qa-selector="detailed_metric_content" + data-testid="detailed-metric-content" > <gl-button v-gl-tooltip.viewport diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index c5f8fd1904f..9711610d0e2 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -175,7 +175,7 @@ export default { <div v-if="currentRequest" class="gl-display-flex container-fluid gl-overflow-x-auto" - data-qa-selector="performance_bar" + data-testid="performance-bar" > <div class="gl-display-flex gl-flex-shrink-0 view-performance-container"> <div v-if="hasHost" id="peek-view-host" class="gl-display-flex gl-gap-2 view"> diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 2914b9762ac..b4f2140945f 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -28,13 +28,13 @@ export default { }; </script> <template> - <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view gl-mr-5"> + <div id="peek-request-selector" data-testid="request-dropdown" class="view gl-mr-5"> <gl-form-select v-model="currentRequestId" class="gl-px-3! gl-py-2!"> <option v-for="request in requests" :key="request.id" :value="request.id" - data-qa-selector="request_dropdown_option" + data-testid="request-dropdown-option" > {{ request.displayName }} </option> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 71dc8c3d020..007b1454138 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -22,12 +22,17 @@ export default class PersistentUserCallout { init() { const followLink = this.container.querySelector('.js-follow-link'); + const closeAndFollowLink = this.container.querySelector('.js-close-and-follow-link'); if (this.closeButtons.length) { this.handleCloseButtonCallout(); } else if (followLink) { this.handleFollowLinkCallout(followLink); } + + if (closeAndFollowLink) { + this.handleFollowLinkCallout(closeAndFollowLink); + } } handleCloseButtonCallout() { diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index bba8e1f7ba5..7420542a065 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -4,7 +4,6 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-recovery-settings-callout', '.js-users-over-license-callout', '.js-admin-licensed-user-count-threshold', - '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', '.js-registration-enabled-callout', '.js-new-user-signups-cap-reached', @@ -25,6 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-branch-rules-info-callout', '.js-new-nav-for-everyone-callout', '.js-namespace-over-storage-users-combined-alert', + '.js-code-suggestions-ga-non-owner-alert', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index 915f6578ac3..e9a67a401b8 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -42,7 +42,7 @@ export default { text: __('Delete account'), attributes: { variant: 'danger', - 'data-qa-selector': 'confirm_delete_account_button', + 'data-testid': 'confirm-delete-account-button', category: 'primary', disabled: !this.canSubmit, }, @@ -128,7 +128,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), name="password" class="form-control" type="password" - data-qa-selector="password_confirmation_field" + data-testid="password-confirmation-field" aria-labelledby="input-label" /> <input diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue index 815b8742500..eedb5d7764e 100644 --- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue +++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue @@ -3,7 +3,6 @@ import { nextTick } from 'vue'; import { GlForm, GlButton } from '@gitlab/ui'; import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { readFileAsDataURL } from '~/lib/utils/file_utility'; import SetStatusForm from '~/set_status_modal/set_status_form.vue'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils'; @@ -106,20 +105,12 @@ export default { this.updateProfileSettings = false; } }, - async syncHeaderAvatars() { - const dataURL = await readFileAsDataURL(this.avatarBlob); - - const elements = gon?.use_new_navigation - ? ['[data-testid="user-dropdown"] .gl-avatar'] - : ['.header-user-avatar', '.js-sidebar-user-avatar']; - - elements.forEach((selector) => { - const node = document.querySelector(selector); - if (!node) return; - - node.setAttribute('src', dataURL); - node.setAttribute('srcset', dataURL); - }); + syncHeaderAvatars() { + document.dispatchEvent( + new CustomEvent('userAvatar:update', { + detail: { url: URL.createObjectURL(this.avatarBlob) }, + }), + ); }, onBlobChange(blob) { this.avatarBlob = blob; diff --git a/app/assets/javascripts/profile/edit/components/user_avatar.vue b/app/assets/javascripts/profile/edit/components/user_avatar.vue index f0ff972336b..d69db9f3d6c 100644 --- a/app/assets/javascripts/profile/edit/components/user_avatar.vue +++ b/app/assets/javascripts/profile/edit/components/user_avatar.vue @@ -50,7 +50,7 @@ export default { modalCrop: '.modal-profile-crop', pickImageEl: '.js-choose-user-avatar-button', uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image', + modalCropImg: document.querySelector('.modal-profile-crop-image'), onBlobChange: this.onBlobChange, }; // This has to be used with jQuery, considering migrate that from jQuery to Vue in the future. diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index ea1a5199ece..0c57dc3cbbe 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -1,7 +1,7 @@ -/* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */ +/* eslint-disable no-underscore-dangle, func-names */ import $ from 'jquery'; -import 'cropper'; +import Cropper from 'cropperjs'; import { isString } from 'lodash'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; @@ -9,7 +9,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; (() => { // Matches everything but the file name - const FILENAMEREGEX = /^.*[\\\/]/; + const FILENAMEREGEX = /^.*[\\/]/; class GitLabCrop { constructor( @@ -33,7 +33,6 @@ import { loadCSSFile } from '../lib/utils/css_utils'; this.onModalShow = this.onModalShow.bind(this); this.onPickImageClick = this.onPickImageClick.bind(this); this.fileInput = $(input); - this.modalCropImg = isString(this.modalCropImg) ? $(this.modalCropImg) : this.modalCropImg; this.fileInput .attr('name', `${this.fileInput.attr('name')}-trigger`) .attr('id', `${this.fileInput.attr('id')}-trigger`); @@ -53,9 +52,9 @@ import { loadCSSFile } from '../lib/utils/css_utils'; this.pickImageEl = this.getElement(pickImageEl); this.modalCrop = isString(modalCrop) ? $(modalCrop) : modalCrop; this.uploadImageBtn = isString(uploadImageBtn) ? $(uploadImageBtn) : uploadImageBtn; - this.modalCropImg = isString(modalCropImg) ? $(modalCropImg) : modalCropImg; this.cropActionsBtn = this.modalCrop.find('[data-method]'); this.onBlobChange = onBlobChange; + this.cropperInstance = null; this.bindEvents(); } @@ -78,7 +77,8 @@ import { loadCSSFile } from '../lib/utils/css_utils'; return _this.onActionBtnClick(btn); }); this.onBlobChange(null); - return (this.croppedImageBlob = null); + this.croppedImageBlob = null; + return null; } onPickImageClick() { @@ -87,7 +87,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; onModalShow() { const _this = this; - return this.modalCropImg.cropper({ + this.cropperInstance = new Cropper(this.modalCropImg, { viewMode: 1, center: false, aspectRatio: 1, @@ -104,11 +104,10 @@ import { loadCSSFile } from '../lib/utils/css_utils'; cropBoxResizable: false, toggleDragModeOnDblclick: false, built() { - const $image = $(this); - const container = $image.cropper('getContainerData'); + const container = this.cropperInstance.getContainerData(); const { cropBoxWidth, cropBoxHeight } = _this; - return $image.cropper('setCropBoxData', { + return this.cropperInstance.setCropBoxData({ width: cropBoxWidth, height: cropBoxHeight, left: (container.width - cropBoxWidth) / 2, @@ -119,7 +118,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; } onModalHide() { - this.modalCropImg.attr('src', '').cropper('destroy'); + this.cropperInstance.destroy(); const modalElement = document.querySelector('.modal-profile-crop'); if (modalElement) modalElement.remove(); } @@ -134,9 +133,10 @@ import { loadCSSFile } from '../lib/utils/css_utils'; onActionBtnClick(btn) { const data = $(btn).data(); - if (this.modalCropImg.data('cropper') && data.method) { - return this.modalCropImg.cropper(data.method, data.option); + if (data.method) { + return this.cropperInstance[data.method](data.option); } + return null; } onFileInputChange(e, input) { @@ -146,7 +146,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; readFile(input) { const reader = new FileReader(); reader.onload = () => { - this.modalCropImg.attr('src', reader.result); + this.modalCropImg.setAttribute('src', reader.result); import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal') .then(() => { this.modalCrop.modal('show'); @@ -162,7 +162,7 @@ import { loadCSSFile } from '../lib/utils/css_utils'; return reader.readAsDataURL(input.files[0]); } - dataURLtoBlob(dataURL) { + static dataURLtoBlob(dataURL) { let i = 0; let len = 0; const binary = atob(dataURL.split(',')[1]); @@ -184,14 +184,14 @@ import { loadCSSFile } from '../lib/utils/css_utils'; } setBlob() { - this.dataURL = this.modalCropImg - .cropper('getCroppedCanvas', { + this.dataURL = this.cropperInstance + .getCroppedCanvas({ width: 200, height: 200, }) .toDataURL('image/png'); - this.croppedImageBlob = this.dataURLtoBlob(this.dataURL); + this.croppedImageBlob = GitLabCrop.dataURLtoBlob(this.dataURL); this.onBlobChange(this.croppedImageBlob); return this.croppedImageBlob; } diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 2ccb360c7c1..16f0110a1af 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -23,7 +23,7 @@ export default class Profile { modalCrop: '.modal-profile-crop', pickImageEl: '.js-choose-user-avatar-button', uploadImageBtn: '.js-upload-user-avatar', - modalCropImg: '.modal-profile-crop-image', + modalCropImg: document.querySelector('.modal-profile-crop-image'), }; this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); } @@ -89,12 +89,9 @@ export default class Profile { } updateHeaderAvatar() { - if (gon?.use_new_navigation) { - $('[data-testid="user-dropdown"] .gl-avatar').attr('src', this.avatarGlCrop.dataURL); - } else { - $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); - $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL); - } + const url = URL.createObjectURL(this.avatarGlCrop.getBlob()); + + document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } })); } setRepoRadio() { diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue index a4851b4fe4b..d2901500459 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { createAlert } from '~/alert'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql'; diff --git a/app/assets/javascripts/projects/components/shared/delete_modal.vue b/app/assets/javascripts/projects/components/shared/delete_modal.vue index 44e29d00d45..db2e283f9d2 100644 --- a/app/assets/javascripts/projects/components/shared/delete_modal.vue +++ b/app/assets/javascripts/projects/components/shared/delete_modal.vue @@ -74,7 +74,7 @@ export default { attributes: { variant: 'danger', disabled: this.confirmDisabled, - 'data-qa-selector': 'confirm_delete_button', + 'data-testid': 'confirm-delete-button', }, }, cancel: { @@ -147,7 +147,7 @@ export default { v-model="userInput" name="confirm_name_input" type="text" - data-qa-selector="confirm_name_field" + data-testid="confirm-name-field" /> <slot name="modal-footer"></slot> </div> diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue index e1c8c66a214..d19ec4bcab6 100644 --- a/app/assets/javascripts/projects/details/upload_button.vue +++ b/app/assets/javascripts/projects/details/upload_button.vue @@ -36,7 +36,10 @@ export default { <span> <gl-button v-gl-modal="$options.uploadBlobModalId" + variant="link" icon="upload" + class="stat-link gl-px-0!" + button-text-classes="gl-ml-2" data-testid="upload-file-button" >{{ __('Upload File') }}</gl-button > diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index a841766a93c..7b55fe6c01c 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -2,7 +2,7 @@ import PROJECT_CREATE_FROM_TEMPLATE_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?url'; import PROJECT_CREATE_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?url'; import PROJECT_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?url'; -import PROJECT_RUN_CICD_PIPELINES_SVG_URL from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?url'; +import PROJECT_RUN_CICD_PIPELINES_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-devops-md.svg?url'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 06d96ef7bef..6422b4ac8d8 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -1,14 +1,14 @@ <script> import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { GlColumnChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from '~/lib/dateformat'; import { getDateInPast } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; +import { dateFormats } from '~/analytics/shared/constants'; import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; import { DEFAULT, CHART_CONTAINER_HEIGHT, - CHART_DATE_FORMAT, INNER_CHART_HEIGHT, ONE_WEEK_AGO_DAYS, ONE_MONTH_AGO_DAYS, @@ -51,6 +51,7 @@ export default { components: { GlAlert, GlColumnChart, + GlChartSeriesLabel, GlSkeletonLoader, StatisticsList, CiCdAnalyticsCharts, @@ -67,6 +68,8 @@ export default { failureType: null, analytics: { ...defaultAnalyticsValues }, counts: { ...defaultCountValues }, + tooltipTitle: '', + tooltipContent: [], }; }, apollo: { @@ -248,6 +251,15 @@ export default { ], }; }, + formatTooltipText({ value, seriesData }) { + this.tooltipTitle = value; + this.tooltipContent = seriesData.map(({ seriesId, seriesName, color, value: metric }) => ({ + key: seriesId, + name: seriesName, + color, + value: metric[1], + })); + }, }, successColor: '#366800', chartContainerHeight: CHART_CONTAINER_HEIGHT, @@ -286,9 +298,9 @@ export default { lastYear: __('Last year'), }, get chartRanges() { - const today = dateFormat(new Date(), CHART_DATE_FORMAT); + const today = dateFormat(new Date(), dateFormats.defaultDate); const pastDate = (timeScale) => - dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT); + dateFormat(getDateInPast(new Date(), timeScale), dateFormats.defaultDate); return { lastWeekRange: sprintf(__('%{oneWeekAgo} - %{today}'), { oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS), @@ -335,7 +347,25 @@ export default { <template v-if="!loading"> <hr /> <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4> - <ci-cd-analytics-charts :charts="areaCharts" :chart-options="$options.areaChartOptions" /> + <ci-cd-analytics-charts + :charts="areaCharts" + :chart-options="$options.areaChartOptions" + :format-tooltip-text="formatTooltipText" + > + <template #tooltip-title>{{ tooltipTitle }}</template> + <template #tooltip-content> + <div + v-for="{ key, name, color, value } in tooltipContent" + :key="key" + class="gl-display-flex gl-justify-content-space-between" + > + <gl-chart-series-label class="gl-font-sm gl-mr-7" :color="color"> + {{ name }} + </gl-chart-series-label> + <div class="gl-font-weight-bold">{{ value }}</div> + </div> + </template> + </ci-cd-analytics-charts> </template> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js index dcec77ac6a4..e2fda0d9bf1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/constants.js +++ b/app/assets/javascripts/projects/pipelines/charts/constants.js @@ -12,8 +12,6 @@ export const ONE_MONTH_AGO_DAYS = 31; export const ONE_YEAR_AGO_DAYS = 365; -export const CHART_DATE_FORMAT = 'dd mmm'; - export const DEFAULT = 'default'; export const PARSE_FAILURE = 'parse_failure'; export const LOAD_ANALYTICS_FAILURE = 'load_analytics_failure'; diff --git a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue index f5fb72e84bc..d1c143b96f7 100644 --- a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue +++ b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue @@ -8,6 +8,11 @@ export default { RefSelector, }, props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, persistedDefaultBranch: { type: String, required: true, @@ -26,6 +31,7 @@ export default { </script> <template> <ref-selector + :disabled="disabled" :value="persistedDefaultBranch" class="gl-w-full" :project-id="projectId" diff --git a/app/assets/javascripts/projects/settings/mount_default_branch_selector.js b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js index 611561e38f2..8e64d29e947 100644 --- a/app/assets/javascripts/projects/settings/mount_default_branch_selector.js +++ b/app/assets/javascripts/projects/settings/mount_default_branch_selector.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import DefaultBranchSelector from './components/default_branch_selector.vue'; export default (el) => { @@ -6,13 +7,14 @@ export default (el) => { return null; } - const { projectId, defaultBranch } = el.dataset; + const { projectId, defaultBranch, disabled } = el.dataset; return new Vue({ el, render(createElement) { return createElement(DefaultBranchSelector, { props: { + disabled: parseBoolean(disabled), persistedDefaultBranch: defaultBranch, projectId, }, diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 0a5fa288828..9aca74c9863 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -12,8 +12,8 @@ export const i18n = { statusChecks: s__('BranchRules|%{total} status %{subject}'), approvalRules: s__('BranchRules|%{total} approval %{subject}'), matchingBranches: s__('BranchRules|%{total} matching %{subject}'), - pushAccessLevels: s__('BranchRules|Allowed to merge'), - mergeAccessLevels: s__('BranchRules|Allowed to push and merge'), + pushAccessLevels: s__('BranchRules|Allowed to push and merge'), + mergeAccessLevels: s__('BranchRules|Allowed to merge'), }; export default { diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue index f7a9949db4b..6d5443a5df0 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue @@ -25,6 +25,10 @@ export default { I18N_STATE_VERIFICATION_FINISHED_TOGGLE_HELP, I18N_RESET_BUTTON_LABEL, props: { + incomingEmail: { + type: String, + required: true, + }, customEmail: { type: String, required: true, @@ -128,7 +132,13 @@ export default { <p class="gl-mb-0"> <strong>{{ errorLabel }}</strong> </p> - <p>{{ errorDescription }}</p> + <p> + <gl-sprintf :message="errorDescription"> + <template #incomingEmail> + <code>{{ incomingEmail }}</code> + </template> + </gl-sprintf> + </p> </template> <p>{{ resetNote }}</p> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue index f72aa19bdf2..2fe3ea4215a 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue @@ -216,6 +216,7 @@ export default { <custom-email v-if="customEmail" + :incoming-email="incomingEmail" :custom-email="customEmail" :smtp-address="smtpAddress" :verification-state="verificationState" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 6674937be67..a9dca81b9f4 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -4,13 +4,12 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __, sprintf } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ServiceDeskSetting from './service_desk_setting.vue'; const CustomEmailWrapper = () => import('./custom_email_wrapper.vue'); export default { - serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk.html', { + serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk/configure.html', { anchor: 'use-an-additional-service-desk-alias-email', }), components: { @@ -23,7 +22,6 @@ export default { directives: { SafeHtml, }, - mixins: [glFeatureFlagsMixin()], inject: { initialIsEnabled: { default: false, @@ -55,6 +53,9 @@ export default { projectKey: { default: '', }, + reopenIssueOnExternalParticipantNote: { + default: false, + }, addExternalParticipantsFromCc: { default: false, }, @@ -81,7 +82,7 @@ export default { }, computed: { showCustomEmailWrapper() { - return this.glFeatures.serviceDeskCustomEmail && this.isEnabled && this.isIssueTrackerEnabled; + return this.isEnabled && this.isIssueTrackerEnabled; }, }, methods: { @@ -117,6 +118,7 @@ export default { fileTemplateProjectId, outgoingName, projectKey, + reopenIssueOnExternalParticipantNote, addExternalParticipantsFromCc, }) { this.isTemplateSaving = true; @@ -125,6 +127,7 @@ export default { issue_template_key: selectedTemplate, outgoing_name: outgoingName, project_key: projectKey, + reopen_issue_on_external_participant_note: reopenIssueOnExternalParticipantNote, add_external_participants_from_cc: addExternalParticipantsFromCc, service_desk_enabled: this.isEnabled, file_template_project_id: fileTemplateProjectId, @@ -197,6 +200,7 @@ export default { :initial-selected-file-template-project-id="selectedFileTemplateProjectId" :initial-outgoing-name="outgoingName" :initial-project-key="projectKey" + :initial-reopen-issue-on-external-participant-note="reopenIssueOnExternalParticipantNote" :initial-add-external-participants-from-cc="addExternalParticipantsFromCc" :templates="templates" :is-template-saving="isTemplateSaving" 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 5febb6ff0aa..2853a7d8d72 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 @@ -23,6 +23,12 @@ export default { issueTrackerEnableMessage: __( 'To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}.', ), + reopenIssueOnExternalParticipantNote: { + label: s__('ServiceDesk|Reopen issues when an external participant comments'), + help: s__( + 'ServiceDesk|This also adds an internal comment that mentions the assignees of the issue.', + ), + }, addExternalParticipantsFromCc: { label: s__('ServiceDesk|Add external participants from the %{codeStart}Cc%{codeEnd} header'), help: s__( @@ -91,6 +97,11 @@ export default { required: false, default: '', }, + initialReopenIssueOnExternalParticipantNote: { + type: Boolean, + required: false, + default: false, + }, initialAddExternalParticipantsFromCc: { type: Boolean, required: false, @@ -113,6 +124,7 @@ export default { selectedFileTemplateProjectId: this.initialSelectedFileTemplateProjectId, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, + reopenIssueOnExternalParticipantNote: this.initialReopenIssueOnExternalParticipantNote, addExternalParticipantsFromCc: this.initialAddExternalParticipantsFromCc, searchTerm: '', projectKeyError: null, @@ -132,12 +144,12 @@ export default { return this.serviceDeskEmail && this.serviceDeskEmail !== this.incomingEmail; }, emailSuffixHelpUrl() { - return helpPagePath('user/project/service_desk.html', { + return helpPagePath('user/project/service_desk/configure.html', { anchor: 'configure-a-suffix-for-service-desk-alias-email', }); }, serviceDeskEmailAddressHelpUrl() { - return helpPagePath('user/project/service_desk.html', { + return helpPagePath('user/project/service_desk/configure.html', { anchor: 'use-an-additional-service-desk-alias-email', }); }, @@ -156,6 +168,7 @@ export default { selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, + reopenIssueOnExternalParticipantNote: this.reopenIssueOnExternalParticipantNote, addExternalParticipantsFromCc: this.addExternalParticipantsFromCc, fileTemplateProjectId: this.selectedFileTemplateProjectId, }); @@ -323,9 +336,22 @@ export default { </gl-form-group> <gl-form-checkbox + v-model="reopenIssueOnExternalParticipantNote" + :disabled="!isIssueTrackerEnabled" + data-testid="reopen-issue-on-external-participant-note" + > + {{ $options.i18n.reopenIssueOnExternalParticipantNote.label }} + + <template #help> + {{ $options.i18n.reopenIssueOnExternalParticipantNote.help }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox v-if="showAddExternalParticipantsFromCC" v-model="addExternalParticipantsFromCc" :disabled="!isIssueTrackerEnabled" + data-testid="add-external-participants-from-cc" > <gl-sprintf :message="$options.i18n.addExternalParticipantsFromCc.label"> <template #code="{ content }"> diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js index 8ac186e292c..a38da7add03 100644 --- a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js +++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js @@ -112,7 +112,7 @@ export const I18N_ERROR_SMTP_HOST_ISSUE_DESC = s__( ); export const I18N_ERROR_INVALID_CREDENTIALS_LABEL = s__('ServiceDesk|Invalid credentials'); export const I18N_ERROR_INVALID_CREDENTIALS_DESC = s__( - 'ServiceDesk|The given credentials (username and password) were rejected by the SMTP server.', + 'ServiceDesk|The given credentials (username and password) were rejected by the SMTP server, or you need to explicitly set an authentication method.', ); export const I18N_ERROR_MAIL_NOT_RECEIVED_IN_TIMEFRAME_LABEL = s__( 'ServiceDesk|Verification email not received within timeframe', @@ -128,6 +128,16 @@ export const I18N_ERROR_INCORRECT_TOKEN_LABEL = s__('ServiceDesk|Incorrect verif export const I18N_ERROR_INCORRECT_TOKEN_DESC = s__( "ServiceDesk|The received email didn't contain the verification token that was sent to your email address.", ); +export const I18N_ERROR_READ_TIMEOUT_LABEL = s__('ServiceDesk|Read timeout'); +export const I18N_ERROR_READ_TIMEOUT_DESC = s__( + 'ServiceDesk|The SMTP server did not respond in time.', +); +export const I18N_ERROR_INCORRECT_FORWARDING_TARGET_LABEL = s__( + 'ServiceDesk|Incorrect forwarding target', +); +export const I18N_ERROR_INCORRECT_FORWARDING_TARGET_DESC = s__( + 'ServiceDesk|Forward all emails to the custom email address to %{incomingEmail}.', +); export const I18N_VERIFICATION_ERRORS = { smtp_host_issue: { @@ -150,4 +160,12 @@ export const I18N_VERIFICATION_ERRORS = { label: I18N_ERROR_INCORRECT_TOKEN_LABEL, description: I18N_ERROR_INCORRECT_TOKEN_DESC, }, + read_timeout: { + label: I18N_ERROR_READ_TIMEOUT_LABEL, + description: I18N_ERROR_READ_TIMEOUT_DESC, + }, + incorrect_forwarding_target: { + label: I18N_ERROR_INCORRECT_FORWARDING_TARGET_LABEL, + description: I18N_ERROR_INCORRECT_FORWARDING_TARGET_DESC, + }, }; diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index ce223b349bf..a3c310ec501 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -21,6 +21,7 @@ export default () => { incomingEmail, outgoingName, projectKey, + reopenIssueOnExternalParticipantNote, addExternalParticipantsFromCc, selectedTemplate, selectedFileTemplateProjectId, @@ -40,6 +41,7 @@ export default () => { isIssueTrackerEnabled: parseBoolean(issueTrackerEnabled), outgoingName, projectKey, + reopenIssueOnExternalParticipantNote: parseBoolean(reopenIssueOnExternalParticipantNote), addExternalParticipantsFromCc: parseBoolean(addExternalParticipantsFromCc), selectedTemplate, selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null, diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue index 074cddac422..7286f91d807 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue @@ -4,7 +4,7 @@ import Visibility from 'visibilityjs'; import { createAlert } from '~/alert'; import Poll from '~/lib/utils/poll'; import { __, s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import CommitPipelineService from '../services/commit_pipeline_service'; export default { @@ -92,7 +92,7 @@ export default { </script> <template> <div class="gl-ml-5"> - <gl-loading-icon v-if="isLoading" size="lg" label="Loading pipeline status" /> + <gl-loading-icon v-if="isLoading" size="sm" label="Loading pipeline status" /> <a v-else :href="ciStatus.details_path"> <ci-icon :status="ciStatus" :title="statusTitle" :aria-label="statusTitle" /> </a> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index ed9fd521e67..0d6b19829f2 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -28,12 +28,17 @@ export default { }, inheritAttrs: false, props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, enabledRefTypes: { type: Array, required: false, default: () => ALL_REF_TYPES, validator: (val) => - // It has to be an arrray + // It has to be an array isArray(val) && // with at least one item val.length > 0 && @@ -234,6 +239,10 @@ export default { this.debouncedSearch(); }, selectRef(ref) { + if (this.disabled) { + return; + } + this.setSelectedRef(ref); this.$emit('input', this.selectedRef); }, @@ -262,6 +271,7 @@ export default { :toggle-class="extendedToggleButtonClass" :toggle-text="buttonText" :icon="dropdownIcon" + :disabled="disabled" v-bind="$attrs" v-on="$listeners" @hidden="$emit('hide')" diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index df9f333afe5..228007dd7d6 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -176,7 +176,10 @@ export default { </p> <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> <tag-field /> - <gl-form-group :label="__('Release title')"> + <gl-form-group + :label="__('Release title')" + :description="s__('Release|Leave blank to use the tag name as the release title.')" + > <gl-form-input id="release-title" ref="releaseTitleInput" @@ -194,12 +197,16 @@ export default { :extra-links="milestoneComboboxExtraLinks" /> </gl-form-group> - <gl-form-group :label="__('Release date')" label-for="release-released-at"> - <template #label-description> + <gl-form-group + :label="__('Release date')" + :label-description="__('The date when the release is ready.')" + label-for="release-released-at" + > + <template #description> <gl-sprintf :message=" __( - 'The date when the release is ready. A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}.', + 'A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}.', ) " > diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index 1e0de045d39..44113788716 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -31,13 +31,14 @@ const fetchData = (projectPath, path, ref, offset, refType) => { fetchedBatches.push(offset); + const encodePathFunc = gon.features.encodingLogsTree ? encodeURI : encodeURIComponent; const url = joinPaths( gon.relative_url_root || '/', projectPath, '/-/refs/', - encodeURIComponent(ref), + encodePathFunc(ref), '/logs_tree/', - encodeURIComponent(removeLeadingSlash(path)), + encodePathFunc(removeLeadingSlash(path)), ); return axios diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 97a1cbda5d0..4ec57676b79 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -79,7 +79,7 @@ export default { const urlHash = getLocationHash(); // If there is a code line hash in the URL we render with the simple viewer const useSimpleViewer = usePlain || urlHash?.startsWith('L') || !this.hasRichViewer; - this.initHighlightWorker(this.blobInfo); + this.initHighlightWorker(this.blobInfo, this.isUsingLfs); this.switchViewer(useSimpleViewer ? SIMPLE_BLOB_VIEWER : RICH_BLOB_VIEWER); // By default, if present, use the rich viewer to render }, error() { @@ -204,7 +204,8 @@ export default { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; }, isBlameEnabled() { - return this.glFeatures.blobBlameInfo && this.blobInfo.language === 'json'; // This feature is currently scoped to JSON files + // Blame information within the blob viewer is not yet supported in our fallback (HAML) viewers + return this.glFeatures.blobBlameInfo && !this.useFallback; }, }, watch: { @@ -295,7 +296,14 @@ export default { }, handleToggleBlame() { this.switchViewer(SIMPLE_BLOB_VIEWER); - this.showBlame = !this.showBlame; + + if (this.$route?.query?.plain === '0') { + // If the user is not viewing plain code and clicks the blame button, we always want to show blame info + // For instance, when viewing the rendered version of a Markdown file + this.showBlame = true; + } else { + this.showBlame = !this.showBlame; + } const blame = this.showBlame === true ? '1' : '0'; if (this.$route?.query?.blame === blame) return; diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue index 4730c9575da..c64100f4f36 100644 --- a/app/assets/javascripts/repository/components/blob_controls.vue +++ b/app/assets/javascripts/repository/components/blob_controls.vue @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import { createAlert } from '~/alert'; import getRefMixin from '~/repository/mixins/get_ref'; import initSourcegraph from '~/sourcegraph'; +import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; import { updateElementsVisibility } from '../utils/dom'; @@ -105,8 +106,7 @@ export default { ); const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); - // eslint-disable-next-line no-new - new ShortcutsBlob({ + addShortcutsExtension(ShortcutsBlob, { fileBlobPermalinkUrl, fileBlobPermalinkUrlElement, }); @@ -124,7 +124,7 @@ export default { </script> <template> - <div v-if="showBlobControls" class="gl-display-flex gl-gap-3"> + <div v-if="showBlobControls" class="gl-display-flex gl-gap-3 gl-align-items-baseline"> <gl-button data-testid="find" :href="blobInfo.findFilePath" :class="$options.buttonClassList"> {{ $options.i18n.findFile }} </gl-button> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index d434700b29f..016f7f9fe43 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -1,4 +1,4 @@ -import { TEXT_FILE_TYPE, JSON_LANGUAGE } from '../../constants'; +import { TEXT_FILE_TYPE } from '../../constants'; export const viewers = { csv: () => import('./csv_viewer.vue'), @@ -17,12 +17,10 @@ export const viewers = { geo_json: () => import('./geo_json/geo_json_viewer.vue'), }; -export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled, language) => { +export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled) => { let viewer = viewers[type]; - if (hljsWorkerEnabled && language === JSON_LANGUAGE && type === TEXT_FILE_TYPE) { - // The New Source Viewer currently only supports JSON files. - // More language support will be added in: https://gitlab.com/gitlab-org/gitlab/-/issues/415753 + if (hljsWorkerEnabled && type === TEXT_FILE_TYPE) { viewer = () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue'); } diff --git a/app/assets/javascripts/repository/components/commit_info.vue b/app/assets/javascripts/repository/components/commit_info.vue index b6674114a20..319ce2cea84 100644 --- a/app/assets/javascripts/repository/components/commit_info.vue +++ b/app/assets/javascripts/repository/components/commit_info.vue @@ -77,25 +77,32 @@ export default { :size="32" /> <div class="commit-detail flex-list gl-display-flex gl-flex-grow-1 gl-min-w-0"> - <div class="commit-content gl-w-full gl-text-truncate" data-testid="commit-content"> - <gl-link - v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" - :href="commit.webPath" - :class="{ 'gl-font-style-italic': !commit.message }" - class="commit-row-message item-title" - /> - <gl-button - v-if="commit.descriptionHtml" - v-gl-tooltip - :class="{ open: showDescription }" - :title="$options.i18n.toggleCommitDescription" - :aria-label="$options.i18n.toggleCommitDescription" - :selected="showDescription" - class="text-expander gl-vertical-align-bottom!" - icon="ellipsis_h" - @click="toggleShowDescription" - /> - <div class="committer"> + <div + class="commit-content gl-w-full gl-display-inline-flex gl-flex-wrap gl-align-items-baseline" + data-testid="commit-content" + > + <div + class="gl-flex-basis-full gl-display-inline-flex gl-align-items-center gl-column-gap-3" + > + <gl-link + v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" + :href="commit.webPath" + :class="{ 'gl-font-style-italic': !commit.message }" + class="commit-row-message item-title gl-line-clamp-1 gl-word-break-all!" + /> + <gl-button + v-if="commit.descriptionHtml" + v-gl-tooltip + :class="{ open: showDescription }" + :title="$options.i18n.toggleCommitDescription" + :aria-label="$options.i18n.toggleCommitDescription" + :selected="showDescription" + class="text-expander gl-ml-0!" + icon="ellipsis_h" + @click="toggleShowDescription" + /> + </div> + <div class="committer gl-flex-basis-full"> <gl-link v-if="commit.author" :href="commit.author.webPath" diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 079d4c522a8..8d4c4384e1d 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -269,6 +269,7 @@ export default { :invalid-feedback="form.fields['commit_message'].feedback" > <gl-form-textarea + id="commit_message" ref="message" v-model="form.fields['commit_message'].value" v-validation:[form.showValidation] @@ -289,6 +290,7 @@ export default { :invalid-feedback="form.fields['branch_name'].feedback" > <gl-form-input + id="branch_name" v-model="form.fields['branch_name'].value" v-validation:[form.showValidation] :state="form.fields['branch_name'].state" diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 7f7a76cd4aa..b85a4138ef1 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlButton, GlButtonGroup, GlLoadingIcon } from '@git import SafeHtml from '~/vue_shared/directives/safe_html'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import SignatureBadge from '~/commit/components/signature_badge.vue'; import getRefMixin from '../mixins/get_ref'; @@ -106,7 +106,7 @@ export default { </script> <template> - <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="m-auto" /> + <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <commit-info v-else-if="commit" :commit="commit"> <div class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 3da7daa3eec..912cc4d2b1c 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -81,22 +81,27 @@ export default { return ['', '/'].indexOf(this.path) === -1; }, }, - watch: { - $route: function routeChange() { - this.$options.totalRowsLoaded = -1; - }, - }, - totalRowsLoaded: -1, methods: { showMore() { this.$emit('showMore'); }, - generateRowNumber(path, id, index) { - const key = `${path}-${id}-${index}`; + generateRowNumber(entry, index) { + const { flatPath, id } = entry; + const key = `${flatPath}-${id}-${index}`; + + // We adjust the offset that we request based on the type of entry + const numTrees = this.entries?.trees?.length || 0; + const numBlobs = this.entries?.blobs?.length || 0; if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) { - this.$options.totalRowsLoaded += 1; - this.rowNumbers[key] = this.$options.totalRowsLoaded; + if (entry.type === 'commit') { + // submodules are rendered before blobs but are in the last pages the api response + this.rowNumbers[key] = numTrees + numBlobs + index; + } else if (entry.type === 'blob') { + this.rowNumbers[key] = numTrees + index; + } else { + this.rowNumbers[key] = index; + } } return this.rowNumbers[key]; @@ -145,7 +150,7 @@ export default { :lfs-oid="entry.lfsOid" :loading-path="loadingPath" :total-entries="totalEntries" - :row-number="generateRowNumber(entry.flatPath, entry.id, index)" + :row-number="generateRowNumber(entry, index)" :commit-info="getCommit(entry.name)" v-on="$listeners" /> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index dd2cfddc94e..49d9e09dde5 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -165,12 +165,8 @@ export default { 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); - } + // Assume we are loading from the top and greedily choose offsets in multiples of COMMIT_BATCH_SIZE to minimize number of requests + this.loadCommitData(rowNumber - (rowNumber % COMMIT_BATCH_SIZE)); }, loadCommitData(rowNumber) { loadCommits(this.projectPath, this.path, this.ref, rowNumber, this.refType) diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js index 5b6f68681bb..422a84dff40 100644 --- a/app/assets/javascripts/repository/mixins/highlight_mixin.js +++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js @@ -3,11 +3,14 @@ import { EVENT_ACTION, EVENT_LABEL_FALLBACK, LINES_PER_CHUNK, + ROUGE_TO_HLJS_LANGUAGE_MAP, } from '~/vue_shared/components/source_viewer/constants'; import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; import LineHighlighter from '~/blob/line_highlighter'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import Tracking from '~/tracking'; +import axios from '~/lib/utils/axios_utils'; +import { TEXT_FILE_TYPE } from '../constants'; /* * This mixin is intended to be used as an interface between our highlight worker and Vue components @@ -27,8 +30,9 @@ export default { this.track(EVENT_ACTION, { label, property: language }); }, isUnsupportedLanguage(language) { + const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; const supportedLanguages = Object.keys(languageLoader); - const isUnsupportedLanguage = !supportedLanguages.includes(language); + const isUnsupportedLanguage = !supportedLanguages.includes(mappedLanguage); return LEGACY_FALLBACKS.includes(language) || isUnsupportedLanguage; }, @@ -36,14 +40,29 @@ export default { this.trackEvent(EVENT_LABEL_FALLBACK, language); this?.onError(); }, - initHighlightWorker({ rawTextBlob, language, fileType }) { - if (language !== 'json' || !this.glFeatures.highlightJsWorker) return; + async handleLFSBlob(externalStorageUrl, rawPath, language) { + await axios + .get(externalStorageUrl || rawPath) + .then(({ data }) => this.instructWorker(data, language)) + .catch(() => this.$emit('error')); + }, + initHighlightWorker(blob, isUsingLfs) { + const { rawTextBlob, language, fileType, externalStorageUrl, rawPath, simpleViewer } = blob; + + if (!this.glFeatures.highlightJsWorker || simpleViewer?.fileType !== TEXT_FILE_TYPE) return; if (this.isUnsupportedLanguage(language)) { this.handleUnsupportedLanguage(language); return; } + this.highlightWorker.onmessage = this.handleWorkerMessage; + + if (isUsingLfs) { + this.handleLFSBlob(externalStorageUrl, rawPath, language); + return; + } + /* * We want to start rendering content as soon as possible, but highlighting large amounts of * content can take long, so we render the content in phases: @@ -64,8 +83,6 @@ export default { this.chunks = splitIntoChunks(language, firstSeventyLines); - this.highlightWorker.onmessage = this.handleWorkerMessage; - // Instruct the worker to highlight the first 70 lines ASAP, this improves perceived performance. this.instructWorker(firstSeventyLines, language); diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index f83130213f2..5414e712912 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,22 +1,69 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; import syntaxHighlight from '~/syntax_highlight'; -import { initSidebar, sidebarInitState } from './sidebar'; +import { initSidebar } from './sidebar'; import { initSearchSort } from './sort'; import createStore from './store'; import { initTopbar } from './topbar'; import { initBlobRefSwitcher } from './under_topbar'; +const sidebarInitState = () => { + const el = document.getElementById('js-search-sidebar'); + if (!el) return {}; + + const { + navigationJson, + searchType, + searchLevel, + groupInitialJson, + projectInitialJson, + } = el.dataset; + + const navigationJsonParsed = JSON.parse(navigationJson); + const groupInitialJsonParsed = JSON.parse(groupInitialJson); + const projectInitialJsonParsed = JSON.parse(projectInitialJson); + + return { + navigationJsonParsed, + searchType, + searchLevel, + groupInitialJsonParsed, + projectInitialJsonParsed, + }; +}; + +const topBarInitState = () => { + const el = document.getElementById('js-search-topbar'); + + if (!el) { + return false; + } + + const { defaultBranchName } = el.dataset; + return { defaultBranchName }; +}; + export const initSearchApp = () => { syntaxHighlight(document.querySelectorAll('.js-search-results')); const query = queryToObject(window.location.search, { gatherArrays: true }); - const { navigationJsonParsed: navigation, searchType } = sidebarInitState() || {}; + const { + navigationJsonParsed: navigation, + searchType, + searchLevel, + groupInitialJsonParsed: groupInitialJson, + projectInitialJsonParsed: projectInitialJson, + } = sidebarInitState() || {}; + + const { defaultBranchName } = topBarInitState() || {}; const store = createStore({ query, navigation, - useSidebarNavigation: gon.use_new_navigation, searchType, + searchLevel, + groupInitialJson, + projectInitialJson, + defaultBranchName, }); initTopbar(store); diff --git a/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue b/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue new file mode 100644 index 00000000000..cb017b6898b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/all_scopes_start_filters.vue @@ -0,0 +1,19 @@ +<script> +import GroupFilter from './group_filter.vue'; +import ProjectFilter from './project_filter.vue'; + +export default { + name: 'AllScopesStartFilters', + components: { + GroupFilter, + ProjectFilter, + }, +}; +</script> + +<template> + <div class="gl-px-5 gl-pt-6"> + <group-filter class="gl-mb-5" /> + <project-filter class="gl-mb-5" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 86a5f5107f8..bbee0e441cc 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -2,9 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; -import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; @@ -27,6 +25,7 @@ import NotesFilters from './notes_filters.vue'; import CommitsFilters from './commits_filters.vue'; import MilestonesFilters from './milestones_filters.vue'; import WikiBlobsFilters from './wiki_blobs_filters.vue'; +import AllScopesStartFilters from './all_scopes_start_filters.vue'; export default { name: 'GlobalSearchSidebar', @@ -37,18 +36,23 @@ export default { ProjectsFilters, NotesFilters, WikiBlobsFilters, - ScopeLegacyNavigation, ScopeSidebarNavigation, SidebarPortal, DomElementListener, - SmallScreenDrawerNavigation, CommitsFilters, MilestonesFilters, + AllScopesStartFilters, }, mixins: [glFeatureFlagsMixin()], + props: { + headerText: { + required: false, + type: String, + default: '', + }, + }, computed: { - // useSidebarNavigation refers to whether the new left sidebar navigation is enabled - ...mapState(['useSidebarNavigation', 'searchType']), + ...mapState(['searchType']), ...mapGetters(['currentScope']), showIssuesFilters() { return this.currentScope === SCOPE_ISSUES; @@ -72,16 +76,7 @@ export default { return this.currentScope === SCOPE_MILESTONES; }, showWikiBlobsFilters() { - return ( - this.currentScope === SCOPE_WIKI_BLOBS && - this.glFeatures?.searchProjectWikisHideArchivedProjects - ); - }, - showScopeNavigation() { - // showScopeNavigation refers to whether the scope navigation should be shown - // while the legacy navigation is being used and there are no search results - // the scope navigation has to be hidden - return Boolean(this.currentScope); + return this.currentScope === SCOPE_WIKI_BLOBS; }, }, methods: { @@ -93,9 +88,16 @@ export default { </script> <template> - <section v-if="useSidebarNavigation"> + <section> <dom-element-listener selector="#js-open-mobile-filters" @click="toggleFiltersFromSidebar" /> <sidebar-portal> + <all-scopes-start-filters /> + <div + v-if="headerText" + class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-weight-bold gl-font-sm super-sidebar-context-header" + > + {{ headerText }} + </div> <scope-sidebar-navigation /> <issues-filters v-if="showIssuesFilters" /> <merge-requests-filters v-if="showMergeRequestFilters" /> @@ -107,32 +109,4 @@ export default { <wiki-blobs-filters v-if="showWikiBlobsFilters" /> </sidebar-portal> </section> - - <section - v-else-if="showScopeNavigation" - class="gl-display-flex gl-flex-direction-column gl-lg-mr-0 gl-md-mr-5 gl-lg-mb-6 gl-lg-mt-5" - > - <div class="search-sidebar gl-display-none gl-lg-display-block"> - <scope-legacy-navigation /> - <issues-filters v-if="showIssuesFilters" /> - <merge-requests-filters v-if="showMergeRequestFilters" /> - <blobs-filters v-if="showBlobFilters" /> - <projects-filters v-if="showProjectsFilters" /> - <notes-filters v-if="showNotesFilters" /> - <commits-filters v-if="showCommitsFilters" /> - <milestones-filters v-if="showMilestonesFilters" /> - <wiki-blobs-filters v-if="showWikiBlobsFilters" /> - </div> - <small-screen-drawer-navigation class="gl-lg-display-none"> - <scope-legacy-navigation /> - <issues-filters v-if="showIssuesFilters" /> - <merge-requests-filters v-if="showMergeRequestFilters" /> - <blobs-filters v-if="showBlobFilters" /> - <projects-filters v-if="showProjectsFilters" /> - <notes-filters v-if="showNotesFilters" /> - <commits-filters v-if="showCommitsFilters" /> - <milestones-filters v-if="showMilestonesFilters" /> - <wiki-blobs-filters v-if="showWikiBlobsFilters" /> - </small-screen-drawer-navigation> - </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue index b0e84beabc4..0308db17dc4 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue @@ -21,7 +21,7 @@ export default { tooltip: s__('GlobalSearch|Include search results from archived projects'), }, computed: { - ...mapState(['urlQuery', 'useSidebarNavigation']), + ...mapState(['urlQuery']), selectedFilter: { get() { return [parseBoolean(this.urlQuery?.include_archived)]; @@ -48,9 +48,9 @@ export default { <template> <gl-form-checkbox-group v-model="selectedFilter"> - <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> + <div class="gl-mb-2 gl-font-weight-bold gl-font-sm" data-testid="archived-filter-title"> {{ $options.archivedFilterData.headerLabel }} - </h5> + </div> <gl-form-checkbox class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" :class="$options.LABEL_DEFAULT_CLASSES" diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue index 0ed2c24efba..e282bacae31 100644 --- a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue @@ -1,8 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapGetters, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { HR_DEFAULT_CLASSES } from '../constants'; import LanguageFilter from './language_filter/index.vue'; import ArchivedFilter from './archived_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; @@ -14,24 +10,12 @@ export default { FiltersTemplate, ArchivedFilter, }, - mixins: [glFeatureFlagsMixin()], - computed: { - ...mapGetters(['currentScope']), - ...mapState(['useSidebarNavigation', 'searchType']), - showDivider() { - return !this.useSidebarNavigation; - }, - hrClasses() { - return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; - }, - }, }; </script> <template> <filters-template> <language-filter class="gl-mb-5" /> - <hr v-if="showDivider" :class="hrClasses" /> <archived-filter class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue index 176614be6da..4e91158fa36 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter/index.vue @@ -1,6 +1,4 @@ <script> -// eslint-disable-next-line no-restricted-imports -import { mapState } from 'vuex'; import RadioFilter from '../radio_filter.vue'; import { confidentialFilterData } from './data'; @@ -9,9 +7,6 @@ export default { components: { RadioFilter, }, - computed: { - ...mapState(['useSidebarNavigation']), - }, confidentialFilterData, }; </script> diff --git a/app/assets/javascripts/search/sidebar/components/filters_template.vue b/app/assets/javascripts/search/sidebar/components/filters_template.vue index 0f68bf92048..2f40a430bfa 100644 --- a/app/assets/javascripts/search/sidebar/components/filters_template.vue +++ b/app/assets/javascripts/search/sidebar/components/filters_template.vue @@ -5,7 +5,6 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import Tracking from '~/tracking'; import { - HR_DEFAULT_CLASSES, TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, TRACKING_LABEL_RESET, @@ -19,7 +18,7 @@ export default { GlForm, }, computed: { - ...mapState(['sidebarDirty', 'useSidebarNavigation']), + ...mapState(['sidebarDirty']), ...mapGetters(['currentScope']), }, methods: { @@ -37,15 +36,16 @@ export default { this.resetQuery(); }, }, - HR_DEFAULT_CLASSES, }; </script> <template> - <gl-form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking"> - <hr v-if="!useSidebarNavigation" :class="$options.HR_DEFAULT_CLASSES" /> + <gl-form + class="issue-filters gl-px-5 gl-pt-0" + :aria-label="__('Search filters')" + @submit.prevent="applyQueryWithTracking" + > <slot></slot> - <hr v-if="!useSidebarNavigation" :class="$options.HR_DEFAULT_CLASSES" /> <div class="gl-display-flex gl-align-items-center gl-mt-4"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/sidebar/components/group_filter.vue index a177eb28991..20231cdda6a 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/group_filter.vue @@ -2,37 +2,49 @@ import { isEmpty } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; import SearchableDropdown from './searchable_dropdown.vue'; export default { name: 'GroupFilter', + i18n: { + groupFieldLabel: s__('GlobalSearch|Group'), + }, components: { SearchableDropdown, }, - props: { - initialData: { - type: Object, - required: false, - default: () => ({}), - }, + data() { + return { + search: '', + labelId: 'group-filter-dropdown-id', + }; }, computed: { - ...mapState(['query', 'groups', 'fetchingGroups']), + ...mapState(['query', 'groups', 'fetchingGroups', 'groupInitialJson', 'useSidebarNavigation']), ...mapGetters(['frequentGroups', 'currentScope']), selectedGroup() { - return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; + return isEmpty(this.groupInitialJson) ? ANY_OPTION : this.groupInitialJson; + }, + }, + watch: { + search() { + this.debounceSearch(); }, }, created() { // This tracks groups searched via the top nav search bar - if (this.query.nav_source === 'navbar' && this.initialData?.id) { - this.setFrequentGroup(this.initialData); + if (this.query.nav_source === 'navbar' && this.groupInitialJson?.id) { + this.setFrequentGroup(this.groupInitialJson); } }, methods: { ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']), + firstLoad() { + this.loadFrequentGroups(); + this.fetchGroups(); + }, handleGroupChange(group) { // If group.id is null we are clearing the filter and don't need to store that in LS. if (group.id) { @@ -54,17 +66,22 @@ export default { </script> <template> - <searchable-dropdown - data-testid="group-filter" - :header-text="$options.GROUP_DATA.headerText" - :name="$options.GROUP_DATA.name" - :full-name="$options.GROUP_DATA.fullName" - :loading="fetchingGroups" - :selected-item="selectedGroup" - :items="groups" - :frequent-items="frequentGroups" - @first-open="loadFrequentGroups" - @search="fetchGroups" - @change="handleGroupChange" - /> + <div> + <h5 :id="labelId" class="gl-mt-0 gl-mb-5 gl-font-sm"> + {{ $options.i18n.groupFieldLabel }} + </h5> + <searchable-dropdown + data-testid="group-filter" + :header-text="$options.GROUP_DATA.headerText" + :name="$options.GROUP_DATA.name" + :loading="fetchingGroups" + :selected-item="selectedGroup" + :items="groups" + :frequent-items="frequentGroups" + :search-handler="fetchGroups" + :label-id="labelId" + @first-open="firstLoad" + @change="handleGroupChange" + /> + </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index a77fb34cdba..d815b68b98a 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -2,7 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { HR_DEFAULT_CLASSES, SEARCH_TYPE_ADVANCED } from '../constants'; +import { SEARCH_TYPE_ADVANCED } from '../constants'; import { confidentialFilterData } from './confidentiality_filter/data'; import { statusFilterData } from './status_filter/data'; import ConfidentialityFilter from './confidentiality_filter/index.vue'; @@ -26,7 +26,7 @@ export default { mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentScope']), - ...mapState(['useSidebarNavigation', 'searchType']), + ...mapState(['searchType']), showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.currentScope); }, @@ -36,19 +36,12 @@ export default { showLabelFilter() { return ( Object.values(labelFilterData.scopes).includes(this.currentScope) && - this.glFeatures.searchIssueLabelAggregation && this.searchType === SEARCH_TYPE_ADVANCED ); }, showArchivedFilter() { return archivedFilterData.scopes.includes(this.currentScope); }, - showDivider() { - return !this.useSidebarNavigation; - }, - hrClasses() { - return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; - }, }, }; </script> @@ -56,11 +49,8 @@ export default { <template> <filters-template> <status-filter v-if="showStatusFilter" class="gl-mb-5" /> - <hr v-if="showConfidentialityFilter && showDivider" :class="hrClasses" /> <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" /> - <hr v-if="showLabelFilter && showDivider" :class="hrClasses" /> <label-filter v-if="showLabelFilter" class="gl-mb-5" /> - <hr v-if="showArchivedFilter && showDivider" :class="hrClasses" /> <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue index 97583730958..106093b5ad1 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -55,7 +55,7 @@ export default { }, i18n: I18N, computed: { - ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'urlQuery', 'aggregations']), + ...mapState(['searchLabelString', 'query', 'urlQuery', 'aggregations']), ...mapGetters([ 'filteredLabels', 'filteredUnselectedLabels', @@ -179,14 +179,10 @@ export default { <template> <div class="gl-pb-0 gl-md-pt-0 label-filter gl-relative"> - <h5 - class="gl-my-0" - data-testid="label-filter-title" - :class="{ 'gl-font-sm': useSidebarNavigation }" - > + <div class="gl-mb-2 gl-font-weight-bold gl-font-sm" data-testid="label-filter-title"> {{ $options.labelFilterData.header }} - </h5> - <div class="gl-my-5"> + </div> + <div> <gl-label v-for="label in unappliedNewLabels" :key="label.key" @@ -246,12 +242,7 @@ export default { v-if="isFocused" v-outside="closeDropdown" data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-2" - :class="{ - 'gl-max-w-none!': useSidebarNavigation, - 'gl-min-w-full!': useSidebarNavigation, - 'gl-w-full!': useSidebarNavigation, - }" + class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-2 gl-w-full! gl-min-w-full! gl-max-w-none!" > <div class="header-search-dropdown-content gl-py-2"> <dropdown-keyboard-navigation diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue index 784207cc702..d0c895530cd 100644 --- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue @@ -27,7 +27,7 @@ export default { loadError: s__('GlobalSearch|Aggregations load error.'), }, computed: { - ...mapState(['aggregations', 'useSidebarNavigation']), + ...mapState(['aggregations']), ...mapGetters(['languageAggregationBuckets']), hasBuckets() { return this.languageAggregationBuckets.length > 0; @@ -75,9 +75,9 @@ export default { <template> <div v-if="hasBuckets" class="language-filter-checkbox"> - <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> + <div class="gl-mb-2 gl-font-weight-bold gl-font-sm"> {{ $options.languageFilterData.header }} - </h5> + </div> <div v-if="!aggregations.error" class="gl-overflow-x-hidden gl-overflow-y-auto" diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue index f86906ebd26..18074db7603 100644 --- a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue @@ -1,7 +1,6 @@ <script> // eslint-disable-next-line no-restricted-imports -import { mapGetters, mapState } from 'vuex'; -import { HR_DEFAULT_CLASSES } from '../constants'; +import { mapGetters } from 'vuex'; import { statusFilterData } from './status_filter/data'; import StatusFilter from './status_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; @@ -17,19 +16,12 @@ export default { }, computed: { ...mapGetters(['currentScope']), - ...mapState(['useSidebarNavigation', 'searchType']), showArchivedFilter() { return archivedFilterData.scopes.includes(this.currentScope); }, showStatusFilter() { return Object.values(statusFilterData.scopes).includes(this.currentScope); }, - showDivider() { - return !this.useSidebarNavigation; - }, - hrClasses() { - return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; - }, }, }; </script> @@ -37,7 +29,6 @@ export default { <template> <filters-template> <status-filter v-if="showStatusFilter" class="gl-mb-5" /> - <hr v-if="showArchivedFilter && showDivider" :class="hrClasses" /> <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/project_filter.vue b/app/assets/javascripts/search/sidebar/components/project_filter.vue new file mode 100644 index 00000000000..76983644e60 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/project_filter.vue @@ -0,0 +1,94 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants'; +import SearchableDropdown from './searchable_dropdown.vue'; + +export default { + name: 'ProjectFilter', + i18n: { + projectFieldLabel: s__('GlobalSearch|Project'), + }, + components: { + SearchableDropdown, + }, + data() { + return { + search: '', + labelId: 'projects-filter-dropdown-id', + }; + }, + computed: { + ...mapState([ + 'query', + 'projects', + 'fetchingProjects', + 'projectInitialJson', + 'useSidebarNavigation', + ]), + ...mapGetters(['frequentProjects', 'currentScope']), + selectedProject() { + return isEmpty(this.projectInitialJson) ? ANY_OPTION : this.projectInitialJson; + }, + }, + watch: { + search() { + this.debounceSearch(); + }, + }, + created() { + // This tracks projects searched via the top nav search bar + if (this.query.nav_source === 'navbar' && this.projectInitialJson?.id) { + this.setFrequentProject(this.projectInitialJson); + } + }, + methods: { + ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), + firstLoad() { + this.loadFrequentProjects(); + this.fetchProjects(); + }, + handleProjectChange(project) { + // If project.id is null we are clearing the filter and don't need to store that in LS. + if (project.id) { + this.setFrequentProject(project); + } + + // This determines if we need to update the group filter or not + const queryParams = { + ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), + [PROJECT_DATA.queryParam]: project.id, + nav_source: null, + scope: this.currentScope, + }; + + visitUrl(setUrlParams(queryParams)); + }, + }, + PROJECT_DATA, +}; +</script> + +<template> + <div> + <h5 :id="labelId" class="gl-mt-0 gl-mb-5 gl-font-sm"> + {{ $options.i18n.projectFieldLabel }} + </h5> + <searchable-dropdown + data-testid="project-filter" + :header-text="$options.PROJECT_DATA.headerText" + :name="$options.PROJECT_DATA.name" + :loading="fetchingProjects" + :selected-item="selectedProject" + :items="projects" + :frequent-items="frequentProjects" + :search-handler="fetchProjects" + :label-id="labelId" + @first-open="firstLoad" + @change="handleProjectChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue index a1eb5ccecd8..f961bfea608 100644 --- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -17,7 +17,7 @@ export default { }, }, computed: { - ...mapState(['query', 'useSidebarNavigation']), + ...mapState(['query']), ...mapGetters(['currentScope']), ANY() { return this.filterData.filters.ANY; @@ -57,9 +57,9 @@ export default { <template> <div> - <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useSidebarNavigation }"> + <div class="gl-mb-2 gl-font-weight-bold gl-font-sm"> {{ filterData.header }} - </h5> + </div> <gl-form-radio-group v-model="selectedFilter"> <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value"> {{ radioLabel(f) }} diff --git a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue deleted file mode 100644 index a4c1119736f..00000000000 --- a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import Tracking from '~/tracking'; -import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; -import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; -import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; - -export default { - name: 'ScopeLegacyNavigation', - i18n: { - countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), - }, - components: { - GlNav, - GlNavItem, - GlIcon, - }, - mixins: [Tracking.mixin()], - computed: { - ...mapState(['navigation', 'urlQuery']), - }, - created() { - if (this.urlQuery?.search) { - this.fetchSidebarCount(); - } - }, - methods: { - ...mapActions(['fetchSidebarCount']), - showFormatedCount(countString) { - return formatSearchResultCount(countString); - }, - isCountOverLimit(countString) { - return Boolean(addCountOverLimit(countString)); - }, - handleClick(scope) { - this.track('click_menu_item', { label: `vertical_navigation_${scope}` }); - }, - linkClasses(isHighlighted) { - return [...this.$options.NAV_LINK_DEFAULT_CLASSES, { 'gl-font-weight-bold': isHighlighted }]; - }, - countClasses(isHighlighted) { - return [ - ...this.$options.NAV_LINK_COUNT_DEFAULT_CLASSES, - isHighlighted ? 'gl-text-gray-900' : 'gl-text-gray-500', - ]; - }, - qaSelectorValue(item) { - return `${slugifyWithUnderscore(item.label)}_tab`; - }, - }, - NAV_LINK_DEFAULT_CLASSES, - NAV_LINK_COUNT_DEFAULT_CLASSES, -}; -</script> - -<template> - <nav data-testid="search-filter" class="gl-border-none"> - <gl-nav vertical pills> - <gl-nav-item - v-for="(item, scope) in navigation" - :key="scope" - :link-classes="linkClasses(item.active)" - class="gl-mb-1" - :href="item.link" - :active="item.active" - :data-qa-selector="qaSelectorValue(item)" - :data-testid="qaSelectorValue(item)" - @click="handleClick(scope)" - ><span data-testid="label">{{ item.label }}</span - ><span v-if="item.count" data-testid="count" :class="countClasses(item.active)"> - {{ showFormatedCount(item.count) - }}<gl-icon - v-if="isCountOverLimit(item.count)" - name="plus" - :aria-label="$options.i18n.countOverLimitLabel" - :size="8" - /> - </span> - </gl-nav-item> - </gl-nav> - </nav> -</template> diff --git a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue index f30618ad9b7..874803a720d 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue @@ -2,6 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import eventHub from '~/super_sidebar/event_hub'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; @@ -18,6 +19,8 @@ export default { ...mapGetters(['navigationItems']), }, created() { + eventHub.$emit('toggle-menu-header', false); + if (this.urlQuery?.search) { this.fetchSidebarCount(); } diff --git a/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue new file mode 100644 index 00000000000..c1f0bfc59f3 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/searchable_dropdown.vue @@ -0,0 +1,222 @@ +<script> +import { GlCollapsibleListbox, GlAvatar } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { __, s__, n__ } from '~/locale'; +import { ANY_OPTION } from '../constants'; + +export default { + name: 'SearchableDropdown', + components: { + GlAvatar, + GlCollapsibleListbox, + }, + directives: { + SafeHtml, + }, + i18n: { + frequentlySearched: __('Frequently searched'), + availableGroups: s__('GlobalSearch|All available groups'), + nothingFound: s__('GlobalSearch|Nothing found…'), + reset: s__('GlobalSearch|Reset'), + itemsFound(count) { + return n__('%d item found', '%d items found', count); + }, + }, + props: { + headerText: { + type: String, + required: false, + default: "__('Filter')", + }, + name: { + type: String, + required: false, + default: 'name', + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + selectedItem: { + type: Object, + required: true, + }, + items: { + type: Array, + required: false, + default: () => [], + }, + frequentItems: { + type: Array, + required: false, + default: () => [], + }, + searchHandler: { + type: Function, + required: true, + }, + labelId: { + type: String, + required: false, + default: 'labelId', + }, + }, + data() { + return { + searchText: '', + hasBeenOpened: false, + showableItems: [], + searchInProgress: false, + }; + }, + watch: { + items() { + if (this.searchText === '') { + this.showableItems = this.defaultItems(); + } + }, + }, + created() { + this.showableItems = this.defaultItems(); + }, + methods: { + defaultItems() { + const frequentItems = this.convertItemsFormat([...this.frequentItems]); + const nonFrequentItems = this.convertItemsFormat([ + ...this.uniqueItems(this.items, this.frequentItems), + ]); + + return [ + { + text: '', + options: [ + { + value: ANY_OPTION.name, + text: ANY_OPTION.name, + ...ANY_OPTION, + }, + ], + }, + { + text: this.$options.i18n.frequentlySearched, + options: frequentItems, + }, + { + text: this.$options.i18n.availableGroups, + options: nonFrequentItems, + }, + ].filter((group) => { + return group.options.length > 0; + }); + }, + search(search) { + this.searchText = search; + this.searchInProgress = true; + + if (search !== '') { + debounce(() => { + this.searchHandler(this.searchText); + this.showableItems = this.convertItemsFormat([...this.items]); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS)(); + + return; + } + + this.showableItems = this.defaultItems(); + }, + openDropdown() { + if (!this.hasBeenOpened) { + this.hasBeenOpened = true; + this.$emit('first-open'); + } + }, + resetDropdown() { + this.$emit('change', ANY_OPTION); + }, + convertItemsFormat(items) { + return items.map((item) => ({ value: item.id, text: item.full_name, ...item })); + }, + truncatedNamespace(item) { + const itemDuplicat = { ...item }; + const namespaceWithFallback = itemDuplicat.name_with_namespace + ? itemDuplicat.name_with_namespace + : itemDuplicat.full_name; + + return truncateNamespace(namespaceWithFallback); + }, + highlightedItemName(item) { + return highlight(item.name, item.searchText); + }, + onSelectGroup(selected) { + if (selected === ANY_OPTION.name) { + this.$emit('change', ANY_OPTION); + return; + } + + const flatShowableItems = [...this.frequentItems, ...this.items]; + const newSelectedItem = flatShowableItems.find((item) => item.id === selected); + this.$emit('change', newSelectedItem); + }, + uniqueItems(allItems, frequentItems) { + return allItems.filter((item) => { + const itemNotIdentical = frequentItems.some((fitem) => fitem.id === item.id); + return Boolean(!itemNotIdentical); + }); + }, + }, + ANY_OPTION, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <gl-collapsible-listbox + :items="showableItems" + :header-text="headerText" + :toggle-text="selectedItem[name]" + :no-results-text="$options.i18n.nothingFound" + :selected="selectedItem.id" + :searching="loading" + :reset-button-label="$options.i18n.reset" + :toggle-aria-labelled-by="labelId" + searchable + block + @shown="openDropdown" + @search="search" + @select="onSelectGroup" + @reset="resetDropdown" + > + <template #search-summary-sr-only> + {{ $options.i18n.itemsFound(showableItems.length) }} + </template> + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + :src="item.avatar_url" + :entity-id="item.id" + :entity-name="item.name" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="32" + class="gl-mr-3" + aria-hidden="true" + /> + <div class="gl-display-flex gl-flex-direction-column"> + <span + v-safe-html="highlightedItemName(item)" + class="gl-font-weight-bold gl-white-space-nowrap" + data-testid="item-title" + ></span> + <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace"> + {{ truncatedNamespace(item) }}</span + > + </div> + </div> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue b/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue deleted file mode 100644 index e966b8d877e..00000000000 --- a/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue +++ /dev/null @@ -1,61 +0,0 @@ -<script> -import { GlDrawer } from '@gitlab/ui'; -import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; -import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; -import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; -import { s__ } from '~/locale'; - -export default { - name: 'SmallScreenDrawerNavigation', - components: { - GlDrawer, - DomElementListener, - }, - i18n: { - smallScreenFiltersDrawerHeader: s__('GlobalSearch|Filters'), - }, - data() { - return { - openSmallScreenFilters: false, - }; - }, - computed: { - getDrawerHeaderHeight() { - if (!this.openSmallScreenFilters) return '0'; - return getContentWrapperHeight(); - }, - }, - methods: { - closeSmallScreenFilters() { - this.openSmallScreenFilters = false; - }, - toggleSmallScreenFilters() { - this.openSmallScreenFilters = !this.openSmallScreenFilters; - }, - }, - DRAWER_Z_INDEX, -}; -</script> -<template> - <dom-element-listener selector="#js-open-mobile-filters" @click="toggleSmallScreenFilters"> - <gl-drawer - :header-height="getDrawerHeaderHeight" - :z-index="$options.DRAWER_Z_INDEX" - variant="sidebar" - class="small-screen-drawer-navigation" - :open="openSmallScreenFilters" - @close="closeSmallScreenFilters" - > - <template #title> - <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24"> - {{ $options.i18n.smallScreenFiltersDrawerHeader }} - </h2> - </template> - <template #default> - <div> - <slot></slot> - </div> - </template> - </gl-drawer> - </dom-element-listener> -</template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter/index.vue b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue index a5f717dcf06..cbc1a26f1ae 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter/index.vue @@ -1,5 +1,4 @@ <script> -import { HR_DEFAULT_CLASSES } from '../../constants'; import RadioFilter from '../radio_filter.vue'; import { statusFilterData } from './data'; @@ -9,7 +8,6 @@ export default { RadioFilter, }, statusFilterData, - HR_DEFAULT_CLASSES, }; </script> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 1559155a941..e3b0db670b5 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const SCOPE_ISSUES = 'issues'; export const SCOPE_MERGE_REQUESTS = 'merge_requests'; export const SCOPE_BLOB = 'blobs'; @@ -18,8 +20,6 @@ export const NAV_LINK_DEFAULT_CLASSES = [ 'gl-justify-content-space-between', ]; export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; -export const HR_DEFAULT_CLASSES = ['hr-x', 'gl-border-gray-100']; -export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; export const TRACKING_ACTION_CLICK = 'search:filters:click'; export const TRACKING_LABEL_APPLY = 'Apply Filters'; @@ -28,3 +28,23 @@ export const TRACKING_LABEL_RESET = 'Reset Filters'; export const SEARCH_TYPE_BASIC = 'basic'; export const SEARCH_TYPE_ADVANCED = 'advanced'; export const SEARCH_TYPE_ZOEKT = 'zoekt'; + +export const ANY_OPTION = { + id: null, + name: __('Any'), + name_with_namespace: __('Any'), +}; + +export const GROUP_DATA = { + headerText: __('Filter results by group'), + queryParam: 'group_id', + name: 'name', + fullName: 'full_name', +}; + +export const PROJECT_DATA = { + headerText: __('Filter results by project'), + queryParam: 'project_id', + name: 'name', + fullName: 'name_with_namespace', +}; diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index 3a699355dc9..9a7472ccad3 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -4,27 +4,23 @@ import GlobalSearchSidebar from './components/app.vue'; Vue.use(Translate); -export const sidebarInitState = () => { - const el = document.getElementById('js-search-sidebar'); - if (!el) return {}; - - const { navigationJson, searchType } = el.dataset; - - const navigationJsonParsed = JSON.parse(navigationJson); - - return { navigationJsonParsed, searchType }; -}; - export const initSidebar = (store) => { const el = document.getElementById('js-search-sidebar'); + const hederEl = document.getElementById('super-sidebar-context-header'); + const headerText = hederEl.innerText; if (!el) return false; return new Vue({ el, + name: 'GlobalSearchSidebar', store, render(createElement) { - return createElement(GlobalSearchSidebar); + return createElement(GlobalSearchSidebar, { + props: { + headerText, + }, + }); }, }); }; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index ad47cd975f8..1d6720b5936 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -39,3 +39,7 @@ export const ICON_MAP = { export const ZOEKT_SEARCH_TYPE = 'zoekt'; export const ADVANCED_SEARCH_TYPE = 'advanced'; export const BASIC_SEARCH_TYPE = 'basic'; + +export const SEARCH_LEVEL_GLOBAL = 'global'; +export const SEARCH_LEVEL_PROJECT = 'project'; +export const SEARCH_LEVEL_GROUP = 'group'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index b248681f053..7627b2e0e08 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -6,7 +6,7 @@ export default { }, [types.RECEIVE_GROUPS_SUCCESS](state, data) { state.fetchingGroups = false; - state.groups = data; + state.groups = [...data]; }, [types.RECEIVE_GROUPS_ERROR](state) { state.fetchingGroups = false; @@ -17,7 +17,7 @@ export default { }, [types.RECEIVE_PROJECTS_SUCCESS](state, data) { state.fetchingProjects = false; - state.projects = data; + state.projects = [...data]; }, [types.RECEIVE_PROJECTS_ERROR](state) { state.fetchingProjects = false; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index b4cd2af65ba..279ba467bba 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,7 +1,15 @@ import { cloneDeep } from 'lodash'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; -const createState = ({ query, navigation, useSidebarNavigation, searchType }) => ({ +const createState = ({ + query, + navigation, + searchType, + searchLevel, + groupInitialJson, + projectInitialJson, + defaultBranchName, +}) => ({ urlQuery: cloneDeep(query), query, groups: [], @@ -14,7 +22,6 @@ const createState = ({ query, navigation, useSidebarNavigation, searchType }) => }, sidebarDirty: false, navigation, - useSidebarNavigation, aggregations: { error: false, fetching: false, @@ -22,6 +29,10 @@ const createState = ({ query, navigation, useSidebarNavigation, searchType }) => }, searchLabelString: '', searchType, + searchLevel, + groupInitialJson, + projectInitialJson, + defaultBranchName, }); export default createState; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 49e66492519..da189fe9f06 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,56 +1,30 @@ <script> -import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; +import { GlSearchBoxByType, GlButton } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import { s__ } from '~/locale'; -import { parseBoolean } from '~/lib/utils/common_utils'; import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; import { ZOEKT_SEARCH_TYPE, ADVANCED_SEARCH_TYPE } from '~/search/store/constants'; import { SYNTAX_OPTIONS_ADVANCED_DOCUMENT, SYNTAX_OPTIONS_ZOEKT_DOCUMENT } from '../constants'; -import GroupFilter from './group_filter.vue'; -import ProjectFilter from './project_filter.vue'; +import SearchTypeIndicator from './search_type_indicator.vue'; export default { name: 'GlobalSearchTopbar', i18n: { searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`), searchLabel: s__(`GlobalSearch|What are you searching for?`), - documentFetchErrorMessage: s__( - 'GlobalSearch|There was an error fetching the "Syntax Options" document.', - ), - searchFieldLabel: s__('GlobalSearch|What are you searching for?'), syntaxOptionsLabel: s__('GlobalSearch|Syntax options'), groupFieldLabel: s__('GlobalSearch|Group'), projectFieldLabel: s__('GlobalSearch|Project'), - searchButtonLabel: s__('GlobalSearch|Search'), - closeButtonLabel: s__('GlobalSearch|Close'), }, components: { GlButton, - GlSearchBoxByClick, - GroupFilter, - ProjectFilter, + GlSearchBoxByType, MarkdownDrawer, - }, - props: { - groupInitialJson: { - type: Object, - required: false, - default: () => ({}), - }, - projectInitialJson: { - type: Object, - required: false, - default: () => ({}), - }, - defaultBranchName: { - type: String, - required: false, - default: '', - }, + SearchTypeIndicator, }, computed: { - ...mapState(['query', 'searchType']), + ...mapState(['query', 'searchType', 'defaultBranchName']), search: { get() { return this.query ? this.query.search : ''; @@ -59,9 +33,6 @@ export default { this.setQuery({ key: 'search', value }); }, }, - showFilters() { - return !parseBoolean(this.query.snippets); - }, showSyntaxOptions() { return ( (this.searchType === ZOEKT_SEARCH_TYPE || this.searchType === ADVANCED_SEARCH_TYPE) && @@ -90,46 +61,40 @@ export default { </script> <template> - <section class="gl-p-5 gl-bg-gray-10 gl-border-b gl-border-t"> + <section> + <div + class="gl-lg-display-flex gl-flex-direction-row gl-py-5" + :class="{ + 'gl-justify-content-space-between': showSyntaxOptions, + 'gl-justify-content-end': !showSyntaxOptions, + }" + > + <template v-if="showSyntaxOptions"> + <div> + <gl-button + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm!" + @click="onToggleDrawer" + >{{ $options.i18n.syntaxOptionsLabel }} + </gl-button> + </div> + <markdown-drawer ref="markdownDrawer" :document-path="documentBasedOnSearchType" /> + </template> + <search-type-indicator /> + </div> <div class="search-page-form gl-lg-display-flex gl-flex-direction-column"> - <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end"> - <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-0 gl-md-mb-4" - > - <label class="gl-mb-1 gl-md-pb-2">{{ $options.i18n.searchLabel }}</label> - <template v-if="showSyntaxOptions"> - <gl-button - category="tertiary" - variant="link" - size="small" - button-text-classes="gl-font-sm!" - @click="onToggleDrawer" - >{{ $options.i18n.syntaxOptionsLabel }} - </gl-button> - <markdown-drawer ref="markdownDrawer" :document-path="documentBasedOnSearchType" /> - </template> - </div> - <gl-search-box-by-click + <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-start"> + <div class="gl-flex-grow-1 gl-pb-8 gl-lg-mb-0 gl-lg-mr-2"> + <gl-search-box-by-type id="dashboard_search" v-model="search" name="search" :placeholder="$options.i18n.searchPlaceholder" - @submit="applyQuery" + @keydown.enter.stop.prevent="applyQuery" /> </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3"> - <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{ - $options.i18n.groupFieldLabel - }}</label> - <group-filter :initial-data="groupInitialJson" /> - </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3"> - <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{ - $options.i18n.projectFieldLabel - }}</label> - <project-filter :initial-data="projectInitialJson" /> - </div> </div> </div> </section> diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue deleted file mode 100644 index c8190b4002d..00000000000 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ /dev/null @@ -1,70 +0,0 @@ -<script> -// eslint-disable-next-line no-restricted-imports -import { mapState, mapActions, mapGetters } from 'vuex'; -import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; -import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants'; -import SearchableDropdown from './searchable_dropdown.vue'; - -export default { - name: 'ProjectFilter', - components: { - SearchableDropdown, - }, - props: { - initialData: { - type: Object, - required: false, - default: () => null, - }, - }, - computed: { - ...mapState(['query', 'projects', 'fetchingProjects']), - ...mapGetters(['frequentProjects', 'currentScope']), - selectedProject() { - return this.initialData ? this.initialData : ANY_OPTION; - }, - }, - created() { - // This tracks projects searched via the top nav search bar - if (this.query.nav_source === 'navbar' && this.initialData?.id) { - this.setFrequentProject(this.initialData); - } - }, - methods: { - ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), - handleProjectChange(project) { - // If project.id is null we are clearing the filter and don't need to store that in LS. - if (project.id) { - this.setFrequentProject(project); - } - - // This determines if we need to update the group filter or not - const queryParams = { - ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), - [PROJECT_DATA.queryParam]: project.id, - nav_source: null, - scope: this.currentScope, - }; - - visitUrl(setUrlParams(queryParams)); - }, - }, - PROJECT_DATA, -}; -</script> - -<template> - <searchable-dropdown - data-testid="project-filter" - :header-text="$options.PROJECT_DATA.headerText" - :name="$options.PROJECT_DATA.name" - :full-name="$options.PROJECT_DATA.fullName" - :loading="fetchingProjects" - :selected-item="selectedProject" - :items="projects" - :frequent-items="frequentProjects" - @first-open="loadFrequentProjects" - @search="fetchProjects" - @change="handleProjectChange" - /> -</template> diff --git a/app/assets/javascripts/search/topbar/components/search_type_indicator.vue b/app/assets/javascripts/search/topbar/components/search_type_indicator.vue new file mode 100644 index 00000000000..362139bf64d --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/search_type_indicator.vue @@ -0,0 +1,120 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState } from 'vuex'; +import { GlSprintf, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { + ZOEKT_SEARCH_TYPE, + ADVANCED_SEARCH_TYPE, + BASIC_SEARCH_TYPE, + SEARCH_LEVEL_PROJECT, +} from '~/search/store/constants'; +import { + ZOEKT_HELP_PAGE, + ADVANCED_SEARCH_HELP_PAGE, + ADVANCED_SEARCH_SYNTAX_HELP_ANCHOR, + ZOEKT_HELP_PAGE_SYNTAX_ANCHOR, +} from '../constants'; + +export default { + name: 'SearchTypeIndicator', + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + zoekt_enabled: s__( + 'GlobalSearch|%{linkStart}Exact code search (powered by Zoekt)%{linkEnd} is enabled', + ), + zoekt_disabled: s__( + 'GlobalSearch|%{linkStart}Exact code search (powered by Zoekt)%{linkEnd} is disabled since %{ref_elem} is not the default branch. %{docs_link}', + ), + advanced_enabled: __('%{linkStart}Advanced search%{linkEnd} is enabled.'), + advanced_disabled: __( + '%{linkStart}Advanced search%{linkEnd} is disabled since %{ref_elem} is not the default branch. %{docs_link}', + ), + more: __('Learn more.'), + }, + components: { + GlSprintf, + GlLink, + }, + computed: { + ...mapState(['searchType', 'defaultBranchName', 'query', 'searchLevel']), + zoektHelpUrl() { + return helpPagePath(ZOEKT_HELP_PAGE); + }, + zoektSyntaxHelpUrl() { + return helpPagePath(ZOEKT_HELP_PAGE, { + anchor: ZOEKT_HELP_PAGE_SYNTAX_ANCHOR, + }); + }, + advancedSearchHelpUrl() { + return helpPagePath(ADVANCED_SEARCH_HELP_PAGE); + }, + advancedSearchSyntaxHelpUrl() { + return helpPagePath(ADVANCED_SEARCH_HELP_PAGE, { + anchor: ADVANCED_SEARCH_SYNTAX_HELP_ANCHOR, + }); + }, + isZoekt() { + return this.searchType === ZOEKT_SEARCH_TYPE; + }, + isAdvancedSearch() { + return this.searchType === ADVANCED_SEARCH_TYPE; + }, + isEnabled() { + if (this.searchLevel !== SEARCH_LEVEL_PROJECT) { + return true; + } + + return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; + }, + isBasicSearch() { + return this.searchType === BASIC_SEARCH_TYPE; + }, + disabledMessage() { + return this.isZoekt + ? this.$options.i18n.zoekt_disabled + : this.$options.i18n.advanced_disabled; + }, + helpUrl() { + return this.isZoekt ? this.zoektHelpUrl : this.advancedSearchHelpUrl; + }, + enabledMessage() { + return this.isZoekt ? this.$options.i18n.zoekt_enabled : this.$options.i18n.advanced_enabled; + }, + syntaxHelpUrl() { + return this.isZoekt ? this.zoektSyntaxHelpUrl : this.advancedSearchSyntaxHelpUrl; + }, + }, +}; +</script> + +<template> + <div class="gl-text-gray-600"> + <div v-if="isBasicSearch" data-testid="basic"> </div> + <div v-else-if="isEnabled" :data-testid="`${searchType}-enabled`"> + <gl-sprintf :message="enabledMessage"> + <template #link="{ content }"> + <gl-link :href="helpUrl" target="_blank" data-testid="docs-link">{{ content }} </gl-link> + </template> + </gl-sprintf> + </div> + <div v-else :data-testid="`${searchType}-disabled`"> + <gl-sprintf :message="disabledMessage"> + <template #link="{ content }"> + <gl-link :href="helpUrl" target="_blank" data-testid="docs-link">{{ content }} </gl-link> + </template> + <template #ref_elem> + <code v-gl-tooltip :title="query.repository_ref">{{ query.repository_ref }}</code> + </template> + <template #docs_link> + <gl-link :href="syntaxHelpUrl" target="_blank" data-testid="syntax-docs-link" + >{{ $options.i18n.more }} + </gl-link> + </template> + </gl-sprintf> + </div> + </div> +</template> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue deleted file mode 100644 index ff639d538b3..00000000000 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ /dev/null @@ -1,195 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlLoadingIcon, - GlIcon, - GlButton, - GlSkeletonLoader, - GlTooltipDirective, -} from '@gitlab/ui'; -import { __ } from '~/locale'; -import { ANY_OPTION } from '../constants'; -import SearchableDropdownItem from './searchable_dropdown_item.vue'; - -export default { - i18n: { - clearLabel: __('Clear'), - frequentlySearched: __('Frequently searched'), - }, - name: 'SearchableDropdown', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlLoadingIcon, - GlIcon, - GlButton, - GlSkeletonLoader, - SearchableDropdownItem, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - headerText: { - type: String, - required: false, - default: "__('Filter')", - }, - name: { - type: String, - required: false, - default: 'name', - }, - fullName: { - type: String, - required: false, - default: 'name', - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - selectedItem: { - type: Object, - required: true, - }, - items: { - type: Array, - required: false, - default: () => [], - }, - frequentItems: { - type: Array, - required: false, - default: () => [], - }, - }, - data() { - return { - searchText: '', - hasBeenOpened: false, - }; - }, - computed: { - showFrequentItems() { - return !this.searchText && this.frequentItems.length > 0; - }, - }, - methods: { - isSelected(selected) { - return selected.id === this.selectedItem.id; - }, - openDropdown() { - if (!this.hasBeenOpened) { - this.hasBeenOpened = true; - this.$emit('first-open'); - } - - this.$emit('search', this.searchText); - }, - resetDropdown() { - this.$emit('change', ANY_OPTION); - }, - updateDropdown(item) { - this.$emit('change', item); - }, - }, - ANY_OPTION, -}; -</script> - -<template> - <gl-dropdown - class="gl-w-full" - menu-class="global-search-dropdown-menu" - toggle-class="gl-text-truncate" - :header-text="headerText" - :right="true" - @show="openDropdown" - @shown="$refs.searchBox.focusInput()" - > - <template #button-content> - <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> - {{ selectedItem[name] }} - </span> - <gl-loading-icon v-if="loading" size="sm" inline class="gl-mr-3" /> - <gl-button - v-if="!isSelected($options.ANY_OPTION)" - v-gl-tooltip - name="clear" - category="tertiary" - :title="$options.i18n.clearLabel" - :aria-label="$options.i18n.clearLabel" - class="gl-p-0! gl-mr-2" - @keydown.enter.stop="resetDropdown" - @click.stop="resetDropdown" - > - <gl-icon name="clear" /> - </gl-button> - <gl-icon name="chevron-down" /> - </template> - <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> - <gl-search-box-by-type - ref="searchBox" - v-model="searchText" - class="gl-m-3" - :debounce="500" - @input="openDropdown" - /> - <gl-dropdown-item - class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" - is-check-item - :is-checked="isSelected($options.ANY_OPTION)" - is-check-centered - @click="updateDropdown($options.ANY_OPTION)" - > - <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span> - </gl-dropdown-item> - </div> - <div - v-if="showFrequentItems" - class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2 gl-mb-2" - > - <gl-dropdown-section-header>{{ - $options.i18n.frequentlySearched - }}</gl-dropdown-section-header> - <searchable-dropdown-item - v-for="item in frequentItems" - :key="item.id" - :item="item" - :selected-item="selectedItem" - :search-text="searchText" - :name="name" - :full-name="fullName" - data-testid="frequent-items" - @change="updateDropdown" - /> - </div> - <div v-if="!loading"> - <searchable-dropdown-item - v-for="item in items" - :key="item.id" - :item="item" - :selected-item="selectedItem" - :search-text="searchText" - :name="name" - :full-name="fullName" - data-testid="searchable-items" - @change="updateDropdown" - /> - </div> - <div v-if="loading" class="gl-mx-4 gl-mt-3"> - <gl-skeleton-loader :height="100"> - <rect y="0" width="90%" height="20" rx="4" /> - <rect y="40" width="70%" height="20" rx="4" /> - <rect y="80" width="80%" height="20" rx="4" /> - </gl-skeleton-loader> - </div> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue deleted file mode 100644 index c1e33df3c42..00000000000 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ /dev/null @@ -1,78 +0,0 @@ -<script> -import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import highlight from '~/lib/utils/highlight'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; - -export default { - name: 'SearchableDropdownItem', - components: { - GlDropdownItem, - GlAvatar, - }, - directives: { - SafeHtml, - }, - props: { - item: { - type: Object, - required: true, - }, - selectedItem: { - type: Object, - required: true, - }, - searchText: { - type: String, - required: false, - default: '', - }, - name: { - type: String, - required: true, - }, - fullName: { - type: String, - required: true, - }, - }, - computed: { - isSelected() { - return this.item.id === this.selectedItem.id; - }, - truncatedNamespace() { - return truncateNamespace(this.item[this.fullName]); - }, - highlightedItemName() { - return highlight(this.item[this.name], this.searchText); - }, - }, - AVATAR_SHAPE_OPTION_RECT, -}; -</script> - -<template> - <gl-dropdown-item - is-check-item - :is-checked="isSelected" - is-check-centered - @click="$emit('change', item)" - > - <div class="gl-display-flex gl-align-items-center"> - <gl-avatar - :src="item.avatar_url" - :entity-id="item.id" - :entity-name="item[name]" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :size="32" - /> - <div class="gl-display-flex gl-flex-direction-column"> - <span v-safe-html="highlightedItemName" data-testid="item-title"></span> - <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{ - truncatedNamespace - }}</span> - </div> - </div> - </gl-dropdown-item> -</template> diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index 1ad40fbe3db..2bd0a4d2c66 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -1,24 +1,8 @@ -import { __ } from '~/locale'; - -export const ANY_OPTION = Object.freeze({ - id: null, - name: __('Any'), - name_with_namespace: __('Any'), -}); - -export const GROUP_DATA = { - headerText: __('Filter results by group'), - queryParam: 'group_id', - name: 'name', - fullName: 'full_name', -}; - -export const PROJECT_DATA = { - headerText: __('Filter results by project'), - queryParam: 'project_id', - name: 'name', - fullName: 'name_with_namespace', -}; - export const SYNTAX_OPTIONS_ADVANCED_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md'; export const SYNTAX_OPTIONS_ZOEKT_DOCUMENT = 'drawers/drawers/exact_code_search_syntax.md'; + +export const ZOEKT_HELP_PAGE = 'user/search/exact_code_search'; +export const ADVANCED_SEARCH_HELP_PAGE = 'user/search/advanced_search'; + +export const ADVANCED_SEARCH_SYNTAX_HELP_ANCHOR = 'use-the-advanced-search-syntax'; +export const ZOEKT_HELP_PAGE_SYNTAX_ANCHOR = 'syntax'; diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js index aad7445ebdc..b88dc8ed516 100644 --- a/app/assets/javascripts/search/topbar/index.js +++ b/app/assets/javascripts/search/topbar/index.js @@ -11,22 +11,12 @@ export const initTopbar = (store) => { return false; } - const { groupInitialJson, projectInitialJson, defaultBranchName } = el.dataset; - - const groupInitialJsonParsed = JSON.parse(groupInitialJson); - const projectInitialJsonParsed = JSON.parse(projectInitialJson); - return new Vue({ el, + name: 'GlobalSearchTopbar', store, render(createElement) { - return createElement(GlobalSearchTopbar, { - props: { - groupInitialJson: groupInitialJsonParsed, - projectInitialJson: projectInitialJsonParsed, - defaultBranchName, - }, - }); + return createElement(GlobalSearchTopbar); }, }); }; diff --git a/app/assets/javascripts/search/under_topbar/index.js b/app/assets/javascripts/search/under_topbar/index.js index 8e50c6655dd..0be803d68fd 100644 --- a/app/assets/javascripts/search/under_topbar/index.js +++ b/app/assets/javascripts/search/under_topbar/index.js @@ -14,6 +14,7 @@ export const initBlobRefSwitcher = () => { return new Vue({ el, + name: 'GlobalSearchUnderTopbar', render(createElement) { return createElement(RefSelector, { props: { diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 32d46a0d4af..4a4c91c6ba7 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,43 +1,21 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import Api from '~/api'; -import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT } from '~/tracking/constants'; -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, TAB_VULNERABILITY_MANAGEMENT_INDEX, -} from './constants'; + i18n, +} from '../constants'; +import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; +import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import FeatureCard from './feature_card.vue'; import TrainingProviderList from './training_provider_list.vue'; -export const i18n = { - configurationHistory: s__('SecurityConfiguration|Configuration history'), - securityTesting: s__('SecurityConfiguration|Security testing'), - latestPipelineDescription: s__( - `SecurityConfiguration|The status of the tools only applies to the - default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, - ), - description: s__( - `SecurityConfiguration|Once you've enabled a scan for the default branch, - any subsequent feature branch you create will include the scan. An enabled - scanner will not be reflected as such until the pipeline has been - successfully executed and it has generated valid artifacts.`, - ), - securityConfiguration: __('Security configuration'), - vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), - securityTraining: s__('SecurityConfiguration|Security training'), - securityTrainingDescription: s__( - 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', - ), - securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), -}; - export default { i18n, components: { diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js deleted file mode 100644 index da213b0ed43..00000000000 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ /dev/null @@ -1,332 +0,0 @@ -import { helpPagePath } from '~/helpers/help_page_helper'; -import { __, s__ } from '~/locale'; -import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; - -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SAST_IAC, - REPORT_TYPE_DAST, - REPORT_TYPE_DAST_PROFILES, - REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, - REPORT_TYPE_SECRET_DETECTION, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_CORPUS_MANAGEMENT, - REPORT_TYPE_API_FUZZING, -} from '~/vue_shared/security_reports/constants'; - -import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw'; -import scwLogo from 'images/vulnerability/scw-logo.svg?raw'; -import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw'; -import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; -import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql'; -import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; - -/** - * 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)'); -export const SAST_SHORT_NAME = s__('ciReport|SAST'); -export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); -export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); -export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { - anchor: 'configuration', -}); - -export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); -export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC'); -export const SAST_IAC_DESCRIPTION = __( - 'Analyze your infrastructure as code configuration files for known vulnerabilities.', -); -export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); -export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/iac_scanning/index', - { - anchor: 'configuration', - }, -); - -export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); -export const DAST_SHORT_NAME = s__('ciReport|DAST'); -export const DAST_DESCRIPTION = s__( - 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', -); -export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { - anchor: 'enable-automatic-dast-run', -}); -export const DAST_BADGE_TEXT = __('Available on demand'); -export const DAST_BADGE_TOOLTIP = __( - 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', -); - -export const DAST_PROFILES_NAME = __('DAST profiles'); -export const DAST_PROFILES_DESCRIPTION = s__( - 'SecurityConfiguration|Manage profiles for use by DAST scans.', -); -export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); - -export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); -export const BAS_BADGE_TOOLTIP = s__( - 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', -); -export const BAS_DESCRIPTION = s__( - 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', -); -export const BAS_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', -); -export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); -export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); - -export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( - 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', -); -export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', - { anchor: 'extend-dynamic-application-security-testing-dast' }, -); -export const BAS_DAST_FEATURE_FLAG_NAME = s__( - 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', -); - -export const SECRET_DETECTION_NAME = __('Secret Detection'); -export const SECRET_DETECTION_DESCRIPTION = __( - 'Analyze your source code and git history for secrets.', -); -export const SECRET_DETECTION_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', -); -export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', - { anchor: 'configuration' }, -); - -export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); -export const DEPENDENCY_SCANNING_DESCRIPTION = __( - 'Analyze your dependencies for known vulnerabilities.', -); -export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', -); -export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', - { anchor: 'configuration' }, -); - -export const CONTAINER_SCANNING_NAME = __('Container Scanning'); -export const CONTAINER_SCANNING_DESCRIPTION = __( - 'Check your Docker images for known vulnerabilities.', -); -export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', -); -export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', - { anchor: 'configuration' }, -); - -export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); -export const COVERAGE_FUZZING_DESCRIPTION = __( - 'Find bugs in your code with coverage-guided fuzzing.', -); -export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', -); -export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', - { anchor: 'enable-coverage-guided-fuzz-testing' }, -); - -export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); -export const CORPUS_MANAGEMENT_DESCRIPTION = s__( - 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', -); -export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); - -export const API_FUZZING_NAME = __('API Fuzzing'); -export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); -export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); - -export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); - -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, - COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, - SECRET_DETECTION: SECRET_DETECTION_NAME, - DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, - BREACH_AND_ATTACK_SIMULATION: BAS_NAME, - CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, - GENERIC: s__('ciReport|Manually added'), -}; - -export const securityFeatures = [ - { - name: SAST_NAME, - shortName: SAST_SHORT_NAME, - description: SAST_DESCRIPTION, - helpPath: SAST_HELP_PATH, - configurationHelpPath: SAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST, - }, - { - 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, - }, - { - badge: { - text: DAST_BADGE_TEXT, - tooltipText: DAST_BADGE_TOOLTIP, - variant: 'info', - }, - secondary: { - type: REPORT_TYPE_DAST_PROFILES, - name: DAST_PROFILES_NAME, - description: DAST_PROFILES_DESCRIPTION, - configurationText: DAST_PROFILES_CONFIG_TEXT, - }, - name: DAST_NAME, - shortName: DAST_SHORT_NAME, - description: DAST_DESCRIPTION, - helpPath: DAST_HELP_PATH, - configurationHelpPath: DAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_DAST, - anchor: 'dast', - }, - { - name: DEPENDENCY_SCANNING_NAME, - description: DEPENDENCY_SCANNING_DESCRIPTION, - helpPath: DEPENDENCY_SCANNING_HELP_PATH, - configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_DEPENDENCY_SCANNING, - anchor: 'dependency-scanning', - slotComponent: ContinuousVulnerabilityScan, - }, - { - name: CONTAINER_SCANNING_NAME, - description: CONTAINER_SCANNING_DESCRIPTION, - helpPath: CONTAINER_SCANNING_HELP_PATH, - configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_CONTAINER_SCANNING, - }, - { - name: SECRET_DETECTION_NAME, - description: SECRET_DETECTION_DESCRIPTION, - helpPath: SECRET_DETECTION_HELP_PATH, - configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, - type: REPORT_TYPE_SECRET_DETECTION, - }, - { - name: API_FUZZING_NAME, - description: API_FUZZING_DESCRIPTION, - helpPath: API_FUZZING_HELP_PATH, - type: REPORT_TYPE_API_FUZZING, - }, - { - name: COVERAGE_FUZZING_NAME, - description: COVERAGE_FUZZING_DESCRIPTION, - helpPath: COVERAGE_FUZZING_HELP_PATH, - configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, - type: REPORT_TYPE_COVERAGE_FUZZING, - secondary: { - type: REPORT_TYPE_CORPUS_MANAGEMENT, - name: CORPUS_MANAGEMENT_NAME, - description: CORPUS_MANAGEMENT_DESCRIPTION, - configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, - }, - }, - { - anchor: 'bas', - badge: { - alwaysDisplay: true, - text: BAS_BADGE_TEXT, - tooltipText: BAS_BADGE_TOOLTIP, - variant: 'info', - }, - description: BAS_DESCRIPTION, - name: BAS_NAME, - helpPath: BAS_HELP_PATH, - secondary: { - configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, - description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, - name: BAS_DAST_FEATURE_FLAG_NAME, - }, - shortName: BAS_SHORT_NAME, - type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, - }, -]; - -export const featureToMutationMap = { - [REPORT_TYPE_SAST]: { - mutationId: 'configureSast', - getMutationPayload: (projectPath) => ({ - mutation: configureSastMutation, - variables: { - input: { - projectPath, - configuration: { global: [], pipeline: [], analyzers: [] }, - }, - }, - }), - }, - [REPORT_TYPE_SAST_IAC]: { - mutationId: 'configureSastIac', - getMutationPayload: (projectPath) => ({ - mutation: configureSastIacMutation, - variables: { - input: { - projectPath, - }, - }, - }), - }, - [REPORT_TYPE_SECRET_DETECTION]: { - mutationId: 'configureSecretDetection', - getMutationPayload: (projectPath) => ({ - mutation: configureSecretDetectionMutation, - variables: { - input: { - projectPath, - }, - }, - }), - }, -}; - -export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = - 'security_configuration_auto_devops_enabled_dismissed_projects'; - -// Fetch the svg path from the GraphQL query once this issue is resolved -// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 -export const TEMP_PROVIDER_LOGOS = { - Kontra: { - svg: kontraLogo, - }, - [__('Secure Code Warrior')]: { - svg: scwLogo, - }, - SecureFlag: { - svg: secureflagLogo, - }, -}; - -// Use the `url` field from the GraphQL query once this issue is resolved -// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 -export const TEMP_PROVIDER_URLS = { - Kontra: 'https://application.security/', - [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', - SecureFlag: 'https://www.secureflag.com/', -}; - -export const TAB_VULNERABILITY_MANAGEMENT_INDEX = 1; diff --git a/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue b/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue deleted file mode 100644 index 61cbde2107c..00000000000 --- a/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue +++ /dev/null @@ -1,127 +0,0 @@ -<script> -import { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; -import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { __, s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -export default { - name: 'ContinuousVulnerabilityscan', - components: { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert }, - mixins: [glFeatureFlagsMixin()], - inject: ['continuousVulnerabilityScansEnabled', 'projectFullPath'], - i18n: { - badgeLabel: __('Experiment'), - title: s__('CVS|Continuous Vulnerability Scan'), - description: s__( - 'CVS|Detect vulnerabilities outside a pipeline as new data is added to the GitLab Advisory Database.', - ), - learnMore: __('Learn more'), - testingAgreementMessage: s__( - 'CVS|By enabling this feature, you accept the %{linkStart}Testing Terms of Use%{linkEnd}', - ), - }, - props: { - feature: { - type: Object, - required: true, - }, - }, - data() { - return { - toggleValue: this.continuousVulnerabilityScansEnabled, - errorMessage: '', - isAlertDismissed: false, - }; - }, - computed: { - isFeatureConfigured() { - return this.feature.available && this.feature.configured; - }, - shouldShowAlert() { - return this.errorMessage && !this.isAlertDismissed; - }, - }, - methods: { - reportError(error) { - this.errorMessage = error; - this.isAlertDismissed = false; - }, - async toggleCVS(checked) { - try { - const { data } = await this.$apollo.mutate({ - mutation: ProjectSetContinuousVulnerabilityScanning, - variables: { - input: { - projectPath: this.projectFullPath, - enable: checked, - }, - }, - }); - - const { errors } = data.projectSetContinuousVulnerabilityScanning; - - if (errors.length > 0) { - this.reportError(errors[0].message); - } - if (data.projectSetContinuousVulnerabilityScanning !== null) { - this.toggleValue = checked; - } - } catch (error) { - this.reportError(error); - } - }, - }, - CVSHelpPagePath: helpPagePath( - 'user/application_security/continuous_vulnerability_scanning/index', - ), - experimentHelpPagePath: helpPagePath('policy/experiment-beta-support', { anchor: 'experiment' }), -}; -</script> - -<template> - <div v-if="glFeatures.dependencyScanningOnAdvisoryIngestion"> - <h4 class="gl-font-base gl-m-0 gl-mt-6"> - {{ $options.i18n.title }} - <gl-badge - ref="badge" - :href="$options.experimentHelpPagePath" - target="_blank" - size="sm" - variant="neutral" - class="gl-cursor-pointer" - >{{ $options.i18n.badgeLabel }}</gl-badge - > - </h4> - <gl-alert - v-if="shouldShowAlert" - class="gl-mb-5 gl-mt-2" - variant="danger" - @dismiss="isAlertDismissed = true" - >{{ errorMessage }}</gl-alert - > - <gl-toggle - class="gl-mt-5" - :disabled="!isFeatureConfigured" - :value="toggleValue" - :label="s__('CVS|Toggle CVS')" - label-position="hidden" - @change="toggleCVS" - /> - - <p class="gl-mb-0 gl-mt-5"> - {{ $options.i18n.description }} - <gl-link :href="$options.CVSHelpPagePath" target="_blank">{{ - $options.i18n.learnMore - }}</gl-link> - <br /> - <gl-sprintf :message="$options.i18n.testingAgreementMessage"> - <template #link="{ content }"> - <gl-link href="https://about.gitlab.com/handbook/legal/testing-agreement" target="_blank"> - {{ content }} <gl-icon name="external-link" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> -</template> diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 395bdad5dcc..2100da78219 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -73,9 +73,6 @@ export default { hasSecondary() { return Boolean(this.feature.secondary); }, - hasSlotComponent() { - return Boolean(this.feature.slotComponent); - }, // This condition is a temporary hack to not display any wrong information // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. // More Information: https://gitlab.com/gitlab-org/gitlab/-/issues/350307#note_825447417 @@ -221,9 +218,5 @@ export default { {{ $options.i18n.configurationGuide }} </gl-button> </div> - - <div v-if="hasSlotComponent"> - <component :is="feature.slotComponent" :feature="feature" /> - </div> </gl-card> </template> diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index d424ec6dfeb..ae2894e25a2 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -18,6 +18,8 @@ import { TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, + TEMP_PROVIDER_LOGOS, + TEMP_PROVIDER_URLS, } from '~/security_configuration/constants'; import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; @@ -26,7 +28,6 @@ import { updateSecurityTrainingCache, updateSecurityTrainingOptimisticResponse, } from '~/security_configuration/graphql/cache_utils'; -import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants'; const i18n = { providerQueryErrorMessage: __( diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index 14eb10ac2aa..94bcf81a3eb 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -1,3 +1,334 @@ +import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw'; +import scwLogo from 'images/vulnerability/scw-logo.svg?raw'; +import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SAST_IAC, + REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + REPORT_TYPE_SECRET_DETECTION, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_CORPUS_MANAGEMENT, + REPORT_TYPE_API_FUZZING, +} from '~/vue_shared/security_reports/constants'; + +import configureSastMutation from './graphql/configure_sast.mutation.graphql'; +import configureSastIacMutation from './graphql/configure_iac.mutation.graphql'; +import configureSecretDetectionMutation from './graphql/configure_secret_detection.mutation.graphql'; + +/** + * 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)'); +export const SAST_SHORT_NAME = s__('ciReport|SAST'); +export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); +export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); +export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { + anchor: 'configuration', +}); + +export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); +export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC'); +export const SAST_IAC_DESCRIPTION = __( + 'Analyze your infrastructure as code configuration files for known vulnerabilities.', +); +export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); +export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/iac_scanning/index', + { + anchor: 'configuration', + }, +); + +export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); +export const DAST_SHORT_NAME = s__('ciReport|DAST'); +export const DAST_DESCRIPTION = s__( + 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', +); +export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); +export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { + anchor: 'enable-automatic-dast-run', +}); +export const DAST_BADGE_TEXT = __('Available on demand'); +export const DAST_BADGE_TOOLTIP = __( + 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', +); + +export const DAST_PROFILES_NAME = __('DAST profiles'); +export const DAST_PROFILES_DESCRIPTION = s__( + 'SecurityConfiguration|Manage profiles for use by DAST scans.', +); +export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); + +export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); +export const BAS_BADGE_TOOLTIP = s__( + 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', +); +export const BAS_DESCRIPTION = s__( + 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', +); +export const BAS_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', +); +export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); +export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); + +export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( + 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', +); +export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', + { anchor: 'extend-dynamic-application-security-testing-dast' }, +); +export const BAS_DAST_FEATURE_FLAG_NAME = s__( + 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', +); + +export const SECRET_DETECTION_NAME = __('Secret Detection'); +export const SECRET_DETECTION_DESCRIPTION = __( + 'Analyze your source code and git history for secrets.', +); +export const SECRET_DETECTION_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', +); +export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', + { anchor: 'configuration' }, +); + +export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); +export const DEPENDENCY_SCANNING_DESCRIPTION = __( + 'Analyze your dependencies for known vulnerabilities.', +); +export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', +); +export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', + { anchor: 'configuration' }, +); + +export const CONTAINER_SCANNING_NAME = __('Container Scanning'); +export const CONTAINER_SCANNING_DESCRIPTION = __( + 'Check your Docker images for known vulnerabilities.', +); +export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', +); +export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', + { anchor: 'configuration' }, +); + +export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); +export const COVERAGE_FUZZING_DESCRIPTION = __( + 'Find bugs in your code with coverage-guided fuzzing.', +); +export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', +); +export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', + { anchor: 'enable-coverage-guided-fuzz-testing' }, +); + +export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); +export const CORPUS_MANAGEMENT_DESCRIPTION = s__( + 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', +); +export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); + +export const API_FUZZING_NAME = __('API Fuzzing'); +export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); +export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); + +export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); + +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, + COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, + SECRET_DETECTION: SECRET_DETECTION_NAME, + DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, + BREACH_AND_ATTACK_SIMULATION: BAS_NAME, + CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, + GENERIC: s__('ciReport|Manually added'), +}; + +export const securityFeatures = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + }, + { + 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, + }, + { + badge: { + text: DAST_BADGE_TEXT, + tooltipText: DAST_BADGE_TOOLTIP, + variant: 'info', + }, + secondary: { + type: REPORT_TYPE_DAST_PROFILES, + name: DAST_PROFILES_NAME, + description: DAST_PROFILES_DESCRIPTION, + configurationText: DAST_PROFILES_CONFIG_TEXT, + }, + name: DAST_NAME, + shortName: DAST_SHORT_NAME, + description: DAST_DESCRIPTION, + helpPath: DAST_HELP_PATH, + configurationHelpPath: DAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_DAST, + anchor: 'dast', + }, + { + name: DEPENDENCY_SCANNING_NAME, + description: DEPENDENCY_SCANNING_DESCRIPTION, + helpPath: DEPENDENCY_SCANNING_HELP_PATH, + configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_DEPENDENCY_SCANNING, + anchor: 'dependency-scanning', + }, + { + name: CONTAINER_SCANNING_NAME, + description: CONTAINER_SCANNING_DESCRIPTION, + helpPath: CONTAINER_SCANNING_HELP_PATH, + configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_CONTAINER_SCANNING, + }, + { + name: SECRET_DETECTION_NAME, + description: SECRET_DETECTION_DESCRIPTION, + helpPath: SECRET_DETECTION_HELP_PATH, + configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, + type: REPORT_TYPE_SECRET_DETECTION, + }, + { + name: API_FUZZING_NAME, + description: API_FUZZING_DESCRIPTION, + helpPath: API_FUZZING_HELP_PATH, + type: REPORT_TYPE_API_FUZZING, + }, + { + name: COVERAGE_FUZZING_NAME, + description: COVERAGE_FUZZING_DESCRIPTION, + helpPath: COVERAGE_FUZZING_HELP_PATH, + configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, + type: REPORT_TYPE_COVERAGE_FUZZING, + secondary: { + type: REPORT_TYPE_CORPUS_MANAGEMENT, + name: CORPUS_MANAGEMENT_NAME, + description: CORPUS_MANAGEMENT_DESCRIPTION, + configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, + }, + }, + { + anchor: 'bas', + badge: { + alwaysDisplay: true, + text: BAS_BADGE_TEXT, + tooltipText: BAS_BADGE_TOOLTIP, + variant: 'info', + }, + description: BAS_DESCRIPTION, + name: BAS_NAME, + helpPath: BAS_HELP_PATH, + secondary: { + configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, + description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, + name: BAS_DAST_FEATURE_FLAG_NAME, + }, + shortName: BAS_SHORT_NAME, + type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + }, +]; + +export const featureToMutationMap = { + [REPORT_TYPE_SAST]: { + mutationId: 'configureSast', + getMutationPayload: (projectPath) => ({ + mutation: configureSastMutation, + variables: { + input: { + projectPath, + configuration: { global: [], pipeline: [], analyzers: [] }, + }, + }, + }), + }, + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, + [REPORT_TYPE_SECRET_DETECTION]: { + mutationId: 'configureSecretDetection', + getMutationPayload: (projectPath) => ({ + mutation: configureSecretDetectionMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, +}; + +export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = + 'security_configuration_auto_devops_enabled_dismissed_projects'; + +// Fetch the svg path from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 +export const TEMP_PROVIDER_LOGOS = { + Kontra: { + svg: kontraLogo, + }, + [__('Secure Code Warrior')]: { + svg: scwLogo, + }, + SecureFlag: { + svg: secureflagLogo, + }, +}; + +// Use the `url` field from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 +export const TEMP_PROVIDER_URLS = { + Kontra: 'https://application.security/', + [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', + SecureFlag: 'https://www.secureflag.com/', +}; + +export const TAB_VULNERABILITY_MANAGEMENT_INDEX = 1; + export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider'; export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider'; export const TRACK_CLICK_TRAINING_LINK_ACTION = 'click_security_training_link'; @@ -6,3 +337,25 @@ export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider export const TRACK_TRAINING_LOADED_ACTION = 'security_training_link_loaded'; export const TRACK_PROMOTION_BANNER_CTA_CLICK_ACTION = 'click_button'; export const TRACK_PROMOTION_BANNER_CTA_CLICK_LABEL = 'security_training_promotion_cta'; + +export const i18n = { + configurationHistory: s__('SecurityConfiguration|Configuration history'), + securityTesting: s__('SecurityConfiguration|Security testing'), + latestPipelineDescription: s__( + `SecurityConfiguration|The status of the tools only applies to the + default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, + ), + description: s__( + `SecurityConfiguration|Once you've enabled a scan for the default branch, + any subsequent feature branch you create will include the scan. An enabled + scanner will not be reflected as such until the pipeline has been + successfully executed and it has generated valid artifacts.`, + ), + securityConfiguration: __('Security configuration'), + vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), + securityTraining: s__('SecurityConfiguration|Security training'), + securityTrainingDescription: s__( + 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability. Please note that security training is not accessible in an environment that is offline.', + ), + securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), +}; diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 4b498091134..8086b200891 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; -import { securityFeatures } from './components/constants'; +import { securityFeatures } from './constants'; import { augmentFeatures } from './utils'; export const initSecurityConfiguration = (el) => { @@ -26,7 +26,6 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, - continuousVulnerabilityScansEnabled, } = el.dataset; const { augmentedSecurityFeatures } = augmentFeatures( @@ -44,7 +43,6 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, - continuousVulnerabilityScansEnabled, }, render(createElement) { return createElement(SecurityConfigurationApp, { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 7f0caf1af46..59b49cb3820 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,5 +1,5 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants'; /** diff --git a/app/assets/javascripts/set_status_modal/constants.js b/app/assets/javascripts/set_status_modal/constants.js index 53e64db1497..86e24c29775 100644 --- a/app/assets/javascripts/set_status_modal/constants.js +++ b/app/assets/javascripts/set_status_modal/constants.js @@ -12,3 +12,5 @@ export const AVAILABILITY_STATUS = { BUSY: 'busy', NOT_SET: 'not_set', }; + +export const SET_STATUS_MODAL_ID = 'set-user-status-modal'; diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index 60ed0d073fe..b6d609ab1fa 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -14,7 +14,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; import { s__ } from '~/locale'; -import { formatDate, newDate, nSecondsAfter, isToday } from '~/lib/utils/datetime_utility'; +import { newDate, nSecondsAfter, isToday, localeDateFormat } from '~/lib/utils/datetime_utility'; import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants'; export default { @@ -148,10 +148,10 @@ export default { }, formatClearStatusAfterDate(date) { if (isToday(date)) { - return formatDate(date, 'h:MMtt'); + return localeDateFormat.asTime.format(date); } - return formatDate(date, 'mmm d, yyyy h:MMtt'); + return localeDateFormat.asDateTime.format(date); }, }, TIME_RANGES_WITH_NEVER, 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 270d7f0d182..7f229e5c5ed 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 @@ -2,17 +2,18 @@ import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; import { createAlert } from '~/alert'; -import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import { BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isUserBusy, computedClearStatusAfterValue } from './utils'; -import { AVAILABILITY_STATUS } from './constants'; +import { AVAILABILITY_STATUS, SET_STATUS_MODAL_ID } from './constants'; import SetStatusForm from './set_status_form.vue'; Vue.use(GlToast); export default { + SET_STATUS_MODAL_ID, components: { GlModal, SetStatusForm, @@ -29,11 +30,13 @@ export default { }, currentEmoji: { type: String, - required: true, + required: false, + default: '', }, currentMessage: { type: String, - required: true, + required: false, + default: '', }, currentAvailability: { type: String, @@ -51,7 +54,6 @@ export default { defaultEmojiTag: '', emoji: this.currentEmoji, message: this.currentMessage, - modalId: 'set-user-status-modal', availability: isUserBusy(this.currentAvailability), clearStatusAfter: null, }; @@ -65,11 +67,11 @@ export default { }, }, mounted() { - this.$root.$emit(BV_SHOW_MODAL, this.modalId); + this.$emit('mounted'); }, methods: { closeModal() { - this.$root.$emit(BV_HIDE_MODAL, this.modalId); + this.$root.$emit(BV_HIDE_MODAL, SET_STATUS_MODAL_ID); }, removeStatus() { this.availability = false; @@ -132,7 +134,7 @@ export default { <template> <gl-modal :title="s__('SetStatusModal|Set a status')" - :modal-id="modalId" + :modal-id="$options.SET_STATUS_MODAL_ID" :action-primary="$options.actionPrimary" :action-secondary="$options.actionSecondary" modal-class="set-user-status-modal" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 0563ed8394c..897cd3583c8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,5 +1,4 @@ <script> -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; @@ -104,8 +103,6 @@ export default { .then(() => { this.loading = false; this.store.resetChanging(); - - refreshUserMergeRequestCounts(); }) .catch(() => { this.loading = false; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue index 50fcd3c9350..478d261d06c 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue @@ -37,7 +37,6 @@ export default { category="tertiary" size="small" class="float-right js-sidebar-dropdown-toggle gl-mr-n2" - data-qa-selector="labels_edit_button" @click="toggleDropdownContents" > {{ __('Edit') }} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue index f2ce02526e7..e3be7d549ab 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue @@ -27,6 +27,11 @@ export default { type: Boolean, required: true, }, + supportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, labelsFilterBasePath: { type: String, required: true, @@ -67,6 +72,12 @@ export default { scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); }, + isLabelLocked(label) { + return label.lockOnMerge && this.supportsLockOnMerge; + }, + showCloseButton(label) { + return this.allowLabelRemove && !this.isLabelLocked(label); + }, removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, @@ -115,7 +126,7 @@ export default { :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" - :show-close-button="allowLabelRemove" + :show-close-button="showCloseButton(label)" :disabled="disableLabels" tooltip-placement="top" @close="removeLabel(label.id)" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue index a3bacc4a674..93c3dce4308 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue @@ -22,6 +22,11 @@ export default { type: Boolean, required: true, }, + supportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, labelsFilterBasePath: { type: String, required: true, @@ -42,9 +47,17 @@ export default { return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`; }, - showScopedLabel(label) { + scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); }, + isLabelLocked(label) { + // These particular labels were initialized from HAML data, so the attributes are + // in snake case instead of camel case + return label.lock_on_merge && this.supportsLockOnMerge; + }, + showCloseButton(label) { + return this.allowLabelRemove && !this.isLabelLocked(label); + }, removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, @@ -63,8 +76,8 @@ export default { :description="label.description" :background-color="label.color" :target="buildFilterUrl(label)" - :scoped="showScopedLabel(label)" - :show-close-button="allowLabelRemove" + :scoped="scopedLabel(label)" + :show-close-button="showCloseButton(label)" :disabled="disabled" tooltip-placement="top" @close="removeLabel(label.id)" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql index e0cdfd91658..5280054f0cc 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -5,9 +5,11 @@ query mergeRequestLabels($fullPath: ID!, $iid: String!) { id issuable: mergeRequest(iid: $iid) { id + supportsLockOnMerge labels { nodes { ...Label + lockOnMerge } } } diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index ac52e4dbf3f..e0d7400f7a6 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -101,6 +101,11 @@ export default { type: String, required: true, }, + issuableSupportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, workspaceType: { type: String, required: true, @@ -151,6 +156,9 @@ export default { isLabelListEnabled() { return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant); }, + isLockOnMergeSupported() { + return this.issuableSupportsLockOnMerge || this.issuable?.supportsLockOnMerge; + }, }, apollo: { issuable: { @@ -376,6 +384,7 @@ export default { :disable-labels="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" @@ -389,6 +398,7 @@ export default { :disable-labels="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" class="gl-mb-2" @@ -440,6 +450,7 @@ export default { :disabled="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 99d36a61632..251a038f7ee 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -110,12 +110,12 @@ export default { </div> <div v-if="showParticipantLabel" - class="title hide-collapsed gl-mb-2! gl-line-height-20 gl-font-weight-bold" + class="title hide-collapsed gl-line-height-20 gl-font-weight-bold gl-mb-0!" > <gl-loading-icon v-if="loading" size="sm" :inline="true" /> {{ participantLabel }} </div> - <div class="hide-collapsed gl-display-flex gl-flex-wrap"> + <div class="hide-collapsed gl-display-flex gl-flex-wrap gl-mt-2 gl-mb-n3"> <div v-for="participant in visibleParticipants" :key="participant.id" diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index 9c23f239b4c..1d1dbd51756 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -2,11 +2,12 @@ // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import Vue from 'vue'; -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; import eventHub from '../../event_hub'; import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql'; import mergeRequestReviewersUpdatedSubscription from '../../queries/merge_request_reviewers.subscription.graphql'; @@ -26,6 +27,7 @@ export default { ReviewerTitle, Reviewers, }, + mixins: [glFeatureFlagsMixin()], props: { mediator: { type: Object, @@ -56,6 +58,7 @@ export default { return { iid: this.issuableIid, fullPath: this.projectPath, + mrRequestChanges: this.glFeatures.mrRequestChanges, }; }, update(data) { @@ -74,6 +77,7 @@ export default { variables() { return { issuableId: this.issuable?.id, + mrRequestChanges: this.glFeatures.mrRequestChanges, }; }, skip() { @@ -153,7 +157,7 @@ export default { .saveReviewers(this.field) .then(() => { this.loading = false; - refreshUserMergeRequestCounts(); + fetchUserCounts(); this.$apollo.queries.issuable.refetch(); }) .catch(() => { diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index 99f9d5e872c..3d0e7db6a68 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -2,12 +2,36 @@ import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf, s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; const SUCCESS_STATE = 'success'; const JUST_APPROVED = 'approved'; +const REVIEW_STATE_ICONS = { + APPROVED: { + name: 'status-success', + class: 'gl-text-green-500', + title: __('Reviewer approved changes'), + }, + REQUESTED_CHANGES: { + name: 'status-alert', + class: 'gl-text-red-500', + title: __('Reviewer requested changes'), + }, + REVIEWED: { + name: 'comment', + class: 'gl-bg-blue-500 gl-text-white gl-icon s16 gl-rounded-full gl--flex-center', + size: 8, + title: __('Reviewer commented'), + }, + UNREVIEWED: { + name: 'dotted-circle', + title: __('Awaiting review'), + }, +}; + export default { i18n: { reRequestReview: __('Re-request review'), @@ -20,6 +44,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { users: { type: Array, @@ -105,6 +130,25 @@ export default { this.loadingStates[userId] = null; } }, + reviewStateIcon(user) { + if (user.mergeRequestInteraction.approved) { + return { + ...REVIEW_STATE_ICONS.APPROVED, + class: [ + REVIEW_STATE_ICONS.APPROVED.class, + this.loadingStates[user.id] === JUST_APPROVED && 'merge-request-approved-icon', + ], + }; + } + return REVIEW_STATE_ICONS[user.mergeRequestInteraction.reviewState]; + }, + showRequestReviewButton(user) { + if (this.glFeatures.mrRequestChanges && !user.mergeRequestInteraction.approved) { + return user.mergeRequestInteraction.reviewState !== 'UNREVIEWED'; + } + + return true; + }, }, LOADING_STATE, SUCCESS_STATE, @@ -134,7 +178,7 @@ export default { </div> </reviewer-avatar-link> <gl-button - v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed" + v-if="user.mergeRequestInteraction.canUpdate && showRequestReviewButton(user)" v-gl-tooltip.left :title="$options.i18n.reRequestReview" :aria-label="$options.i18n.reRequestReview" @@ -146,25 +190,42 @@ export default { data-testid="re-request-button" @click="reRequestReview(user.id)" /> - <gl-icon - v-if="user.mergeRequestInteraction.approved" - v-gl-tooltip.left - :size="16" - :title="approvedByTooltipTitle(user)" - name="status-success" - class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" - :class="approveAnimation(user.id)" - data-testid="approved" - /> - <gl-icon - v-else-if="user.mergeRequestInteraction.reviewed" - v-gl-tooltip.left - :size="16" - :title="reviewedButNotApprovedTooltip(user)" - name="dotted-circle" - class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0" - data-testid="reviewed-not-approved" - /> + <template v-if="glFeatures.mrRequestChanges"> + <span + v-gl-tooltip.top.viewport + :title="reviewStateIcon(user).title" + :class="reviewStateIcon(user).class" + class="float-right gl-my-2 gl-ml-auto gl-flex-shrink-0" + > + <gl-icon + :size="reviewStateIcon(user).size || 16" + :name="reviewStateIcon(user).name" + :aria-label="reviewStateIcon(user).title" + data-testid="reviewer-state-icon" + /> + </span> + </template> + <template v-else> + <gl-icon + v-if="user.mergeRequestInteraction.approved" + v-gl-tooltip.left + :size="16" + :title="approvedByTooltipTitle(user)" + name="status-success" + class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0" + :class="approveAnimation(user.id)" + data-testid="approved" + /> + <gl-icon + v-else-if="user.mergeRequestInteraction.reviewed" + v-gl-tooltip.left + :size="16" + :title="reviewedButNotApprovedTooltip(user)" + name="dotted-circle" + class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0" + data-testid="reviewed-not-approved" + /> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue index c9450244b40..5cc3c552bf8 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue @@ -87,6 +87,11 @@ export default { return [WORKSPACE_GROUP, WORKSPACE_PROJECT].includes(value); }, }, + showWorkItemEpics: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -115,6 +120,7 @@ export default { fullPath: this.attrWorkspacePath, state: this.issuableAttributesState[this.issuableAttribute], sort: defaultEpicSort, + includeWorkItems: this.showWorkItemEpics, }; if (epicIidPattern.test(this.searchTerm)) { @@ -127,7 +133,12 @@ export default { return variables; }, - update: (data) => data?.workspace?.attributes?.nodes ?? [], + update(data) { + return [ + ...(data?.workspace?.attributes?.nodes ?? []), + ...(data?.workspace?.workItems?.nodes ?? []), + ]; + }, error(error) { createAlert({ message: this.i18n.listFetchError, captureError: true, error }); }, @@ -188,7 +199,7 @@ export default { this.skipQuery = false; }, setFocus() { - this.$refs.search.focusInput(); + this.$refs?.search?.focusInput(); }, show() { this.$refs.dropdown.show(); @@ -211,7 +222,12 @@ export default { @show="handleShow" @shown="setFocus" > - <gl-search-box-by-type ref="search" v-model="searchTerm" :placeholder="__('Search')" /> + <gl-search-box-by-type + v-if="!showWorkItemEpics" + ref="search" + v-model="searchTerm" + :placeholder="__('Search')" + /> <gl-dropdown-item :data-testid="`no-${formatIssuableAttribute.kebab}-item`" is-check-item diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 28b88a59405..0ecf89bd169 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -3,10 +3,11 @@ import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab import { kebabCase, snakeCase } from 'lodash'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + import { dropdowni18nText, LocalizedIssuableAttributeType, @@ -79,6 +80,21 @@ export default { required: false, default: undefined, }, + showWorkItemEpics: { + type: Boolean, + required: false, + default: false, + }, + isEpicAttribute: { + type: Boolean, + required: false, + default: false, + }, + issuableParent: { + type: Object, + required: false, + default: null, + }, }, apollo: { issuable: { @@ -98,7 +114,7 @@ export default { return data.workspace?.issuable || {}; }, result({ data }) { - if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { + if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpicAttribute) { this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; } }, @@ -140,6 +156,9 @@ export default { }, computed: { currentAttribute() { + if (this.isEpicAttribute && this.issuableParent?.attribute) { + return this.issuableParent.attribute; + } return this.issuable.attribute; }, issuableId() { @@ -171,10 +190,6 @@ export default { LocalizedIssuableAttributeType[IssuableAttributeTypeKeyMap[this.issuableAttribute]]; return dropdowni18nText(localizedAttribute, this.issuableType); }, - isEpic() { - // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 - return this.issuableAttribute === TYPE_EPIC; - }, formatIssuableAttribute() { return { kebab: kebabCase(this.issuableAttribute), @@ -186,7 +201,7 @@ export default { return false; } - return this.isEpic && this.currentAttribute === null && this.hasCurrentAttribute + return this.isEpicAttribute && this.currentAttribute === null && this.hasCurrentAttribute ? !this.editConfirmation : false; }, @@ -195,46 +210,50 @@ export default { }, }, methods: { - updateAttribute({ id }) { + updateAttribute({ id, workItemType }) { if (this.currentAttribute === null && id === null) return; if (id === this.currentAttribute?.id) return; - this.updating = true; + if (this.showWorkItemEpics && this.isEpicAttribute) { + this.$emit('updateAttribute', { id, workItemType }); + } else { + this.updating = true; - const { current } = this.issuableAttributeQuery; - const { mutation } = current[this.issuableType]; + const { current } = this.issuableAttributeQuery; + const { mutation } = current[this.issuableType]; - this.$apollo - .mutate({ - mutation, - variables: { - fullPath: this.workspacePath, - attributeId: - this.issuableAttribute === IssuableAttributeType.Milestone && - this.issuableType === TYPE_ISSUE - ? getIdFromGraphQLId(id) - : id, - iid: this.iid, - }, - }) - .then(({ data }) => { - if (data.issuableSetAttribute?.errors?.length) { - createAlert({ - message: data.issuableSetAttribute.errors[0], - captureError: true, - error: data.issuableSetAttribute.errors[0], - }); - } else { - this.$emit('attribute-updated', data); - } - }) - .catch((error) => { - createAlert({ message: this.i18n.updateError, captureError: true, error }); - }) - .finally(() => { - this.updating = false; - this.selectedTitle = null; - }); + this.$apollo + .mutate({ + mutation, + variables: { + fullPath: this.workspacePath, + attributeId: + this.issuableAttribute === IssuableAttributeType.Milestone && + this.issuableType === TYPE_ISSUE + ? getIdFromGraphQLId(id) + : id, + iid: this.iid, + }, + }) + .then(({ data }) => { + if (data.issuableSetAttribute?.errors?.length) { + createAlert({ + message: data.issuableSetAttribute.errors[0], + captureError: true, + error: data.issuableSetAttribute.errors[0], + }); + } else { + this.$emit('attribute-updated', data); + } + }) + .catch((error) => { + createAlert({ message: this.i18n.updateError, captureError: true, error }); + }) + .finally(() => { + this.updating = false; + this.selectedTitle = null; + }); + } }, isAttributeOverdue(attribute) { return this.issuableAttribute === IssuableAttributeType.Milestone @@ -356,6 +375,7 @@ export default { :current-attribute="currentAttribute" :issuable-attribute="issuableAttribute" :issuable-type="issuableType" + :show-work-item-epics="showWorkItemEpics" @change="updateAttribute" > <template #list="{ attributesList, isAttributeChecked, updateAttribute: update }"> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 2653748861b..ad83866ceb2 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -164,7 +164,7 @@ export default { </gl-button> </div> <template v-if="!initialLoading"> - <div v-show="!edit" data-testid="collapsed-content" class="gl-line-height-14"> + <div v-show="!edit" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> diff --git a/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql index f087ca6c982..6d3d2302a19 100644 --- a/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query mergeRequestReviewers($fullPath: ID!, $iid: String!) { +query mergeRequestReviewers($fullPath: ID!, $iid: String!, $mrRequestChanges: Boolean!) { workspace: project(fullPath: $fullPath) { id issuable: mergeRequest(iid: $iid) { @@ -14,7 +14,8 @@ query mergeRequestReviewers($fullPath: ID!, $iid: String!) { canMerge canUpdate approved - reviewed + reviewed @skip(if: $mrRequestChanges) + reviewState @include(if: $mrRequestChanges) } } } diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql index a1b16b378b3..0fcfe297394 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) { +subscription mergeRequestReviewersUpdated($issuableId: IssuableID!, $mrRequestChanges: Boolean!) { mergeRequestReviewersUpdated(issuableId: $issuableId) { ... on MergeRequest { id @@ -13,7 +13,8 @@ subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) { canMerge canUpdate approved - reviewed + reviewed @skip(if: $mrRequestChanges) + reviewState @include(if: $mrRequestChanges) } } } diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 9b0a1db23f2..3a85f66cfa2 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -56,6 +56,7 @@ export default { .get(url, { // This prevents axios from automatically JSON.parse response transformResponse: [(f) => f], + headers: { 'Cache-Control': 'no-cache' }, }) .then((res) => { this.notifyAboutUpdates({ content: res.data }); diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue index 0e4dbf55963..33058fcc58b 100644 --- a/app/assets/javascripts/snippets/components/snippet_title.vue +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -1,15 +1,24 @@ <script> -import { GlSprintf } from '@gitlab/ui'; - +import { GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import SnippetDescription from './snippet_description_view.vue'; export default { + name: 'SnippetTitle', + i18n: { + hiddenTooltip: s__('Snippets|This snippet is hidden because its author has been banned'), + hiddenAriaLabel: __('Hidden'), + }, components: { + GlIcon, TimeAgoTooltip, GlSprintf, SnippetDescription, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { snippet: { type: Object, @@ -19,14 +28,31 @@ export default { }; </script> <template> - <div class="snippet-header limited-header-width"> - <h2 class="snippet-title gl-mt-0 mb-3" data-testid="snippet-title-content"> - {{ snippet.title }} - </h2> + <div class="snippet-header limited-header-width gl-py-3"> + <div class="gl-display-flex"> + <span + v-if="snippet.hidden" + class="gl-bg-orange-50 gl-text-orange-600 gl-h-6 gl-w-6 border-radius-default gl-line-height-24 gl-text-center gl-mr-3 gl-mt-2" + > + <gl-icon + v-gl-tooltip.bottom + name="spam" + :title="$options.i18n.hiddenTooltip" + :aria-label="$options.i18n.hiddenAriaLabel" + /> + </span> + + <h2 class="snippet-title gl-mt-0 gl-mb-5" data-testid="snippet-title-content"> + {{ snippet.title }} + </h2> + </div> <snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" /> - <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text"> + <small + v-if="snippet.updatedAt !== snippet.createdAt" + class="edited-text gl-text-secondary gl-display-inline-block gl-mt-4" + > <gl-sprintf :message="__('Edited %{timeago}')"> <template #timeago> <time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/sortable/constants.js b/app/assets/javascripts/sortable/constants.js index f5bb0a3b11f..eac8033448b 100644 --- a/app/assets/javascripts/sortable/constants.js +++ b/app/assets/javascripts/sortable/constants.js @@ -1,4 +1,5 @@ export const DRAG_CLASS = 'is-dragging'; +export const DRAG_DELAY = 100; /** * Default config options for sortablejs. diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index 49efc5ab5b9..267f9d4321b 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -48,7 +48,7 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none gl-focus--focus" + class="user-bar-button gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-rounded-base gl-border-none gl-line-height-1 gl-font-sm gl-hover-text-decoration-none" > <gl-icon aria-hidden="true" :name="icon" /> <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ formattedCount }}</span> diff --git a/app/assets/javascripts/super_sidebar/components/extra_info.vue b/app/assets/javascripts/super_sidebar/components/extra_info.vue new file mode 100644 index 00000000000..23340f1190f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/extra_info.vue @@ -0,0 +1,7 @@ +<script> +export default {}; +</script> +<template> + <!-- This is intentionally left blank --> + <div></div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue index 6f0a0a1fe79..252967b33b5 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue @@ -1,36 +1,33 @@ <script> import { s__ } from '~/locale'; -import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants'; +import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql'; import FrequentItems from './frequent_items.vue'; export default { name: 'FrequentlyVisitedGroups', + apollo: { + frecentGroups: { + query: currentUserFrecentGroupsQuery, + }, + }, components: { FrequentItems, }, inject: ['groupsPath'], - data() { - const username = gon.current_username; - - return { - storageKey: username ? `${username}/frequent-groups` : null, - }; - }, i18n: { groupName: s__('Navigation|Frequently visited groups'), viewAllText: s__('Navigation|View all my groups'), emptyStateText: s__('Navigation|Groups you visit often will appear here.'), }, - MAX_FREQUENT_GROUPS_COUNT, }; </script> <template> <frequent-items + :loading="$apollo.queries.frecentGroups.loading" :empty-state-text="$options.i18n.emptyStateText" :group-name="$options.i18n.groupName" - :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" - :storage-key="storageKey" + :items="frecentGroups" view-all-items-icon="group" :view-all-items-text="$options.i18n.viewAllText" :view-all-items-path="groupsPath" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue index 5371887ee0f..b76d238c559 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue @@ -1,31 +1,17 @@ <script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -import { __ } from '~/locale'; export default { name: 'FrequentlyVisitedItem', components: { - GlButton, ProjectAvatar, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { item: { type: Object, required: true, }, }, - methods: { - onRemove() { - this.$emit('remove', this.item); - }, - }, - i18n: { - remove: __('Remove'), - }, }; </script> @@ -49,16 +35,5 @@ export default { {{ item.subtitle }} </div> </div> - - <gl-button - v-gl-tooltip.left - icon="dash" - category="tertiary" - :aria-label="$options.i18n.remove" - :title="$options.i18n.remove" - class="show-on-focus-or-hover--target" - @click.stop.prevent="onRemove" - @keydown.enter.stop.prevent="onRemove" - /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue new file mode 100644 index 00000000000..dce18b2c46e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item_skeleton.vue @@ -0,0 +1,17 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <gl-skeleton-loader :width="737" :height="48"> + <rect width="24" height="24" y="12" x="8" /> + <rect width="120" height="12" y="10" x="36" /> + <rect width="100" height="12" y="26" x="36" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue index ddadd6856ca..60692361683 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue @@ -1,10 +1,10 @@ <script> import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; import { truncateNamespace } from '~/lib/utils/text_utility'; -import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; import { TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; import FrequentItem from './frequent_item.vue'; +import FrequentItemSkeleton from './frequent_item_skeleton.vue'; export default { name: 'FrequentlyVisitedItems', @@ -13,8 +13,14 @@ export default { GlDisclosureDropdownItem, GlIcon, FrequentItem, + FrequentItemSkeleton, }, props: { + loading: { + type: Boolean, + required: false, + default: false, + }, emptyStateText: { type: String, required: true, @@ -23,15 +29,6 @@ export default { type: String, required: true, }, - maxItems: { - type: Number, - required: true, - }, - storageKey: { - type: String, - required: false, - default: null, - }, viewAllItemsText: { type: String, required: true, @@ -45,14 +42,11 @@ export default { required: false, default: null, }, - }, - data() { - return { - items: getItemsFromLocalStorage({ - storageKey: this.storageKey, - maxItems: this.maxItems, - }), - }; + items: { + type: Array, + required: false, + default: () => [], + }, }, computed: { formattedItems() { @@ -83,7 +77,7 @@ export default { })); }, showEmptyState() { - return this.items.length === 0; + return !this.loading && this.formattedItems.length === 0; }, viewAllItem() { return { @@ -92,38 +86,26 @@ export default { }; }, }, - created() { - if (!this.storageKey) { - this.$emit('nothing-to-render'); - } - }, - methods: { - removeItem(item) { - removeItemFromLocalStorage({ - storageKey: this.storageKey, - item, - }); - - this.items = this.items.filter((i) => i.id !== item.id); - }, - }, }; </script> <template> - <gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs"> + <gl-disclosure-dropdown-group v-bind="$attrs"> <template #group-label>{{ groupName }}</template> - <gl-disclosure-dropdown-item - v-for="item of formattedItems" - :key="item.forDropdown.id" - :item="item.forDropdown" - class="show-on-focus-or-hover--context" - > - <template #list-item - ><frequent-item :item="item.forRenderer" @remove="removeItem" - /></template> + <gl-disclosure-dropdown-item v-if="loading"> + <frequent-item-skeleton /> </gl-disclosure-dropdown-item> + <template v-else> + <gl-disclosure-dropdown-item + v-for="item of formattedItems" + :key="item.forDropdown.id" + :item="item.forDropdown" + class="show-on-focus-or-hover--context" + > + <template #list-item><frequent-item :item="item.forRenderer" /></template> + </gl-disclosure-dropdown-item> + </template> <gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text"> <span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue index 35b254099c2..2d13ab3dd4a 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue @@ -1,36 +1,33 @@ <script> import { s__ } from '~/locale'; -import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants'; +import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql'; import FrequentItems from './frequent_items.vue'; export default { name: 'FrequentlyVisitedProjects', + apollo: { + frecentProjects: { + query: currentUserFrecentProjectsQuery, + }, + }, components: { FrequentItems, }, inject: ['projectsPath'], - data() { - const username = gon.current_username; - - return { - storageKey: username ? `${username}/frequent-projects` : null, - }; - }, i18n: { groupName: s__('Navigation|Frequently visited projects'), viewAllText: s__('Navigation|View all my projects'), emptyStateText: s__('Navigation|Projects you visit often will appear here.'), }, - MAX_FREQUENT_PROJECTS_COUNT, }; </script> <template> <frequent-items + :loading="$apollo.queries.frecentProjects.loading" :empty-state-text="$options.i18n.emptyStateText" :group-name="$options.i18n.groupName" - :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" - :storage-key="storageKey" + :items="frecentProjects" view-all-items-icon="project" :view-all-items-text="$options.i18n.viewAllText" :view-all-items-path="projectsPath" diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 069987d4006..5278bd66f47 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -14,9 +14,6 @@ import { STORAGE_KEY } from '~/whats_new/utils/notification'; import Tracking from '~/tracking'; import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS, helpCenterState } from '../constants'; -// Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET = -4; - export default { components: { GlBadge, @@ -50,6 +47,7 @@ export default { return { showWhatsNewNotification: this.shouldShowWhatsNewNotification(), helpCenterState, + toggleWhatsNewDrawer: null, }; }, computed: { @@ -180,12 +178,11 @@ export default { this.showWhatsNewNotification = false; if (!this.toggleWhatsNewDrawer) { - const appEl = document.getElementById('whats-new-app'); const { default: toggleWhatsNewDrawer } = await import( /* webpackChunkName: 'whatsNewApp' */ '~/whats_new' ); this.toggleWhatsNewDrawer = toggleWhatsNewDrawer; - this.toggleWhatsNewDrawer(appEl); + this.toggleWhatsNewDrawer(this.sidebarData.whats_new_version_digest); } else { this.toggleWhatsNewDrawer(); } @@ -204,7 +201,7 @@ export default { }); }, }, - dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, + dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET }, }; </script> @@ -215,7 +212,11 @@ export default { @hidden="trackDropdownToggle(false)" > <template #toggle> - <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> + <gl-button + category="tertiary" + icon="question-o" + class="super-sidebar-help-center-toggle btn-with-notification" + > <span v-if="showWhatsNewNotification" data-testid="notification-dot" @@ -250,7 +251,7 @@ export default { <template #list-item="{ item }"> <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> {{ item.text }} - <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-purple-600" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-gray-500" /> </span> </template> </gl-disclosure-dropdown-group> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index a672e254004..292373df9f4 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -1,6 +1,7 @@ <script> import { kebabCase } from 'lodash'; import { GlCollapse, GlIcon } from '@gitlab/ui'; +import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants'; import NavItem from './nav_item.vue'; import FlyoutMenu from './flyout_menu.vue'; @@ -61,9 +62,7 @@ export default { return this.isExpanded ? 'chevron-up' : 'chevron-down'; }, computedLinkClasses() { - return { - 'gl-bg-t-gray-a-08': this.isActive, - }; + return this.isActive ? NAV_ITEM_LINK_ACTIVE_CLASS : null; }, isActive() { return !this.isExpanded && this.item.is_active; @@ -109,9 +108,9 @@ export default { <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" /> <button :id="`menu-section-button-${itemId}`" - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" + class="super-sidebar-nav-item gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" :class="computedLinkClasses" - data-qa-selector="menu_section_button" + data-testid="menu-section-button" :data-qa-section-name="item.title" v-bind="buttonProps" @click="isExpanded = !isExpanded" @@ -126,7 +125,11 @@ export default { ></span> <span class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" /> + <gl-icon + v-if="item.icon" + :name="item.icon" + class="super-sidebar-nav-item-icon gl-m-auto" + /> </slot> </span> @@ -153,7 +156,7 @@ export default { :id="itemId" v-model="isExpanded" class="gl-list-style-none gl-p-0 gl-m-0 gl-transition-duration-medium gl-transition-timing-function-ease" - data-qa-selector="menu_section" + data-testid="menu-section" :data-qa-section-name="item.title" > <slot> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 3ae33bf8b37..14dd704c24c 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -223,20 +223,24 @@ export default { :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--control hide-on-focus-or-hover--control" + class="super-sidebar-nav-item gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--control hide-on-focus-or-hover--control" :class="computedLinkClasses" data-testid="nav-item-link" > <div :class="[isActive ? 'gl-opacity-10' : 'gl-opacity-0']" - class="active-indicator gl-bg-blue-500 gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow" + class="active-indicator gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow" aria-hidden="true" :style="activeIndicatorStyle" data-testid="active-indicator" ></div> <div v-if="!isFlyout" class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" /> + <gl-icon + v-if="item.icon" + :name="item.icon" + class="super-sidebar-nav-item-icon gl-m-auto" + /> <gl-icon v-else-if="isInPinnedSection" name="grip" @@ -264,7 +268,6 @@ export default { v-if="hasPill" size="sm" variant="neutral" - class="gl-bg-t-gray-a-08!" :class="{ 'hide-on-focus-or-hover--target transition-opacity-on-hover--target': isPinnable, }" diff --git a/app/assets/javascripts/super_sidebar/components/scroll_scrim.vue b/app/assets/javascripts/super_sidebar/components/scroll_scrim.vue new file mode 100644 index 00000000000..0e849b08a39 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/scroll_scrim.vue @@ -0,0 +1,72 @@ +<script> +export default { + name: 'ScrollScrim', + data() { + return { + topBoundaryVisible: true, + bottomBoundaryVisible: true, + }; + }, + computed: { + scrimClasses() { + return { + 'top-scrim-visible': !this.topBoundaryVisible, + 'bottom-scrim-visible gl-border-b': !this.bottomBoundaryVisible, + }; + }, + }, + mounted() { + this.observeScroll(); + }, + beforeDestroy() { + this.scrollObserver?.disconnect(); + }, + + methods: { + observeScroll() { + const root = this.$el; + + const options = { + rootMargin: '8px', + root, + threshold: 1.0, + }; + + this.scrollObserver?.disconnect(); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + this[entry.target?.$__visibilityProp] = entry.isIntersecting; + }); + }, options); + + const topBoundary = this.$refs['top-boundary']; + const bottomBoundary = this.$refs['bottom-boundary']; + + topBoundary.$__visibilityProp = 'topBoundaryVisible'; + observer.observe(topBoundary); + + bottomBoundary.$__visibilityProp = 'bottomBoundaryVisible'; + observer.observe(bottomBoundary); + + this.scrollObserver = observer; + }, + }, +}; +</script> + +<template> + <div class="gl-scroll-scrim gl-overflow-auto" :class="scrimClasses"> + <div class="top-scrim-wrapper"> + <div class="top-scrim"></div> + </div> + <div ref="top-boundary"></div> + + <slot></slot> + + <div ref="bottom-boundary"></div> + <div class="bottom-scrim-wrapper"> + <div class="bottom-scrim"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index c04addf5262..5f067621814 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -194,7 +194,7 @@ export default { /> <ul aria-labelledby="super-sidebar-context-header" - class="gl-p-0 gl-list-style-none" + class="gl-p-0 gl-mb-0 gl-list-style-none" data-testid="non-static-items-section" > <template v-for="item in nonStaticItems"> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 5f7cfce93b1..57ba00ee0a6 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -1,9 +1,13 @@ <script> import { GlButton } from '@gitlab/ui'; +import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils'; +import ExtraInfo from 'jh_else_ce/super_sidebar/components/extra_info.vue'; import { Mousetrap } from '~/lib/mousetrap'; +import { TAB_KEY_CODE } from '~/lib/utils/keycodes'; import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; +import eventHub from '../event_hub'; import { sidebarState, SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, @@ -18,16 +22,19 @@ import HelpCenter from './help_center.vue'; import SidebarMenu from './sidebar_menu.vue'; import SidebarPeekBehavior from './sidebar_peek_behavior.vue'; import SidebarHoverPeekBehavior from './sidebar_hover_peek_behavior.vue'; +import ScrollScrim from './scroll_scrim.vue'; export default { components: { GlButton, UserBar, HelpCenter, + ExtraInfo, SidebarMenu, SidebarPeekBehavior, SidebarHoverPeekBehavior, SidebarPortalTarget, + ScrollScrim, TrialStatusWidget: () => import('ee_component/contextual_sidebar/components/trial_status_widget.vue'), TrialStatusPopover: () => @@ -37,6 +44,7 @@ export default { i18n: { skipToMainContent: __('Skip to main content'), primaryNavigation: s__('Navigation|Primary navigation'), + adminArea: s__('Navigation|Admin Area'), }, inject: ['showTrialStatusWidget'], props: { @@ -51,6 +59,7 @@ export default { showPeekHint: false, isMouseover: false, breakpoint: null, + showSuperSidebarContextHeader: true, }; }, computed: { @@ -68,6 +77,13 @@ export default { }; }, }, + watch: { + 'sidebarState.isCollapsed': { + handler() { + this.setupFocusTrapListener(); + }, + }, + }, created() { const { is_logged_in: isLoggedIn, @@ -80,9 +96,12 @@ export default { } }, mounted() { + this.setupFocusTrapListener(); Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar); + eventHub.$on('toggle-menu-header', this.onToggleMenuHeader); }, beforeDestroy() { + document.removeEventListener('keydown', this.focusTrap); Mousetrap.unbind(keysFor(TOGGLE_SUPER_SIDEBAR)); }, methods: { @@ -93,6 +112,17 @@ export default { }); toggleSuperSidebarCollapsed(!isCollapsed(), true); }, + setupFocusTrapListener() { + /** + * Only trap focus when sidebar displays over page content to avoid + * focus moving to page content and being obscured by the sidebar + */ + if (GlBreakpointInstance.windowWidth() < breakpoints.xl && !this.sidebarState.isCollapsed) { + document.addEventListener('keydown', this.focusTrap); + } else { + document.removeEventListener('keydown', this.focusTrap); + } + }, collapseSidebar() { toggleSuperSidebarCollapsed(true, false); }, @@ -122,6 +152,26 @@ export default { this.sidebarState.isCollapsed = true; } }, + focusTrap(event) { + const { keyCode, shiftKey } = event; + const firstFocusableElement = this.$refs.userBar.$el.querySelector('a'); + const lastFocusableElement = this.$refs.helpCenter.$el.querySelector('button'); + + if (keyCode !== TAB_KEY_CODE) return; + + if (shiftKey) { + if (document.activeElement === firstFocusableElement) { + lastFocusableElement.focus(); + event.preventDefault(); + } + } else if (document.activeElement === lastFocusableElement) { + firstFocusableElement.focus(); + event.preventDefault(); + } + }, + onToggleMenuHeader(forceState) { + this.showSuperSidebarContextHeader = forceState; + }, }, }; </script> @@ -144,7 +194,6 @@ export default { class="super-sidebar" :class="peekClasses" data-testid="super-sidebar" - data-qa-selector="navbar" :inert="sidebarState.isCollapsed" @mouseenter="isMouseover = true" @mouseleave="isMouseover = false" @@ -152,18 +201,19 @@ export default { <h2 id="super-sidebar-heading" class="gl-sr-only"> {{ $options.i18n.primaryNavigation }} </h2> - <user-bar :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" /> + <user-bar ref="userBar" :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" /> <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> <trial-status-widget - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-py-3" + class="super-sidebar-nav-item gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-text-decoration-none! gl-py-3" /> <trial-status-popover /> </div> <div class="contextual-nav gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden" > - <div class="gl-flex-grow-1 gl-overflow-auto" data-testid="nav-container"> + <scroll-scrim class="gl-flex-grow-1" data-testid="nav-container"> <div + v-if="showSuperSidebarContextHeader" id="super-sidebar-context-header" class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-weight-bold gl-font-sm super-sidebar-context-header" > @@ -178,9 +228,20 @@ export default { :update-pins-url="sidebarData.update_pins_url" /> <sidebar-portal-target /> - </div> - <div class="gl-p-3"> - <help-center :sidebar-data="sidebarData" /> + </scroll-scrim> + <div class="gl-p-2"> + <help-center ref="helpCenter" :sidebar-data="sidebarData" /> + <gl-button + v-if="sidebarData.is_admin" + class="gl-fixed gl-right-0 gl-mr-3 gl-mt-2" + data-testid="sidebar-admin-link" + :href="sidebarData.admin_url" + icon="admin" + size="small" + > + {{ $options.i18n.adminArea }} + </gl-button> + <extra-info /> </div> </div> </nav> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 3c47245a1a6..3c8bf62ff5c 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -200,7 +200,7 @@ export default { id="super-sidebar-search" v-gl-tooltip.bottom.html="searchTooltip" v-gl-modal="$options.SEARCH_MODAL_ID" - class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full" + class="user-bar-button gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-line-height-1 gl-w-full" data-testid="super-sidebar-search-button" > <gl-icon name="search" /> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 5712b716f48..f129d067cdc 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -5,11 +5,13 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlButton, + GlModalDirective, } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __, sprintf } from '~/locale'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; +import { SET_STATUS_MODAL_ID } from '~/set_status_modal/constants'; import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; import UserMenuProfileItem from './user_menu_profile_item.vue'; @@ -18,10 +20,8 @@ const DROPDOWN_X_OFFSET_BASE = -211; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { + SET_STATUS_MODAL_ID, i18n: { - newNavigation: { - sectionTitle: s__('NorthstarNavigation|Navigation redesign'), - }, setStatus: s__('SetStatusModal|Set status'), editStatus: s__('SetStatusModal|Edit status'), editProfile: s__('CurrentUser|Edit profile'), @@ -39,9 +39,14 @@ export default { GlDisclosureDropdownItem, GlButton, UserMenuProfileItem, + SetStatusModal: () => + import( + /* webpackChunkName: 'statusModalBundle' */ '~/set_status_modal/set_status_modal_wrapper.vue' + ), }, directives: { SafeHtml, + GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], inject: ['isImpersonating'], @@ -51,7 +56,16 @@ export default { type: Object, }, }, + data() { + return { + setStatusModalReady: false, + updatedAvatarUrl: null, + }; + }, computed: { + avatarUrl() { + return this.updatedAvatarUrl || this.data.avatar_url; + }, toggleText() { return sprintf(__('%{user} user’s menu'), { user: this.data.name }); }, @@ -64,7 +78,8 @@ export default { return { text: statusLabel, extraAttrs: { - class: 'js-set-status-modal-trigger', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_edit_status', }, }; }, @@ -86,7 +101,7 @@ export default { text: this.$options.i18n.editProfile, href: this.data.settings.profile_path, extraAttrs: { - 'data-testid': 'edit_profile_link', + 'data-testid': 'edit-profile-link', ...USER_MENU_TRACKING_DEFAULTS, 'data-track-label': 'user_edit_profile', }, @@ -135,7 +150,7 @@ export default { href: this.data.sign_out_link, extraAttrs: { 'data-method': 'post', - 'data-testid': 'sign_out_link', + 'data-testid': 'sign-out-link', class: 'sign-out-link', }, }, @@ -143,24 +158,22 @@ export default { }; }, statusModalData() { - const defaultData = { - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', - }; + if (!this.data?.status?.can_update) { + return null; + } const { busy, customized } = this.data.status; if (!busy && !customized) { - return defaultData; + return {}; } + const { emoji, message, availability, clear_after: clearAfter } = this.data.status; return { - ...defaultData, - 'data-current-emoji': this.data.status.emoji, - 'data-current-message': this.data.status.message, - 'data-current-availability': this.data.status.availability, - 'data-current-clear-status-after': this.data.status.clear_after, + 'current-emoji': emoji || '', + 'current-message': message || '', + 'current-availability': availability || '', + 'current-clear-status-after': clearAfter || '', }; }, buyPipelineMinutesCalloutData() { @@ -181,7 +194,16 @@ export default { }; }, }, + mounted() { + document.addEventListener('userAvatar:update', this.updateAvatar); + }, + unmounted() { + document.removeEventListener('userAvatar:update', this.updateAvatar); + }, methods: { + updateAvatar(event) { + this.updatedAvatarUrl = event.detail?.url; + }, onShow() { this.initBuyCIMinsCallout(); }, @@ -226,14 +248,14 @@ export default { @shown="onShow" > <template #toggle> - <gl-button category="tertiary" class="user-bar-item btn-with-notification"> + <gl-button category="tertiary" class="user-bar-dropdown-toggle btn-with-notification"> <span class="gl-sr-only">{{ toggleText }}</span> <gl-avatar :size="24" :entity-name="data.name" - :src="data.avatar_url" + :src="avatarUrl" aria-hidden="true" - data-testid="user_avatar_content" + data-testid="user-avatar-content" /> <span v-if="showNotificationDot" @@ -251,7 +273,8 @@ export default { <gl-disclosure-dropdown-group bordered> <gl-disclosure-dropdown-item - v-if="data.status.can_update" + v-if="setStatusModalReady && statusModalData" + v-gl-modal="$options.SET_STATUS_MODAL_ID" :item="statusItem" data-testid="status-item" @action="closeDropdown" @@ -307,11 +330,11 @@ export default { @action="trackSignOut" /> </gl-disclosure-dropdown> - - <div - v-if="data.status.can_update" - class="js-set-status-modal-wrapper" + <set-status-modal + v-if="statusModalData" + default-emoji="speech_balloon" v-bind="statusModalData" - ></div> + @mounted="setStatusModalReady = true" + /> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index e96dca3f365..815c2ec8e8e 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -25,9 +25,6 @@ export const helpCenterState = Vue.observable({ showTanukiBotChatDrawer: false, }); -export const MAX_FREQUENT_PROJECTS_COUNT = 5; -export const MAX_FREQUENT_GROUPS_COUNT = 3; - export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; export const SUPER_SIDEBAR_PEEK_STATE_CLOSED = 'closed'; @@ -57,6 +54,18 @@ export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10; export const DROPDOWN_Y_OFFSET = 4; -export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08'; +export const NAV_ITEM_LINK_ACTIVE_CLASS = 'super-sidebar-nav-item-current'; export const IMPERSONATING_OFFSET = 34; + +// Frequent items constants +export const FREQUENT_ITEMS = { + MAX_COUNT: 20, + ELIGIBLE_FREQUENCY: 3, +}; + +export const FIFTEEN_MINUTES_IN_MS = 900000; + +export const STORAGE_KEY = { + projects: 'frequent-projects', +}; diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql new file mode 100644 index 00000000000..82b9a53c36e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql @@ -0,0 +1,9 @@ +query CurrentUserFrecentGroups { + frecentGroups { + id + name + namespace: fullName + webUrl + avatarUrl + } +} diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql new file mode 100644 index 00000000000..4b406d1ea6c --- /dev/null +++ b/app/assets/javascripts/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql @@ -0,0 +1,9 @@ +query CurrentUserFrecentProjects { + frecentProjects { + id + name + namespace: nameWithNamespace + webUrl + avatarUrl + } +} diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 9e540175b48..6aa974878d0 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,7 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; -import { initStatusTriggers } from '../header'; +import createDefaultClient from '~/lib/graphql'; import { JS_TOGGLE_EXPAND_CLASS } from './constants'; import createStore from './components/global_search/store'; import { @@ -12,6 +13,11 @@ import SuperSidebar from './components/super_sidebar.vue'; import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; Vue.use(GlToast); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); const getTrialStatusWidgetData = (sidebarData) => { if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { @@ -90,6 +96,7 @@ export const initSuperSidebar = () => { return new Vue({ el, name: 'SuperSidebarRoot', + apolloProvider, provide: { rootPath, isImpersonating, @@ -145,5 +152,3 @@ export const initSuperSidebarToggle = () => { }, }); }; - -requestIdleCallback(initStatusTriggers); diff --git a/app/assets/javascripts/super_sidebar/user_counts_fetch.js b/app/assets/javascripts/super_sidebar/user_counts_fetch.js new file mode 100644 index 00000000000..779cb2609c2 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/user_counts_fetch.js @@ -0,0 +1,10 @@ +/** + * This triggers a re-fetch of the user counts + * + * It is separate from the user_counts_manager, so that + * this function is side-effect free and can be used in + * anywhere in the app without bloating bundle size + */ +export function fetchUserCounts() { + document.dispatchEvent(new CustomEvent('userCounts:fetch')); +} diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index 3d6eef62ad2..18334a7d139 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -1,6 +1,6 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; import AccessorUtilities from '~/lib/utils/accessor'; -import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; +import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/super_sidebar/constants'; import axios from '~/lib/utils/axios_utils'; /** @@ -26,8 +26,13 @@ const sortItemsByFrequencyAndLastAccess = (items) => return 0; }); -// This imitates getTopFrequentItems from app/assets/javascripts/frequent_items/utils.js, but -// adjusts the rules to accommodate for the context switcher's designs. +/** + * Returns the most frequently visited items. + * + * @param {Array} items - A list of items retrieved from the local storage + * @param {Number} maxCount - The maximum number of items to be returned + * @returns {Array} + */ export const getTopFrequentItems = (items, maxCount) => { if (!Array.isArray(items)) return []; @@ -39,11 +44,12 @@ export const getTopFrequentItems = (items, maxCount) => { /** * This tracks projects' and groups' visits in order to suggest a list of frequently visited - * entities to the user. Currently, this track visits in two ways: - * - The legacy approach uses a simple counting algorithm and stores the data in the local storage. - * - The above approach is being migrated to a backend-based one, where visits will be stored in the - * DB, and suggestions will be made through a smarter algorithm. When we are ready to transition - * to the newer approach, the legacy one will be cleaned up. + * entities to the user. The suggestion logic is implemented server-side and computed items can be + * retrieved through the GraphQL API. + * To persist a visit in the DB, an AJAX request needs to be triggered by the client. To avoid making + * the request on every visited page, we also keep track of the visits in the local storage so that + * the request is only sent once every 15 minutes per namespace per user. + * * @param {object} item The project/group item being tracked. * @param {string} namespace A string indicating whether the tracked entity is a project or a group. * @param {string} trackVisitsPath The API endpoint to track visits server-side. @@ -115,31 +121,4 @@ export const trackContextAccess = (username, context, trackVisitsPath) => { return localStorage.setItem(storageKey, JSON.stringify(storedItems)); }; -export const getItemsFromLocalStorage = ({ storageKey, maxItems }) => { - if (!AccessorUtilities.canUseLocalStorage()) { - return []; - } - - try { - const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey)); - return getTopFrequentItems(parsedCachedFrequentItems, maxItems); - } catch (e) { - Sentry.captureException(e); - return []; - } -}; - -export const removeItemFromLocalStorage = ({ storageKey, item }) => { - try { - const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(storageKey)); - const filteredItems = parsedCachedFrequentItems.filter((i) => i.id !== item.id); - localStorage.setItem(storageKey, JSON.stringify(filteredItems)); - - return filteredItems; - } catch (e) { - Sentry.captureException(e); - return []; - } -}; - export const ariaCurrent = (isActive) => (isActive ? 'page' : null); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index bb344ade344..c4aaacb4159 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import 'deckar01-task_list'; import { __ } from '~/locale'; import { createAlert } from '~/alert'; +import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import axios from './lib/utils/axios_utils'; export default class TaskList { @@ -94,7 +95,8 @@ export default class TaskList { const { index, checked, lineNumber, lineSource } = e.detail; const patchData = {}; - patchData[this.dataType] = { + const dataType = this.dataType === TYPE_INCIDENT ? TYPE_ISSUE : this.dataType; + patchData[dataType] = { [this.fieldName]: $target.val(), lock_version: this.lockVersion, update_task: { diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index 29099bcc366..75ee0e16d4e 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -66,7 +66,7 @@ export default { <template> <div> - <div class="gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> + <div class="gl-relative gl-pb-0 gl-px-0" data-testid="terms-content"> <div class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none" ></div> @@ -97,7 +97,7 @@ export default { type="submit" variant="confirm" :disabled="acceptDisabled" - data-qa-selector="accept_terms_button" + data-testid="accept-terms-button" >{{ $options.i18n.accept }}</gl-button > <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index 273cd599308..bbd9a056efc 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -11,7 +11,7 @@ import { } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import StateActions from './states_table_actions.vue'; diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index 345db1752f6..3c44d014edc 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -43,14 +43,12 @@ export default { key: 'project', label: __('Project with access'), thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', }, { key: 'actions', label: '', tdClass: 'gl-text-right', thClass: 'gl-border-t-none!', - columnClass: 'gl-w-10p', }, ], components: { diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index 846b0d1791f..3df466de4d3 100644 --- a/app/assets/javascripts/token_access/components/outbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -51,14 +51,12 @@ export default { key: 'project', label: __('Project that can be accessed'), thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', }, { key: 'actions', label: '', tdClass: 'gl-text-right', thClass: 'gl-border-t-none!', - columnClass: 'gl-w-10p', }, ], components: { diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js index d5bc428934c..a6d14bfbfd8 100644 --- a/app/assets/javascripts/tracking/internal_events.js +++ b/app/assets/javascripts/tracking/internal_events.js @@ -13,24 +13,17 @@ const InternalEvents = { /** * * @param {string} event - * @param {object} data */ - trackEvent(event, data = {}) { - const { context, ...rest } = data; - - const defaultContext = { - schema: SERVICE_PING_SCHEMA, - data: { - event_name: event, - data_source: 'redis_hll', - }, - }; - const mergedContext = context ? [defaultContext, context] : defaultContext; - + trackEvent(event) { API.trackInternalEvent(event); Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, { - context: mergedContext, - ...rest, + context: { + schema: SERVICE_PING_SCHEMA, + data: { + event_name: event, + data_source: 'redis_hll', + }, + }, }); this.trackBrowserSDK(event); }, @@ -41,8 +34,8 @@ const InternalEvents = { mixin() { return { methods: { - trackEvent(event, data = {}) { - InternalEvents.trackEvent(event, data); + trackEvent(event) { + InternalEvents.trackEvent(event); }, }, }; diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js index 923aea433f1..f4c8781ae20 100644 --- a/app/assets/javascripts/tracking/tracking.js +++ b/app/assets/javascripts/tracking/tracking.js @@ -13,12 +13,17 @@ const Tracking = Object.assign(Tracker, { return { computed: { trackingCategory() { - const localCategory = this.tracking ? this.tracking.category : null; + // TODO: refactor to remove potentially undefined property + // https://gitlab.com/gitlab-org/gitlab/-/issues/432995 + const localCategory = 'tracking' in this ? this.tracking.category : null; return localCategory || opts.category; }, trackingOptions() { const options = addExperimentContext(opts); - return { ...options, ...this.tracking }; + // TODO: refactor to remove potentially undefined property + // https://gitlab.com/gitlab-org/gitlab/-/issues/432995 + const tracking = 'tracking' in this ? this.tracking : {}; + return { ...options, ...tracking }; }, }, methods: { diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js index 9bf6d27235c..cd990ccc77a 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.stories.js @@ -1,6 +1,6 @@ import { mockGetProjectStorageStatisticsGraphQLResponse } from 'jest/usage_quotas/storage/mock_data'; import createMockApollo from 'helpers/mock_apollo_helper'; -import getProjectStorageStatisticsQuery from '../queries/project_storage.query.graphql'; +import getProjectStorageStatisticsQuery from 'ee_else_ce/usage_quotas/storage/queries/project_storage.query.graphql'; import ProjectStorageApp from './project_storage_app.vue'; const meta = { diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue index a5e1cc398e3..cc4219c2ca9 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue @@ -4,6 +4,9 @@ import { sprintf } from '~/locale'; import { updateRepositorySize } from '~/api/projects_api'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import SectionedPercentageBar from '~/usage_quotas/components/sectioned_percentage_bar.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getCostFactoredProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql'; +import getProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/project_storage.query.graphql'; import { ERROR_MESSAGE, LEARN_MORE_LABEL, @@ -18,7 +21,6 @@ import { usageQuotasHelpPaths, storageTypeHelpPaths, } from '../constants'; -import getProjectStorageStatistics from '../queries/project_storage.query.graphql'; import { getStorageTypesFromProjectStatistics, descendingStorageUsageSort } from '../utils'; import ProjectStorageDetail from './project_storage_detail.vue'; @@ -32,10 +34,15 @@ export default { ProjectStorageDetail, SectionedPercentageBar, }, + mixins: [glFeatureFlagsMixin()], inject: ['projectPath'], apollo: { project: { - query: getProjectStorageStatistics, + query() { + return this.glFeatures?.displayCostFactoredStorageSizeOnProjectPages + ? getCostFactoredProjectStorageStatistics + : getProjectStorageStatistics; + }, variables() { return { fullPath: this.projectPath, diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue index 6cc1f63e04f..35e43c76310 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlSprintf, GlTableLite, GlPopover } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; +import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue'; import { thWidthPercent } from '~/lib/utils/table_utility'; import { sprintf } from '~/locale'; import { @@ -19,6 +19,7 @@ export default { GlSprintf, StorageTypeIcon, GlPopover, + NumberToHumanSize, }, props: { storageTypes: { @@ -32,7 +33,6 @@ export default { linkTitle, }); }, - numberToHumanSize, }, projectTableFields: [ { @@ -92,9 +92,7 @@ export default { </template> <template #cell(value)="{ item }"> - <span :data-testid="item.id + '-value'"> - {{ numberToHumanSize(item.value, 1) }} - </span> + <number-to-human-size :value="item.value" :data-testid="item.id + '-value'" /> <template v-if="item.warning"> <gl-icon diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index 3fdf61a5947..ac447fc96d1 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -31,6 +31,9 @@ export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); export const usageQuotasHelpPaths = { + repositorySizeLimit: helpPagePath('administration/settings/account_and_limit_settings', { + anchor: 'repository-size-limit', + }), usageQuotas: helpPagePath('user/usage_quotas'), usageQuotasProjectStorageLimit: helpPagePath('user/usage_quotas', { anchor: 'project-storage-limit', diff --git a/app/assets/javascripts/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql new file mode 100644 index 00000000000..4438ad4cc3d --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql @@ -0,0 +1,23 @@ +query getCostFactoredProjectStorageStatistics($fullPath: ID!) { + project(fullPath: $fullPath) { + id + statisticsDetailsPaths { + containerRegistry + buildArtifacts + packages + repository + snippets + wiki + } + statistics { + containerRegistrySize + buildArtifactsSize + lfsObjectsSize + packagesSize + repositorySize + snippetsSize + storageSize + wikiSize + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 524f2c045e6..6a1f5f0bb44 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,7 +1,7 @@ <script> -import { GlButton, GlSprintf } from '@gitlab/ui'; +import { GlForm, GlButton, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { visitUrl } from '~/lib/utils/url_utility'; +import csrf from '~/lib/utils/csrf'; import { STATUS_MERGED } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; @@ -23,7 +23,9 @@ export default { StateContainer, GlButton, GlSprintf, + GlForm, }, + csrf, mixins: [approvalsMixin, glFeatureFlagsMixin()], props: { mr: { @@ -169,16 +171,15 @@ export default { .join(', ') .concat('.'); }, + samlApprovalPath() { + return this.mr.samlApprovalPath; + }, requireSamlAuthToApprove() { return this.mr.requireSamlAuthToApprove; }, }, methods: { approve() { - if (this.requireSamlAuthToApprove) { - this.approveWithSamlAuth(); - return; - } if (this.requirePasswordToApprove) { this.$root.$emit(BV_SHOW_MODAL, this.modalId); return; @@ -195,7 +196,7 @@ export default { }, approveWithSamlAuth() { // Intentionally direct to SAML Identity Provider for renewed authorization even if SSO session exists - visitUrl(this.mr.samlApprovalPath); + this.$refs.form.$el.submit(); }, approveWithAuth(data) { this.updateApproval( @@ -270,17 +271,40 @@ export default { <template v-else> <div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> - <gl-button - v-if="action" - :variant="action.variant" - :category="action.category" - :loading="isApproving" - class="gl-mr-3" - data-testid="approve-button" - @click="action.action" - > - {{ action.text }} - </gl-button> + <div v-if="requireSamlAuthToApprove && showApprove"> + <gl-form + ref="form" + :action="samlApprovalPath" + method="post" + data-testid="approve-form" + > + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="gl-mr-3" + data-testid="approve-button" + type="submit" + > + {{ action.text }} + </gl-button> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </gl-form> + </div> + <span v-if="!requireSamlAuthToApprove || showUnapprove"> + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="gl-mr-3" + data-testid="approve-button" + @click="action.action" + > + {{ action.text }} + </gl-button> + </span> <approvals-summary-optional v-if="isOptional" :can-approve="hasAction" @@ -293,7 +317,7 @@ export default { :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> - <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules"> + <div v-if="hasInvalidRules" class="gl-text-secondary gl-mt-2" data-testid="invalid-rules"> <gl-sprintf :message="pluralizedRuleText"> <template #danger="{ content }"> <span class="gl-font-weight-bold text-danger">{{ content }}</span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js index 431348e1d57..24bc7017e06 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js @@ -1,6 +1,7 @@ export const COMPONENTS = { conflict: () => import('./conflicts.vue'), - unresolved_discussions: () => import('./unresolved_discussions.vue'), + discussions_not_resolved: () => import('./unresolved_discussions.vue'), + draft_status: () => import('./draft.vue'), need_rebase: () => import('./rebase.vue'), default: () => import('./message.vue'), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.stories.js new file mode 100644 index 00000000000..537c975652f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.stories.js @@ -0,0 +1,74 @@ +import createMockApollo from 'helpers/mock_apollo_helper'; +import draftStateQuery from '../../queries/states/draft.query.graphql'; +import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; +import Draft from './draft.vue'; + +const defaultRender = ({ apolloProvider, check, mr }) => ({ + components: { Draft }, + apolloProvider, + data() { + return { mr, check }; + }, + template: '<draft :check="check" :mr="mr" />', +}); + +const Template = ({ userPermissionUpdateMergeRequest }) => { + const requestHandlers = [ + [ + draftStateQuery, + () => + Promise.resolve({ + data: { + project: { + id: '1', + mergeRequest: { + draft: false, + id: '2', + title: 'MR title', + mergeableDiscussionsState: true, + userPermissions: { + updateMergeRequest: userPermissionUpdateMergeRequest, + }, + }, + }, + }, + }), + ], + [ + removeDraftMutation, + () => + Promise.resolve({ + data: { + mergeRequestSetDraft: { + mergeRequest: { + draft: false, + id: '2', + title: 'MR title', + mergeableDiscussionsState: true, + }, + errors: [], + }, + }, + }), + ], + ]; + const apolloProvider = createMockApollo(requestHandlers); + + return defaultRender({ + apolloProvider, + check: { + identifier: 'draft_status', + status: 'FAILED', + }, + }); +}; + +export const Default = Template.bind({}); +Default.args = { + userPermissionUpdateMergeRequest: true, +}; + +export default { + title: 'vue_merge_request_widget/merge_checks/draft', + component: Draft, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.vue new file mode 100644 index 00000000000..dbe0d2ac243 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/draft.vue @@ -0,0 +1,169 @@ +<script> +import { produce } from 'immer'; + +import { createAlert } from '~/alert'; +import MergeRequest from '~/merge_request'; + +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; + +import draftStateQuery from '../../queries/states/draft.query.graphql'; +import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; + +import ActionButtons from '../action_buttons.vue'; +import MergeChecksMessage from './message.vue'; + +import { DRAFT_CHECK_READY, DRAFT_CHECK_ERROR } from './i18n'; + +export default { + name: 'MergeChecksDraft', + components: { + MergeChecksMessage, + ActionButtons, + }, + mixins: [mergeRequestQueryVariablesMixin], + apollo: { + state: { + query: draftStateQuery, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data?.project?.mergeRequest, + }, + }, + props: { + mr: { + type: Object, + required: false, + default: () => ({}), + }, + check: { + type: Object, + required: true, + }, + }, + data() { + return { + state: {}, + isMutating: false, + }; + }, + computed: { + networking() { + return this.isLoading || this.isMutating; + }, + isLoading() { + return this.$apollo.queries.state.loading; + }, + userCanUpdateMergeRequest() { + return this.state.userPermissions.updateMergeRequest; + }, + showTertiaryButton() { + return !this.networking && this.userCanUpdateMergeRequest; + }, + tertiaryActionsButtons() { + return [ + { + text: DRAFT_CHECK_READY, + category: 'default', + testId: 'mark-as-ready-button', + onClick: () => this.removeDraft(), + }, + ]; + }, + }, + methods: { + removeDraft() { + const { mergeRequestQueryVariables } = this; + + this.isMutating = true; + + this.$apollo + .mutate({ + mutation: removeDraftMutation, + variables: { + ...mergeRequestQueryVariables, + draft: false, + }, + update( + store, + { + data: { + mergeRequestSetDraft: { + errors, + mergeRequest: { mergeableDiscussionsState, draft, title }, + }, + }, + }, + ) { + if (errors?.length) { + createAlert({ + message: DRAFT_CHECK_ERROR, + }); + + return; + } + + const sourceData = store.readQuery({ + query: draftStateQuery, + variables: mergeRequestQueryVariables, + }); + + const data = produce(sourceData, (draftState) => { + draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState; + draftState.project.mergeRequest.draft = draft; + draftState.project.mergeRequest.title = title; + }); + + store.writeQuery({ + query: draftStateQuery, + data, + variables: mergeRequestQueryVariables, + }); + }, + optimisticResponse: { + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + mergeRequestSetDraft: { + __typename: 'MergeRequestSetWipPayload', + errors: [], + mergeRequest: { + __typename: 'MergeRequest', + id: this.mr.issuableId, + mergeableDiscussionsState: true, + title: this.mr.title, + draft: false, + }, + }, + }, + }) + .then( + ({ + data: { + mergeRequestSetDraft: { + mergeRequest: { title }, + }, + }, + }) => { + MergeRequest.toggleDraftStatus(title, true); + }, + ) + .catch(() => + createAlert({ + message: DRAFT_CHECK_ERROR, + }), + ) + .finally(() => { + this.isMutating = false; + }); + }, + }, +}; +</script> + +<template> + <merge-checks-message :check="check"> + <template #failed> + <action-buttons v-if="showTertiaryButton" :tertiary-buttons="tertiaryActionsButtons" /> + </template> + </merge-checks-message> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/i18n.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/i18n.js new file mode 100644 index 00000000000..de504af5fcc --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/i18n.js @@ -0,0 +1,4 @@ +import { __, s__ } from '~/locale'; + +export const DRAFT_CHECK_ERROR = __('Something went wrong. Please try again.'); +export const DRAFT_CHECK_READY = s__('mrWidgetDraftCheck|Mark as ready'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue index 058b9e1fe99..7f21445559a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue @@ -8,7 +8,7 @@ const ICON_NAMES = { success: 'success', }; -const FAILURE_REASONS = { +export const FAILURE_REASONS = { broken_status: __('Cannot merge the source into the target branch, due to a conflict.'), ci_must_pass: __('Pipeline must succeed.'), conflict: __('Merge conflicts must be resolved.'), @@ -20,6 +20,7 @@ const FAILURE_REASONS = { policies_denied: __('Denied licenses must be removed or approved.'), merge_request_blocked: __('Merge request is blocked by another merge request.'), status_checks_must_pass: __('Status checks must pass.'), + jira_association_missing: __('Either the title or description must reference a Jira issue.'), }; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js index c0ac1818ffa..a4594409977 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js @@ -63,7 +63,7 @@ const Template = ({ apolloProvider, check: { identifier: 'need_rebase', - status: failed ? 'failed' : 'passed', + status: failed ? 'FAILED' : 'SUCCESS', }, mr: { onlyAllowMergeIfPipelineSucceeds }, canCreatePipelineInTargetProject, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue index 72140c22a89..63fa90fcc7a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue @@ -65,8 +65,9 @@ export default { }, showRebaseWithoutPipeline() { return ( - !this.mr.onlyAllowMergeIfPipelineSucceeds || - (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline) + this.state.userPermissions.pushToSourceBranch && + (!this.mr.onlyAllowMergeIfPipelineSucceeds || + (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline)) ); }, isForkMergeRequest() { @@ -85,10 +86,8 @@ export default { ); }, tertiaryActionsButtons() { - if (this.check.result === 'success') return []; - return [ - { + this.state.userPermissions.pushToSourceBranch && { text: s__('mrWidget|Rebase'), loading: this.isMakingRequest || this.rebaseInProgress, testId: 'standard-rebase-button', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue index 1e5f91e12cf..d4c00aa86e3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -66,7 +66,7 @@ export default { :name="$options.EXTENSION_ICON_NAMES[iconName]" :size="size" :aria-label="iconAriaLabel" - :data-qa-selector="`status_${iconName}_icon`" + :data-testid="`status-${iconName}-icon`" class="gl-display-block" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js index 77dc5b1d0da..a1171fe5d25 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js @@ -41,6 +41,10 @@ const Template = ({ canMerge, failed, pushToSourceBranch }) => { identifier: 'CONFLICT', status: failed ? 'FAILED' : 'SUCCESS', }, + { + identifier: 'DRAFT_STATUS', + status: failed ? 'FAILED' : 'SUCCESS', + }, ], }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue index ac403c2c6f2..750f53a29b6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue @@ -1,9 +1,12 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants'; import mergeRequestQueryVariablesMixin from '../mixins/merge_request_query_variables'; import mergeChecksQuery from '../queries/merge_checks.query.graphql'; +import mergeChecksSubscription from '../queries/merge_checks.subscription.graphql'; import StateContainer from './state_container.vue'; import BoldText from './bold_text.vue'; @@ -18,6 +21,31 @@ export default { return this.mergeRequestQueryVariables; }, update: (data) => data?.project?.mergeRequest, + subscribeToMore: { + document() { + return mergeChecksSubscription; + }, + skip() { + return !this.mr?.id; + }, + variables() { + return { + issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id), + }; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestMergeStatusUpdated }, + }, + }, + ) { + if (mergeRequestMergeStatusUpdated) { + this.state = mergeRequestMergeStatusUpdated; + } + }, + }, }, }, components: { @@ -86,7 +114,7 @@ export default { </script> <template> - <div> + <div class="gl-rounded-0!"> <state-container :is-loading="isLoading" :status="statusIcon" 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 efc74241941..e19617b2e28 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -10,7 +10,7 @@ import { } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 370e07b397c..d0771a79a5d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; +import { STATUS_CLOSED, STATUS_MERGED, STATUS_EMPTY } from '~/issues/constants'; import StatusIcon from './extensions/status_icon.vue'; export default { @@ -24,6 +24,9 @@ export default { isMerged() { return this.status === STATUS_MERGED; }, + isEmpty() { + return this.status === STATUS_EMPTY; + }, }, }; </script> @@ -33,6 +36,7 @@ export default { <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" /> <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" /> <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" /> + <status-icon v-else-if="isEmpty" icon-name="neutral" :level="1" class="gl-m-0!" /> <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" /> </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 55ae390216d..d2c1c914028 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 @@ -22,14 +22,14 @@ export default { variables() { return this.mergeRequestQueryVariables; }, - update: (data) => data.project.mergeRequest.userPermissions, + update: (data) => data.project?.mergeRequest.userPermissions || {}, }, state: { query: conflictsStateQuery, variables() { return this.mergeRequestQueryVariables; }, - update: (data) => data.project.mergeRequest, + update: (data) => data.project?.mergeRequest || {}, }, }, props: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 122abc7d034..8fb2b6acc4d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -50,7 +50,6 @@ export default { { text: s__('mrWidget|Refresh now'), onClick: () => this.refresh(), - testId: 'merge-request-failed-refresh-button', }, ]; }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 9258bc39bcb..2c5f6b9a3ec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,9 +1,9 @@ <script> -import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { STATUS_MERGED } from '~/issues/constants'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; import eventHub from '../../event_hub'; import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; import StatusIcon from '../mr_widget_status_icon.vue'; @@ -58,7 +58,7 @@ export default { MergeRequest.decreaseCounter(); stopPolling(); - refreshUserMergeRequestCounts(); + fetchUserCounts(); // If user checked remove source branch and we didn't remove the branch yet // we should start another polling for source branch remove process diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue index 2db5c71be82..16f1bac73ab 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue @@ -37,7 +37,7 @@ export default { <template> <div class="mr-widget-body media"> <status-icon status="success" /> - <p class="media-body gl-m-0! gl-font-weight-bold gl-text-gray-900!"> + <p class="media-body gl-mt-1 gl-mb-0! gl-font-weight-bold gl-text-gray-900!"> <template v-if="canMerge"> {{ __('Ready to merge!') }} </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index e1c54a8827c..b80b5246e24 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,48 +1,48 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-merge-requests-md.svg?url'; +import { STATUS_EMPTY } from '~/issues/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetNothingToMerge', components: { GlSprintf, GlLink, + StatusIcon, + }, + computed: { + statusEmpty() { + return STATUS_EMPTY; + }, }, ciHelpPage: helpPagePath('ci/quick_start/index.html'), - EMPTY_STATE_SVG_URL, }; </script> <template> - <div class="mr-widget-body mr-widget-empty-state"> - <div class="row"> - <div - class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-130 pb-0 pt-0" - > - <img :src="$options.EMPTY_STATE_SVG_URL" :alt="''" /> - </div> - <div class="text col-md-9 col-12"> - <p class="highlight mt-3"> - {{ s__('mrWidgetNothingToMerge|Merge request contains no changes') }} - </p> - <p data-testid="nothing-to-merge-body"> - <gl-sprintf - :message=" - s__( - 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the %{boldStart}Code%{boldEnd} dropdown list above, then test them with %{linkStart}CI/CD%{linkEnd} before merging.', - ) - " - > - <template #bold="{ content }"> - <b>{{ content }}</b> - </template> - <template #link="{ content }"> - <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> + <div class="mr-widget-body media"> + <status-icon :status="statusEmpty" /> + <div> + <p class="media-body gl-mt-1 gl-mb-1 gl-font-weight-bold gl-text-gray-900!"> + {{ s__('mrWidgetNothingToMerge|Merge request contains no changes') }} + </p> + <p class="gl-m-0! gl-text-secondary" data-testid="nothing-to-merge-body"> + <gl-sprintf + :message=" + s__( + 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the %{boldStart}Code%{boldEnd} dropdown list above, then test them with %{linkStart}CI/CD%{linkEnd} before merging.', + ) + " + > + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + <template #link="{ content }"> + <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> </div> </div> </template> 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 3c2d8efaffc..1516b63f96d 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 @@ -59,6 +59,7 @@ export default { apollo: { state: { query: readyToMergeQuery, + fetchPolicy: fetchPolicies.NO_CACHE, variables() { return this.mergeRequestQueryVariables; }, @@ -119,6 +120,14 @@ export default { ) { if (mergeRequestMergeStatusUpdated) { this.state = mergeRequestMergeStatusUpdated; + + if (!this.commitMessageIsTouched) { + this.commitMessage = mergeRequestMergeStatusUpdated.defaultMergeCommitMessage; + } + + if (!this.squashCommitMessageIsTouched) { + this.squashCommitMessage = mergeRequestMergeStatusUpdated.defaultSquashCommitMessage; + } } }, }, @@ -349,12 +358,6 @@ export default { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState); eventHub.$on('mr.discussion.updated', this.updateGraphqlState); - - if (this.glFeatures.widgetPipelinePassSubscriptionUpdate) { - this.$apollo.queries.state.setOptions({ - fetchPolicy: fetchPolicies.NO_CACHE, - }); - } }, beforeDestroy() { eventHub.$off('ApprovalUpdated', this.updateGraphqlState); @@ -610,6 +613,7 @@ export default { :label="__('Merge commit message')" input-id="merge-message-edit" class="gl-m-0! gl-p-0!" + data-testid="merge-commit-message" @input="setCommitMessage" > <template #header> 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 267facb0a50..4a23f8847d3 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 @@ -29,7 +29,7 @@ export default { variables() { return this.mergeRequestQueryVariables; }, - update: (data) => data.project.mergeRequest.userPermissions, + update: (data) => data.project?.mergeRequest?.userPermissions || {}, }, }, props: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index d4375690ad1..cbc7b91922b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -72,10 +72,14 @@ export default { :widget-name="widgetName" :header="data.header" :help-popover="data.helpPopover" - :class="{ 'gl-border-top-0': rowIndex === 0 }" + :class="{ + 'gl-border-top-0': rowIndex === 0, + 'gl-align-items-start': data.supportingText, + 'gl-align-items-baseline': !data.supportingText, + }" > <template #body> - <div class="gl-w-full gl-display-flex" :class="{ 'gl-flex-direction-column': level === 1 }"> + <div class="gl-w-full gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-grow-1"> <div class="gl-display-flex gl-flex-grow-1 gl-align-items-baseline"> <div> @@ -109,7 +113,6 @@ export default { :data="childData" :widget-name="widgetName" :level="3" - data-qa-selector="child_content" @clickedAction="onClickedAction" /> </li> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue index 4e8098677cc..e1378e78df8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -58,7 +58,7 @@ export default { :name="$options.EXTENSION_ICON_NAMES[iconName]" :size="12" :aria-label="iconAriaLabel" - :data-qa-selector="`status_${iconName}_icon`" + :data-testid="`status-${iconName}-icon`" class="gl-relative gl-z-index-1" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index 0eb50b9ff4f..d85ba5374d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -19,7 +19,7 @@ import ActionButtons from './action_buttons.vue'; const WIDGET_PREFIX = 'Widget'; const MISSING_RESPONSE_HEADERS = - 'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.'; + 'MR Widget: response object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.'; const LOADING_STATE_COLLAPSED = 'collapsed'; const LOADING_STATE_EXPANDED = 'expanded'; @@ -386,7 +386,6 @@ export default { category="tertiary" data-testid="toggle-button" size="small" - data-qa-selector="expand_report_button" @click="toggleCollapsed" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue index bb82da7796a..7413e2237c3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -81,8 +81,7 @@ export default { <div class="gl-display-flex" :class="{ - 'gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline': level === 2, - 'gl-align-items-center': level === 3, + 'gl-border-t gl-py-3 gl-pl-7': level === 2, }" > <status-icon diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js deleted file mode 100644 index 3af984dcf6c..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ /dev/null @@ -1,110 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; -import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; -import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { i18n, codeQualityPrefixes } from './constants'; - -export default { - name: 'WidgetCodeQuality', - enablePolling: true, - props: ['codeQuality', 'blobPath'], - i18n, - computed: { - shouldCollapse(data) { - const { newErrors, resolvedErrors, parsingInProgress } = data; - if (parsingInProgress || (newErrors.length === 0 && resolvedErrors.length === 0)) { - return false; - } - return true; - }, - summary(data) { - const { newErrors, resolvedErrors, parsingInProgress } = data; - if (parsingInProgress) { - return i18n.loading; - } - if (newErrors.length >= 1 && resolvedErrors.length >= 1) { - return i18n.improvementAndDegradationCopy( - i18n.findings(resolvedErrors, codeQualityPrefixes.fixed), - i18n.findings(newErrors, codeQualityPrefixes.new), - ); - } - if (resolvedErrors.length >= 1) { - return i18n.singularCopy(i18n.findings(resolvedErrors, codeQualityPrefixes.fixed)); - } - if (newErrors.length >= 1) { - return i18n.singularCopy(i18n.findings(newErrors, codeQualityPrefixes.new)); - } - return i18n.noChanges; - }, - statusIcon() { - if (this.collapsedData.newErrors.length >= 1) { - return EXTENSION_ICONS.warning; - } - if (this.collapsedData.resolvedErrors.length >= 1) { - return EXTENSION_ICONS.success; - } - return EXTENSION_ICONS.neutral; - }, - }, - methods: { - fetchCollapsedData() { - return axios.get(this.codeQuality).then((response) => { - const { data = {}, status } = response; - return { - ...response, - data: { - parsingInProgress: status === HTTP_STATUS_NO_CONTENT, - resolvedErrors: parseCodeclimateMetrics(data.resolved_errors, this.blobPath.head_path), - newErrors: parseCodeclimateMetrics(data.new_errors, this.blobPath.head_path), - }, - }; - }); - }, - fetchFullData() { - const fullData = []; - - this.collapsedData.newErrors.map((e) => { - return fullData.push({ - text: e.check_name - ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` - : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, - subtext: { - prependText: i18n.prependText, - text: `${e.file_path}:${e.line}`, - href: e.urlPath, - }, - icon: { - name: SEVERITY_ICONS_MR_WIDGET[e.severity], - }, - }); - }); - - this.collapsedData.resolvedErrors.map((e) => { - return fullData.push({ - text: e.check_name - ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` - : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, - subtext: { - prependText: i18n.prependText, - text: `${e.file_path}:${e.line}`, - href: e.urlPath, - }, - icon: { - name: SEVERITY_ICONS_MR_WIDGET[e.severity], - }, - badge: { - variant: 'neutral', - text: i18n.fixed, - }, - }); - }); - - return Promise.resolve(fullData); - }, - fetchReport(endpoint) { - return axios.get(endpoint).then((res) => res.data); - }, - }, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index cc116b42f1e..e2f301b1911 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -54,6 +54,7 @@ import getStateQuery from './queries/get_state.query.graphql'; import getStateSubscription from './queries/get_state.subscription.graphql'; import ReportWidgetContainer from './components/report_widget_container.vue'; import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; +import MergeChecks from './components/merge_checks.vue'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -94,6 +95,7 @@ export default { MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'), ReadyToMerge: ReadyToMergeState, ReportWidgetContainer, + MergeChecks, }, apollo: { state: { @@ -248,6 +250,25 @@ export default { hasExtensions() { return registeredExtensions.extensions.length; }, + mergeBlockedComponentEnabled() { + return ( + window.gon?.features?.mergeBlockedComponent && + !( + [ + 'checking', + 'preparing', + 'nothingToMerge', + 'archived', + 'missingBranch', + 'merged', + 'closed', + 'merging', + 'autoMergeEnabled', + 'shaMismatch', + ].includes(this.mr.state) || ['MERGING', 'AUTO_MERGE'].includes(this.mr.machineValue) + ) + ); + }, }, watch: { 'mr.machineValue': { @@ -556,7 +577,8 @@ export default { </div> <div class="mr-widget-section" data-testid="mr-widget-content"> - <component :is="componentName" :mr="mr" :service="service" /> + <merge-checks v-if="mergeBlockedComponentEnabled" :mr="mr" :service="service" /> + <component :is="componentName" v-else :mr="mr" :service="service" /> <ready-to-merge v-if="mr.commitsCount" v-show="shouldShowMergeDetails" diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.subscription.graphql new file mode 100644 index 00000000000..9cf2b9be405 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.subscription.graphql @@ -0,0 +1,14 @@ +subscription mergeChecksSubscrption($issuableId: IssuableID!) { + mergeRequestMergeStatusUpdated(issuableId: $issuableId) { + ... on MergeRequest { + id + userPermissions { + canMerge + } + mergeabilityChecks { + identifier + status + } + } + } +} 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 54f2233439f..c1190a07ef8 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 @@ -2,7 +2,10 @@ query mrUserPermission($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { id mergeRequest(iid: $iid) { + draft id + mergeableDiscussionsState + title userPermissions { updateMergeRequest } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index d6bab074f3f..5765d7a56fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -15,25 +15,25 @@ export default function deviseState() { return stateKey.missingBranch; } if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CHECKING) { - return stateKey.checking; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.checking; } if (this.hasConflicts) { - return stateKey.conflicts; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.conflicts; } if (this.shouldBeRebased) { - return stateKey.rebase; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.rebase; } if (this.hasMergeChecksFailed && !this.autoMergeEnabled) { - return stateKey.mergeChecksFailed; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.mergeChecksFailed; } if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.CI_MUST_PASS) { - return stateKey.pipelineFailed; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.pipelineFailed; } if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DRAFT_STATUS) { - return stateKey.draft; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.draft; } if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.DISCUSSIONS_NOT_RESOLVED) { - return stateKey.unresolvedDiscussions; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.unresolvedDiscussions; } if (this.canMerge && this.isSHAMismatch) { return stateKey.shaMismatch; @@ -47,5 +47,5 @@ export default function deviseState() { ) { return stateKey.readyToMerge; } - return stateKey.checking; + return window.gon?.features?.mergeBlockedComponent ? null : stateKey.checking; } 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 a1b86c86979..9ce5448d86e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -30,7 +30,8 @@ export default class MergeRequestStore { this.stateMachine = machine(STATE_MACHINE.definition); this.machineValue = this.stateMachine.value; - this.mergeDetailsCollapsed = window.innerWidth < 768; + this.mergeDetailsCollapsed = + !window.gon?.features?.mergeBlockedComponent && window.innerWidth < 768; this.mergeError = data.mergeError; this.multipleApprovalRulesAvailable = data.multiple_approval_rules_available || false; this.id = data.id; @@ -121,6 +122,7 @@ export default class MergeRequestStore { this.availableAutoMergeStrategies, ); this.ffOnlyEnabled = data.ff_only_enabled; + this.ffMergePossible = data.ff_merge_possible; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.mergeRequestState = data.state; this.isOpen = this.mergeRequestState === STATUS_OPEN; @@ -196,7 +198,9 @@ export default class MergeRequestStore { } this.commitsCount = mergeRequest.commitCount; - this.branchMissing = !mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists; + this.branchMissing = + mergeRequest.detailedMergeStatus !== 'NOT_OPEN' && + (!mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists); this.hasConflicts = mergeRequest.conflicts; this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false; this.mergeError = mergeRequest.mergeError; @@ -418,6 +422,10 @@ export default class MergeRequestStore { } toggleMergeDetails(val = !this.mergeDetailsCollapsed) { + if (window.gon?.features?.mergeBlockedComponent) { + return; + } + this.mergeDetailsCollapsed = val; } } diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 93581dbbd40..655a16dea01 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -36,7 +36,7 @@ export default { <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!" - data-qa-selector="alert_system_note_container" + data-testid="alert-system-note-container" > <div class="gl-display-inline-flex gl-align-items-center gl-relative"> <div diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 59f03b41144..3c19df9c196 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -94,14 +94,12 @@ export default { return awardList.some((award) => award.user.id === this.currentUserId); }, createAwardList(name, list) { - const url = list.length ? list[0].url : null; - return { name, list, title: this.getAwardListTitle(list, name), classes: this.getAwardClassBindings(list), - html: glEmojiTag(name, { url }), + html: glEmojiTag(name), }; }, getAwardListTitle(awardsList, name) { diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 2a47e96b2e2..5a807d10f24 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -82,8 +82,6 @@ export default { :title="tooltipTitle" :class="{ 'ml-auto': isCentered }" class="file-changed-icon d-inline-block" - data-qa-selector="changed_file_icon_content" - :data-qa-title="tooltipTitle" > <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.stories.js b/app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.stories.js new file mode 100644 index 00000000000..66012cefeaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.stories.js @@ -0,0 +1,31 @@ +import CiIcon from './ci_icon.vue'; + +export default { + component: CiIcon, + title: 'vue_shared/ci_icon', +}; + +const Template = (args, { argTypes }) => ({ + components: { CiIcon }, + props: Object.keys(argTypes), + template: '<ci-icon v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + status: { + icon: 'status_success', + text: 'Success', + detailsPath: 'https://gitab.com/', + }, +}; + +export const WithText = Template.bind({}); +WithText.args = { + status: { + icon: 'status_success', + text: 'Success', + detailsPath: 'https://gitab.com/', + }, + showStatusText: true, +}; diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.vue index a2b6b4642c9..a2b6b4642c9 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.vue 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 2bdc8a174d0..e12e06a2454 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 @@ -36,11 +36,6 @@ export default { required: false, default: 'confirm-danger-button', }, - buttonQaSelector: { - type: String, - required: false, - default: null, - }, buttonVariant: { type: String, required: false, @@ -58,7 +53,6 @@ export default { :variant="buttonVariant" :disabled="disabled" :data-testid="buttonTestid" - :data-qa-selector="buttonQaSelector" >{{ buttonText }}</gl-button > <confirm-danger-modal 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 a1ef1f30ebb..5019ab901fd 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 @@ -72,7 +72,7 @@ export default { attributes: { variant: 'danger', disabled: !this.isValid, - 'data-qa-selector': 'confirm_danger_modal_button', + 'data-testid': 'confirm-danger-modal-button', }, }; }, @@ -133,8 +133,7 @@ export default { id="confirm_name_input" v-model="confirmationPhrase" class="form-control" - data-qa-selector="confirm_danger_field" - data-testid="confirm-danger-input" + data-testid="confirm-danger-field" type="text" /> </gl-form-group> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 1370f7b2a8c..7b9ecc18ce1 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -54,7 +54,7 @@ export default { </script> <template> - <div class="preview-container" data-qa-selector="preview_container"> + <div class="preview-container"> <image-viewer v-if="type === 'image'" :path="path" :file-size="fileSize" /> <markdown-viewer v-if="type === 'markdown'" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index f28a2801bc0..332424c70ac 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -41,14 +41,7 @@ export default { {{ fileName }} <template v-if="fileSize > 0"> ({{ fileSizeReadable }}) </template> </p> - <a - :href="path" - class="btn btn-default" - rel="nofollow" - :download="fileName" - target="_blank" - data-qa-selector="download_button" - > + <a :href="path" class="btn btn-default" rel="nofollow" :download="fileName" target="_blank"> <gl-icon :size="16" name="download" class="float-left gl-mr-3" /> {{ __('Download') }} </a> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 04ab0fd00aa..9742118cd5f 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -88,7 +88,7 @@ export default { </script> <template> - <div data-testid="image-viewer" data-qa-selector="image_viewer_container"> + <div data-testid="image-viewer"> <div :class="innerCssClasses" class="position-relative"> <img ref="contentImg" :src="safePath" @load="onImgLoad" /> <slot diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 1a215454ab6..ea787bfe63e 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -57,7 +57,7 @@ export default { type: Function, required: true, }, - fetchInitialSelectionText: { + fetchInitialSelection: { type: Function, required: false, default: null, @@ -77,35 +77,23 @@ export default { searchString: '', items: [], page: 1, - selectedValue: null, - selectedText: null, + selected: this.initialSelection || '', + initialSelectedItem: {}, errorMessage: '', }; }, computed: { - selected: { - set(value) { - this.selectedValue = value; - this.selectedText = - value === null ? null : this.items.find((item) => item.value === value).text; - this.$emit('input', { - value: this.selectedValue, - text: this.selectedText, - }); - }, - get() { - return this.selectedValue; - }, + selectedItem() { + const item = this.items.find(({ value }) => value === this.selected); + + return item || this.initialSelectedItem; }, toggleText() { - return this.selectedText ?? this.defaultToggleText; + return this.selectedItem?.text ?? this.defaultToggleText; }, resetButtonLabel() { return this.clearable ? RESET_LABEL : ''; }, - inputValue() { - return this.selectedValue ? this.selectedValue : ''; - }, isSearchQueryTooShort() { return this.searchString && this.searchString.length < MINIMUM_QUERY_LENGTH; }, @@ -115,8 +103,13 @@ export default { : this.$options.i18n.noResultsText; }, }, + watch: { + selected() { + this.$emit('input', this.selectedItem); + }, + }, created() { - this.fetchInitialSelection(); + this.getInitialSelection(); }, methods: { search: debounce(function debouncedSearch(searchString) { @@ -148,23 +141,20 @@ export default { this.searching = false; this.infiniteScrollLoading = false; }, - async fetchInitialSelection() { + async getInitialSelection() { if (!this.initialSelection) { this.pristine = false; return; } - if (!this.fetchInitialSelectionText) { + if (!this.fetchInitialSelection) { throw new Error( '`initialSelection` is provided but lacks `fetchInitialSelectionText` to retrieve the corresponding text', ); } this.searching = true; - const name = await this.fetchInitialSelectionText(this.initialSelection); - - this.selectedValue = this.initialSelection; - this.selectedText = name; + this.initialSelectedItem = await this.fetchInitialSelection(this.initialSelection); this.pristine = false; this.searching = false; }, @@ -218,6 +208,6 @@ export default { <slot name="list-item" :item="item"></slot> </template> </gl-collapsible-listbox> - <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> + <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="selected" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue index 8a338551fbe..da42c017541 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -76,11 +76,7 @@ export default { try { const url = groupsPath(this.groupsFilter, this.parentGroupID); const { data = [], headers } = await axios.get(url, { params }); - groups = data.map((group) => ({ - ...group, - text: group.full_name, - value: String(group.id), - })); + groups = data.map((group) => this.mapGroupData(group)); totalPages = parseIntPagination(normalizeHeaders(headers)).totalPages; } catch (error) { @@ -88,15 +84,19 @@ export default { } return { items: groups, totalPages }; }, - async fetchGroupName(groupId) { - let groupName = ''; + async fetchInitialGroup(groupId) { try { const group = await Api.group(groupId); - groupName = group.full_name; + + return this.mapGroupData(group); } catch (error) { this.handleError({ message: FETCH_GROUP_ERROR, error }); + + return {}; } - return groupName; + }, + mapGroupData(group) { + return { ...group, text: group.full_name, value: String(group.id) }; }, handleError({ message, error }) { Sentry.captureException(error); @@ -123,7 +123,7 @@ export default { :header-text="$options.i18n.selectGroup" :default-toggle-text="$options.i18n.toggleText" :fetch-items="fetchGroups" - :fetch-initial-selection-text="fetchGroupName" + :fetch-initial-selection="fetchInitialGroup" v-on="$listeners" > <template #error> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue index d068d86d95b..9f4671abbb1 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue @@ -1,10 +1,11 @@ <script> import { GlAlert } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import getCurrentUserOrganizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import getCurrentUserOrganizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql'; import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPENAME_ORGANIZATION } from '~/graphql_shared/constants'; +import { TYPE_ORGANIZATION } from '~/graphql_shared/constants'; +import { DEFAULT_PER_PAGE } from '~/api'; import { ORGANIZATION_TOGGLE_TEXT, ORGANIZATION_HEADER_TEXT, @@ -62,54 +63,60 @@ export default { data() { return { errorMessage: '', + endCursor: null, }; }, methods: { - async fetchOrganizations() { + async fetchOrganizations(search, page = 1) { + if (page === 1) { + this.endCursor = null; + } + try { - const { - data: { - currentUser: { - organizations: { nodes }, - }, - }, - } = await this.$apollo.query({ + const response = await this.$apollo.query({ query: getCurrentUserOrganizationsQuery, - // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. + // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/433954. + variables: { after: this.endCursor, first: DEFAULT_PER_PAGE }, }); + const { nodes, pageInfo } = response.data.currentUser.organizations; + this.endCursor = pageInfo.endCursor; return { - items: nodes.map((organization) => ({ - text: organization.name, - value: getIdFromGraphQLId(organization.id), - })), - // TODO: implement pagination - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. - totalPages: 1, + items: nodes.map((organization) => this.mapOrganizationData(organization)), + // `EntitySelect` expects a `totalPages` key but GraphQL requests don't provide this data + // because it uses keyset pagination. Since the dropdown uses infinite scroll it + // only needs to know if there is a next page. We pass `page + 1` if there is a next page, + // otherwise we just set this to the current page. + totalPages: pageInfo.hasNextPage ? page + 1 : page, }; } catch (error) { + this.endCursor = null; this.handleError({ message: FETCH_ORGANIZATIONS_ERROR, error }); return { items: [], totalPages: 0 }; } }, - async fetchOrganizationName(id) { + async fetchInitialOrganization(id) { try { - const { - data: { - organization: { name }, - }, - } = await this.$apollo.query({ + const response = await this.$apollo.query({ query: getOrganizationQuery, - variables: { id: convertToGraphQLId(TYPENAME_ORGANIZATION, id) }, + variables: { id: convertToGraphQLId(TYPE_ORGANIZATION, id) }, }); - return name; + return this.mapOrganizationData(response.data.organization); } catch (error) { this.handleError({ message: FETCH_ORGANIZATION_ERROR, error }); - return ''; + return {}; } }, + mapOrganizationData(organization) { + return { + ...organization, + text: organization.name, + value: getIdFromGraphQLId(organization.id), + }; + }, handleError({ message, error }) { Sentry.captureException(error); this.errorMessage = message; @@ -137,7 +144,7 @@ export default { :header-text="$options.i18n.selectGroup" :default-toggle-text="$options.i18n.toggleText" :fetch-items="fetchOrganizations" - :fetch-initial-selection-text="fetchOrganizationName" + :fetch-initial-selection="fetchInitialOrganization" :toggle-class="toggleClass" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 8c371e3d4ce..8c873d39496 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -120,24 +120,29 @@ export default { membership: this.membership, }); })(); - projects = data.map((item) => ({ - text: item.name_with_namespace || item.name, - value: String(item.id), - })); + projects = data.map((project) => this.mapProjectData(project)); } catch (error) { this.handleError({ message: FETCH_PROJECTS_ERROR, error }); } return { items: projects, totalPages: 1 }; }, - async fetchProjectName(projectId) { - let projectName = ''; + async fetchInitialProject(projectId) { try { - const { data: project } = await Api.project(projectId); - projectName = project.name_with_namespace; + const response = await Api.project(projectId); + + return this.mapProjectData(response.data); } catch (error) { this.handleError({ message: FETCH_PROJECT_ERROR, error }); + + return {}; } - return projectName; + }, + mapProjectData(project) { + return { + ...project, + text: project.name_with_namespace || project.name, + value: String(project.id), + }; }, handleError({ message, error }) { Sentry.captureException(error); @@ -163,7 +168,7 @@ export default { :header-text="$options.i18n.selectProject" :default-toggle-text="$options.i18n.searchForProject" :fetch-items="fetchProjects" - :fetch-initial-selection-text="fetchProjectName" + :fetch-initial-selection="fetchInitialProject" :block="block" clearable v-on="$listeners" diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 6a10557c6bc..4738d0f5a38 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -90,12 +90,6 @@ export default { <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use :href="spriteHref" /> </svg> - <gl-icon - v-else - :name="folderIconName" - :size="size" - class="folder-icon" - data-qa-selector="folder_icon_content" - /> + <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index cecd1be82e9..6ac75230d88 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -132,11 +132,7 @@ export default { @click="clickFile" @mouseleave="$emit('mouseleave', $event)" > - <div - class="file-row-name-container" - data-qa-selector="file_row_container" - :data-qa-file-name="file.name" - > + <div class="file-row-name-container"> <span ref="textOutput" class="file-row-name" 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 c698b94749d..5362ceac9ee 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 @@ -31,6 +31,8 @@ export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT]; export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR]; export const OPERATORS_AFTER_BEFORE = [...OPERATORS_AFTER, ...OPERATORS_BEFORE]; +export const OPERATORS_TO_GROUP = [OPERATOR_OR, OPERATOR_NOT]; + export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const OPTION_CURRENT = { value: FILTER_CURRENT, text: __('Current') }; @@ -66,6 +68,7 @@ export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_CONTACT = s__('Crm|Contact'); export const TOKEN_TITLE_GROUP = __('Group'); export const TOKEN_TITLE_LABEL = __('Label'); +export const TOKEN_TITLE_PROJECT = __('Project'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); @@ -76,6 +79,7 @@ export const TOKEN_TITLE_STATUS = __('Status'); export const TOKEN_TITLE_JOBS_RUNNER_TYPE = s__('Job|Runner type'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); +export const TOKEN_TITLE_VERSION = __('Version'); export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); export const TOKEN_TITLE_CREATED = __('Created date'); export const TOKEN_TITLE_CLOSED = __('Closed date'); @@ -91,6 +95,7 @@ export const TOKEN_TYPE_EPIC = 'epic'; // this is in the shared constants. Until we have not decoupled the EE filtered search bar // from the CE component, we need to keep this in the CE code. // https://gitlab.com/gitlab-org/gitlab/-/issues/377838 +export const TOKEN_TYPE_PROJECT = 'project'; export const TOKEN_TYPE_HEALTH = 'health'; export const TOKEN_TYPE_ITERATION = 'iteration'; export const TOKEN_TYPE_LABEL = 'label'; @@ -104,6 +109,7 @@ export const TOKEN_TYPE_STATUS = 'status'; export const TOKEN_TYPE_JOBS_RUNNER_TYPE = 'jobs-runner-type'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; +export const TOKEN_TYPE_VERSION = 'version'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; export const TOKEN_TYPE_CREATED = 'created'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index d39e4d2ee42..364ba10e888 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -1,13 +1,5 @@ <script> -import { - GlFilteredSearch, - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, - GlFormCheckbox, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlFilteredSearch, GlSorting, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; @@ -22,10 +14,7 @@ import { filterEmptySearchTerm, uniqueTokens } from './filtered_search_utils'; export default { components: { GlFilteredSearch, - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, + GlSorting, GlFormCheckbox, }, directives: { @@ -118,8 +107,7 @@ export default { recentSearchesPromise: null, recentSearches: [], filterValue: this.initialFilterValue, - selectedSortOption: this.sortOptions[0], - selectedSortDirection: SORT_DIRECTION.descending, + ...this.getInitialSort(), }; }, computed: { @@ -141,15 +129,14 @@ export default { {}, ); }, - sortDirectionIcon() { - return this.selectedSortDirection === SORT_DIRECTION.ascending - ? 'sort-lowest' - : 'sort-highest'; + transformedSortOptions() { + return this.sortOptions.map(({ id: value, title: text }) => ({ value, text })); }, - sortDirectionTooltip() { - return this.selectedSortDirection === SORT_DIRECTION.ascending - ? __('Sort direction: Ascending') - : __('Sort direction: Descending'); + selectedSortDirection() { + return this.sortDirectionAscending ? SORT_DIRECTION.ascending : SORT_DIRECTION.descending; + }, + selectedSortOption() { + return this.sortOptions.find((sortOption) => sortOption.id === this.sortById); }, /** * This prop fixes a behaviour affecting GlFilteredSearch @@ -184,14 +171,13 @@ export default { this.filterValue = newValue; } }, - initialSortBy(newValue) { - if (this.syncFilterAndSort) { - this.updateSelectedSortValues(newValue); + initialSortBy(newInitialSortBy) { + if (this.syncFilterAndSort && newInitialSortBy) { + this.updateSelectedSortValues(); } }, }, created() { - this.updateSelectedSortValues(this.initialSortBy); if (this.recentSearchesStorageKey) this.setupRecentSearch(); }, methods: { @@ -273,15 +259,12 @@ export default { return filter; }); }, - handleSortOptionClick(sortBy) { - this.selectedSortOption = sortBy; - this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); + handleSortByChange(sortById) { + this.sortById = sortById; + this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, - handleSortDirectionClick() { - this.selectedSortDirection = - this.selectedSortDirection === SORT_DIRECTION.ascending - ? SORT_DIRECTION.descending - : SORT_DIRECTION.ascending; + handleSortDirectionChange(isAscending) { + this.sortDirectionAscending = isAscending; this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, handleHistoryItemSelected(filters) { @@ -328,18 +311,30 @@ export default { const cleared = true; this.$emit('onFilter', [], cleared); }, - updateSelectedSortValues(sort) { - if (!sort) { - return; + updateSelectedSortValues() { + Object.assign(this, this.getInitialSort()); + }, + getInitialSort() { + for (const sortOption of this.sortOptions) { + if (sortOption.sortDirection.ascending === this.initialSortBy) { + return { + sortById: sortOption.id, + sortDirectionAscending: true, + }; + } + + if (sortOption.sortDirection.descending === this.initialSortBy) { + return { + sortById: sortOption.id, + sortDirectionAscending: false, + }; + } } - this.selectedSortOption = this.sortOptions.find( - (sortBy) => - sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort, - ); - this.selectedSortDirection = Object.keys(this.selectedSortOption?.sortDirection || {}).find( - (key) => this.selectedSortOption.sortDirection[key] === sort, - ); + return { + sortById: this.sortOptions[0]?.id, + sortDirectionAscending: false, + }; }, }, }; @@ -390,25 +385,14 @@ export default { </template> </template> </gl-filtered-search> - <gl-button-group v-if="selectedSortOption" class="sort-dropdown-container d-flex"> - <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> - <gl-dropdown-item - v-for="sortBy in sortOptions" - :key="sortBy.id" - is-check-item - :is-checked="sortBy.id === selectedSortOption.id" - @click="handleSortOptionClick(sortBy)" - >{{ sortBy.title }}</gl-dropdown-item - > - </gl-dropdown> - <gl-button - v-gl-tooltip - :title="sortDirectionTooltip" - :aria-label="sortDirectionTooltip" - :icon="sortDirectionIcon" - class="flex-shrink-1" - @click="handleSortDirectionClick" - /> - </gl-button-group> + <gl-sorting + v-if="selectedSortOption" + :sort-options="transformedSortOptions" + :sort-by="sortById" + :is-ascending="sortDirectionAscending" + class="sort-dropdown-container" + @sortByChange="handleSortByChange" + @sortDirectionChange="handleSortDirectionChange" + /> </div> </template> 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 3857dd9c55d..5d72ac34e73 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 @@ -11,7 +11,13 @@ import { debounce, last } from 'lodash'; import { stripQuotes } from '~/lib/utils/text_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; +import { + DEBOUNCE_DELAY, + FILTERS_NONE_ANY, + OPERATOR_NOT, + OPERATOR_OR, + OPERATORS_TO_GROUP, +} from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { @@ -102,7 +108,7 @@ export default { }, activeTokenValue() { const data = - this.glFeatures.groupMultiSelectTokens && Array.isArray(this.value.data) + this.multiSelectEnabled && Array.isArray(this.value.data) ? last(this.value.data) : this.value.data; return this.getActiveTokenValue(this.suggestions, data); @@ -153,6 +159,22 @@ export default { ? this.activeTokenValue[this.searchBy] : undefined; }, + multiSelectEnabled() { + return ( + this.config.multiSelect && + this.glFeatures.groupMultiSelectTokens && + OPERATORS_TO_GROUP.includes(this.value.operator) + ); + }, + validatedConfig() { + if (this.config.multiSelect && !this.multiSelectEnabled) { + return { + ...this.config, + multiSelect: false, + }; + } + return this.config; + }, }, watch: { active: { @@ -199,7 +221,7 @@ export default { } }, DEBOUNCE_DELAY), handleTokenValueSelected(selectedValue) { - if (this.glFeatures.groupMultiSelectTokens) { + if (this.multiSelectEnabled) { this.$emit('token-selected', selectedValue); } @@ -228,7 +250,7 @@ export default { <template> <gl-filtered-search-token - :config="config" + :config="validatedConfig" :value="value" :active="active" :multi-select-values="multiSelectValues" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index c5326ead60d..87e295d00dd 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -7,7 +7,7 @@ import { __ } from '~/locale'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { OPTIONS_NONE_ANY } from '../constants'; +import { OPERATORS_TO_GROUP, OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -57,7 +57,11 @@ export default { return this.config.fetchUsers ? this.config.fetchUsers : this.fetchUsersBySearchTerm; }, multiSelectEnabled() { - return this.config.multiSelect && this.glFeatures.groupMultiSelectTokens; + return ( + this.config.multiSelect && + this.glFeatures.groupMultiSelectTokens && + OPERATORS_TO_GROUP.includes(this.value.operator) + ); }, }, watch: { @@ -94,7 +98,7 @@ export default { return user?.avatarUrl || user?.avatar_url; }, displayNameFor(username) { - return this.getActiveUser(this.allUsers, username)?.name || `@${username}`; + return this.getActiveUser(this.allUsers, username)?.name || username; }, avatarFor(username) { const user = this.getActiveUser(this.allUsers, username); diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index 0455685627d..b03da19a896 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -179,7 +179,6 @@ export default { :aria-label="toggleVisibilityLabel" :icon="toggleVisibilityIcon" data-testid="toggle-visibility-button" - data-qa-selector="toggle_visibility_button" @click.stop="handleToggleVisibilityButtonClick" /> <clipboard-button diff --git a/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue deleted file mode 100644 index d68c4399275..00000000000 --- a/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -export default { - props: { - slotKey: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - aliveSlotsLookup: {}, - }; - }, - computed: { - aliveSlots() { - return Object.keys(this.aliveSlotsLookup); - }, - }, - watch: { - slotKey: { - handler(val) { - if (!val) { - return; - } - - this.$set(this.aliveSlotsLookup, val, true); - }, - immediate: true, - }, - }, - methods: { - isCurrentSlot(key) { - return key === this.slotKey; - }, - }, -}; -</script> - -<template> - <div> - <div - v-for="slot in aliveSlots" - v-show="isCurrentSlot(slot)" - :key="slot" - class="gl-h-full gl-w-full" - > - <slot :name="slot"></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/constants.js b/app/assets/javascripts/vue_shared/components/list_selector/constants.js index cff9c56a1c0..ad826c6f3e5 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/constants.js +++ b/app/assets/javascripts/vue_shared/components/list_selector/constants.js @@ -1,6 +1,26 @@ import { __ } from '~/locale'; +import UserItem from './user_item.vue'; +import GroupItem from './group_item.vue'; +import DeployKeyItem from './deploy_key_item.vue'; export const CONFIG = { - users: { title: __('Users'), icon: 'user', filterKey: 'username', showNamespaceDropdown: true }, - groups: { title: __('Groups'), icon: 'group', filterKey: 'name' }, + users: { + title: __('Users'), + icon: 'user', + filterKey: 'username', + showNamespaceDropdown: true, + component: UserItem, + }, + groups: { + title: __('Groups'), + icon: 'group', + filterKey: 'name', + component: GroupItem, + }, + deployKeys: { + title: __('Deploy keys'), + icon: 'key', + filterKey: 'name', + component: DeployKeyItem, + }, }; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/deploy_key_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/deploy_key_item.vue new file mode 100644 index 00000000000..4dbbd44f0b5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/deploy_key_item.vue @@ -0,0 +1,51 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + name: 'DeployKeyItem', + components: { GlButton, GlIcon }, + props: { + data: { + type: Object, + required: true, + }, + canDelete: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + const { title, owner, id } = this.data; + return { + deleteButtonLabel: sprintf(__('Delete %{name}'), { name: title }), + title, + owner, + id, + }; + }, +}; +</script> + +<template> + <span + class="gl-display-flex gl-align-items-center gl-gap-3" + data-testid="deploy-key-wrapper" + @click="$emit('select', id)" + > + <gl-icon name="key" /> + <span class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <span class="gl-font-weight-bold">{{ title }}</span> + <span class="gl-text-gray-600">@{{ owner }}</span> + </span> + + <gl-button + v-if="canDelete" + icon="remove" + :aria-label="deleteButtonLabel" + category="tertiary" + @click.stop="$emit('delete', id)" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/index.vue b/app/assets/javascripts/vue_shared/components/list_selector/index.vue index b8480a0c496..d79a8d6a00c 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/index.vue +++ b/app/assets/javascripts/vue_shared/components/list_selector/index.vue @@ -5,8 +5,6 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; import Api from '~/api'; -import UserItem from './user_item.vue'; -import GroupItem from './group_item.vue'; import { CONFIG } from './constants'; const I18N = { @@ -25,10 +23,6 @@ export default { GlCollapsibleListbox, }, props: { - title: { - type: String, - required: true, - }, type: { type: String, required: true, @@ -61,12 +55,6 @@ export default { config() { return CONFIG[this.type]; }, - isUserVariant() { - return this.type === 'users'; - }, - component() { - return this.isUserVariant ? UserItem : GroupItem; - }, namespaceDropdownText() { return parseBoolean(this.isProjectNamespace) ? this.$options.i18n.projectGroups @@ -77,12 +65,14 @@ export default { async handleSearchInput(search) { this.$refs.results.open(); + const searchMethod = { + users: this.fetchUsersBySearchTerm, + groups: this.fetchGroupsBySearchTerm, + deployKeys: this.fetchDeployKeysBySearchTerm, + }; + try { - if (this.isUserVariant) { - this.items = await this.fetchUsersBySearchTerm(search); - } else { - this.items = await this.fetchGroupsBySearchTerm(search); - } + this.items = await searchMethod[this.type](search); } catch (e) { createAlert({ message: this.$options.i18n.apiErrorMessage, @@ -114,6 +104,10 @@ export default { })), ); }, + fetchDeployKeysBySearchTerm() { + // TODO - implement API request (follow-up) + // https://gitlab.com/gitlab-org/gitlab/-/issues/432494 + }, getItemByKey(key) { return this.items.find((item) => item[this.config.filterKey] === key); }, @@ -139,7 +133,7 @@ export default { <gl-card header-class="gl-new-card-header gl-border-none" body-class="gl-card-footer"> <template #header ><strong data-testid="list-selector-title" - >{{ title }} + >{{ config.title }} <span class="gl-text-gray-700 gl-ml-3" ><gl-icon :name="config.icon" /> {{ selectedItems.length }}</span ></strong @@ -166,7 +160,7 @@ export default { </template> <template #list-item="{ item }"> - <component :is="component" :data="item" @select="handleSelectItem" /> + <component :is="config.component" :data="item" @select="handleSelectItem" /> </template> </gl-collapsible-listbox> @@ -180,7 +174,7 @@ export default { </div> <component - :is="component" + :is="config.component" v-for="(item, index) of selectedItems" :key="index" :class="{ 'gl-border-t': index > 0 }" diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index d99b90fa561..a7dfc1e2cdb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -88,7 +88,7 @@ export default { placement="right" searchable size="small" - class="comment-template-dropdown gl-mr-3" + class="comment-template-dropdown gl-mr-2" positioning-strategy="fixed" :searching="$apollo.queries.savedReplies.loading" @shown="fetchCommentTemplates" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 24211833026..e80f5c7f092 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -361,7 +361,7 @@ export default { <template> <div ref="gl-form" - class="js-vue-markdown-field md-area position-relative gfm-form gl-overflow-hidden" + class="js-vue-markdown-field md-area position-relative gfm-form" :data-uploads-path="uploadsPath" > <markdown-header diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index cc3c95a047b..cffd8471d18 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -21,6 +21,7 @@ import { updateText } from '~/lib/utils/text_markdown'; import ToolbarButton from './toolbar_button.vue'; import DrawioToolbarButton from './drawio_toolbar_button.vue'; import CommentTemplatesDropdown from './comment_templates_dropdown.vue'; +import HeaderDivider from './header_divider.vue'; export default { components: { @@ -30,6 +31,7 @@ export default { DrawioToolbarButton, CommentTemplatesDropdown, AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'), + HeaderDivider, }, directives: { GlTooltip: GlTooltipDirective, @@ -188,14 +190,6 @@ export default { }) .catch(() => {}); }, - handleAttachFile(e) { - e.preventDefault(); - const $gfmForm = $(this.$el).closest('.gfm-form'); - const $gfmTextarea = $gfmForm.find('.js-gfm-input'); - - $gfmForm.find('.div-dropzone').click(); - $gfmTextarea.focus(); - }, insertIntoTextarea(text) { const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); if (textArea) { @@ -254,252 +248,281 @@ export default { </script> <template> - <div class="md-header gl-border-b gl-border-gray-100 gl-px-3"> + <div + class="md-header gl-bg-white gl-border-b gl-border-gray-100 gl-rounded-lg gl-rounded-bottom-left-none gl-rounded-bottom-right-none gl-px-3" + :class="{ 'md-header-preview': previewMarkdown }" + > <div class="gl-display-flex gl-align-items-center gl-flex-wrap"> <div data-testid="md-header-toolbar" - class="md-header-toolbar gl-display-flex gl-py-3 gl-flex-wrap gl-row-gap-3" + class="md-header-toolbar gl-display-flex gl-py-3 gl-row-gap-2 gl-flex-grow-1 gl-align-items-flex-start" > - <gl-button - v-if="enablePreview" - data-testid="preview-toggle" - :value="previewMarkdown ? 'preview' : 'edit'" - :label="$options.i18n.previewTabTitle" - class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal! gl-mr-2" - size="small" - category="tertiary" - @click="switchPreview" - >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button - > - <template v-if="!previewMarkdown && canSuggest"> + <div class="gl-display-flex gl-flex-wrap gl-row-gap-2"> + <gl-button + v-if="enablePreview" + data-testid="preview-toggle" + :value="previewMarkdown ? 'preview' : 'edit'" + :label="$options.i18n.previewTabTitle" + class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal!" + size="small" + category="tertiary" + @click="switchPreview" + >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button + > + <template v-if="!previewMarkdown && canSuggest"> + <div class="gl-display-flex gl-row-gap-2"> + <header-divider :preview-markdown="previewMarkdown" /> + <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + tracking-property="codeSuggestion" + icon="doc-code" + data-testid="suggestion-button" + class="js-suggestion-btn" + @click="handleSuggestDismissed" + /> + <gl-popover + v-if="suggestPopoverVisible" + :target="$refs.suggestButton.$el" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="suggestPopoverVisible" + triggers="" + > + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} + </p> + <gl-button + variant="confirm" + category="primary" + size="small" + data-testid="dismiss-suggestion-popover-button" + @click="handleSuggestDismissed" + > + {{ __('Got it') }} + </gl-button> + </gl-popover> + </div> + </template> + <div class="gl-display-flex gl-row-gap-2"> + <div + v-if="!previewMarkdown && editorAiActions.length" + class="gl-display-flex gl-row-gap-2" + > + <header-divider :preview-markdown="previewMarkdown" /> + <ai-actions-dropdown + :actions="editorAiActions" + @input="insertAIAction" + @replace="replaceTextarea" + /> + </div> + <header-divider :preview-markdown="previewMarkdown" /> + </div> <toolbar-button - ref="suggestButton" - :tag="mdSuggestion" + v-show="!previewMarkdown" + tag="**" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ + " + :shortcuts="$options.shortcuts.bold" + icon="bold" + tracking-property="bold" + /> + <toolbar-button + v-show="!previewMarkdown" + tag="_" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ + " + :shortcuts="$options.shortcuts.italic" + icon="italic" + tracking-property="italic" + /> + <div class="gl-display-flex gl-row-gap-2"> + <toolbar-button + v-if="!restrictedToolBarItems.includes('strikethrough')" + v-show="!previewMarkdown" + tag="~~" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}%{shiftKey}X)'), { + modifierKey, + shiftKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.strikethrough" + icon="strikethrough" + tracking-property="strike" + /> + <header-divider :preview-markdown="previewMarkdown" /> + </div> + <toolbar-button + v-if="!restrictedToolBarItems.includes('quote')" + v-show="!previewMarkdown" :prepend="true" - :button-title="__('Insert suggestion')" - :cursor-offset="4" - :tag-content="lineContent" - tracking-property="codeSuggestion" - icon="doc-code" - data-testid="suggestion-button" - class="js-suggestion-btn" - @click="handleSuggestDismissed" + :tag="tag" + :button-title="__('Insert a quote')" + icon="quote" + tracking-property="blockquote" + @click="handleQuote" /> - <gl-popover - v-if="suggestPopoverVisible" - :target="$refs.suggestButton.$el" - :css-classes="['diff-suggest-popover']" - placement="bottom" - :show="suggestPopoverVisible" - triggers="" - > - <strong>{{ __('New! Suggest changes directly') }}</strong> - <p class="mb-2"> - {{ - __( - 'Suggest code changes which can be immediately applied in one click. Try it out!', - ) - }} - </p> - <gl-button - variant="confirm" - category="primary" - size="small" - data-testid="dismiss-suggestion-popover-button" - @click="handleSuggestDismissed" - > - {{ __('Got it') }} - </gl-button> - </gl-popover> - </template> - <ai-actions-dropdown - v-if="!previewMarkdown && editorAiActions.length" - :actions="editorAiActions" - @input="insertAIAction" - @replace="replaceTextarea" - /> - <toolbar-button - v-show="!previewMarkdown" - tag="**" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { - modifierKey, - }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ - " - :shortcuts="$options.shortcuts.bold" - icon="bold" - tracking-property="bold" - /> - <toolbar-button - v-show="!previewMarkdown" - tag="_" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { - modifierKey, - }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ - " - :shortcuts="$options.shortcuts.italic" - icon="italic" - tracking-property="italic" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('strikethrough')" - v-show="!previewMarkdown" - tag="~~" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}%{shiftKey}X)'), { - modifierKey, - shiftKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, - }) - " - :shortcuts="$options.shortcuts.strikethrough" - icon="strikethrough" - tracking-property="strike" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('quote')" - v-show="!previewMarkdown" - :prepend="true" - :tag="tag" - :button-title="__('Insert a quote')" - icon="quote" - tracking-property="blockquote" - @click="handleQuote" - /> - <toolbar-button - v-show="!previewMarkdown" - tag="`" - tag-block="```" - :button-title="__('Insert code')" - icon="code" - tracking-property="code" - /> - <toolbar-button - v-show="!previewMarkdown" - tag="[{text}](url)" - tag-select="url" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { - modifierKey, - }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ - " - :shortcuts="$options.shortcuts.link" - icon="link" - tracking-property="link" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('bullet-list')" - v-show="!previewMarkdown" - :prepend="true" - tag="- " - :button-title="__('Add a bullet list')" - icon="list-bulleted" - tracking-property="bulletList" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('numbered-list')" - v-show="!previewMarkdown" - :prepend="true" - tag="1. " - :button-title="__('Add a numbered list')" - icon="list-numbered" - tracking-property="orderedList" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('task-list')" - v-show="!previewMarkdown" - :prepend="true" - tag="- [ ] " - :button-title="__('Add a checklist')" - icon="list-task" - tracking-property="taskList" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('indent')" - v-show="!previewMarkdown" - class="gl-display-none" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), { - modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, - }) - " - :shortcuts="$options.shortcuts.indent" - command="indentLines" - icon="list-indent" - tracking-property="indent" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('outdent')" - v-show="!previewMarkdown" - class="gl-display-none" - :button-title=" - /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), { - modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, - }) - " - :shortcuts="$options.shortcuts.outdent" - command="outdentLines" - icon="list-outdent" - tracking-property="outdent" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('collapsible-section')" - v-show="!previewMarkdown" - :tag="mdCollapsibleSection" - :prepend="true" - tag-select="Click to expand" - :button-title="__('Add a collapsible section')" - icon="details-block" - tracking-property="details" - /> - <toolbar-button - v-if="!restrictedToolBarItems.includes('table')" - v-show="!previewMarkdown" - :tag="mdTable" - :prepend="true" - :button-title="__('Add a table')" - icon="table" - tracking-property="table" - /> - <toolbar-button - v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')" - data-testid="button-attach-file" - :button-title="__('Attach a file or image')" - icon="paperclip" - class="gl-mr-3" - tracking-property="upload" - @click="handleAttachFile" - /> - <drawio-toolbar-button - v-if="!previewMarkdown && drawioEnabled" - :uploads-path="uploadsPath" - :markdown-preview-path="markdownPreviewPath" - /> - <!-- TODO Add icon and trigger functionality from here --> - <toolbar-button - v-if="supportsQuickActions" - v-show="!previewMarkdown" - :prepend="true" - tag="/" - :button-title="__('Add a quick action')" - icon="quick-actions" - tracking-property="quickAction" - /> - <comment-templates-dropdown - v-if="!previewMarkdown && newCommentTemplatePath" - :new-comment-template-path="newCommentTemplatePath" - @select="insertSavedReply" - /> - <div v-if="!previewMarkdown" class="full-screen"> + <toolbar-button + v-show="!previewMarkdown" + tag="`" + tag-block="```" + :button-title="__('Insert code')" + icon="code" + tracking-property="code" + /> + <toolbar-button + v-show="!previewMarkdown" + tag="[{text}](url)" + tag-select="url" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { + modifierKey, + }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */ + " + :shortcuts="$options.shortcuts.link" + icon="link" + tracking-property="link" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('bullet-list')" + v-show="!previewMarkdown" + :prepend="true" + tag="- " + :button-title="__('Add a bullet list')" + icon="list-bulleted" + tracking-property="bulletList" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('numbered-list')" + v-show="!previewMarkdown" + :prepend="true" + tag="1. " + :button-title="__('Add a numbered list')" + icon="list-numbered" + tracking-property="orderedList" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('task-list')" + v-show="!previewMarkdown" + :prepend="true" + tag="- [ ] " + :button-title="__('Add a checklist')" + icon="list-task" + tracking-property="taskList" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('indent')" + v-show="!previewMarkdown" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.indent" + command="indentLines" + icon="list-indent" + tracking-property="indent" + /> + <toolbar-button + v-if="!restrictedToolBarItems.includes('outdent')" + v-show="!previewMarkdown" + class="gl-display-none" + :button-title=" + /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ + sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), { + modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + }) + " + :shortcuts="$options.shortcuts.outdent" + command="outdentLines" + icon="list-outdent" + tracking-property="outdent" + /> + <div class="gl-display-flex gl-row-gap-2"> + <toolbar-button + v-if="!restrictedToolBarItems.includes('collapsible-section')" + v-show="!previewMarkdown" + :tag="mdCollapsibleSection" + :prepend="true" + tag-select="Click to expand" + :button-title="__('Add a collapsible section')" + icon="details-block" + tracking-property="details" + /> + <header-divider :preview-markdown="previewMarkdown" /> + </div> + <toolbar-button + v-if="!restrictedToolBarItems.includes('table')" + v-show="!previewMarkdown" + :tag="mdTable" + :prepend="true" + :button-title="__('Add a table')" + icon="table" + tracking-property="table" + /> + <!-- + The attach file button's click behavior is added by + dropzone_input.js. + --> + <toolbar-button + v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')" + data-testid="button-attach-file" + data-button-type="attach-file" + :button-title="__('Attach a file or image')" + icon="paperclip" + class="gl-mr-2" + tracking-property="upload" + /> + <drawio-toolbar-button + v-if="!previewMarkdown && drawioEnabled" + :uploads-path="uploadsPath" + :markdown-preview-path="markdownPreviewPath" + /> + <!-- TODO Add icon and trigger functionality from here --> + <toolbar-button + v-if="supportsQuickActions" + v-show="!previewMarkdown" + :prepend="true" + tag="/" + :button-title="__('Add a quick action')" + icon="quick-actions" + tracking-property="quickAction" + /> + <comment-templates-dropdown + v-if="!previewMarkdown && newCommentTemplatePath" + :new-comment-template-path="newCommentTemplatePath" + @select="insertSavedReply" + /> + </div> + <div + v-if="!previewMarkdown" + class="full-screen gl-flex-grow-1 gl-justify-content-end gl-display-flex" + > <toolbar-button v-if="!restrictedToolBarItems.includes('full-screen')" - class="js-zen-enter" + class="js-zen-enter gl-mr-0!" icon="maximize" :button-title="__('Go full screen')" :prepend="true" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header_divider.vue b/app/assets/javascripts/vue_shared/components/markdown/header_divider.vue new file mode 100644 index 00000000000..d08a3d4cd34 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/header_divider.vue @@ -0,0 +1,16 @@ +<script> +export default { + props: { + previewMarkdown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> +<template> + <div v-if="!previewMarkdown" class="md-toolbar-divider gl-display-flex gl-py-2"> + <div class="gl-border-l gl-pl-3 gl-ml-2"></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index cf484443c07..182da7945ff 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -111,7 +111,7 @@ export default { type="button" category="tertiary" size="small" - class="js-md gl-mr-3" + class="js-md gl-mr-2" data-container="body" @click="$emit('click', $event)" /> diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index 3bee539688b..1ee752e8c19 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -72,7 +72,7 @@ export default { }; </script> <template> - <div class="issuable-note-warning"> + <div class="issuable-note-warning" data-testid="issuable-note-warning"> <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> diff --git a/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.stories.js b/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.stories.js new file mode 100644 index 00000000000..59b1967ad31 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.stories.js @@ -0,0 +1,34 @@ +import NumberToHumanSize from './number_to_human_size.vue'; + +export default { + component: NumberToHumanSize, + title: 'vue_shared/number_to_human_size', +}; + +const Template = (args, { argTypes }) => ({ + components: { NumberToHumanSize }, + props: Object.keys(argTypes), + template: '<number-to-human-size v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + value: 42.55 * 1024 * 1024 * 1024, + fractionDigits: 1, + labelClass: '', + plainZero: false, +}; + +export const PlainZero = Template.bind({}); +PlainZero.args = { + ...Default.args, + value: 0, + plainZero: true, +}; + +export const CustomStyles = Template.bind({}); +CustomStyles.args = { + ...Default.args, + class: 'gl-font-weight-bold', + labelClass: 'gl-font-sm gl-text-gray-500', +}; diff --git a/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.vue b/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.vue new file mode 100644 index 00000000000..d6c56b2c465 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/number_to_human_size/number_to_human_size.vue @@ -0,0 +1,48 @@ +<script> +import { numberToHumanSizeSplit } from '~/lib/utils/number_utils'; + +export default { + name: 'NumberToHumanSize', + props: { + value: { + type: Number, + required: true, + }, + fractionDigits: { + type: Number, + required: false, + default: 1, + }, + labelClass: { + type: String, + required: false, + default: null, + }, + plainZero: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + formattedValue() { + if (this.plainZero && this.value === 0) { + return ['0']; + } + + return numberToHumanSizeSplit(this.value, this.fractionDigits); + }, + number() { + return this.formattedValue[0]; + }, + label() { + return this.formattedValue[1]; + }, + }, +}; +</script> +<template> + <span + >{{ number }}<span v-if="label" :class="labelClass"> {{ label }}</span></span + > +</template> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 67ad7769c7c..f3b483c5f53 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -100,7 +100,7 @@ export default { type="search" class="mb-3" autofocus - data-qa-selector="project_search_field" + data-testid="project-search-field" @input="onInput" /> <div class="d-flex flex-column"> @@ -120,7 +120,7 @@ export default { :project="project" :matcher="searchQuery" class="js-project-list-item" - data-qa-selector="project_list_item" + data-testid="project-list-item" @click="projectClicked(project)" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/registry/details_row.vue b/app/assets/javascripts/vue_shared/components/registry/details_row.vue index 72e06b45561..85b4ea241ef 100644 --- a/app/assets/javascripts/vue_shared/components/registry/details_row.vue +++ b/app/assets/javascripts/vue_shared/components/registry/details_row.vue @@ -32,12 +32,14 @@ export default { <template> <div - class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all" + class="gl-display-flex gl-align-items-top gl-font-monospace gl-font-sm gl-word-break-all" :class="[padding, borderClass]" > - <gl-icon v-if="icon" :name="icon" class="gl-mr-4" /> - <span> + <div v-if="icon" class="gl-w-5 gl-mr-4"> + <gl-icon :name="icon" /> + </div> + <div> <slot></slot> - </span> + </div> </div> </template> 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 ccda8c5fea7..868e348adc0 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -70,9 +70,11 @@ export default { <slot name="left-action"></slot> </div> <div - class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" > - <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-direction-column gl-mb-3 gl-sm-mb-0 gl-min-w-0 gl-flex-grow-1" + > <div v-if=" $slots['left-primary'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue index 3b6dcace8fe..89b64f03e1f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -66,6 +66,10 @@ export default { const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); }, + codeStyling() { + const defaultGutterWidth = 96; + return { marginLeft: `${this.$refs.lineNumbers?.offsetWidth || defaultGutterWidth}px` }; + }, }, methods: { handleChunkAppear() { @@ -80,7 +84,7 @@ export default { </script> <template> <div class="gl-display-flex"> - <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column gl-absolute"> <div v-for="(n, index) in totalLines" :key="index" @@ -102,14 +106,14 @@ export default { </div> </div> - <div v-else class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <div v-else ref="lineNumbers" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> <!-- Placeholder for line numbers while content is not highlighted --> </div> <gl-intersection-observer class="gl-w-full" @appear="handleChunkAppear"> <pre class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" - ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + ><code v-if="shouldHighlight" v-safe-html="highlightedContent" :style="codeStyling" data-testid="content"></code><code v-else v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> </gl-intersection-observer> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index 582093e5739..47b802d9d17 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -14,6 +14,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { clean: 'clean', clojure: 'clojure', cmake: 'cmake', + codeowners: 'codeowners', coffeescript: 'coffeescript', coq: 'coq', cpp: 'cpp', diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql index a5f3f348cfc..c497224cde3 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql +++ b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql @@ -14,6 +14,7 @@ query getBlameData($fullPath: ID!, $filePath: String!, $fromLine: Int, $toLine: span commit { id + authorName titleHtml message authoredDate diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index dcefa66c403..bc46f11ab2d 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -5,7 +5,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import LineHighlighter from '~/blob/line_highlighter'; -import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER, CODEOWNERS_FILE_NAME } from './constants'; import Chunk from './components/chunk_new.vue'; import Blame from './components/blame_info.vue'; import { calculateBlameOffset, shouldRender, toggleBlameClasses } from './utils'; @@ -21,6 +21,7 @@ export default { components: { Chunk, Blame, + CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'), }, directives: { SafeHtml, @@ -45,6 +46,10 @@ export default { type: String, required: true, }, + currentRef: { + type: String, + required: true, + }, }, data() { return { @@ -66,6 +71,9 @@ export default { return result; }, []); }, + isCodeownersFile() { + return this.blob.name === CODEOWNERS_FILE_NAME; + }, }, watch: { showBlame: { @@ -136,11 +144,19 @@ export default { <blame v-if="showBlame && blameInfo.length" :blame-info="blameInfo" /> <div - class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto gl-w-full" + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto gl-w-full blob-viewer" :class="$options.userColorScheme" data-type="simple" :data-path="blob.path" + data-testid="blob-viewer-file-content" > + <codeowners-validation + v-if="isCodeownersFile" + class="gl-text-black-normal" + :current-ref="currentRef" + :project-path="projectPath" + :file-path="blob.path" + /> <chunk v-for="(chunk, index) in chunks" :key="index" diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js index 8d8e945cd5f..057a1c2d113 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -1,13 +1,35 @@ import hljs from 'highlight.js/lib/core'; -import json from 'highlight.js/lib/languages/json'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import { registerPlugins } from '../plugins/index'; import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = (fileType, content, language) => { - // The Highlight Worker is currently scoped to JSON files. - // See the following issue for more: https://gitlab.com/gitlab-org/gitlab/-/issues/415753 - hljs.registerLanguage(language, json); +const loadLanguage = async (language) => { + const languageDefinition = await languageLoader[language](); + hljs.registerLanguage(language, languageDefinition.default); +}; + +const loadSubLanguages = async (languageDefinition) => { + // Some files can contain sub-languages (i.e., Svelte); this ensures that sub-languages are also loaded + if (!languageDefinition?.contains) return; + + // generate list of languages to load + const languages = new Set( + languageDefinition.contains + .filter((component) => Boolean(component.subLanguage)) + .map((component) => component.subLanguage), + ); + + if (languageDefinition.subLanguage) { + languages.add(languageDefinition.subLanguage); + } + + await Promise.all([...languages].map(loadLanguage)); +}; + +const initHighlightJs = async (fileType, content, language) => { registerPlugins(hljs, fileType, content, true); + await loadLanguage(language); + await loadSubLanguages(hljs.getLanguage(language)); }; const splitByLineBreaks = (content = '') => content.split(/\r?\n/); @@ -35,12 +57,12 @@ const splitIntoChunks = (language, rawContent, highlightedContent) => { return result; }; -const highlight = (fileType, rawContent, lang) => { +const highlight = async (fileType, rawContent, lang) => { const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()]; let result; if (language) { - initHighlightJs(fileType, rawContent, language); + await initHighlightJs(fileType, rawContent, language); const highlightedContent = hljs.highlight(rawContent, { language }).value; result = splitIntoChunks(language, rawContent, highlightedContent); } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js index 535e857d7a9..49afaba3d2f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js @@ -4,7 +4,7 @@ import { highlight } from './highlight_utils'; * A webworker for highlighting large amounts of content with Highlight.js */ // eslint-disable-next-line no-restricted-globals -self.addEventListener('message', ({ data: { fileType, content, language } }) => { +self.addEventListener('message', async ({ data: { fileType, content, language } }) => { // eslint-disable-next-line no-restricted-globals - self.postMessage(highlight(fileType, content, language)); + self.postMessage(await highlight(fileType, content, language)); }); diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue index 779a2ab5461..45d49e5339a 100644 --- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue +++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue @@ -21,9 +21,11 @@ export default { <div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-align-items-center gl-py-3"> <div - class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" > - <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-direction-column gl-mb-3 gl-sm-mb-0 gl-min-w-0 gl-flex-grow-1" + > <div v-if=" /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[ diff --git a/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue index e5558c038b3..43e35f2b1f0 100644 --- a/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue +++ b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue @@ -12,11 +12,18 @@ export default { components: { GlBadge, }, + props: { + size: { + type: String, + required: false, + default: 'md', + }, + }, }; </script> <template> - <gl-badge class="gl-bg-transparent! gl-inset-border-1-gray-100!"> + <gl-badge :size="size" class="gl-bg-transparent! gl-inset-border-1-gray-100!"> <slot></slot> </gl-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue deleted file mode 100644 index 46496d2e483..00000000000 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ /dev/null @@ -1,18 +0,0 @@ -<script> -export default { - provide() { - return { - vuexModule: this.vuexModule, - }; - }, - props: { - vuexModule: { - type: String, - required: true, - }, - }, - render() { - return this.$scopedSlots.default?.(); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue index b4afb27c497..96b2bd37080 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue @@ -82,7 +82,6 @@ export default { attributes: { href: this.forkPath, variant: 'confirm', - 'data-qa-selector': 'fork_project_button', }, }, }; @@ -94,7 +93,6 @@ export default { <template> <gl-modal :visible="visible" - data-qa-selector="confirm_fork_modal" :modal-id="modalId" :title="$options.i18n.title" :action-primary="btnActions.primary" diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 441b4c31b3a..3514a9c2d5d 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -145,6 +145,11 @@ export default { required: false, default: '', }, + cssClasses: { + type: String, + required: false, + default: 'gl-sm-ml-3', + }, }, data() { return { @@ -329,7 +334,7 @@ export default { </script> <template> - <div class="gl-sm-ml-3"> + <div :class="cssClasses"> <gl-disclosure-dropdown v-if="hasActions" :variant="isBlob ? 'confirm' : 'default'" diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 14ea0389bad..b3840a0adbf 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -1,11 +1,10 @@ -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; export const AUTOCOMPLETE_ERROR_MESSAGE = s__( 'GlobalSearch|There was an error fetching search autocomplete suggestions.', ); export const ALL_GITLAB = __('All GitLab'); -export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab'); export const PLACES = s__('GlobalSearch|Places'); export const COMMAND_PALETTE = s__('GlobalSearch|Command palette'); @@ -24,17 +23,9 @@ export const SEARCH_DESCRIBED_BY_UPDATED = s__( ); export const SEARCH_RESULTS_LOADING = s__('GlobalSearch|Search results are loading'); export const SEARCH_RESULTS_SCOPE = s__('GlobalSearch|in %{scope}'); -export const KBD_HELP = sprintf( - s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'), - { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, - false, -); export const MIN_SEARCH_TERM = s__( 'GlobalSearch|The search term must be at least 3 characters long.', ); - -export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}'); - export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created"); @@ -76,8 +67,6 @@ export const SEARCH_RESULTS_ORDER = [ SETTINGS_CATEGORY, HELP_CATEGORY, ]; -export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER; - export const SEARCH_LABELS = s__('GlobalSearch|Search labels'); export const DROPDOWN_HEADER = s__('GlobalSearch|Labels'); diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue index b4287d86289..1828208bd0f 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue @@ -18,6 +18,7 @@ export default { 'initialLabels', 'issuableType', 'labelType', + 'issuableSupportsLockOnMerge', 'variant', 'workspaceType', ], @@ -76,6 +77,7 @@ export default { :issuable-type="issuableType" :label-create-type="labelType" :selected-labels="selectedLabels" + :issuable-supports-lock-on-merge="issuableSupportsLockOnMerge" @updateSelectedLabels="handleUpdateSelectedLabels" @onLabelRemove="handleLabelRemove" > diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 0db7417cebc..ad908a674d3 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -6,6 +6,7 @@ import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { DRAG_DELAY } from '~/sortable/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -24,6 +25,8 @@ export default { forceFallback: true, ghostClass: 'gl-visibility-hidden', tag: 'ul', + delay: DRAG_DELAY, + delayOnTouchOnly: true, }, components: { GlAlert, diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index dae3ddfe016..bac71c1eda2 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -31,6 +31,10 @@ export default { type: Boolean, required: true, }, + hideEditButton: { + type: Boolean, + required: false, + }, enableAutocomplete: { type: Boolean, required: true, @@ -166,6 +170,7 @@ export default { :issuable="issuable" :status-icon="statusIcon" :enable-edit="enableEdit" + :hide-edit-button="hideEditButton" :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" > @@ -181,12 +186,12 @@ export default { :task-list-update-path="taskListUpdatePath" /> <slot name="secondary-content"></slot> - <small v-if="isUpdated" class="edited-text gl-font-sm!"> + <small v-if="isUpdated" class="edited-text gl-font-sm! gl-text-secondary"> {{ __('Edited') }} <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> <span v-if="updatedBy"> {{ __('by') }} - <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!"> + <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm! gl-text-secondary"> <span>{{ updatedBy.name }}</span> </gl-link> </span> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 7c3dd5c3623..3353374310f 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -153,7 +153,10 @@ export default { </template> </markdown-field> </gl-form-group> - <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix"> + <div + data-testid="actions" + class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix gl-display-flex gl-gap-3" + > <slot name="edit-form-actions" :issuable-title="title" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index 62a2b44e660..1b95a2abdf9 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -155,8 +155,8 @@ export default { </script> <template> - <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row"> - <div class="detail-page-header-body gl-flex-wrap gl-gap-2"> + <div class="detail-page-header gl-flex-direction-column gl-md-flex-direction-row"> + <div class="detail-page-header-body gl-flex-wrap gl-column-gap-2"> <gl-badge :variant="badgeVariant" data-testid="issue-state-badge"> <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> <span class="gl-display-none gl-sm-display-block" :class="{ 'gl-ml-2': statusIcon }"> @@ -221,7 +221,7 @@ export default { @click="handleRightSidebarToggleClick" /> </div> - <div class="detail-page-header-actions gl-align-self-center gl-display-flex"> + <div class="detail-page-header-actions gl-align-self-center gl-display-flex gl-gap-3"> <slot name="header-actions"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 040f49c7c25..1d44c4a1c14 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + hideEditButton: { + type: Boolean, + required: false, + default: false, + }, enableAutocomplete: { type: Boolean, required: false, @@ -137,6 +142,7 @@ export default { :status-icon="statusIcon" :status-icon-class="statusIconClass" :enable-edit="enableEdit" + :hide-edit-button="hideEditButton" :enable-autocomplete="enableAutocomplete" :enable-autosave="enableAutosave" :enable-zen-mode="enableZenMode" @@ -169,6 +175,9 @@ export default { </issuable-discussion> <issuable-sidebar> + <template #right-sidebar-top-items="{ sidebarExpanded, toggleSidebar }"> + <slot name="right-sidebar-top-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot> + </template> <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }"> <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 5387e39e3eb..3dae894b127 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -33,6 +33,10 @@ export default { type: Boolean, required: true, }, + hideEditButton: { + type: Boolean, + required: false, + }, workspaceType: { type: String, required: false, @@ -70,7 +74,7 @@ export default { data-testid="issuable-title" ></h1> <gl-button - v-if="enableEdit" + v-if="enableEdit && !hideEditButton" v-gl-tooltip.bottom :title="$options.i18n.editTitleAndDescription" :aria-label="$options.i18n.editTitleAndDescription" diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue index 774267639fc..cb9ad6418a4 100644 --- a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue @@ -1,13 +1,17 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants'; export default { components: { - GlIcon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, }, data() { const userExpanded = !parseBoolean(getCookie(USER_COLLAPSED_GUTTER_COOKIE)); @@ -20,6 +24,20 @@ export default { isExpanded: userExpanded ? bp.isDesktop() : userExpanded, }; }, + computed: { + toggleLabel() { + return this.isExpanded ? __('Collapse sidebar') : __('Expand sidebar'); + }, + toggleIcon() { + return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left'; + }, + expandedToggleClass() { + return this.isExpanded ? 'block' : ''; + }, + collapsedToggleClass() { + return !this.isExpanded ? 'block' : ''; + }, + }, mounted() { window.addEventListener('resize', this.handleWindowResize); this.updatePageContainerClass(); @@ -59,23 +77,24 @@ export default { class="right-sidebar" aria-live="polite" > - <button - class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!" - data-testid="toggle-right-sidebar-button" - :title="__('Toggle sidebar')" - @click="toggleSidebar" - > - <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{ - __('Collapse sidebar') - }}</span> - <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" /> - <gl-icon - v-show="!isExpanded" - data-testid="icon-expand" - name="chevron-double-lg-left" - class="gl-ml-2" + <div class="right-sidebar-header" :class="expandedToggleClass"> + <gl-button + v-gl-tooltip.hover.left + category="tertiary" + size="small" + class="gl-float-right gutter-toggle toggle-right-sidebar-button js-toggle-right-sidebar-button gl-shadow-none!" + :class="collapsedToggleClass" + data-testid="toggle-right-sidebar-button" + :icon="toggleIcon" + :title="toggleLabel" + :aria-label="toggleLabel" + @click="toggleSidebar" /> - </button> + <slot + name="right-sidebar-top-items" + v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }" + ></slot> + </div> <div data-testid="sidebar-items" class="issuable-sidebar"> <slot name="right-sidebar-items" diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index 61e45fa5195..438da925937 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -1,4 +1,4 @@ -import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; +import { getTimeago, localeDateFormat, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; /** * Mixin with time ago methods used in some vue components @@ -12,7 +12,7 @@ export default { }, tooltipTitle(time) { - return formatDate(time); + return localeDateFormat.asDateTimeFull.format(time); }, }, }; diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 3412848a9b7..a5c34b4b619 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -86,11 +86,7 @@ export default { }, showSuperSidebarToggle() { - return gon.use_new_navigation && sidebarState.isCollapsed; - }, - - topBarClasses() { - return gon.use_new_navigation ? 'top-bar-fixed container-fluid' : ''; + return sidebarState.isCollapsed; }, }, @@ -124,7 +120,7 @@ export default { <template> <div> - <div :class="topBarClasses" data-testid="top-bar"> + <div class="top-bar-fixed container-fluid" data-testid="top-bar"> <div class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" > 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 c1ec39e1545..dccff4a288f 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 @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/constants'; import { parseErrorMessage } from '~/lib/utils/error_message'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { sprintf, s__ } from '~/locale'; @@ -110,7 +110,6 @@ export default { :loading="isLoading" :variant="variant" :category="category" - :data-qa-selector="`${feature.type}_mr_button`" @click="mutate" >{{ $options.i18n.buttonLabel }}</gl-button > diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue index f20d4d9312b..25f994c1e6e 100644 --- a/app/assets/javascripts/webhooks/components/form_url_app.vue +++ b/app/assets/javascripts/webhooks/components/form_url_app.vue @@ -1,6 +1,13 @@ <script> import { cloneDeep, isEmpty } from 'lodash'; -import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; +import { + GlFormGroup, + GlFormInput, + GlFormRadio, + GlFormRadioGroup, + GlLink, + GlAlert, +} from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { scrollToElement } from '~/lib/utils/common_utils'; @@ -14,6 +21,7 @@ export default { GlFormRadio, GlFormRadioGroup, GlLink, + GlAlert, }, props: { initialUrl: { @@ -40,6 +48,9 @@ export default { urlState() { return !this.isValidated || !isEmpty(this.url); }, + urlHasChanged() { + return this.url !== this.initialUrl; + }, maskedUrl() { if (!this.url) { return null; @@ -152,6 +163,9 @@ export default { urlPlaceholder: 'http://example.com/trigger-ci.json', urlPreview: s__('Webhooks|URL preview'), valuePartOfUrl: s__('Webhooks|Must match part of URL'), + tokenWillBeCleared: s__( + 'Webhooks|Secret token will be cleared on save unless token is updated.', + ), }, }; </script> @@ -175,6 +189,14 @@ export default { :placeholder="$options.i18n.urlPlaceholder" data-testid="form-url" /> + <gl-alert + v-if="urlHasChanged" + variant="warning" + :dismissible="false" + class="gl-my-4 gl-form-input-xl" + > + {{ $options.i18n.tokenWillBeCleared }} + </gl-alert> </gl-form-group> <div class="gl-mt-5"> <gl-form-radio-group v-model="maskEnabled"> diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js index c1baa7b8dd3..60f7eb14ffe 100644 --- a/app/assets/javascripts/webpack_non_compiled_placeholder.js +++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js @@ -39,6 +39,26 @@ div.innerHTML = ` ${reloadMessage}<br /> If it doesn't, please <a href="">reload the page manually</a>. </p> +<div class="gl-card gl-layout-w-limited gl-m-auto"> + <div class="gl-card-body"> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 410 404" width="100"> + <path fill="url(#a)" d="m399.641 59.525-183.998 329.02c-3.799 6.793-13.559 6.833-17.415.073L10.582 59.556C6.38 52.19 12.68 43.266 21.028 44.76l184.195 32.923c1.175.21 2.378.208 3.553-.006l180.343-32.87c8.32-1.517 14.649 7.337 10.522 14.719Z"/> + <path fill="url(#b)" d="M292.965 1.574 156.801 28.255a5 5 0 0 0-4.03 4.611l-8.376 141.464c-.197 3.332 2.863 5.918 6.115 5.168l37.91-8.749c3.547-.818 6.752 2.306 6.023 5.873l-11.263 55.153c-.758 3.712 2.727 6.886 6.352 5.785l23.415-7.114c3.63-1.102 7.118 2.081 6.35 5.796l-17.899 86.633c-1.12 5.419 6.088 8.374 9.094 3.728l2.008-3.103 110.954-221.428c1.858-3.707-1.346-7.935-5.418-7.15l-39.022 7.532c-3.667.707-6.787-2.708-5.752-6.296l25.469-88.291c1.036-3.594-2.095-7.012-5.766-6.293Z"/> + <defs> + <linearGradient id="a" x1="6" x2="235" y1="33" y2="344" gradientUnits="userSpaceOnUse"><stop stop-color="#41D1FF"/><stop offset="1" stop-color="#BD34FE"/></linearGradient> + <linearGradient id="b" x1="194.651" x2="236.076" y1="8.818" y2="292.989" gradientUnits="userSpaceOnUse"><stop stop-color="#FFEA83"/><stop offset=".083" stop-color="#FFDD35"/><stop offset="1" stop-color="#FFA800"/></linearGradient> + </defs> + </svg> + <h2>Don't want to see this message anymore?</h2> + <p class="gl-text-body"> + Follow the documentation to switch to using Vite.<br /> + Vite compiles frontend assets faster and eliminates the need for this message. + </p> + <a href="https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md?ref_type=heads#vite-settings" rel="noopener noreferrer" target="_blank" class="btn btn-confirm btn-md gl-button"> + <span class="gl-button-text">Switch to Vite</span> + </a> + </div> +</div> `; document.body.append(div); diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index bc9e2d5c3b1..57db8cde110 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -1,34 +1,22 @@ import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import { mapState } from 'vuex'; -import App from './components/app.vue'; +import WhatsNewApp from './components/app.vue'; import store from './store'; -import { getVersionDigest, setNotification } from './utils/notification'; let whatsNewApp; -export default (el) => { +export default (versionDigest) => { if (whatsNewApp) { store.dispatch('openDrawer'); } else { + const el = document.createElement('div'); + document.body.append(el); whatsNewApp = new Vue({ el, store, - components: { - App, - }, - computed: { - ...mapState(['open']), - }, - watch: { - open() { - setNotification(el); - }, - }, render(createElement) { - return createElement('app', { + return createElement(WhatsNewApp, { props: { - versionDigest: getVersionDigest(el), + versionDigest, }, }); }, diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js index 1621c4d5f27..fb6ce7454dc 100644 --- a/app/assets/javascripts/whats_new/utils/notification.js +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -1,21 +1 @@ export const STORAGE_KEY = 'display-whats-new-notification'; - -export const getVersionDigest = (appEl) => appEl.dataset.versionDigest; - -export const setNotification = (appEl) => { - const versionDigest = getVersionDigest(appEl); - const notificationEl = document.querySelector('.header-help'); - if (!notificationEl) return; - - let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); - - if (localStorage.getItem(STORAGE_KEY) === versionDigest || versionDigest === undefined) { - notificationEl.classList.remove('with-notifications'); - if (notificationCountEl) { - notificationCountEl.parentElement.removeChild(notificationCountEl); - notificationCountEl = null; - } - } else { - notificationEl.classList.add('with-notifications'); - } -}; diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 74bcc2717bd..23a9671c914 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -18,6 +18,18 @@ export default { required: false, default: false, }, + useH1: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + headerClasses() { + return this.useH1 + ? 'gl-w-full gl-font-size-h-display gl-m-0!' + : 'gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-w-full'; + }, }, methods: { handleBlur({ target }) { @@ -39,9 +51,10 @@ export default { </script> <template> - <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-w-full" - :class="{ 'gl-cursor-text': disabled }" + <component + :is="useH1 ? 'h1' : 'h2'" + class="gl-w-full" + :class="[{ 'gl-cursor-text': disabled }, headerClasses]" aria-labelledby="item-title" > <span @@ -64,5 +77,5 @@ export default { @keydown.meta.b.prevent >{{ title }}</span > - </h2> + </component> </template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index c3b7b7a2953..3636f222c2d 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -287,9 +287,9 @@ export default { v-else ref="textarea" rows="1" - class="reply-placeholder-text-field gl-font-regular!" + class="reply-placeholder-text-field" data-testid="note-reply-textarea" - :placeholder="__('Reply')" + :placeholder="__('Reply…')" :aria-label="__('Reply to comment')" @focus="showReplyForm" @click="showReplyForm" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue index cb9a560f9e1..eb61b5e8e18 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -33,6 +33,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['isGroup'], props: { fullPath: { type: String, @@ -152,6 +153,7 @@ export default { note: this.note, name, fullPath: this.fullPath, + isGroup: this.isGroup, workItemIid: this.workItemIid, }), }); @@ -207,6 +209,7 @@ export default { <gl-button v-if="showEdit" v-gl-tooltip + data-testid="note-actions-edit" data-track-action="click_button" data-track-label="edit_button" category="tertiary" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue index 75a8a7b29c0..c3b3c0e6db7 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue @@ -8,6 +8,7 @@ export default { components: { AwardsList, }, + inject: ['isGroup'], props: { fullPath: { type: String, @@ -73,6 +74,7 @@ export default { note: this.note, name, fullPath: this.fullPath, + isGroup: this.isGroup, workItemIid: this.workItemIid, }), }); diff --git a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue index 1578c78ac4f..722ba898f80 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue @@ -36,6 +36,16 @@ export default { default: WORK_ITEM_NOTES_FILTER_ALL_NOTES, required: false, }, + useH2: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + headerClasses() { + return this.useH2 ? 'gl-font-size-h1 gl-m-0' : 'gl-font-base gl-m-0'; + }, }, methods: { changeNotesSortOrder(direction) { @@ -58,7 +68,9 @@ export default { <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-pb-3 gl-align-items-center" > - <h3 class="gl-font-base gl-m-0">{{ $options.i18n.activityLabel }}</h3> + <component :is="useH2 ? 'h2' : 'h3'" :class="headerClasses">{{ + $options.i18n.activityLabel + }}</component> <div class="gl-display-flex gl-gap-3"> <work-item-activity-sort-filter :work-item-type="workItemType" diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue index cbe7de4abcd..503328f7b03 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -58,11 +58,6 @@ export default { default: true, }, }, - data() { - return { - isFocused: false, - }; - }, computed: { labels() { return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; @@ -117,7 +112,7 @@ export default { return false; }, showRemove() { - return this.canUpdate && this.isFocused; + return this.canUpdate; }, displayLabels() { return this.showLabels && this.labels.length; @@ -135,10 +130,6 @@ export default { <div class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base gl-gap-3" data-testid="links-child" - @mouseover="isFocused = true" - @mouseleave="isFocused = false" - @focusin="isFocused = true" - @focusout="isFocused = false" > <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> <div @@ -203,12 +194,14 @@ export default { </div> <div v-if="canUpdate"> <gl-button + v-gl-tooltip :class="{ 'gl-visibility-visible': showRemove }" class="gl-visibility-hidden" category="tertiary" size="small" icon="close" :aria-label="$options.i18n.remove" + :title="$options.i18n.remove" data-testid="remove-work-item-link" @click="$emit('removeChild', childItem)" /> diff --git a/app/assets/javascripts/work_items/components/update_work_item.js b/app/assets/javascripts/work_items/components/update_work_item.js deleted file mode 100644 index fc395fa5be3..00000000000 --- a/app/assets/javascripts/work_items/components/update_work_item.js +++ /dev/null @@ -1,23 +0,0 @@ -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; - -export function getUpdateWorkItemMutation({ input, workItemParentId }) { - let mutation = updateWorkItemMutation; - - const variables = { - input, - }; - - if (workItemParentId) { - mutation = updateWorkItemTaskMutation; - variables.input = { - id: workItemParentId, - taskData: input, - }; - } - - return { - mutation, - variables, - }; -} diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 0a71fbc9a34..013c9f229ec 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -134,11 +134,6 @@ export default { required: false, default: false, }, - workItemParentId: { - type: String, - required: false, - default: null, - }, }, apollo: { workItemTypes: { @@ -328,7 +323,6 @@ export default { :data-testid="$options.stateToggleTestId" :work-item-id="workItemId" :work-item-state="workItemState" - :work-item-parent-id="workItemParentId" :work-item-type="workItemType" show-as-dropdown-item /> diff --git a/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy.vue b/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy.vue new file mode 100644 index 00000000000..708121ee210 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy.vue @@ -0,0 +1,127 @@ +<script> +import uniqueId from 'lodash/uniqueId'; +import { GlIcon, GlTooltip, GlDisclosureDropdown } from '@gitlab/ui'; +import DisclosureHierarchyItem from './disclosure_hierarchy_item.vue'; + +export default { + components: { + GlDisclosureDropdown, + GlIcon, + GlTooltip, + DisclosureHierarchyItem, + }, + props: { + /** + * A list of items in the form: + * ``` + * { + * title: String, required + * icon: String, optional + * } + * ``` + */ + items: { + type: Array, + required: false, + default: () => [], + validator: (items) => { + return items.every((item) => Object.keys(item).includes('title')); + }, + }, + /** + * When set, displays only first and last item, and groups the rest under an ellipsis button + */ + withEllipsis: { + type: Boolean, + default: false, + required: false, + }, + /** + * When set, a tooltip displays when hovering middle ellipsis button + */ + ellipsisTooltipLabel: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + itemUuid: uniqueId('disclosure-hierarchy-'), + }; + }, + computed: { + middleItems() { + return this.items.slice(1, -1).map((item) => ({ ...item, text: item.title })); + }, + firstItem() { + return this.items[0]; + }, + lastItemIndex() { + return this.items.length - 1; + }, + lastItem() { + return this.items[this.lastItemIndex]; + }, + }, + methods: { + itemId(index) { + return `${this.itemUuid}-item-${index}`; + }, + }, +}; +</script> + +<template> + <div class="gl-relative gl-display-flex"> + <ul class="gl-p-0 gl-m-0 gl-relative gl-list-style-none gl-display-inline-flex gl-w-85p"> + <template v-if="withEllipsis"> + <disclosure-hierarchy-item :item="firstItem" :item-id="itemId(0)"> + <slot :item="firstItem" :item-id="itemId(0)"></slot> + </disclosure-hierarchy-item> + <li class="disclosure-hierarchy-item"> + <gl-disclosure-dropdown :items="middleItems"> + <template #toggle> + <button + id="disclosure-hierarchy-ellipsis-button" + class="disclosure-hierarchy-button" + :aria-label="ellipsisTooltipLabel" + > + <gl-icon name="ellipsis_h" class="gl-ml-3 gl-text-gray-600 gl-z-index-200" /> + </button> + </template> + <template #list-item="{ item }"> + <span class="gl-display-flex"> + <gl-icon + v-if="item.icon" + :name="item.icon" + class="gl-mr-3 gl-vertical-align-middle gl-text-gray-600 gl-flex-shrink-0" + /> + {{ item.title }} + </span> + </template> + </gl-disclosure-dropdown> + </li> + <gl-tooltip + v-if="ellipsisTooltipLabel" + target="disclosure-hierarchy-ellipsis-button" + triggers="hover" + > + {{ ellipsisTooltipLabel }} + </gl-tooltip> + <disclosure-hierarchy-item :item="lastItem" :item-id="itemId(lastItemIndex)"> + <slot :item="lastItem" :item-id="itemId(lastItemIndex)"></slot> + </disclosure-hierarchy-item> + </template> + <disclosure-hierarchy-item + v-for="(item, index) in items" + v-else + :key="index" + :item="item" + :item-id="itemId(index)" + > + <slot :item="item" :item-id="itemId(index)"></slot> + </disclosure-hierarchy-item> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue b/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue new file mode 100644 index 00000000000..8347583f3c5 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue @@ -0,0 +1,61 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import iconSpriteInfo from '@gitlab/svgs/dist/icons.json'; +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + /** + * Path item in the form: + * ``` + * { + * title: String, required + * icon: String, optional + * } + * ``` + */ + item: { + type: Object, + required: false, + default: () => {}, + }, + itemId: { + type: String, + required: false, + default: null, + }, + }, + methods: { + shouldDisplayIcon(icon) { + return icon && iconSpriteInfo.icons.includes(icon); + }, + }, +}; +</script> + +<template> + <li class="disclosure-hierarchy-item"> + <gl-link + :id="itemId" + :href="item.webUrl" + class="disclosure-hierarchy-button gl-text-gray-900 gl-hover-text-decoration-none gl-active-text-decoration-none!" + > + <gl-icon + v-if="shouldDisplayIcon(item.icon)" + :name="item.icon" + class="gl-mx-2 gl-text-gray-600 gl-flex-shrink-0" + /> + <span class="gl-z-index-200 gl-text-truncate">{{ item.title }}</span> + </gl-link> + <!-- + @slot Additional content to be displayed in an item. + @binding {Object} item The item being rendered. + @binding {String} itemId The rendered item's ID. + --> + <slot :item="item" :item-id="itemId"></slot> + </li> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_ancestors/work_item_ancestors.vue b/app/assets/javascripts/work_items/components/work_item_ancestors/work_item_ancestors.vue new file mode 100644 index 00000000000..bebe5d64761 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_ancestors/work_item_ancestors.vue @@ -0,0 +1,95 @@ +<script> +import { GlIcon, GlPopover, GlBadge, GlSprintf } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { formatAncestors } from '../../utils'; +import workItemAncestorsQuery from '../../graphql/work_item_ancestors.query.graphql'; +import WorkItemStateBadge from '../work_item_state_badge.vue'; +import DisclosureHierarchy from './disclosure_hierarchy.vue'; + +export default { + i18n: { + ancestorLabel: s__('WorkItem|Ancestor'), + ancestorsTooltipLabel: s__('WorkItem|Show all ancestors'), + }, + components: { + GlIcon, + GlPopover, + GlBadge, + GlSprintf, + TimeAgoTooltip, + WorkItemStateBadge, + DisclosureHierarchy, + }, + props: { + workItem: { + type: Object, + required: true, + }, + }, + data() { + return { + ancestors: [], + }; + }, + apollo: { + ancestors: { + query: workItemAncestorsQuery, + variables() { + return { + id: this.workItem.id, + }; + }, + update(data) { + return formatAncestors(data.workItem); + }, + skip() { + return !this.workItem.id; + }, + error(error) { + createAlert({ + message: s__('Hierarchy|Something went wrong while fetching ancestors.'), + captureError: true, + error, + }); + }, + }, + }, +}; +</script> + +<template> + <disclosure-hierarchy + v-if="ancestors.length > 0" + class="gl-mr-auto" + :items="ancestors" + :with-ellipsis="ancestors.length > 2" + :ellipsis-tooltip-label="$options.i18n.ancestorsTooltipLabel" + > + <template #default="{ item, itemId }"> + <gl-popover triggers="hover focus" placement="bottom" :target="itemId"> + <template #title> + <gl-badge variant="muted" size="sm">{{ $options.i18n.ancestorLabel }}</gl-badge> + <div class="gl-pt-3"> + {{ item.title }} + </div> + </template> + <div class="gl-pb-3 gl-text-gray-500"> + <gl-icon v-if="item.icon" :name="item.icon" /> + <span>{{ item.reference }}</span> + </div> + <work-item-state-badge v-if="item.state" :work-item-state="item.state" /> + <span class="gl-text-gray-500"> + <gl-sprintf v-if="item.createdAt" :message="__('Created %{timeAgo}')"> + <template #timeAgo> + <time-ago-tooltip :time="item.createdAt" /> + </template> + </gl-sprintf> + </span> + </gl-popover> + </template> + </disclosure-hierarchy> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index 7d09a003926..b7206d502a6 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -1,7 +1,6 @@ <script> import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - sprintfWorkItem, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HEALTH_STATUS, WIDGET_TYPE_HIERARCHY, @@ -19,7 +18,8 @@ import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; import WorkItemMilestone from './work_item_milestone.vue'; -import WorkItemParent from './work_item_parent.vue'; +import WorkItemParentInline from './work_item_parent_inline.vue'; +import WorkItemParent from './work_item_parent_with_edit.vue'; export default { components: { @@ -28,11 +28,17 @@ export default { WorkItemAssignees, WorkItemDueDate, WorkItemParent, - WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), + WorkItemParentInline, + WorkItemWeightInline: () => + import('ee_component/work_items/components/work_item_weight_inline.vue'), + WorkItemWeight: () => + import('ee_component/work_items/components/work_item_weight_with_edit.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), WorkItemHealthStatus: () => - import('ee_component/work_items/components/work_item_health_status.vue'), + import('ee_component/work_items/components/work_item_health_status_with_edit.vue'), + WorkItemHealthStatusInline: () => + import('ee_component/work_items/components/work_item_health_status_inline.vue'), }, mixins: [glFeatureFlagMixin()], props: { @@ -44,11 +50,6 @@ export default { type: Object, required: true, }, - workItemParentId: { - type: String, - required: false, - default: null, - }, }, computed: { workItemType() { @@ -60,15 +61,6 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, - canSetWorkItemMetadata() { - return this.workItem?.userPermissions?.setWorkItemMetadata; - }, - canAssignUnassignUser() { - return this.workItemAssignees && this.canSetWorkItemMetadata; - }, - confidentialTooltip() { - return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); - }, workItemAssignees() { return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, @@ -154,16 +146,28 @@ export default { :can-update="canUpdate" @error="$emit('error', $event)" /> - <work-item-weight - v-if="workItemWeight" - class="gl-mb-5" - :can-update="canUpdate" - :weight="workItemWeight.weight" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="$emit('error', $event)" - /> + <template v-if="workItemWeight"> + <work-item-weight + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-weight-inline + v-else + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + </template> <work-item-progress v-if="workItemProgress" class="gl-mb-5" @@ -184,24 +188,47 @@ export default { :work-item-type="workItemType" @error="$emit('error', $event)" /> - <work-item-health-status - v-if="workItemHealthStatus" - class="gl-mb-5" - :health-status="workItemHealthStatus.healthStatus" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="$emit('error', $event)" - /> - <work-item-parent - v-if="showWorkItemParent" - class="gl-mb-5" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - :parent="workItemParent" - @error="$emit('error', $event)" - /> + <template v-if="workItemHealthStatus"> + <work-item-health-status + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-health-status-inline + v-else + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + </template> + <template v-if="showWorkItemParent"> + <work-item-parent + v-if="glFeatures.workItemsMvc2" + class="gl-mb-5" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :parent="workItemParent" + @error="$emit('error', $event)" + /> + <work-item-parent-inline + v-else + class="gl-mb-5" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :parent="workItemParent" + @error="$emit('error', $event)" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 45d3aa564a5..b74cbc85379 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,15 +1,6 @@ <script> import { isEmpty } from 'lodash'; -import { - GlAlert, - GlSkeletonLoader, - GlLoadingIcon, - GlIcon, - GlButton, - GlTooltipDirective, - GlEmptyState, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlButton, GlTooltipDirective, GlEmptyState } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw'; import { s__ } from '~/locale'; import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; @@ -17,7 +8,6 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; -import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import { i18n, @@ -27,7 +17,6 @@ import { WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_AWARD_EMOJI, WIDGET_TYPE_HIERARCHY, - WORK_ITEM_TYPE_VALUE_ISSUE, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WIDGET_TYPE_NOTES, WIDGET_TYPE_LINKED_ITEMS, @@ -35,7 +24,6 @@ import { import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import { findHierarchyWidgetChildren } from '../utils'; @@ -51,7 +39,8 @@ import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; -import WorkItemTypeIcon from './work_item_type_icon.vue'; +import WorkItemStickyHeader from './work_item_sticky_header.vue'; +import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue'; export default { i18n, @@ -62,9 +51,7 @@ export default { components: { GlAlert, GlButton, - GlLoadingIcon, GlSkeletonLoader, - GlIcon, GlEmptyState, WorkItemActions, WorkItemTodos, @@ -73,14 +60,13 @@ export default { WorkItemAwardEmoji, WorkItemTitle, WorkItemAttributesWrapper, - WorkItemTypeIcon, WorkItemTree, WorkItemNotes, WorkItemDetailModal, AbuseCategorySelector, - GlIntersectionObserver, - ConfidentialityBadge, WorkItemRelationships, + WorkItemStickyHeader, + WorkItemAncestors, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'isGroup', 'reportAbusePath'], @@ -95,11 +81,6 @@ export default { required: false, default: null, }, - workItemParentId: { - type: String, - required: false, - default: null, - }, }, data() { return { @@ -175,9 +156,6 @@ export default { workItemTypeId() { return this.workItem.workItemType?.id; }, - workItemBreadcrumbReference() { - return this.workItemType ? `#${this.workItem.iid}` : ''; - }, canUpdate() { return this.workItem.userPermissions?.updateWorkItem; }, @@ -190,35 +168,15 @@ export default { canAssignUnassignUser() { return this.workItemAssignees && this.canSetWorkItemMetadata; }, - projectFullPath() { - return this.workItem.namespace?.fullPath; - }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, parentWorkItem() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, - parentWorkItemType() { - return this.parentWorkItem?.workItemType?.name; - }, - parentWorkItemIconName() { - return this.parentWorkItem?.workItemType?.iconName; - }, parentWorkItemConfidentiality() { return this.parentWorkItem?.confidential; }, - parentWorkItemReference() { - return this.parentWorkItem ? `${this.parentWorkItem.title} #${this.parentWorkItem.iid}` : ''; - }, - parentUrl() { - // Once more types are moved to have Work Items involved - // we need to handle this properly. - if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) { - return `../../-/issues/${this.parentWorkItem?.iid}`; - } - return this.parentWorkItem?.webUrl; - }, workItemIconName() { return this.workItem.workItemType?.iconName; }, @@ -235,7 +193,7 @@ export default { return this.isWidgetPresent(WIDGET_TYPE_CURRENT_USER_TODOS); }, showWorkItemCurrentUserTodos() { - return this.$options.isLoggedIn && this.workItemCurrentUserTodos; + return Boolean(this.$options.isLoggedIn && this.workItemCurrentUserTodos); }, currentUserTodos() { return this.workItemCurrentUserTodos?.currentUserTodos?.nodes; @@ -284,6 +242,12 @@ export default { 'gl-display-none gl-sm-display-block!': this.parentWorkItem, }; }, + headerWrapperClass() { + return { + 'flex-wrap': this.parentWorkItem, + 'gl-display-block gl-md-display-flex! gl-align-items-flex-start gl-flex-direction-column gl-md-flex-direction-row gl-gap-3 gl-pt-3': true, + }; + }, }, mounted() { if (this.modalWorkItemIid) { @@ -299,34 +263,21 @@ export default { }, toggleConfidentiality(confidentialStatus) { this.updateInProgress = true; - let updateMutation = updateWorkItemMutation; - let inputVariables = { - id: this.workItem.id, - confidential: confidentialStatus, - }; - - if (this.parentWorkItem) { - updateMutation = updateWorkItemTaskMutation; - inputVariables = { - id: this.parentWorkItem.id, - taskData: { - id: this.workItem.id, - confidential: confidentialStatus, - }, - }; - } this.$apollo .mutate({ - mutation: updateMutation, + mutation: updateWorkItemMutation, variables: { - input: inputVariables, + input: { + id: this.workItem.id, + confidential: confidentialStatus, + }, }, }) .then( ({ data: { - workItemUpdate: { errors, workItem, task }, + workItemUpdate: { errors, workItem }, }, }) => { if (errors?.length) { @@ -334,7 +285,7 @@ export default { } this.$emit('workItemUpdated', { - confidential: workItem?.confidential || task?.confidential, + confidential: workItem?.confidential, }); }, ) @@ -430,38 +381,8 @@ export default { @click="$emit('close')" /> </div> - <div - class="gl-display-block gl-sm-display-flex! gl-align-items-flex-start gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-pt-3" - > - <ul - v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-min-w-0 gl-mr-auto gl-mb-0 gl-z-index-0" - data-testid="work-item-parent" - > - <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-min-w-0"> - <gl-button - v-gl-tooltip.hover - class="gl-text-truncate" - :icon="parentWorkItemIconName" - category="tertiary" - :href="parentUrl" - :title="parentWorkItemReference" - @click="openInModal({ event: $event, modalWorkItem: parentWorkItem })" - >{{ parentWorkItemReference }}</gl-button - > - <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" /> - </li> - <li - class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0" - > - <work-item-type-icon - :work-item-icon-name="workItemIconName" - :work-item-type="workItemType" - show-text - /> - {{ workItemBreadcrumbReference }} - </li> - </ul> + <div :class="headerWrapperClass"> + <work-item-ancestors v-if="parentWorkItem" :work-item="workItem" class="gl-mb-1" /> <div v-if="!error && !workItemLoading" :class="titleClassHeader" @@ -474,17 +395,18 @@ export default { :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" - :work-item-parent-id="workItemParentId" :can-update="canUpdate" @error="updateError = $event" /> </div> - <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3"> + <div + class="detail-page-header-actions gl-display-flex gl-align-self-start gl-ml-auto gl-gap-3" + > <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" :work-item-iid="workItemIid" - :work-item-fullpath="projectFullPath" + :work-item-fullpath="fullPath" :current-user-todos="currentUserTodos" @error="updateError = $event" /> @@ -502,7 +424,6 @@ export default { :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" :work-item-state="workItem.state" - :work-item-parent-id="workItemParentId" @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" @@ -527,8 +448,8 @@ export default { :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" - :work-item-parent-id="workItemParentId" :can-update="canUpdate" + :use-h1="!isModal" @error="updateError = $event" /> <work-item-created-updated @@ -537,62 +458,24 @@ export default { :update-in-progress="updateInProgress" /> </div> - <gl-intersection-observer + <work-item-sticky-header v-if="showIntersectionObserver" - @appear="hideStickyHeader" - @disappear="showStickyHeader" - > - <transition name="issuable-header-slide"> - <div - v-if="isStickyHeaderShowing" - class="issue-sticky-header gl-fixed gl-bg-white gl-border-b gl-z-index-3 gl-py-2" - data-testid="work-item-sticky-header" - > - <div - class="gl-align-items-center gl-mx-auto gl-px-5 gl-display-flex gl-max-w-container-xl" - > - <span class="gl-text-truncate gl-font-weight-bold gl-pr-3 gl-mr-auto"> - {{ workItem.title }} - </span> - <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> - <confidentiality-badge - v-if="workItem.confidential" - class="gl-mr-3" - :issuable-type="workItemType" - :workspace-type="$options.WORKSPACE_PROJECT" - /> - <work-item-todos - v-if="showWorkItemCurrentUserTodos" - :work-item-id="workItem.id" - :work-item-iid="workItemIid" - :work-item-fullpath="projectFullPath" - :current-user-todos="currentUserTodos" - @error="updateError = $event" - /> - <work-item-actions - :full-path="fullPath" - :work-item-id="workItem.id" - :subscribed-to-notifications="workItemNotificationsSubscribed" - :work-item-type="workItemType" - :work-item-type-id="workItemTypeId" - :can-delete="canDelete" - :can-update="canUpdate" - :is-confidential="workItem.confidential" - :is-parent-confidential="parentWorkItemConfidentiality" - :work-item-reference="workItem.reference" - :work-item-create-note-email="workItem.createNoteEmail" - :is-modal="isModal" - @deleteWorkItem=" - $emit('deleteWorkItem', { workItemType, workItemId: workItem.id }) - " - @toggleWorkItemConfidentiality="toggleConfidentiality" - @error="updateError = $event" - @promotedToObjective="$emit('promotedToObjective', workItemIid)" - /> - </div> - </div> - </transition> - </gl-intersection-observer> + :current-user-todos="currentUserTodos" + :show-work-item-current-user-todos="showWorkItemCurrentUserTodos" + :parent-work-item-confidentiality="parentWorkItemConfidentiality" + :update-in-progress="updateInProgress" + :full-path="fullPath" + :is-modal="isModal" + :work-item="workItem" + :is-sticky-header-showing="isStickyHeaderShowing" + :work-item-notifications-subscribed="workItemNotificationsSubscribed" + @hideStickyHeader="hideStickyHeader" + @showStickyHeader="showStickyHeader" + @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" + @toggleWorkItemConfidentiality="toggleConfidentiality" + @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" + /> <div data-testid="work-item-overview" :class="{ 'work-item-overview': workItemsMvc2Enabled }" @@ -603,7 +486,6 @@ export default { class="gl-border-b" :full-path="fullPath" :work-item="workItem" - :work-item-parent-id="workItemParentId" @error="updateError = $event" /> <work-item-description @@ -617,7 +499,7 @@ export default { <work-item-award-emoji v-if="workItemAwardEmoji" :work-item-id="workItem.id" - :work-item-fullpath="projectFullPath" + :work-item-fullpath="fullPath" :award-emoji="workItemAwardEmoji.awardEmoji" :work-item-iid="workItemIid" @error="updateError = $event" @@ -640,7 +522,7 @@ export default { v-if="showWorkItemLinkedItems" :work-item-id="workItem.id" :work-item-iid="workItemIid" - :work-item-full-path="projectFullPath" + :work-item-full-path="fullPath" :work-item-type="workItem.workItemType.name" @showModal="openInModal" /> @@ -656,6 +538,7 @@ export default { :report-abuse-path="reportAbusePath" :is-work-item-confidential="workItem.confidential" class="gl-pt-5" + :use-h2="!isModal" @error="updateError = $event" @has-notes="updateHasNotes" @openReportAbuse="openReportAbuseDrawer" @@ -677,7 +560,6 @@ export default { <work-item-attributes-wrapper :full-path="fullPath" :work-item="workItem" - :work-item-parent-id="workItemParentId" @error="updateError = $event" /> </aside> diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue deleted file mode 100644 index c5be1a3ead3..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue +++ /dev/null @@ -1,72 +0,0 @@ -<script> -import { GlDisclosureDropdown } from '@gitlab/ui'; - -import { s__, __ } from '~/locale'; - -const objectiveActionItems = [ - { - title: s__('OKR|New objective'), - eventName: 'showCreateObjectiveForm', - }, - { - title: s__('OKR|Existing objective'), - eventName: 'showAddObjectiveForm', - }, -]; - -const keyResultActionItems = [ - { - title: s__('OKR|New key result'), - eventName: 'showCreateKeyResultForm', - }, - { - title: s__('OKR|Existing key result'), - eventName: 'showAddKeyResultForm', - }, -]; - -export default { - keyResultActionItems, - objectiveActionItems, - components: { - GlDisclosureDropdown, - }, - computed: { - objectiveDropdownItems() { - return { - name: __('Objective'), - items: this.$options.objectiveActionItems.map((item) => ({ - text: item.title, - action: () => this.change(item), - })), - }; - }, - keyResultDropdownItems() { - return { - name: __('Key result'), - items: this.$options.keyResultActionItems.map((item) => ({ - text: item.title, - action: () => this.change(item), - })), - }; - }, - dropdownItems() { - return [this.objectiveDropdownItems, this.keyResultDropdownItems]; - }, - }, - methods: { - change({ eventName }) { - this.$emit(eventName); - }, - }, -}; -</script> - -<template> - <gl-disclosure-dropdown - :toggle-text="__('Add')" - size="small" - placement="right" - :items="dropdownItems" - /> -</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_actions_split_button.vue new file mode 100644 index 00000000000..1290fc49df3 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_actions_split_button.vue @@ -0,0 +1,24 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; + +export default { + components: { + GlDisclosureDropdown, + }, + props: { + actions: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + :toggle-text="__('Add')" + size="small" + placement="right" + :items="actions" + /> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index b6ea09edbd4..ca62f3c4693 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -6,7 +6,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__ } from '~/locale'; -import { defaultSortableOptions } from '~/sortable/constants'; +import { defaultSortableOptions, DRAG_DELAY } from '~/sortable/constants'; import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants'; import { findHierarchyWidgets } from '../../utils'; @@ -77,6 +77,8 @@ export default { 'ghost-class': 'tree-item-drag-active', 'data-parent-id': this.workItemId, value: this.children, + delay: DRAG_DELAY, + delayOnTouchOnly: true, }; return this.canReorder ? options : {}; diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 49454c3d9f3..f43718c4cb8 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -213,10 +213,10 @@ export default { </script> <template> - <li class="tree-item"> + <li class="tree-item gl-p-0! gl-border-bottom-0!"> <div class="gl-display-flex gl-align-items-flex-start" - :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" + :class="{ 'gl-ml-5 gl-pl-2': canHaveChildren && !hasChildren && hasIndirectChildren }" > <gl-button v-if="hasChildren" @@ -227,7 +227,7 @@ export default { category="tertiary" size="small" :loading="isLoadingChildren" - class="gl-px-0! gl-py-3! gl-mr-3" + class="gl-px-0! gl-py-3! gl-mr-2" data-testid="expand-child" @click="toggleItem" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index dd0a26c0b9c..1e323d99c93 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -201,10 +201,12 @@ export default { }, }, i18n: { - title: s__('WorkItem|Tasks'), - fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'), + title: s__('WorkItem|Child items'), + fetchError: s__( + 'WorkItem|Something went wrong when fetching child items. Please refresh this page.', + ), emptyStateMessage: s__( - 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', + 'WorkItem|No child items are currently assigned. Use child items to break down this issue into smaller parts.', ), addChildButtonLabel: s__('WorkItem|Add'), addChildOptionLabel: s__('WorkItem|Existing task'), diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 3d09a90169c..2ba9e1bd3e6 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -1,15 +1,20 @@ <script> import { GlToggle } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; import { FORM_TYPES, WIDGET_TYPE_HIERARCHY, WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_VALUE_MAP, + WORK_ITEMS_TYPE_MAP, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, I18N_WORK_ITEM_SHOW_LABELS, } from '../../constants'; +import { findHierarchyWidgetDefinition } from '../../utils'; +import getAllowedWorkItemChildTypes from '../../graphql/work_item_allowed_children.query.graphql'; import WidgetWrapper from '../widget_wrapper.vue'; -import OkrActionsSplitButton from './okr_actions_split_button.vue'; +import WorkItemActionsSplitButton from './work_item_actions_split_button.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemChildrenWrapper from './work_item_children_wrapper.vue'; @@ -19,7 +24,7 @@ export default { WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, components: { - OkrActionsSplitButton, + WorkItemActionsSplitButton, WidgetWrapper, WorkItemLinksForm, WorkItemChildrenWrapper, @@ -72,8 +77,23 @@ export default { childType: null, widgetName: 'tasks', showLabels: true, + allowedChildrenTypes: [], }; }, + apollo: { + allowedChildrenTypes: { + query: getAllowedWorkItemChildTypes, + variables() { + return { + id: this.workItemId, + }; + }, + update(data) { + return findHierarchyWidgetDefinition(data.workItem.workItemType.widgetDefinitions) + .allowedChildTypes.nodes; + }, + }, + }, computed: { childrenIds() { return this.children.map((c) => c.id); @@ -85,8 +105,37 @@ export default { ) .some((hierarchy) => hierarchy.hasChildren); }, + addItemsActions() { + const reorderedChildTypes = this.allowedChildrenTypes + .slice() + .sort((a, b) => a.id.localeCompare(b.id)); + return reorderedChildTypes.map((type) => { + const enumType = WORK_ITEM_TYPE_VALUE_MAP[type.name]; + return { + name: WORK_ITEMS_TYPE_MAP[enumType].name, + items: this.genericActionItems(type.name).map((item) => ({ + text: item.title, + action: item.action, + })), + }; + }); + }, }, methods: { + genericActionItems(workItem) { + const enumType = WORK_ITEM_TYPE_VALUE_MAP[workItem]; + const workItemName = WORK_ITEMS_TYPE_MAP[enumType].name.toLowerCase(); + return [ + { + title: sprintf(s__('WorkItem|New %{workItemName}'), { workItemName }), + action: () => this.showAddForm(FORM_TYPES.create, enumType), + }, + { + title: sprintf(s__('WorkItem|Existing %{workItemName}'), { workItemName }), + action: () => this.showAddForm(FORM_TYPES.add, enumType), + }, + ]; + }, showAddForm(formType, childType) { this.$refs.wrapper.show(); this.isShownAddForm = true; @@ -129,56 +178,42 @@ export default { label-id="relationship-toggle-labels" @change="showLabels = $event" /> - <okr-actions-split-button - v-if="canUpdate" - @showCreateObjectiveForm=" - showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) - " - @showAddObjectiveForm=" - showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) - " - @showCreateKeyResultForm=" - showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) - " - @showAddKeyResultForm=" - showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) - " - /> + <work-item-actions-split-button v-if="canUpdate" :actions="addItemsActions" /> </template> <template #body> - <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <div class="gl-new-card-content"> + <div class="gl-new-card-content"> + <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> <p class="gl-new-card-empty"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> </div> + <work-item-links-form + v-if="isShownAddForm" + ref="wiLinksForm" + data-testid="add-tree-form" + :full-path="fullPath" + :issuable-gid="workItemId" + :work-item-iid="workItemIid" + :form-type="formType" + :parent-work-item-type="parentWorkItemType" + :children-type="childType" + :children-ids="childrenIds" + :parent-confidential="confidential" + @cancel="hideAddForm" + @addChild="$emit('addChild')" + /> + <work-item-children-wrapper + :children="children" + :can-update="canUpdate" + :full-path="fullPath" + :work-item-id="workItemId" + :work-item-iid="workItemIid" + :work-item-type="workItemType" + :show-labels="showLabels" + @error="error = $event" + @show-modal="showModal" + /> </div> - <work-item-links-form - v-if="isShownAddForm" - ref="wiLinksForm" - data-testid="add-tree-form" - :full-path="fullPath" - :issuable-gid="workItemId" - :work-item-iid="workItemIid" - :form-type="formType" - :parent-work-item-type="parentWorkItemType" - :children-type="childType" - :children-ids="childrenIds" - :parent-confidential="confidential" - @cancel="hideAddForm" - @addChild="$emit('addChild')" - /> - <work-item-children-wrapper - :children="children" - :can-update="canUpdate" - :full-path="fullPath" - :work-item-id="workItemId" - :work-item-iid="workItemIid" - :work-item-type="workItemType" - :show-labels="showLabels" - @error="error = $event" - @show-modal="showModal" - /> </template> </widget-wrapper> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index 9c6fa158169..dbeb3d4d3ff 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -47,7 +47,7 @@ export default { workItemMilestone: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, workItemType: { type: String, @@ -155,9 +155,6 @@ export default { }, }, methods: { - handleMilestoneClick(milestone) { - this.localMilestone = milestone; - }, onDropdownShown() { this.shouldFetch = true; }, @@ -168,9 +165,6 @@ export default { setSearchKey(value) { this.searchTerm = value; }, - isMilestoneChecked(milestone) { - return this.localMilestone?.id === milestone?.id; - }, updateMilestone() { this.localMilestone = this.milestones.find(({ id }) => id === this.localMilestoneId) ?? noMilestoneItem; @@ -234,7 +228,6 @@ export default { v-model="localMilestoneId" :items="dropdownGroups" category="tertiary" - data-testid="work-item-milestone-dropdown" class="gl-max-w-full" :toggle-text="dropdownText" :loading="updateInProgress" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 6756acd4495..faf43c3d5dd 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -28,6 +28,7 @@ import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_ite import workItemNoteUpdatedSubscription from '~/work_items/graphql/notes/work_item_note_updated.subscription.graphql'; import workItemNoteDeletedSubscription from '~/work_items/graphql/notes/work_item_note_deleted.subscription.graphql'; import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql'; +import groupWorkItemNotesByIidQuery from '../graphql/notes/group_work_item_notes_by_iid.query.graphql'; import workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql'; import WorkItemAddNote from './notes/work_item_add_note.vue'; @@ -46,6 +47,7 @@ export default { WorkItemNotesActivityHeader, WorkItemHistoryOnlyFilterNote, }, + inject: ['isGroup'], props: { fullPath: { type: String, @@ -87,6 +89,11 @@ export default { required: false, default: false, }, + useH2: { + type: Boolean, + default: false, + required: false, + }, }, data() { return { @@ -169,7 +176,9 @@ export default { }, apollo: { workItemNotes: { - query: workItemNotesByIidQuery, + query() { + return this.isGroup ? groupWorkItemNotesByIidQuery : workItemNotesByIidQuery; + }, variables() { return { fullPath: this.fullPath, @@ -326,6 +335,7 @@ export default { :disable-activity-filter-sort="disableActivityFilterSort" :work-item-type="workItemType" :discussion-filter="discussionFilter" + :use-h2="useH2" @changeSort="changeNotesSortOrder" @changeFilter="filterDiscussions" /> diff --git a/app/assets/javascripts/work_items/components/work_item_parent.vue b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue index ce30f7985cf..0c0842a3e05 100644 --- a/app/assets/javascripts/work_items/components/work_item_parent.vue +++ b/app/assets/javascripts/work_items/components/work_item_parent_inline.vue @@ -211,6 +211,7 @@ export default { id="work-item-parent-listbox-value" class="gl-max-w-max-content" data-testid="work-item-parent-listbox" + block searchable is-check-centered category="tertiary" diff --git a/app/assets/javascripts/work_items/components/work_item_parent_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_parent_with_edit.vue new file mode 100644 index 00000000000..75c49ed5027 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_parent_with_edit.vue @@ -0,0 +1,295 @@ +<script> +import { GlButton, GlForm, GlLink, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__ } from '~/locale'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; + +import { removeHierarchyChild } from '../graphql/cache_utils'; +import groupWorkItemsQuery from '../graphql/group_work_items.query.graphql'; +import projectWorkItemsQuery from '../graphql/project_work_items.query.graphql'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + SUPPORTED_PARENT_TYPE_MAP, +} from '../constants'; + +export default { + inputId: 'work-item-parent-listbox-value', + noWorkItemId: 'no-work-item-id', + i18n: { + assignParentLabel: s__('WorkItem|Assign parent'), + parentLabel: s__('WorkItem|Parent'), + none: s__('WorkItem|None'), + noMatchingResults: s__('WorkItem|No matching results'), + unAssign: s__('WorkItem|Unassign'), + workItemsFetchError: s__( + 'WorkItem|Something went wrong while fetching items. Please try again.', + ), + }, + components: { + GlButton, + GlLoadingIcon, + GlLink, + GlForm, + GlCollapsibleListbox, + }, + inject: ['fullPath', 'isGroup'], + props: { + workItemId: { + type: String, + required: true, + }, + parent: { + type: Object, + required: false, + default: null, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isEditing: false, + search: '', + updateInProgress: false, + searchStarted: false, + availableWorkItems: [], + localSelectedItem: this.parent?.id, + oldParent: this.parent, + }; + }, + computed: { + hasParent() { + return this.parent !== null; + }, + isLoading() { + return this.$apollo.queries.availableWorkItems.loading; + }, + listboxText() { + return ( + this.workItems.find(({ value }) => this.localSelectedItem === value)?.text || + this.parent?.title || + this.$options.i18n.none + ); + }, + workItems() { + return this.availableWorkItems.map(({ id, title }) => ({ text: title, value: id })); + }, + parentType() { + return SUPPORTED_PARENT_TYPE_MAP[this.workItemType]; + }, + }, + watch: { + parent: { + handler(newVal) { + if (!this.isEditing) { + this.localSelectedItem = newVal?.id; + } + }, + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + apollo: { + availableWorkItems: { + query() { + return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery; + }, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.search, + types: this.parentType, + in: this.search ? 'TITLE' : undefined, + iid: null, + isNumber: false, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace.workItems.nodes.filter((wi) => this.workItemId !== wi.id) || []; + }, + error() { + this.$emit('error', this.$options.i18n.workItemsFetchError); + }, + }, + }, + methods: { + blurInput() { + this.$refs.input.$el.blur(); + }, + handleFocus() { + this.isEditing = true; + }, + setSearchKey(value) { + this.search = value; + }, + async updateParent() { + if (this.parent?.id === this.localSelectedItem) return; + + this.updateInProgress = true; + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + hierarchyWidget: { + parentId: + this.localSelectedItem === this.$options.noWorkItemId + ? null + : this.localSelectedItem, + }, + }, + }, + update: (cache) => + removeHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.oldParent?.iid, + isGroup: this.isGroup, + workItem: { id: this.workItemId }, + }), + }); + + if (errors.length) { + this.$emit('error', errors.join('\n')); + this.localSelectedItem = this.parent?.id || this.$options.noWorkItemId; + } + } catch (error) { + this.$emit('error', sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType)); + Sentry.captureException(error); + } finally { + this.updateInProgress = false; + this.isEditing = false; + } + }, + handleItemClick(item) { + this.localSelectedItem = item; + this.searchStarted = false; + this.search = ''; + this.updateParent(); + }, + unassignParent() { + this.localSelectedItem = this.$options.noWorkItemId; + this.isEditing = false; + this.updateParent(); + }, + onListboxShown() { + this.searchStarted = true; + }, + onListboxHide() { + this.searchStarted = false; + this.search = ''; + this.isEditing = false; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-align-items-center"> + <!-- hide header when editing, since we then have a form label. Keep it reachable for screenreader nav --> + <h3 :class="{ 'gl-sr-only': isEditing }" class="gl-mb-0! gl-heading-scale-5"> + {{ __('Parent') }} + </h3> + <gl-loading-icon + v-if="updateInProgress" + data-testid="loading-icon-parent" + size="sm" + inline + class="gl-ml-2 gl-my-0" + /> + <gl-button + v-if="canUpdate && !isEditing" + data-testid="edit-parent" + category="tertiary" + size="small" + class="gl-ml-auto gl-mr-2" + :disabled="updateInProgress" + @click="isEditing = true" + >{{ __('Edit') }}</gl-button + > + </div> + <gl-form v-if="isEditing" class="gl-flex-nowrap" data-testid="work-item-parent-form"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <label :for="$options.inputId" class="gl-mb-0">{{ __('Parent') }}</label> + <gl-button + data-testid="apply-parent" + category="tertiary" + size="small" + class="gl-mr-2" + :disabled="updateInProgress" + @click="isEditing = false" + >{{ __('Apply') }}</gl-button + > + </div> + <div> + <!-- wrapper for the form input so the borders fit inside the sidebar --> + <div class="gl-pr-2 gl-relative"> + <gl-collapsible-listbox + id="$options.inputId" + ref="input" + class="gl-display-block" + data-testid="work-item-parent-listbox" + block + searchable + start-opened + is-check-centered + category="primary" + fluid-width + :searching="isLoading" + :header-text="$options.i18n.assignParentLabel" + :no-results-text="$options.i18n.noMatchingResults" + :loading="updateInProgress" + :items="workItems" + :toggle-text="listboxText" + :selected="localSelectedItem" + :reset-button-label="$options.i18n.unAssign" + @reset="unassignParent" + @search="debouncedSearchKeyUpdate" + @select="handleItemClick" + @shown="onListboxShown" + @hidden="onListboxHide" + > + <template #list-item="{ item }"> + <div @click="handleItemClick(item.value, $event)"> + {{ item.text }} + </div> + </template> + </gl-collapsible-listbox> + </div> + </div> + </gl-form> + <template v-else-if="hasParent"> + <gl-link + data-testid="work-item-parent-link" + class="gl-link gl-text-gray-900 gl-display-inline-block gl-max-w-full gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden" + :href="parent.webUrl" + >{{ listboxText }}</gl-link + > + </template> + <template v-else> + <div data-testid="work-item-parent-none" class="gl-text-secondary">{{ __('None') }}</div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue index 581ef9ec945..69752967efe 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_toggle.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue @@ -3,7 +3,6 @@ import { GlButton, GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; import { __ } from '~/locale'; -import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, @@ -12,6 +11,7 @@ import { STATE_EVENT_REOPEN, TRACKING_CATEGORY_SHOW, } from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; export default { components: { @@ -33,11 +33,6 @@ export default { type: String, required: true, }, - workItemParentId: { - type: String, - required: false, - default: null, - }, showAsDropdownItem: { type: Boolean, required: false, @@ -75,24 +70,19 @@ export default { }, methods: { async updateWorkItem() { - const input = { - id: this.workItemId, - stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, - }; - this.updateInProgress = true; try { this.track('updated_state'); - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - const { data } = await this.$apollo.mutate({ - mutation, - variables, + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, + }, + }, }); const errors = data.workItemUpdate?.errors; @@ -102,7 +92,6 @@ export default { } } catch (error) { const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); - this.$emit('error', msg); Sentry.captureException(error); } diff --git a/app/assets/javascripts/work_items/components/work_item_sticky_header.vue b/app/assets/javascripts/work_items/components/work_item_sticky_header.vue new file mode 100644 index 00000000000..523b145d9ef --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_sticky_header.vue @@ -0,0 +1,136 @@ +<script> +import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; +import { WORKSPACE_PROJECT } from '~/issues/constants'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import WorkItemActions from './work_item_actions.vue'; +import WorkItemTodos from './work_item_todos.vue'; + +export default { + components: { + GlIntersectionObserver, + GlLoadingIcon, + WorkItemActions, + WorkItemTodos, + ConfidentialityBadge, + }, + props: { + workItem: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + isStickyHeaderShowing: { + type: Boolean, + required: true, + }, + workItemNotificationsSubscribed: { + type: Boolean, + required: true, + }, + updateInProgress: { + type: Boolean, + required: false, + default: false, + }, + parentWorkItemConfidentiality: { + type: Boolean, + required: false, + default: false, + }, + showWorkItemCurrentUserTodos: { + type: Boolean, + required: false, + default: false, + }, + isModal: { + type: Boolean, + required: false, + default: false, + }, + currentUserTodos: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + canUpdate() { + return this.workItem.userPermissions?.updateWorkItem; + }, + canDelete() { + return this.workItem.userPermissions?.deleteWorkItem; + }, + workItemType() { + return this.workItem.workItemType?.name; + }, + workItemTypeId() { + return this.workItem.workItemType?.id; + }, + projectFullPath() { + return this.workItem.namespace?.fullPath; + }, + }, + WORKSPACE_PROJECT, +}; +</script> + +<template> + <gl-intersection-observer + @appear="$emit('hideStickyHeader')" + @disappear="$emit('showStickyHeader')" + > + <transition name="issuable-header-slide"> + <div + v-if="isStickyHeaderShowing" + class="issue-sticky-header gl-fixed gl-bg-white gl-border-b gl-z-index-3 gl-py-2" + data-testid="work-item-sticky-header" + > + <div + class="gl-align-items-center gl-mx-auto gl-px-5 gl-display-flex gl-max-w-container-xl gl-gap-3" + > + <span class="gl-text-truncate gl-font-weight-bold gl-pr-3 gl-mr-auto"> + {{ workItem.title }} + </span> + <gl-loading-icon v-if="updateInProgress" /> + <confidentiality-badge + v-if="workItem.confidential" + :issuable-type="workItemType" + :workspace-type="$options.WORKSPACE_PROJECT" + /> + <work-item-todos + v-if="showWorkItemCurrentUserTodos" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-fullpath="projectFullPath" + :current-user-todos="currentUserTodos" + @error="$emit('error')" + /> + <work-item-actions + :full-path="fullPath" + :work-item-id="workItem.id" + :subscribed-to-notifications="workItemNotificationsSubscribed" + :work-item-type="workItemType" + :work-item-type-id="workItemTypeId" + :can-delete="canDelete" + :can-update="canUpdate" + :is-confidential="workItem.confidential" + :is-parent-confidential="parentWorkItemConfidentiality" + :work-item-reference="workItem.reference" + :work-item-create-note-email="workItem.createNoteEmail" + :work-item-state="workItem.state" + :is-modal="isModal" + @deleteWorkItem="$emit('deleteWorkItem')" + @toggleWorkItemConfidentiality=" + $emit('toggleWorkItemConfidentiality', !workItem.confidential) + " + @error="$emit('error')" + @promotedToObjective="$emit('promotedToObjective')" + /> + </div> + </div> + </transition> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index 9b5803421dd..0be57e291f4 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -8,7 +8,7 @@ import { WORK_ITEM_TITLE_MAX_LENGTH, I18N_MAX_CHARS_IN_WORK_ITEM_TITLE_MESSAGE, } from '../constants'; -import { getUpdateWorkItemMutation } from './update_work_item'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import ItemTitle from './item_title.vue'; export default { @@ -32,16 +32,16 @@ export default { required: false, default: '', }, - workItemParentId: { - type: String, - required: false, - default: null, - }, canUpdate: { type: Boolean, required: false, default: false, }, + useH1: { + type: Boolean, + default: false, + required: false, + }, }, computed: { tracking() { @@ -63,24 +63,19 @@ export default { return; } - const input = { - id: this.workItemId, - title: updatedTitle, - }; - this.updateInProgress = true; try { this.track('updated_title'); - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - const { data } = await this.$apollo.mutate({ - mutation, - variables, + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + title: updatedTitle, + }, + }, }); const errors = data.workItemUpdate?.errors; @@ -101,5 +96,10 @@ export default { </script> <template> - <item-title :title="workItemTitle" :disabled="!canUpdate" @title-changed="updateTitle" /> + <item-title + :title="workItemTitle" + :disabled="!canUpdate" + :use-h1="useH1" + @title-changed="updateTitle" + /> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index daa72204609..41cf5d8932d 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -54,9 +54,6 @@ export const i18n = { "WorkItem|This work item is not available. It either doesn't exist or you don't have permission to view it.", ), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), - confidentialTooltip: s__( - 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this %{workItemType}.', - ), }; export const I18N_WORK_ITEM_ERROR_FETCHING_LABELS = s__( @@ -195,6 +192,11 @@ export const WORK_ITEMS_TYPE_MAP = { }, }; +export const WORK_ITEM_TYPE_VALUE_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: WORK_ITEM_TYPE_ENUM_OBJECTIVE, + [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: WORK_ITEM_TYPE_ENUM_KEY_RESULT, +}; + export const WORK_ITEMS_TREE_TEXT_MAP = { [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: { title: s__('WorkItem|Child objectives and key results'), diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql deleted file mode 100644 index ccfe62cc585..00000000000 --- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "./work_item.fragment.graphql" - -mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { - workItemCreateFromTask(input: $input) { - workItem { - ...WorkItem - } - newWorkItem { - ...WorkItem - } - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql new file mode 100644 index 00000000000..f86176b2836 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql @@ -0,0 +1,32 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql" + +query groupWorkItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: group(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ALL_NOTES) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...WorkItemNote + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql deleted file mode 100644 index ad861a60d15..00000000000 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "./work_item.fragment.graphql" - -mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { - workItemUpdate: workItemUpdateTask(input: $input) { - workItem { - id - descriptionHtml - } - task { - ...WorkItem - } - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item_allowed_children.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_allowed_children.query.graphql new file mode 100644 index 00000000000..cfd21421f16 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_allowed_children.query.graphql @@ -0,0 +1,20 @@ +query getAllowedWorkItemChildTypes($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + name + widgetDefinitions { + type + ... on WorkItemWidgetDefinitionHierarchy { + allowedChildTypes { + nodes { + id + name + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_ancestors.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_ancestors.query.graphql new file mode 100644 index 00000000000..bfcac11f51f --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_ancestors.query.graphql @@ -0,0 +1,33 @@ +query workItemAncestorsQuery($id: WorkItemID!) { + workItem(id: $id) { + id + title + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + ancestors { + nodes { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + reference(full: true) + createdAt + closedAt + webUrl + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/notes/award_utils.js b/app/assets/javascripts/work_items/notes/award_utils.js index 5351a22d593..4f35b06a685 100644 --- a/app/assets/javascripts/work_items/notes/award_utils.js +++ b/app/assets/javascripts/work_items/notes/award_utils.js @@ -5,6 +5,7 @@ import { updateCacheAfterAddingAwardEmojiToNote, updateCacheAfterRemovingAwardEmojiFromNote, } from '~/work_items/graphql/cache_utils'; +import groupWorkItemNotesByIidQuery from '../graphql/notes/group_work_item_notes_by_iid.query.graphql'; import workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql'; import addAwardEmojiMutation from '../graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; import removeAwardEmojiMutation from '../graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; @@ -32,7 +33,7 @@ export function getMutation({ note, name }) { }; } -export function optimisticAwardUpdate({ note, name, fullPath, workItemIid }) { +export function optimisticAwardUpdate({ note, name, fullPath, isGroup, workItemIid }) { const { mutation } = getMutation({ note, name }); const currentUserId = window.gon.current_user_id; @@ -40,7 +41,7 @@ export function optimisticAwardUpdate({ note, name, fullPath, workItemIid }) { return (store) => { store.updateQuery( { - query: workItemNotesByIidQuery, + query: isGroup ? groupWorkItemNotesByIidQuery : workItemNotesByIidQuery, variables: { fullPath, iid: workItemIid }, }, (sourceData) => { diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index ac5d8b32fad..c3c292c3dd9 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -26,6 +26,19 @@ export const findHierarchyWidgets = (widgets) => export const findHierarchyWidgetChildren = (workItem) => findHierarchyWidgets(workItem?.widgets)?.children?.nodes || []; +export const findHierarchyWidgetAncestors = (workItem) => + findHierarchyWidgets(workItem?.widgets)?.ancestors?.nodes || []; + +export const formatAncestors = (workItem) => + findHierarchyWidgetAncestors(workItem).map((ancestor) => ({ + ...ancestor, + icon: ancestor.workItemType?.iconName, + href: ancestor.webUrl, + })); + +export const findHierarchyWidgetDefinition = (widgetDefinitions) => + widgetDefinitions?.find((widgetDefinition) => widgetDefinition.type === WIDGET_TYPE_HIERARCHY); + const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => { return `${ gon.relative_url_root || '' diff --git a/app/assets/stylesheets/application_utilities.scss b/app/assets/stylesheets/application_utilities.scss index 8bec12784ed..817e983a0ec 100644 --- a/app/assets/stylesheets/application_utilities.scss +++ b/app/assets/stylesheets/application_utilities.scss @@ -10,5 +10,3 @@ // Gitlab UI util classes @import '@gitlab/ui/src/scss/utilities'; - -@import 'tmp_utilities';
\ No newline at end of file diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 2030f2c7095..97f2add4e77 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -335,7 +335,7 @@ border-radius: 3px; margin-left: 4px; margin-top: -2px; - border: 1px solid $black-transparent; + border: 1px solid $t-gray-a-24; background-color: var(--gl-color-chip-color); } diff --git a/app/assets/stylesheets/components/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss index a5fd57f6c57..98ed7f590ea 100644 --- a/app/assets/stylesheets/components/detail_page.scss +++ b/app/assets/stylesheets/components/detail_page.scss @@ -38,18 +38,9 @@ .detail-page-header-actions { flex: 0 0 auto; - &:not(.is-merge-request) { - @include media-breakpoint-down(xs) { - width: 100%; - margin-top: 10px; - } - } - - &.is-merge-request { - @include media-breakpoint-down(sm) { - width: 100%; - margin-top: 10px; - } + @include media-breakpoint-down(sm) { + width: 100%; + margin-top: 10px; } } @@ -62,13 +53,17 @@ margin: 0 0 $gl-spacing-scale-4; color: $gl-text-color; padding: 0 0 0.3em; - border-bottom: 1px solid $white-dark; } .description { @include clearfix; margin-top: 6px; + + + .edited-text { + display: inline-block; + margin-top: $gl-spacing-scale-4; + } } .author-link { diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 4d4144fe9dd..6f4f7a29334 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -26,7 +26,6 @@ @import 'framework/highlight'; @import 'framework/lists'; @import 'framework/logo'; -@import 'framework/job_log'; @import 'framework/markdown_area'; @import 'framework/media_object'; @import 'framework/modal'; @@ -58,7 +57,6 @@ @import 'framework/responsive_tables'; @import 'framework/stacked_progress_bar'; @import 'framework/sortable'; -@import 'framework/feature_highlight'; @import 'framework/read_more'; @import 'framework/system_messages'; @import 'framework/spinner'; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index c93ef2287a8..07200c9b90a 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -92,8 +92,7 @@ @include transition(background-color, border-color, color, box-shadow); } -.dropdown-menu-toggle, -.header-user-avatar { +.dropdown-menu-toggle { @include transition(border-color); } @@ -102,10 +101,6 @@ @include transition(color); } -.notification-dot { - @include transition(background-color, color, border); -} - .stage-nav-item { @include transition(background-color, box-shadow); } diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 28c0c071dc0..e11fa7d8801 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -24,7 +24,7 @@ width: $award-emoji-width; font-size: 14px; background-color: $white; - border: 1px solid $border-white-light; + border: 1px solid $border-color; border-radius: $border-radius-base; box-shadow: 0 6px 12px $award-emoji-menu-shadow; pointer-events: none; @@ -218,7 +218,7 @@ } .award-control-icon { - color: $border-gray-normal; + color: $gray-100; svg { height: $default-icon-size; @@ -254,11 +254,10 @@ display: contents; gl-emoji { - margin-top: -1px; - margin-bottom: -1px; + margin-block: -0.1em; img { - top: 0; + top: -0.025em; } } } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index cae2ea1716c..4249bb372dc 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -25,8 +25,8 @@ background-color: $gray-light; padding: $gl-padding; margin-bottom: 0; - border-top: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; color: $gl-text-color; &.white { @@ -76,14 +76,14 @@ .sub-header-block { background-color: $white; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid $border-color; padding: 11px 0; margin-bottom: 11px; } .content-block { padding: $gl-padding 0; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid $border-color; > .controls { float: right; diff --git a/app/assets/stylesheets/framework/brand_logo.scss b/app/assets/stylesheets/framework/brand_logo.scss index 1bc1ef797a7..95dcb26c0c5 100644 --- a/app/assets/stylesheets/framework/brand_logo.scss +++ b/app/assets/stylesheets/framework/brand_logo.scss @@ -1,6 +1,3 @@ -$brand-logo-light-background: #e0dfe5; -$brand-logo-dark-background: #53515b; - .brand-logo { display: inline-block; @include gl-rounded-base; @@ -16,14 +13,4 @@ $brand-logo-dark-background: #53515b; &:active { @include gl-focus; } - - &:hover, - &:focus, - &:active { - background-color: $brand-logo-light-background; - - .gl-dark & { - background-color: $brand-logo-dark-background; - } - } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 88509dbc4a1..709c33a2ad8 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -118,7 +118,7 @@ } @mixin btn-white { - @include btn-color($white, $border-color, $white-normal, $border-white-normal, $white-dark, $border-white-normal, $gl-text-color); + @include btn-color($white, $gray-200, $gray-50, $gray-200, $gray-100, $gray-300, $gl-text-color); } @mixin btn-purple { @@ -276,7 +276,7 @@ .active { box-shadow: $gl-btn-active-background; - border: 1px solid $border-white-normal !important; + border: 1px solid $gray-100 !important; background-color: $btn-active-gray-light !important; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 21c252038af..874cfa2fe53 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -58,21 +58,7 @@ } } -@include media-breakpoint-up(md) { - .page-with-contextual-sidebar { - --application-bar-left: #{$contextual-sidebar-collapsed-width}; - } -} - @include media-breakpoint-up(xl) { - .page-with-contextual-sidebar { - --application-bar-left: #{$contextual-sidebar-width}; - } - - .page-with-icon-sidebar { - --application-bar-left: #{$contextual-sidebar-collapsed-width}; - } - .page-with-super-sidebar { --application-bar-left: #{$super-sidebar-width}; } @@ -333,7 +319,7 @@ li.note { .progress { margin-top: 4px; box-shadow: none; - background-color: $border-gray-light; + background-color: $gray-100; } } @@ -495,7 +481,7 @@ li.note { width: 4px; &:hover { - background-color: $white-normal; + background-color: $gray-50; } &.is-dragging { @@ -582,7 +568,7 @@ See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. // used in the Markdown rendering of labels .scoped-label-tooltip-title { - color: var(--indigo-300, $indigo-300); + color: var(--theme-indigo-300, $theme-indigo-300); } .gl-label-scoped { diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index fb9816d1402..4a9f77316e6 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -65,10 +65,6 @@ .avatar-container { margin: 0 auto; } - - li.active:not(.fly-out-top-item) > a { - background-color: $indigo-900-alpha-008; - } } @mixin sub-level-items-flyout { @@ -199,32 +195,6 @@ } // -// PAGE-LAYOUT -// - -.page-with-contextual-sidebar { - transition: padding-left $gl-transition-duration-medium; - - @include media-breakpoint-up(md) { - padding-left: $contextual-sidebar-collapsed-width; - } - - @include media-breakpoint-up(xl) { - padding-left: $contextual-sidebar-width; - } - - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { - padding: 10px 0 15px; - } -} - -.page-with-icon-sidebar { - @include media-breakpoint-up(md) { - padding-left: $contextual-sidebar-collapsed-width; - } -} - -// // THE PANEL // @@ -446,7 +416,7 @@ &.mobile-nav-open { display: block; position: fixed; - background-color: $black-transparent; + background-color: $t-gray-a-24; height: 100%; width: 100%; z-index: $zindex-dropdown-menu; diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 8f07ef73554..b948a57ea33 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -1,3 +1,5 @@ +$diff-file-header: 41px; + // Common .diff-file { margin-bottom: $gl-padding; @@ -38,6 +40,10 @@ &.is-sidebar-moved { top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height} - #{$gl-border-size-1}); + + + .diff-content .md-header-preview { + top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height} + #{$diff-file-header} - #{$gl-border-size-1}); + } } &::before { @@ -683,7 +689,7 @@ table.code { .note-container { background-color: $gray-light; - border-top: 1px solid $white-normal; + border-top: 1px solid $gray-50; // double jagged line divider .discussion-notes + .discussion-notes::before, @@ -744,7 +750,7 @@ table.code { .diff-file .note-container > .new-note, .note-container .discussion-notes.diff-discussions { margin-left: 100px; - border-left: 1px solid $white-normal; + border-left: 1px solid $gray-50; } .notes.active { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index a467d9e8c8a..e791a0dbbbd 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -810,14 +810,6 @@ .navbar-gitlab { li.dropdown { position: static; - - &.user-counter { - margin-left: 8px !important; - - > a { - padding: 0 4px !important; - } - } } } @@ -836,95 +828,6 @@ } } -.frequent-items-dropdown-container { - display: flex; - flex-direction: row; - height: $grid-size * 40; - - .frequent-items-dropdown-content { - @include gl-pt-3; - } - - .loading-animation { - color: $almost-black; - } - - .frequent-items-dropdown-content { - position: relative; - width: 70%; - } - - .section-header, - .frequent-items-list-container li.section-empty { - color: $gl-text-color-secondary; - font-size: $gl-font-size; - } - - .frequent-items-list-container { - padding: 8px 0; - overflow-y: auto; - - li.section-empty.section-failure { - color: $red-700; - } - - .frequent-items-list-item-container .gl-button { - &:active, - &:focus, - &:focus:active, - &.is-focused { - @include gl-focus($inset: true); - } - } - } - - .section-header { - font-weight: 700; - margin-top: 8px; - } -} - -.frequent-items-list-item-container { - .frequent-items-item-metadata-container { - display: flex; - flex-shrink: 0; - flex-direction: column; - justify-content: center; - } - - .frequent-items-item-title, - .frequent-items-item-namespace { - max-width: 220px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - .frequent-items-item-title { - font-size: $gl-font-size; - font-weight: 400; - line-height: 16px; - } - - .frequent-items-item-namespace { - margin-top: 4px; - font-size: 12px; - line-height: 12px; - color: $gl-text-color-secondary; - } - - @include media-breakpoint-down(xs) { - .frequent-items-item-metadata-container { - float: none; - } - - .frequent-items-item-title, - .frequent-items-item-namespace { - max-width: 250px; - } - } -} - .dropdown-content-faded-mask { position: relative; diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index d3986f31d52..9227028e3da 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -27,7 +27,7 @@ gl-emoji { .emoji-picker-category-header { @include gl-sticky; - background-color: $white-transparent; + background: linear-gradient(to bottom, $white 50%, transparent 100%); } .emoji-picker-emoji { @@ -35,11 +35,18 @@ gl-emoji { // Create a width that fits 9 emojis per row width: 100 / 9 * 1%; transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2) !important; - will-change: transform; + transform: scale(1) !important; + mix-blend-mode: normal !important; &:hover, &:focus { - transform: scale(1.3); + @include gl-z-index-2; + transform: scale(1.3) !important; + } + + gl-emoji img { + top: auto; + max-width: unset; } } @@ -51,16 +58,24 @@ gl-emoji { border-bottom-color: transparent; &:hover { - @include gl-text-gray-900; + color: $gray-900 !important; &:not(.emoji-picker-category-active) { - @include gl-border-b-gray-200; + border-bottom-color: $gray-300; } } + + &:focus { + z-index: 2; + } } .emoji-picker-category-active { - border-bottom-color: $blue-500; + border-bottom-color: $blue-500 !important; + + svg { + color: $gray-900 !important; + } } .emoji-picker .gl-dropdown-contents > :last-child { diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss deleted file mode 100644 index 36f1b1f2903..00000000000 --- a/app/assets/stylesheets/framework/feature_highlight.scss +++ /dev/null @@ -1,53 +0,0 @@ -.feature-highlight { - &::before { - content: ''; - display: block; - top: 6px; - left: 6px; - width: 8px; - height: 8px; - background-color: $blue-500; - border-radius: 50%; - box-shadow: 0 0 0 rgba($blue-500, 0.4); - animation: pulse-highlight 2s infinite; - } - - &:hover::before, - &.disable-animation::before { - animation: none; - } - - &[disabled]::before { - display: none; - } -} - - -.feature-highlight-illustration { - background-color: $indigo-50; - border-top-left-radius: 2px; - border-top-right-radius: 2px; - border-bottom: 1px solid darken($gray-normal, 8%); -} - -.feature-highlight-popover { - width: 240px; - - .popover-body { - padding: 0; - } -} - -@include keyframes(pulse-highlight) { - 0% { - box-shadow: 0 0 0 0 rgba($blue-200, 0.4); - } - - 70% { - box-shadow: 0 0 0 10px transparent; - } - - 100% { - box-shadow: 0 0 0 0 transparent; - } -} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index eb627b036fe..9cb264c992b 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -376,7 +376,7 @@ span.idiff { border-bottom: 1px $gray-darkest dashed; &:hover { - border-bottom-color: $almost-black; + border-bottom-color: $gray-950; } } } @@ -603,6 +603,6 @@ span.idiff { right: 0; top: -$gradient-size; height: $gradient-size; - background: linear-gradient(to top, $white, transparentize($white, 1)); + background: linear-gradient(to top, $white, transparent); } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 67e96f08cb0..5949a1b2809 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -110,7 +110,7 @@ } .operator { - background-color: $white-normal; + background-color: $gray-50; color: $gl-text-color; margin-right: 1px; } @@ -118,7 +118,7 @@ .value-container { display: flex; align-items: center; - background-color: $white-normal; + background-color: $gray-50; color: $gl-text-color; border-radius: 0 2px 2px 0; margin-right: 5px; diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 66d163f608a..b87a7f15c1c 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -45,6 +45,6 @@ height: 100%; margin-bottom: 2px; border-radius: 3px; - border: 1px solid $black-transparent; + border: 1px solid $t-gray-a-24; } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index e269ea68e41..23f40dfe4bf 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,6 +1,3 @@ -$search-input-field-min-width: 320px; -$search-input-field-x-min-width: 200px; - .navbar-gitlab { padding: 0 16px; z-index: $header-zindex; @@ -70,252 +67,22 @@ $search-input-field-x-min-width: 200px; border-bottom-color: $white; } } - - .navbar-collapse > ul.nav > li:not(.d-none) { - margin: 0 2px; - } - } - - .header-search-form { - min-width: $search-input-field-min-width; - - // This is a temporary workaround! - // the button in GitLab UI Search components need to be updated to not be the small size - // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 - .gl-search-box-by-type-clear.btn-sm { - padding: 0.5rem !important; - } - - @include media-breakpoint-between(md, lg) { - min-width: $search-input-field-x-min-width; - } - - &.is-searching { - .in-search-scope-help { - position: absolute; - top: $gl-spacing-scale-2; - right: 2.125rem; - z-index: 2; - } - } - - &.is-not-focused { - .gl-search-box-by-type-clear { - display: none; - } - } - - .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; - } - } - - .header-search-dropdown-menu { - max-height: $dropdown-max-height; - top: 100%; - } - - .navbar-collapse { - flex: 0 0 auto; - border-top: 0; - padding: 0; - - @include media-breakpoint-down(xs) { - .legacy-top-bar & { - flex: 1 1 auto; - } - } - - .nav { - flex-wrap: nowrap; - - > li:not(.d-none) a { - @include media-breakpoint-down(xs) { - margin-left: 0; - } - } - } } .container-fluid { padding: 0; - .user-counter { - svg { - margin-right: 3px; - } - } - - .navbar-toggler { - position: relative; - right: -10px; - border-radius: 0; - min-width: 45px; - padding: 0; - margin: $gl-padding-8 $gl-padding-8 $gl-padding-8 0; - font-size: 14px; - text-align: center; - color: currentColor; - - &:hover, - &:focus, - &.active { - color: currentColor; - background-color: transparent; - } - } - - .navbar-nav { - @include media-breakpoint-down(xs) { - display: flex; - padding-right: 10px; - flex-direction: row; - } - - li { - .badge.badge-pill:not(.gl-badge) { - box-shadow: none; - font-weight: $gl-font-weight-bold; - } - } - } - .nav > li { - &.header-user { - @include media-breakpoint-down(xs) { - padding-left: 10px; - } - } - > a { will-change: color; margin: 4px 0; padding: 6px 8px; height: 32px; - - .legacy-top-bar & { - @include media-breakpoint-down(xs) { - padding: 0; - } - } - - &.header-user-dropdown-toggle { - margin-left: 2px; - - .header-user-avatar { - margin-right: 0; - } - } - } - - .header-new-dropdown-toggle { - margin-right: 0; - } - - .impersonated-user, - .impersonated-user:hover { - margin-right: 1px; - background-color: $white; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - .impersonation-btn, - .impersonation-btn:hover { - background-color: $white; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - svg { - color: $orange-500; - } - } - } - } -} - -.navbar-sub-nav, -.navbar-nav { - > li { - > a, - > button { - display: flex; - align-items: center; - justify-content: center; - padding: 6px 8px; - margin: 4px 2px; - font-size: 12px; - color: currentColor; - border-radius: $border-radius-default; - height: 32px; - font-weight: $gl-font-weight-bold; - - &:hover, - &:focus { - text-decoration: none; - outline: 0; - color: $white; - } - - &:active, - &:focus { - @include gl-focus($focus-ring: $focus-ring-dark); } } - - .top-nav-toggle, - > button { - background: transparent; - border: 0; - } - - &.line-separator { - margin: 8px; - } - } - - .dropdown-menu { - position: absolute; } } -.navbar-sub-nav { - display: flex; - align-items: center; - height: 100%; - margin: 0 0 0 6px; - - .dropdown-chevron { - position: relative; - top: -1px; - font-size: 10px; - } - - .frequent-items-item-select-holder { - display: inline; - } - - .impersonation i { - color: $red-500; - } -} - -.caret-down, -.btn .caret-down { - top: 0; - height: 11px; - width: 11px; - margin-left: 4px; - fill: currentColor; -} - -.header-user .dropdown-menu, -.header-new .dropdown-menu { - margin-top: $dropdown-vertical-offset; -} - .top-bar-container { min-height: $top-bar-height; } @@ -333,12 +100,6 @@ $search-input-field-x-min-width: 200px; @media (prefers-reduced-motion: no-preference) { transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium; } - - .breadcrumbs-list { - @include media-breakpoint-down(xs) { - flex-wrap: nowrap; - } - } } .breadcrumbs { @@ -353,59 +114,6 @@ $search-input-field-x-min-width: 200px; border-radius: 50%; vertical-align: sub; } - - .text-expander { - margin-left: 0; - margin-right: 2px; - - > i { - position: relative; - top: 1px; - } - } -} - -.breadcrumbs-list { - display: flex; - margin-bottom: 0; - line-height: 16px; - - @include media-breakpoint-down(xs) { - flex-wrap: wrap; - } - - > li { - display: flex; - align-items: center; - position: relative; - min-width: 0; - padding: 2px 0; - - &:not(:last-child) { - padding-right: 20px; - } - - &:last-child { - > a { - font-weight: 600; - line-height: 16px; - color: $gl-text-color; - } - } - - > a { - font-size: 12px; - color: currentColor; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 0 1 auto; - } - - &:last-of-type > .breadcrumbs-list-angle { - display: none; - } - } } .breadcrumb-item-text { @@ -416,93 +124,6 @@ $search-input-field-x-min-width: 200px; } } -.breadcrumbs-list-angle { - position: absolute; - right: 7px; - top: 50%; - color: $gl-text-color-tertiary; - transform: translateY(-50%); -} - -.breadcrumbs-extra { - display: flex; - flex: 0 0 auto; - margin-left: auto; -} - -@include media-breakpoint-down(xs) { - .navbar-gitlab.legacy-top-bar .container-fluid { - font-size: 18px; - - .navbar-nav { - table-layout: fixed; - width: 100%; - margin: 0; - text-align: right; - } - - .navbar-collapse { - margin-left: -8px; - margin-right: -10px; - - .nav > li:not(.d-none) { - flex: 1; - } - } - } - - .header-user-dropdown-toggle { - text-align: center; - } - - .header-user-avatar { - float: none; - } -} - -.header-user { - &.show .dropdown-menu { - margin-top: 4px; - color: var(--gl-text-color, $gl-text-color); - left: auto; - max-height: $dropdown-max-height-lg; - - .user-status { - max-width: 240px; - } - - svg { - vertical-align: text-top; - } - - a.ci-minutes-emoji gl-emoji, - a.trial-link gl-emoji { - font-size: $gl-font-size; - vertical-align: baseline; - } - } -} - -.header-user-avatar { - float: left; - margin-right: 5px; - border-radius: 50%; - border: 1px solid $gray-normal; -} - -.notification-dot { - background-color: $orange-300; - height: 12px; - width: 12px; - pointer-events: none; - visibility: hidden; - top: 3px; -} - -.with-notifications .notification-dot { - visibility: visible; -} - .navbar-empty { justify-content: center; height: var(--header-height); @@ -552,37 +173,6 @@ $search-input-field-x-min-width: 200px; @include media-breakpoint-down(xs) { margin-right: 3px; } } -.toggle-mobile-nav { - @include gl-display-none; - - @include media-breakpoint-down(sm) { - @include gl-display-block; - - + .breadcrumbs { - @include gl-pl-4; - @include gl-border-l-1; - @include gl-border-l-solid; - @include gl-border-gray-100; - } - } -} - -.top-nav-container-view { - .gl-dropdown & .gl-search-box-by-type { - @include gl-m-0; - } - - .frequent-items-list-item-container > a:hover { - background-color: $nav-active-bg !important; - } -} - -.top-nav-toggle { - .dropdown-chevron { - top: 0; - } -} - .top-nav-menu-item { &.active, &:hover { @@ -594,51 +184,77 @@ $search-input-field-x-min-width: 200px; } } -.top-nav-responsive { - @include gl-display-none; +.header-logged-out { + z-index: $header-zindex; + min-height: var(--header-height); + position: fixed; + top: $calc-system-headers-height; + left: 0; + right: 0; + background-color: $brand-charcoal; } -.top-nav-responsive-open { - .more-icon { - display: none; - } - - .close-icon { - display: block; - margin: auto; - } +.header-logged-out-nav { + position: relative; + min-height: var(--header-height); +} - @include media-breakpoint-down(xs) { - .navbar-collapse { - display: flex; - } +.header-logged-out-logo { + a { + display: flex; + align-items: center; + padding: 4px; + margin-left: -4px; + border-radius: $border-radius-default; - .hide-when-top-nav-responsive-open { - display: none !important; + &:hover, + &:focus { + background-color: $brand-gray-04; } - .top-nav-responsive { - @include gl-display-block; + &:focus, + &:active { + @include gl-focus; } - .navbar-gitlab .header-content .title-container { - flex: 0; + &:active { + background-color: $brand-gray-03; } } } -header.navbar-gitlab.super-sidebar-logged-out { - background-color: $brand-charcoal !important; +.header-logged-out-toggle { + appearance: none; + border: 0; + background-color: transparent; + border-radius: $border-radius-default; +} + +.header-logged-out-dropdown { + position: static; + + .dropdown-menu { + position: absolute; + width: 100%; + min-width: 100%; + } +} - li.nav-item > button, - li.nav-item > a { +.header-logged-out-nav-item { + > button, + > a { + display: inline-block; + padding: 6px 8px; + height: 32px; + border-radius: $border-radius-default; @include gl-text-gray-100; @include gl-font-weight-normal; + @include gl-font-base; &:hover, &:focus, &:active { - @include gl-text-white + @include gl-text-white; } &:hover, diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index bfd55fbb53d..3399847c201 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -44,7 +44,7 @@ .ci-icon { // .ci-icon class is used at - // - app/assets/javascripts/vue_shared/components/ci_icon.vue + // - app/assets/javascripts/vue_shared/components/ci_icon/ci_icon.vue // - app/helpers/ci/status_helper.rb .ci-icon-gl-icon-wrapper { @include gl-rounded-full; diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss deleted file mode 100644 index e409facd081..00000000000 --- a/app/assets/stylesheets/framework/job_log.scss +++ /dev/null @@ -1,47 +0,0 @@ -.job-log { - font-family: $monospace-font; - padding: $gl-padding-8 $input-horizontal-padding; - margin: 0 0 $gl-padding-8; - font-size: 13px; - word-break: break-all; - word-wrap: break-word; - color: color-yiq($builds-log-bg); - border-radius: 0 0 $border-radius-default $border-radius-default; - min-height: 42px; - background-color: $builds-log-bg; -} - -.log-line { - padding: 1px $gl-padding-8 1px $job-log-line-padding; - min-height: $gl-line-height-20; -} - -.line-number { - color: $gray-500; - padding: 0 $gl-padding-8; - min-width: $job-line-number-width; - margin-left: -$job-line-number-margin; - padding-right: 1em; - user-select: none; - - &:hover, - &:active, - &:visited { - text-decoration: underline; - color: $gray-500; - } -} - -.collapsible-line { - &:hover { - background-color: rgba($white, 0.2); - } - - .arrow { - margin-left: -$job-arrow-margin; - } -} - -.loader-animation { - @include build-loader-animation; -} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 33c8a0254fd..7ec13c3d54c 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -71,6 +71,11 @@ body { } .alert-wrapper { + @include gl-media-breakpoint-up(xl) { + --gl-alert-padding-x: #{$gl-spacing-scale-3}; + --gl-broadcast-message-padding-x: #{$gl-spacing-scale-3}; + } + .alert { margin-bottom: 0; @@ -149,7 +154,7 @@ body { overflow: hidden; > #js-peek, - > .navbar-gitlab { + > .header-logged-out { position: static; top: auto; } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index e9a507ebb6b..832b2297673 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -53,7 +53,7 @@ p { padding-top: 1px; margin: 0; - color: $white-normal; + color: $gray-50; img { position: relative; @@ -104,7 +104,7 @@ ul.content-list { padding: 0; li { - border-color: $white-normal; + border-color: $gray-50; font-size: $gl-font-size; color: $gl-text-color; word-break: break-word; @@ -165,7 +165,7 @@ ul.content-list { &.list-placeholder { background-color: $gray-light; - border: dotted 1px $white-normal; + border: dotted 1px $gray-50; margin: 1px 0; min-height: 52px; } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index f76a9cf0373..0265820bfe1 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -85,7 +85,7 @@ body.modal-open { } .modal { - background-color: $black-transparent; + background-color: $t-gray-a-24; .modal-content { border-radius: $modal-border-radius; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index f57d906e73c..5a86a96a96e 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -38,7 +38,7 @@ border: 0; &:not(:last-child) { - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid $gray-50; } } } @@ -69,7 +69,7 @@ min-height: 62px; &:not(:first-child) { - border-top: 1px solid $white-normal; + border-top: 1px solid $gray-50; } } diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index f77a919ef0f..2715c334952 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -349,7 +349,6 @@ &.activities { display: flex; border-bottom: 1px solid $border-color; - overflow: hidden; align-items: center; .nav-links { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 168aa704a69..0eecf7bddc1 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -53,12 +53,21 @@ height: $gl-padding; } } + + .right-sidebar-header { + flex-wrap: wrap; + } } .right-sidebar-expanded { padding-right: 0; z-index: $zindex-dropdown-menu; + .right-sidebar-header { + padding-block: $gl-spacing-scale-4; + margin-left: 20px; + } + .inline-block { @include gl-display-inline-block; } @@ -99,7 +108,7 @@ } .right-sidebar { - border-left: 1px solid $gray-50; + border-left: 1px solid $border-color; &.right-sidebar-merge-requests { @include media-breakpoint-up(lg) { @@ -321,7 +330,7 @@ .right-sidebar { &:not(.right-sidebar-merge-requests) { @include right-sidebar; - top: $calc-application-bars-height; + top: $calc-application-header-height; @include media-breakpoint-down(md) { z-index: 251; @@ -402,7 +411,7 @@ .issuable-sidebar-header { @include clearfix; padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; - border-bottom: 1px solid $border-gray-normal; + border-bottom: 1px solid $border-color; // This prevents the mess when resizing the sidebar // of elements repositioning themselves.. width: $right-sidebar-inner-width; @@ -481,6 +490,10 @@ width: $right-sidebar-width; } + .issuable-sidebar-header { + @include gl-py-5; + } + .value { line-height: 1; } @@ -578,7 +591,7 @@ } .participants { - border-bottom: 1px solid $border-gray-normal; + border-bottom: 1px solid $border-color; } .hide-collapsed { @@ -589,7 +602,7 @@ width: 100%; height: $sidebar-toggle-height; margin-left: 0; - border-bottom: 1px solid $border-white-normal; + border-bottom: 1px solid $border-color; border-radius: 0; } diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 9f8d5d25cb8..edd07dbaafa 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -29,17 +29,8 @@ } } -.snippet-header { - padding: $gl-padding 0; -} - .snippet-title { color: $gl-text-color; font-size: 2em; font-weight: $gl-font-weight-bold; - min-height: $header-height; -} - -.snippet-scope-menu .btn-success { - margin-top: 15px; } diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index fbf9d8c8ca6..84f0612a7b4 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -1,18 +1,9 @@ -@mixin active-toggle { - background-color: $gray-50 !important; - mix-blend-mode: multiply; - - .gl-dark & { - mix-blend-mode: screen; - } -} - $super-sidebar-transition-duration: $gl-transition-duration-medium; $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; @mixin notification-dot($color, $size, $top, $left) { background-color: $color; - border: 2px solid $gray-10; // Same as the sidebar's background color. + border: 2px solid var(--super-sidebar-bg); position: absolute; height: $size; width: $size; @@ -29,14 +20,53 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .super-sidebar { + --super-sidebar-bg: #{$gray-10}; + --super-sidebar-border-color: #{$t-gray-a-08}; + --super-sidebar-primary: #{$blue-500}; + --super-sidebar-notification-dot: #{$blue-500}; + --super-sidebar-user-bar-bg: #{$t-gray-a-04}; + + --super-sidebar-user-bar-button-bg: #{$gray-10}; + --super-sidebar-user-bar-button-color: #{$gray-900}; + --super-sidebar-user-bar-button-border-color: #{$t-gray-a-08}; + --super-sidebar-user-bar-button-hover-bg: #{$t-gray-a-08}; + --super-sidebar-user-bar-button-hover-color: #{$gray-900}; + --super-sidebar-user-bar-button-active-bg: #{$t-gray-a-16}; + + --super-sidebar-user-bar-button-icon-color: #{$gray-500}; + --super-sidebar-user-bar-button-icon-hover-color: #{$gray-700}; + --super-sidebar-user-bar-button-icon-mix-blend-mode: normal; + + --super-sidebar-nav-item-hover-bg: #{$t-gray-a-08}; + --super-sidebar-nav-item-active-bg: #{$t-gray-a-16}; + --super-sidebar-nav-item-current-bg: #{$t-gray-a-08}; + --super-sidebar-nav-item-icon-color: #{$gray-500}; + + .gl-dark & { + --super-sidebar-border-color: #{$t-white-a-08}; + --super-sidebar-user-bar-bg: #{$t-white-a-04}; + + --super-sidebar-user-bar-button-bg: #{$gray-10}; + --super-sidebar-user-bar-button-border-color: #{$t-white-a-08}; + --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-16}; + --super-sidebar-user-bar-button-active-bg: #{$t-white-a-24}; + + --super-sidebar-user-bar-button-icon-color: #{$gray-600}; + + --super-sidebar-nav-item-hover-bg: #{$t-white-a-08}; + --super-sidebar-nav-item-active-bg: #{$t-white-a-16}; + --super-sidebar-nav-item-current-bg: #{$t-white-a-08}; + --super-sidebar-nav-item-icon-color: #{$gray-600}; + } + display: flex; flex-direction: column; position: fixed; top: $calc-application-bars-height; bottom: $calc-application-footer-height; left: 0; - background-color: var(--gray-10, $gray-10); - border-right: 1px solid $t-gray-a-08; + background-color: var(--super-sidebar-bg); + border-right: 1px solid var(--super-sidebar-border-color); transform: translate3d(0, 0, 0); width: $super-sidebar-width; z-index: $super-sidebar-z-index; @@ -55,60 +85,97 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .user-bar { - background-color: $t-gray-a-04; + background-color: var(--super-sidebar-user-bar-bg); - .user-bar-item { - @include gl-rounded-base; + .user-bar-dropdown-toggle { @include gl-p-2; - @include gl-bg-transparent; @include gl-border-none; - &:focus, - &:active { - @include gl-focus; + &[aria-expanded='true'] { + background-color: var(--super-sidebar-user-bar-button-hover-bg); } } - .user-bar-item { + .brand-logo, + .btn-default-tertiary, + .user-bar-button { + color: var(--super-sidebar-user-bar-button-color); + + .gl-icon { + color: var(--super-sidebar-user-bar-button-icon-color) !important; + mix-blend-mode: var(--super-sidebar-user-bar-button-icon-mix-blend-mode); + } + + &:active, &:hover, + &:focus { + background-color: var(--super-sidebar-user-bar-button-hover-bg); + color: var(--super-sidebar-user-bar-button-hover-color); + + .gl-icon { + color: var(--super-sidebar-user-bar-button-icon-hover-color); + } + } + + &:active { + background-color: var(--super-sidebar-user-bar-button-active-bg) !important; + } + &:focus, &:active { - @include active-toggle; + @include gl-focus; + } + } + + .btn-default-tertiary { + mix-blend-mode: normal; + } + + .user-bar-button { + background-color: var(--super-sidebar-user-bar-button-bg); + box-shadow: inset 0 0 0 $gl-border-size-1 var(--super-sidebar-user-bar-button-border-color); + + &[aria-expanded='true'] { + background-color: var(--super-sidebar-user-bar-button-hover-bg); + color: var(--super-sidebar-user-bar-button-hover-color); } } - } - .counter .gl-icon, - .item-icon { - color: var(--gray-600, $gray-500); + .gl-new-dropdown-toggle[aria-expanded='true'] { + background-color: var(--super-sidebar-user-bar-button-hover-bg); + color: var(--super-sidebar-user-bar-button-hover-color); + } } - .counter:hover, - .counter:focus, - .counter[aria-expanded='true'] { - background-color: $gray-50; - border-color: transparent; - mix-blend-mode: multiply; + .super-sidebar-nav-item { + &:hover, + &:focus { + background-color: var(--super-sidebar-nav-item-hover-bg); + } - .gl-dark & { - mix-blend-mode: screen; + &.super-sidebar-nav-item-current { + background-color: var(--super-sidebar-nav-item-current-bg); } - .gl-icon { - color: var(--gray-700, $gray-700); + &:active, + &:focus:active { + background-color: var(--super-sidebar-nav-item-active-bg); } } - .counter:hover, - .counter[aria-expanded='true'] { - box-shadow: none; + .super-sidebar-nav-item-icon { + color: var(--super-sidebar-nav-item-icon-color); + } + + .active-indicator { + background-color: var(--super-sidebar-primary); } .btn-with-notification { position: relative; .notification-dot-info { - @include notification-dot($blue-500, 9px, 5px, 22px); + @include notification-dot(var(--super-sidebar-notification-dot), 9px, 5px, 22px); } .notification-dot-warning { @@ -118,23 +185,13 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; &:hover, &:focus { .notification { - border-color: $gray-50; // Same as the button's hover background color. + background-color: var(--super-sidebar-user-bar-button-hover-bg); } } } - .gl-new-dropdown-toggle[aria-expanded='true'] { - @include active-toggle; - } - - .gl-new-dropdown-custom-toggle { - .btn-with-notification { - mix-blend-mode: unset; // Our tertiary buttons otherwise use another mix-blend mode, making border-color semi-transparent. - } - - [aria-expanded='true'] { - @include active-toggle; - } + .super-sidebar-help-center-toggle[aria-expanded='true'] { + background-color: $gray-50 !important; } #trial-status-sidebar-widget:hover { @@ -191,7 +248,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; bottom: 0; left: 0; right: 0; - background-color: $black-transparent; + background-color: $t-gray-a-24; z-index: $super-sidebar-z-index - 1; @include media-breakpoint-up(md) { @@ -311,7 +368,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; &:hover, &:focus-within { .show-on-focus-or-hover--control { - @include gl-bg-t-gray-a-08; + background-color: var(--super-sidebar-nav-item-hover-bg); } .show-on-focus-or-hover--target { @@ -350,3 +407,57 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } } + + +// Styles for the ScrollScrim component. +// Should eventually be moved to gitlab-ui. +// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1869 + +$scroll-scrim-height: 2.25rem; + +.gl-scroll-scrim { + .top-scrim-wrapper, + .bottom-scrim-wrapper { + height: $scroll-scrim-height; + opacity: 0; + position: sticky; + z-index: 1; + display: block; + left: 0; + right: 0; + pointer-events: none; + transition: opacity 0.1s; + } + + .top-scrim-wrapper { + top: 0; + margin-bottom: -$scroll-scrim-height; + + .top-scrim { + background: linear-gradient(180deg, var(--super-sidebar-bg, $gray-10) 0%, $transparent-rgba 100%); + } + } + + .bottom-scrim-wrapper { + bottom: 0; + margin-top: -$scroll-scrim-height; + + .bottom-scrim { + background: linear-gradient(180deg, $transparent-rgba 0%, var(--super-sidebar-bg, $gray-10)); + } + } + + .top-scrim, + .bottom-scrim { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &.top-scrim-visible .top-scrim-wrapper, + &.bottom-scrim-visible .bottom-scrim-wrapper { + opacity: 1; + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 25542a86e8c..eefdbda8f4f 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -89,7 +89,7 @@ font-weight: $gl-font-weight-bold; margin: 24px 0 16px; padding-bottom: 0.3em; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid $gray-200; color: $gl-text-color; &:first-child { @@ -102,7 +102,7 @@ font-weight: $gl-font-weight-bold; margin: 24px 0 16px; padding-bottom: 0.3em; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid $gray-200; color: $gl-text-color; } @@ -138,7 +138,7 @@ &:dir(rtl) { border-left: 0; - border-right: 3px solid $white-dark; + border-right: 3px solid $gray-100; } p { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ab8547c3fef..31948762972 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -1,3 +1,5 @@ +@import '@gitlab/ui/dist/tokens/scss/tokens'; + /* * Layout */ @@ -85,85 +87,10 @@ $size-scale: ( // Color schema $darken-normal-factor: 7% !default; $darken-dark-factor: 10% !default; -$darken-border-factor: 5% !default; -$darken-border-dashed-factor: 25% !default; $purple: #6d49cb !default; $purple-light: #ede8fb !default; -$green-50: #ecf4ee !default; -$green-100: #c3e6cd !default; -$green-200: #91d4a8 !default; -$green-300: #52b87a !default; -$green-400: #2da160 !default; -$green-500: #108548 !default; -$green-600: #217645 !default; -$green-700: #24663b !default; -$green-800: #0d532a !default; -$green-900: #0a4020 !default; -$green-950: #072b15 !default; - -$blue-50: #e9f3fc !default; -$blue-100: #cbe2f9 !default; -$blue-200: #9dc7f1 !default; -$blue-300: #63a6e9 !default; -$blue-400: #428fdc !default; -$blue-500: #1f75cb !default; -$blue-600: #1068bf !default; -$blue-700: #0b5cad !default; -$blue-800: #064787 !default; -$blue-900: #033464 !default; -$blue-950: #002850 !default; - -$orange-50: #fdf1dd !default; -$orange-100: #f5d9a8 !default; -$orange-200: #e9be74 !default; -$orange-300: #d99530 !default; -$orange-400: #c17d10 !default; -$orange-500: #ab6100 !default; -$orange-600: #9e5400 !default; -$orange-700: #8f4700 !default; -$orange-800: #703800 !default; -$orange-900: #5c2900 !default; -$orange-950: #421f00 !default; - -$red-50: #fcf1ef !default; -$red-100: #fdd4cd !default; -$red-200: #fcb5aa !default; -$red-300: #f57f6c !default; -$red-400: #ec5941 !default; -$red-500: #dd2b0e !default; -$red-600: #c91c00 !default; -$red-700: #ae1800 !default; -$red-800: #8d1300 !default; -$red-900: #660e00 !default; -$red-950: #4d0a00 !default; - -$purple-50: #f4f0ff !default; -$purple-100: #e1d8f9 !default; -$purple-200: #cbbbf2 !default; -$purple-300: #ac93e6 !default; -$purple-400: #9475db !default; -$purple-500: #7b58cf !default; -$purple-600: #694cc0 !default; -$purple-700: #5943b6 !default; -$purple-800: #453894 !default; -$purple-900: #2f2a6b !default; -$purple-950: #232150 !default; - -$gray-10: #fbfafd !default; -$gray-50: #ececef !default; -$gray-100: #dcdcde !default; -$gray-200: #bfbfc3 !default; -$gray-300: #a4a3a8 !default; -$gray-400: #89888d !default; -$gray-500: #737278 !default; -$gray-600: #626168 !default; -$gray-700: #535158 !default; -$gray-800: #434248 !default; -$gray-900: #333238 !default; -$gray-950: #1f1e24 !default; - $gray-lightest: lighten($gray-10, 1) !default; $gray-light: $gray-10 !default; $gray-lighter: lighten($gray-50, 4) !default; @@ -172,211 +99,20 @@ $gray-dark: darken($gray-light, $darken-dark-factor) !default; $gray-darker: $gray-50 !default; $gray-darkest: $gray-200 !default; -$t-gray-a-02: rgba($gray-950, 0.02) !default; -$t-gray-a-04: rgba($gray-950, 0.04) !default; -$t-gray-a-06: rgba($gray-950, 0.06) !default; -$t-gray-a-08: rgba($gray-950, 0.08) !default; -$t-gray-a-16: rgba($gray-950, 0.16) !default; -$t-gray-a-24: rgba($gray-950, 0.24) !default; - -$white: #fff !default; -$white-normal: $gray-50 !default; -$white-dark: darken($gray-50, 2) !default; -$white-transparent: rgba($white, 0.8) !default; - -$black: #000 !default; -$black-transparent: $t-gray-a-24 !default; -$almost-black: $gray-950 !default; - -$greens: ( - '50': $green-50, - '100': $green-100, - '200': $green-200, - '300': $green-300, - '400': $green-400, - '500': $green-500, - '600': $green-600, - '700': $green-700, - '800': $green-800, - '900': $green-900, - '950': $green-950 -); - -$blues: ( - '50': $blue-50, - '100': $blue-100, - '200': $blue-200, - '300': $blue-300, - '400': $blue-400, - '500': $blue-500, - '600': $blue-600, - '700': $blue-700, - '800': $blue-800, - '900': $blue-900, - '950': $blue-950 -); - -$oranges: ( - '50': $orange-50, - '100': $orange-100, - '200': $orange-200, - '300': $orange-300, - '400': $orange-400, - '500': $orange-500, - '600': $orange-600, - '700': $orange-700, - '800': $orange-800, - '900': $orange-900, - '950': $orange-950 -); - -$reds: ( - '50': $red-50, - '100': $red-100, - '200': $red-200, - '300': $red-300, - '400': $red-400, - '500': $red-500, - '600': $red-600, - '700': $red-700, - '800': $red-800, - '900': $red-900, - '950': $red-950 -); - -$purples: ( - '50': $purple-50, - '100': $purple-100, - '200': $purple-200, - '300': $purple-300, - '400': $purple-400, - '500': $purple-500, - '600': $purple-600, - '700': $purple-700, - '800': $purple-800, - '900': $purple-900, - '950': $purple-950 -); - -$grays: ( - '10': $gray-10, - '50': $gray-50, - '100': $gray-100, - '200': $gray-200, - '300': $gray-300, - '400': $gray-400, - '500': $gray-500, - '600': $gray-600, - '700': $gray-700, - '800': $gray-800, - '900': $gray-900, - '950': $gray-950 -); - -$color-ranges: ( - 'primary': $blues, - 'secondary': $grays, - 'success': $greens, - 'warning': $oranges, - 'danger': $reds -); +$t-white-a-02: rgba(255, 255, 255, 0.02) !default; +$t-white-a-04: rgba(255, 255, 255, 0.04) !default; +$t-white-a-06: rgba(255, 255, 255, 0.06) !default; +$t-white-a-08: rgba(255, 255, 255, 0.08) !default; +$t-white-a-16: rgba(255, 255, 255, 0.16) !default; +$t-white-a-24: rgba(255, 255, 255, 0.24) !default; +$t-white-a-36: rgba(255, 255, 255, 0.36) !default; -// GitLab themes - -$indigo-50: #f1f1ff; -$indigo-100: #dbdbf8; -$indigo-200: #c7c7f2; -$indigo-300: #a2a2e6; -$indigo-400: #8181d7; -$indigo-500: #6666c4; -$indigo-600: #5252b5; -$indigo-700: #41419f; -$indigo-800: #303083; -$indigo-900: #222261; -$indigo-950: #14143d; -// To do this variant right for darkmode, we need to create a variable for it. -$indigo-900-alpha-008: rgba($indigo-900, 0.08); - -$theme-blue-50: #cdd8e3; -$theme-blue-100: #b9cadc; -$theme-blue-200: #a6bdd5; -$theme-blue-300: #81a5c9; -$theme-blue-400: #628eb9; -$theme-blue-500: #4977a5; -$theme-blue-600: #346596; -$theme-blue-700: #235180; -$theme-blue-800: #153c63; -$theme-blue-900: #0b2640; -$theme-blue-950: #04101c; - -$theme-light-blue-50: #dde6ee; -$theme-light-blue-100: #c1d4e6; -$theme-light-blue-200: #a0bedc; -$theme-light-blue-300: #74a3d3; -$theme-light-blue-400: #4f8bc7; -$theme-light-blue-500: #3476b9; -$theme-light-blue-600: #2268ae; -$theme-light-blue-700: #145aa1; -$theme-light-blue-800: #0e4d8d; -$theme-light-blue-900: #0c4277; -$theme-light-blue-950: #0a3764; - -$theme-green-50: #dde9de; -$theme-green-100: #b1d6b5; -$theme-green-200: #8cc497; -$theme-green-300: #69af7d; -$theme-green-400: #499767; -$theme-green-500: #308258; -$theme-green-600: #25744c; -$theme-green-700: #1b653f; -$theme-green-800: #155635; -$theme-green-900: #0e4328; -$theme-green-950: #052e19; - -$theme-red-50: #f4e9e7; -$theme-red-100: #ecd3d0; -$theme-red-200: #e3bab5; -$theme-red-300: #d59086; -$theme-red-400: #c66e60; -$theme-red-500: #ad4a3b; -$theme-red-600: #a13322; -$theme-red-700: #8f2110; -$theme-red-800: #761405; -$theme-red-900: #580d02; -$theme-red-950: #380700; - -$theme-light-red-50: #faf2f1; -$theme-light-red-100: #f6d9d5; -$theme-light-red-200: #ebada2; -$theme-light-red-300: #e07f6f; -$theme-light-red-400: #d36250; -$theme-light-red-500: #c24b38; -$theme-light-red-600: #b53a26; -$theme-light-red-700: #a02e1c; -$theme-light-red-800: #8b2212; -$theme-light-red-900: #751709; -$theme-light-red-950: #5c1105; - -// Data visualization color palette - -$data-viz-blue-50: #e9ebff !default; -$data-viz-blue-100: #d2dcff !default; -$data-viz-blue-200: #b7c6ff !default; -$data-viz-blue-300: #97acff !default; -$data-viz-blue-400: #7992f5 !default; -$data-viz-blue-500: #617ae2 !default; -$data-viz-blue-600: #4e65cd !default; -$data-viz-blue-700: #3f51ae !default; -$data-viz-blue-800: #374291 !default; -$data-viz-blue-900: #303470 !default; -$data-viz-blue-950: #2a2b59 !default; - -$border-white-light: darken($white, $darken-border-factor) !default; -$border-white-normal: darken($white-normal, $darken-border-factor) !default; - -$border-gray-light: darken($gray-light, $darken-border-factor); -$border-gray-normal: darken($gray-normal, $darken-border-factor); -$border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor); +$t-gray-a-02: rgba(31, 30, 36, 0.02) !default; +$t-gray-a-04: rgba(31, 30, 36, 0.04) !default; +$t-gray-a-06: rgba(31, 30, 36, 0.06) !default; +$t-gray-a-08: rgba(31, 30, 36, 0.08) !default; +$t-gray-a-16: rgba(31, 30, 36, 0.16) !default; +$t-gray-a-24: rgba(31, 30, 36, 0.24) !default; /* * UI elements @@ -388,7 +124,6 @@ $shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7 !default; $well-inner-border: #eef0f2 !default; $well-light-border: #f1f1f1; -$well-light-text-color: #5b6169; $nav-active-bg: $t-gray-a-08; /* @@ -409,7 +144,6 @@ $gl-text-color-disabled: $gray-400; $link-color: $blue-500 !default; $link-hover-color: $blue-500 !default; $gl-grayish-blue: #7f8fa4; -$gl-header-color: #4c4e54; $gl-font-size-12: 12px; $gl-font-size-14: 14px; $gl-font-size-16: 16px; @@ -472,14 +206,11 @@ $limited-layout-width: 1006px; $fixed-layout-width: 1296px; $container-margin: $gl-padding; $container-margin-xl: $gl-padding-24; -$container-text-max-width: 540px; $border-radius-default: 4px; $border-radius-small: 2px; $border-radius-large: 8px; $default-icon-size: 16px; -$layout-link-gray: #7e7c7c; $btn-side-margin: $grid-size; -$count-arrow-border: #dce0e5; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); @@ -488,10 +219,8 @@ $system-header-height: 16px; $system-footer-height: $system-header-height; $mr-sticky-header-height: 72px; $mr-review-bar-height: calc(2rem + 16px); -$flash-height: 52px; -$context-header-height: 60px; $top-bar-height: 48px; -$home-panel-title-row-height: 64px; +$home-panel-title-row-height: 48px; $home-panel-avatar-mobile-size: 24px; $issuable-title-max-width: 350px; $milestone-title-max-width: 75px; @@ -501,8 +230,6 @@ $gl-line-height-20: 20px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; -$pages-group-name-color: #4c4e54; - /* * Calculated heights */ @@ -512,12 +239,6 @@ $calc-application-header-height: calc(#{$calc-application-bars-height} + var(--t $calc-application-footer-height: var(--system-footer-height); $calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); -/* -* Common component specific colors -*/ -$user-mention-bg: rgba($blue-500, 0.044); -$user-mention-bg-hover: rgba($blue-500, 0.15); - /* tanuki logo colors */ $tanuki-red: #e24329; $tanuki-orange: #fc6d26; @@ -528,7 +249,6 @@ $tanuki-yellow: #fca326; */ $green-500-focus: rgba($green-500, 0.4); $gl-btn-active-background: rgba(0, 0, 0, 0.16); -$gl-btn-active-gradient: inset 0 2px 3px $gl-btn-active-background; /* * Commit Diff Colors @@ -539,6 +259,7 @@ $line-added: #ecfdf0; $line-added-dark: #c7f0d2 !default; $line-removed: #fbe9eb; $line-removed-dark: #fac5cd !default; + /* * The transparent colors are used in Monaco editor. Using full opacity colors * would hide other layers (selected text, matching brackets). @@ -557,19 +278,13 @@ $line-removed-transparent: rgba(235, 145, 155, 0.2); $line-removed-dark-transparent: rgba(246, 53, 85, 0.2); $line-number-old: #f9d7dc; $line-number-new: #ddfbe6; -$line-number-select: #fbf2da; -$line-number-commented: #dae5fb; $line-target-blue: $blue-50; -$line-select-yellow: #fcf8e7; -$line-select-yellow-dark: #f0e2bd; -$line-commented-blue: #e8effc; -$line-commented-blue-dark: #bccef0; $dark-diff-match-bg: rgba($white, 0.3); $dark-diff-match-color: rgba($white, 0.1); $diff-image-info-color: #808080; $diff-view-modes-color: #808080; $diff-view-modes-border: #c1c1c1; -$diff-jagged-border-gradient-color: darken($white-normal, 8%); +$diff-jagged-border-gradient-color: darken($gray-50, 8%); /* * Fonts @@ -592,12 +307,9 @@ $dropdown-min-height: 40px; $dropdown-max-height: 312px; $dropdown-max-height-lg: 445px; $dropdown-vertical-offset: 4px; -$dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; -$dropdown-input-focus-shadow: rgba($blue-300, 0.4); $dropdown-loading-bg: rgba($white, 0.6); -$dropdown-chevron-size: 10px; $dropdown-toggle-active-border-color: darken($border-color, 14%); $dropdown-fade-mask-height: 32px; $dropdown-member-form-control-width: 163px; @@ -610,13 +322,10 @@ $filtered-search-term-shadow-color: rgba(0, 0, 0, 0.09); /* * Contextual Sidebar */ -$link-active-background: rgba($black, 0.04); $link-hover-background: rgba($gray-900, 0.06); -$inactive-badge-background: rgba($black, 0.08); $sidebar-toggle-height: 60px; $sidebar-toggle-width: 40px; $sidebar-milestone-toggle-bottom-margin: 10px; -$sidebar-avatar-size: 32px; $sidebar-top-item-lr-margin: 8px; $sidebar-top-item-tb-margin: 1px; @@ -639,20 +348,6 @@ $gl-btn-small-line-height: 18px; $badge-bg: rgba($black, 0.07); /* -* Pagination -*/ -$pagination-padding-y: 6px; -$pagination-padding-x: 16px; -$pagination-line-height: 20px; -$pagination-disabled-color: #cdcdcd; - -/* - * Status icons - */ -$status-icon-size: 22px; - - -/* * Social Icons */ $discord: #5865f2; @@ -664,7 +359,6 @@ $skype: #0078d7; * Award emoji */ $award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); -$award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; $award-emoji-width: 376px; $award-emoji-width-xs: 90%; @@ -672,7 +366,6 @@ $award-emoji-width-xs: 90%; /* * Search Box */ -$search-input-border-color: rgba($blue-400, 0.8); $search-input-width: 200px; $search-input-xl-width: 320px; @@ -680,8 +373,6 @@ $search-input-xl-width: 320px; * Notes */ $note-disabled-comment-color: #b2b2b2; -$note-targe3-outside: #fffff0; -$note-targe3-inside: #ffffd3; /* * Calendar @@ -689,14 +380,8 @@ $note-targe3-inside: #ffffd3; $calendar-user-contrib-text: #959494; /* -* CI -*/ -$ci-skipped-color: #888; - -/* * Boards */ -$issue-boards-font-size: 14px; $issue-boards-card-shadow: rgba(0, 0, 0, 0.1); /* @@ -716,7 +401,6 @@ $blame-blue: #254e77; * Builds */ $builds-log-bg: #111; -$job-log-highlight-height: 18px; $job-log-line-padding: 63px; $job-line-number-width: 50px; $job-line-number-margin: 51px; @@ -760,20 +444,12 @@ $input-md-width: 240px; $input-lg-width: 320px; /* -* Help -*/ -$document-index-color: #888; -$help-shortcut-header-color: #333; - -/* * Label */ $label-font-size: 12px; $label-padding: 7px; -$label-padding-modal: 10px; $label-gray-bg: #f8fafc; $label-inverse-bg: #333; -$label-remove-border: rgba(0, 0, 0, 0.1); $label-border-radius: 100px; /* @@ -789,37 +465,17 @@ $fade-mask-transition-curve: ease-in-out; $login-brand-holder-color: #888; /* -Stat Graph -*/ -$stat-graph-common-bg: #f3f3f3; -$stat-graph-selection-fill: #333; -$stat-graph-selection-stroke: #333; - -/* * Typography */ $body-text-shadow: rgba(255, 255, 255, 0.01); /* -* UI Dev Kit -*/ -$ui-dev-kit-example-color: #bbb; - -/* Pipeline Graph */ -$ci-action-icon-size: 22px; -$ci-action-icon-size-lg: 24px; -$pipeline-dropdown-line-height: 20px; $ci-action-dropdown-button-size: 24px; $ci-action-dropdown-svg-size: 16px; /* -CI variable lists -*/ -$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); - -/* GitLab Plans */ $gl-ultimate-plan: #d4af37; @@ -827,15 +483,10 @@ $gl-premium-plan: #91a1ab; $gl-bronze-plan: #cd7f32; /* -Cross-project Pipelines - */ -$linked-project-column-margin: 60px; - -/* Performance Bar */ $perf-bar-production: $gray-950; -$perf-bar-staging: $indigo-950; +$perf-bar-staging: $theme-indigo-950; $perf-bar-development: $red-900; $perf-bar-bucket-bg: $black; $perf-bar-bucket-box-shadow-from: rgba($white, 0.2); @@ -875,11 +526,6 @@ $popup-triangle-border-size: 1px; $popup-box-shadow-color: rgba(90, 90, 90, 0.05); /* -Multi file editor -*/ -$border-color-settings: #e1e1e1; - -/* Drawers */ $wide-drawer: 500px; @@ -888,7 +534,6 @@ $wide-drawer: 500px; Modals */ $modal-body-height: 80px; -$modal-border-color: #e9ecef; $modal-border-radius: 0.25rem; $priority-label-empty-state-width: 114px; @@ -900,11 +545,6 @@ $popover-max-width: 384px; $popover-box-shadow: 0 2px 3px 1px $gray-100; /* -Issue Analytics -*/ -$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); - -/* Merge requests */ $mr-tabs-height: 48px; diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 085e25a0cdc..23fa1326881 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -132,7 +132,7 @@ } } -@mixin line-hover-bg($color: $white-normal) { +@mixin line-hover-bg($color: $gray-50) { &:hover, &:focus-within { background-color: darken($color, 10); diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index f36eaa663e5..c2bc35ec91a 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -5,8 +5,8 @@ @import '../common'; @mixin match-line { - color: $black-transparent; - background-color: $white-normal; + color: $t-gray-a-24; + background-color: $gray-50; } :root { @@ -40,13 +40,13 @@ .diff-line-num, .diff-line-num a { - color: $black-transparent; + color: $t-gray-a-24; } // Code itself pre.code, .diff-line-num { - border-color: $white-normal; + border-color: $gray-50; } &, @@ -86,7 +86,7 @@ &.new, &.new-nomappinginraw, &.old-nomappinginraw { - background-color: $white-normal; + background-color: $gray-50; } } @@ -137,27 +137,27 @@ .line_content { &.old, &.old-nomappinginraw { - background-color: $white-normal; + background-color: $gray-50; &::before { color: $gl-text-color; } span.idiff { - background-color: $white-normal; + background-color: $gray-50; text-decoration: underline; } } &.new:not(.hll), &.new-nomappinginraw:not(.hll) { - background-color: $white-normal; + background-color: $gray-50; &::before { color: $gl-text-color; } span.idiff { - background-color: $white-normal; + background-color: $gray-50; text-decoration: underline; } } @@ -170,7 +170,7 @@ // Search result highlight span.highlight_word { - background-color: $white-normal; + background-color: $gray-50; } // Links to URLs, emails, or dependencies diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index b3aa10c3ace..c902d9357e8 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -101,7 +101,7 @@ $solarized-light-il: #2aa198; } @mixin match-line { - color: $black-transparent; + color: $t-gray-a-24; background: $solarized-light-matchline-bg; } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 2631055706f..89d6d93614f 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -73,7 +73,7 @@ $white-gc-bg: #eaf2f5; @mixin match-line { - color: $black-transparent; + color: $t-gray-a-24; background-color: $gray-light; } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index fd212d14e30..c42b7baec39 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -93,8 +93,8 @@ $highlighted-gc-bg: #eaf2f5; text-align: right; width: 35px; background-color: $gray-light; - color: $black-transparent; - border-right: 1px solid $white-normal; + color: $t-gray-a-24; + border-right: 1px solid $gray-50; &.old { background-color: $line-number-old; @@ -130,7 +130,7 @@ $highlighted-gc-bg: #eaf2f5; } &.match { - color: $black-transparent; + color: $t-gray-a-24; background-color: $gray-light; } } @@ -144,7 +144,7 @@ blockquote, color: $gl-grayish-blue; padding: 0 0 0 15px; margin: 0; - border-left: 3px solid $white-dark; + border-left: 3px solid $gray-100; } span.highlight_word { diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss index d93b4f75d77..013e9e020fc 100644 --- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss @@ -75,7 +75,7 @@ .diffOverview { background-color: $white; - border-left: 1px solid $white-dark; + border-left: 1px solid $border-color; cursor: ns-resize; } @@ -92,7 +92,7 @@ } .line-numbers { - color: $black-transparent; + color: $t-gray-a-24; } .view-overlays { diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index c584bbaac09..5c8e9bce0e7 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -67,7 +67,7 @@ } .drag-handle:hover { - background-color: var(--ide-dropdown-hover-background, $white-normal); + background-color: var(--ide-dropdown-hover-background, $gray-50); } .card-header { @@ -147,7 +147,7 @@ .nav-links, .gl-tabs-nav, .common-note-form .md-area.is-focused .nav-links { - border-color: var(--ide-border-color-alt, $white-dark); + border-color: var(--ide-border-color-alt, $border-color); } pre { @@ -221,7 +221,7 @@ .filtered-search-token .value-container, .filtered-search-term .value-container { - background-color: var(--ide-dropdown-hover-background, $white-normal); + background-color: var(--ide-dropdown-hover-background, $gray-50); color: var(--ide-text-color, $gl-text-color); &:hover { @@ -291,14 +291,14 @@ &:hover, &:focus { - border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important; - background-color: var(--ide-btn-default-background, $white-normal) !important; + border-color: var(--ide-btn-default-hover-border, $border-color) !important; + background-color: var(--ide-btn-default-background, $gray-50) !important; } &:active, .active { - border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important; - background-color: var(--ide-btn-default-background, $white-dark) !important; + border-color: var(--ide-btn-default-hover-border, $border-color) !important; + background-color: var(--ide-btn-default-background, $border-color) !important; } } diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 22e42d0a7f7..66482ef42b5 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -131,6 +131,7 @@ height: $gl-padding-24; border-radius: $gl-padding-24; font-size: $gl-font-size-xs; + position: relative; @include media-breakpoint-down(md) { min-width: auto; @@ -140,15 +141,9 @@ } } - .user-avatar-link:not(:only-child) { - margin-left: -$gl-padding; - - &:nth-of-type(1) { - z-index: 2; - } - - &:nth-of-type(2) { - z-index: 1; + .user-avatar-link { + &:not(:last-of-type) { + @include gl-mr-n3; } } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 6165ee6e8b4..379f1470b20 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -82,6 +82,7 @@ .right-sidebar.build-sidebar { padding: 0; + top: $calc-application-header-height; @include media-breakpoint-up(lg) { @include gl-border-l-0; @@ -92,9 +93,7 @@ } .sidebar-container { - @include gl-sticky; - top: #{$top-bar-height - 1px}; - max-height: calc(100vh - #{$top-bar-height - 1px} - var(--performance-bar-height)); + max-height: 100%; overflow-y: scroll; overflow-x: hidden; -webkit-overflow-scrolling: touch; @@ -166,3 +165,64 @@ margin-bottom: 0; } } + +.job-log { + font-family: $monospace-font; + padding: $gl-padding-8 $input-horizontal-padding; + margin: 0 0 $gl-padding-8; + font-size: 13px; + word-break: break-all; + word-wrap: break-word; + color: color-yiq($builds-log-bg); + border-radius: 0 0 $border-radius-default $border-radius-default; + min-height: 42px; + background-color: $builds-log-bg; +} + +.build-log-container:fullscreen { + overflow-y: scroll; + + .top-bar { + top: 0 !important; + } +} + +.job-log-line { + padding: 1px $gl-padding-8 1px $job-log-line-padding; + min-height: $gl-line-height-20; +} + +.job-log-line-number { + color: $gray-500; + padding: 0 1em 0 $gl-padding-8; + min-width: $job-line-number-width; + margin-left: -$job-line-number-margin; + user-select: none; + display: inline-block; + text-align: right; + + &:hover, + &:active, + &:visited { + text-decoration: underline; + color: $gray-500; + } +} + +.job-log-line-header { + display: flex; + position: relative; + align-items: flex-start; + + &:hover { + background-color: rgba($white, 0.2); + } + + .arrow { + margin-left: -$job-arrow-margin; + } +} + +.loader-animation { + @include build-loader-animation; +} diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss index 5086cdbf9bc..ae49993d3df 100644 --- a/app/assets/stylesheets/page_bundles/group.scss +++ b/app/assets/stylesheets/page_bundles/group.scss @@ -2,41 +2,12 @@ .group-home-panel { .home-panel-avatar { - width: $home-panel-title-row-height; - height: $home-panel-title-row-height; flex-basis: $home-panel-title-row-height; } .home-panel-title { .icon { - vertical-align: -1px; - } - } - - .home-panel-title-row { - @include media-breakpoint-down(sm) { - .home-panel-avatar { - width: $home-panel-avatar-mobile-size; - height: $home-panel-avatar-mobile-size; - flex-basis: $home-panel-avatar-mobile-size; - - .avatar { - font-size: 20px; - line-height: 46px; - } - } - - .home-panel-title { - margin-top: 4px; - margin-bottom: 2px; - font-size: $gl-font-size; - line-height: $gl-font-size-large; - } - - - .home-panel-metadata { - font-size: $gl-font-size-small; - } + vertical-align: 1px; } } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 7f8068e5d56..7e2bf4a03a3 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -9,6 +9,9 @@ @import './ide_themes/solarized-dark'; @import './ide_themes/monokai'; +// This whole file is for the legacy Web IDE +// See: https://gitlab.com/groups/gitlab-org/-/epics/7683 + $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; $ide-context-header-padding: 10px; @@ -18,6 +21,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; $ide-commit-row-height: 32px; $ide-commit-header-height: 48px; +.web-ide-loader { + padding-top: 1rem; +} + .project-refs-form, .project-refs-target-form { display: inline-block; @@ -67,15 +74,15 @@ $ide-commit-header-height: 48px; display: flex; flex-direction: column; flex: 1; - border-left: 1px solid var(--ide-border-color, $white-dark); - border-right: 1px solid var(--ide-border-color, $white-dark); + border-left: 1px solid var(--ide-border-color, $border-color); + border-right: 1px solid var(--ide-border-color, $border-color); overflow: hidden; } .multi-file-tabs { display: flex; background-color: var(--ide-background, $gray-light); - box-shadow: inset 0 -1px var(--ide-border-color, $white-dark); + box-shadow: inset 0 -1px var(--ide-border-color, $border-color); > ul { display: flex; @@ -87,8 +94,8 @@ $ide-commit-header-height: 48px; align-items: center; padding: $grid-size $gl-padding; background-color: var(--ide-background-hover, $gray-normal); - border-right: 1px solid var(--ide-border-color, $white-dark); - border-bottom: 1px solid var(--ide-border-color, $white-dark); + border-right: 1px solid var(--ide-border-color, $border-color); + border-bottom: 1px solid var(--ide-border-color, $border-color); &.active, .gl-tab-nav-item-active { @@ -129,12 +136,12 @@ $ide-commit-header-height: 48px; font-weight: normal !important; background-color: var(--ide-background-hover, $gray-normal); - border-right: 1px solid var(--ide-border-color, $white-dark); - border-bottom: 1px solid var(--ide-border-color, $white-dark); + border-right: 1px solid var(--ide-border-color, $border-color); + border-bottom: 1px solid var(--ide-border-color, $border-color); &.gl-tab-nav-item-active { background-color: var(--ide-highlight-background, $white); - border-color: var(--ide-border-color, $white-dark); + border-color: var(--ide-border-color, $border-color); border-bottom-color: transparent; } @@ -238,7 +245,7 @@ $ide-commit-header-height: 48px; } .ide-mode-tabs { - border-bottom: 1px solid var(--ide-border-color, $white-dark); + border-bottom: 1px solid var(--ide-border-color, $border-color); li a { padding: $gl-padding-8 $gl-padding; @@ -253,7 +260,7 @@ $ide-commit-header-height: 48px; .ide-status-bar { color: var(--ide-text-color, $gl-text-color); - border-top: 1px solid var(--ide-border-color, $white-dark); + border-top: 1px solid var(--ide-border-color, $border-color); padding: 2px $gl-padding-8 0; background-color: var(--ide-footer-background, $white); display: flex; @@ -351,8 +358,8 @@ $ide-commit-header-height: 48px; flex: 1; flex-direction: column; background-color: var(--ide-highlight-background, $white); - border-left: 1px solid var(--ide-border-color, $white-dark); - border-top: 1px solid var(--ide-border-color, $white-dark); + border-left: 1px solid var(--ide-border-color, $border-color); + border-top: 1px solid var(--ide-border-color, $border-color); border-top-left-radius: $border-radius-small; min-height: 0; // firefox fix } @@ -377,7 +384,7 @@ $ide-commit-header-height: 48px; .multi-file-commit-panel-header { height: $ide-commit-header-height; - border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); + border-bottom: 1px solid var(--ide-border-color-alt, $border-color); padding: 12px 0; } @@ -436,7 +443,7 @@ $ide-commit-header-height: 48px; } &.is-active { - background-color: var(--ide-background, $white-normal); + background-color: var(--ide-background, $gray-50); } svg { @@ -458,7 +465,7 @@ $ide-commit-header-height: 48px; .multi-file-commit-form { position: relative; background-color: var(--ide-highlight-background, $white); - border-left: 1px solid var(--ide-border-color, $white-dark); + border-left: 1px solid var(--ide-border-color, $border-color); transition: all 0.3s ease; > form, @@ -466,7 +473,7 @@ $ide-commit-header-height: 48px; padding: $gl-padding 0; margin-left: $gl-padding; margin-right: $gl-padding; - border-top: 1px solid var(--ide-border-color-alt, $white-dark); + border-top: 1px solid var(--ide-border-color-alt, $border-color); } .btn { @@ -517,7 +524,6 @@ $ide-commit-header-height: 48px; .ide-empty-state { display: flex; - height: 100vh; align-items: center; justify-content: center; background-color: var(--ide-empty-state-background, transparent); @@ -526,6 +532,7 @@ $ide-commit-header-height: 48px; .ide { overflow: hidden; flex: 1; + height: calc(100vh - var(--top-bar-height)) } .ide-commit-list-container { @@ -537,7 +544,7 @@ $ide-commit-header-height: 48px; margin-right: $gl-padding; &.is-first { - border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); + border-bottom: 1px solid var(--ide-border-color-alt, $border-color); } } @@ -545,7 +552,7 @@ $ide-commit-header-height: 48px; width: $ide-commit-row-height; height: $ide-commit-row-height; color: inherit; - background-color: var(--ide-background, $white-normal); + background-color: var(--ide-background, $gray-50); } .ide-commit-options { @@ -596,8 +603,8 @@ $ide-commit-header-height: 48px; width: calc(100% + 1px); padding-right: $gl-padding + 1px; background-color: var(--ide-highlight-background, $white); - border-top-color: var(--ide-border-color, $white-dark); - border-bottom-color: var(--ide-border-color, $white-dark); + border-top-color: var(--ide-border-color, $border-color); + border-bottom-color: var(--ide-border-color, $border-color); &::after { content: ''; @@ -707,7 +714,7 @@ $ide-commit-header-height: 48px; padding: 12px 0; margin-left: $ide-tree-padding; margin-right: $ide-tree-padding; - border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); + border-bottom: 1px solid var(--ide-border-color-alt, $border-color); svg { color: var(--ide-text-color-secondary, $gray-500); @@ -740,7 +747,7 @@ $ide-commit-header-height: 48px; background-color: var(--ide-input-background, transparent); &:hover { - background-color: var(--ide-dropdown-btn-hover-background, $white-normal); + background-color: var(--ide-dropdown-btn-hover-background, $gray-50); } svg { @@ -899,7 +906,7 @@ $ide-commit-header-height: 48px; .multi-file-commit-panel-inner { padding: $grid-size 0; background-color: var(--ide-highlight-background, $white); - border-right: 1px solid var(--ide-border-color, $white-dark); + border-right: 1px solid var(--ide-border-color, $border-color); } .ide-right-sidebar-jobs-detail { @@ -1063,7 +1070,7 @@ $ide-commit-header-height: 48px; &:active, &:focus { - color: $white-normal; + color: $gray-50; background-color: var(--ide-link-color, $blue-500); outline: 0; } @@ -1077,7 +1084,7 @@ $ide-commit-header-height: 48px; } .dropdown.show .ide-entry-dropdown-toggle { - color: $white-normal; + color: $gray-50; background-color: var(--ide-link-color, $blue-500); } } @@ -1085,7 +1092,7 @@ $ide-commit-header-height: 48px; .ide-file-templates { padding: $grid-size $gl-padding; background-color: var(--ide-background, $gray-light); - border-bottom: 1px solid var(--ide-border-color, $white-dark); + border-bottom: 1px solid var(--ide-border-color, $border-color); .dropdown { min-width: 180px; @@ -1100,7 +1107,7 @@ $ide-commit-header-height: 48px; height: 65px; padding: 8px 16px; background-color: var(--ide-background, $gray-10); - box-shadow: inset 0 -1px var(--ide-border-color, $white-dark); + box-shadow: inset 0 -1px var(--ide-border-color, $border-color); } .ide-commit-list-changed-icon { diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 05563f8e314..8b353b42f58 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -105,3 +105,8 @@ @include gl-font-weight-normal; } } + +[data-page="projects:issues:show"] .top-bar-fixed, +[data-page="groups:epics:show"] .top-bar-fixed { + width: 100%; +} diff --git a/app/assets/stylesheets/page_bundles/merge_request.scss b/app/assets/stylesheets/page_bundles/merge_request.scss index 8dc4401e72c..70aeedb10bf 100644 --- a/app/assets/stylesheets/page_bundles/merge_request.scss +++ b/app/assets/stylesheets/page_bundles/merge_request.scss @@ -250,6 +250,14 @@ $comparison-empty-state-height: 62px; } } +.diffs.tab-pane { + @include media-breakpoint-up(md) { + // ensure consistent page height when selected file is loading + // https://gitlab.com/gitlab-org/gitlab/-/issues/426250 + min-height: 100vh; + } +} + // Wrap MR tabs/buttons so you don't have to scroll on desktop @include media-breakpoint-down(md) { .merge-request-tabs-container { @@ -296,7 +304,7 @@ $comparison-empty-state-height: 62px; .merge-request-details .file-finder-overlay.diff-file-finder { position: fixed; z-index: 99999; - background: $black-transparent; + background: $t-gray-a-24; } .mr-compare { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 847cd3f2ff4..d112fd83ebf 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -487,11 +487,6 @@ $tabs-holder-z-index: 250; background: var(--white, $white); > .mr-widget-section { - > :first-child { - border-top-left-radius: $border-radius-default - 1px; - border-top-right-radius: $border-radius-default - 1px; - } - > :last-child, .deploy-heading:last-child { border-bottom-left-radius: $border-radius-default - 1px; @@ -552,8 +547,13 @@ $tabs-holder-z-index: 250; .mr-widget-section:not(:first-child) > div, .mr-widget-section:not(:first-child) > section, - .mr-widget-section .mr-widget-section > div { + .mr-widget-section .mr-widget-section > div:not(:first-child) { border-top: solid 1px var(--border-color, $border-color); + // Avoid two lines being rendered + // instead of exessively adressing those + // edge cases we can use this as a boring + // solution + margin-top: -1px; } .mr-widget-alert-container + .mr-widget-section { @@ -1056,7 +1056,7 @@ $tabs-holder-z-index: 250; } .merge-request-sticky-header { - z-index: 204; + z-index: $top-bar-z-index; height: $mr-sticky-header-height; } @@ -1115,10 +1115,6 @@ $tabs-holder-z-index: 250; border-top: 1px solid var(--border-color, $border-color); transition: padding $gl-transition-duration-medium; - .page-with-icon-sidebar & { - padding-left: $contextual-sidebar-collapsed-width; - } - @media (max-width: map-get($grid-breakpoints, sm)-1) { padding-left: 0; padding-right: 0; @@ -1126,6 +1122,10 @@ $tabs-holder-z-index: 250; .submit-review-dropdown { margin-left: $grid-size; + + .md-header { + top: -$gl-spacing-scale-2; + } } } @@ -1214,3 +1214,7 @@ $tabs-holder-z-index: 250; @include gl-rounded-top-right-none; } } + +.merge-request-overview .md-header { + top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height}); +} diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 8dc07715989..7a9c7487a7e 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -90,7 +90,7 @@ } .reference { - border-top: 1px solid $border-gray-normal; + border-top: 1px solid $border-color; } } } diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index aaec277cf08..9bab5d65b59 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -218,7 +218,7 @@ min-width: 195px; left: 100%; top: -10px; - box-shadow: 0 1px 5px $black-transparent; + box-shadow: 0 1px 5px $t-gray-a-24; } .codequality-report { @@ -303,7 +303,7 @@ .pipeline-show-container, .pipeline-links-container { @media (max-width: $breakpoint-sm) { - width: 100%; + flex-basis: 100%; } } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index bcc0ad112ac..d61e3f85995 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -24,7 +24,7 @@ .btn.btn-retry:hover, .btn.btn-retry:focus { border-color: $dropdown-toggle-active-border-color; - background-color: $white-normal; + background-color: $gray-50; } svg path { @@ -42,8 +42,8 @@ } .btn-group.open .btn-default { - background-color: $white-normal; - border-color: $border-white-normal; + background-color: $gray-50; + border-color: $gray-100; } .btn .text-center { diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index 2c08db048fd..9a8eeb9c9d6 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -234,7 +234,7 @@ color: $skype; } -.twitter-icon { +.x-icon { color: var(--gl-text-color, $gl-text-color); } diff --git a/app/assets/stylesheets/page_bundles/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss index d09ad42a722..502674deec8 100644 --- a/app/assets/stylesheets/page_bundles/profiles/preferences.scss +++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss @@ -13,11 +13,11 @@ margin-bottom: $gl-padding-8; &.ui-indigo { - background-color: $indigo-900; + background-color: $theme-indigo-900; } &.ui-light-indigo { - background-color: $indigo-700; + background-color: $theme-indigo-700; } &.ui-blue { diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss index 8d8da10268a..c2ecf3702f9 100644 --- a/app/assets/stylesheets/page_bundles/project.scss +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -7,7 +7,7 @@ .home-panel-title { .icon { - vertical-align: -1px; + vertical-align: 1px; } .home-panel-topic-list { @@ -17,28 +17,6 @@ } } - .home-panel-title-row { - @include media-breakpoint-down(sm) { - .home-panel-avatar { - width: $home-panel-avatar-mobile-size; - height: $home-panel-avatar-mobile-size; - flex-basis: $home-panel-avatar-mobile-size; - - .avatar { - font-size: 20px; - line-height: 46px; - } - } - - .home-panel-title { - margin-top: 4px; - margin-bottom: 2px; - font-size: $gl-font-size; - line-height: $gl-font-size-large; - } - } - } - .home-panel-description { @include media-breakpoint-up(md) { font-size: $gl-font-size-large; @@ -53,7 +31,7 @@ } } - .project-clone-holder { + .project-code-holder { display: inline-block; margin: $gl-padding 0 0; @@ -134,7 +112,7 @@ .stat-text, .stat-link { - padding: $gl-btn-vert-padding 0; + padding: $gl-btn-vert-padding; background-color: transparent; font-size: $gl-font-size; line-height: $gl-btn-line-height; @@ -149,7 +127,6 @@ &:hover, &:focus { text-decoration: underline; - border-bottom: 0; } .project-stat-value { @@ -159,13 +136,6 @@ .icon { color: var(--gray-500, $gl-text-color-secondary); } - - .add-license-link { - &, - .icon { - color: var(--blue-600, $blue-600); - } - } } .btn { @@ -186,3 +156,58 @@ color: var(--gl-text-color, $gl-text-color); } } + +// FF :project_overview_reorg enabled +.project-page-indicator:not(.hidden) + .project-page-layout { + @include media-breakpoint-up(lg) { + display: grid; + grid-template-columns: auto $right-sidebar-width; + gap: 2rem; + + .project-page-layout-content, + .project-page-layout-sidebar { + min-width: 1px; + } + + .project-page-layout-sidebar { + order: 2; + overflow-x: clip; + margin-right: -$gl-padding-8; + } + + .project-page-sidebar { + position: sticky; + top: calc(#{$calc-application-header-height} + #{$gl-spacing-scale-4}); + width: calc(100% + 100px); + height: calc( + #{$calc-application-viewport-height} - #{$gl-spacing-scale-4} + ); + padding-inline: $gl-padding-4; + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + + .project-page-sidebar-block { + width: $right-sidebar-width - 1px; + + &:first-of-type { + padding-top: $gl-spacing-scale-1; + } + } + + .nav { + > li { + width: 100%; + } + + .btn { + justify-content: flex-start; + + &:not(.btn-dashed) { + box-shadow: none; + } + } + } + } + } +} diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss index d252afd0b29..bfa350097fa 100644 --- a/app/assets/stylesheets/page_bundles/projects.scss +++ b/app/assets/stylesheets/page_bundles/projects.scss @@ -235,8 +235,7 @@ } .repository-languages-bar { - height: 8px; - margin-bottom: $gl-padding; + height: 0.5rem; background-color: var(--white, $white); border-radius: $border-radius-default; @@ -407,18 +406,6 @@ } @include media-breakpoint-down(md) { - .avatar-container { - @include avatar-size(40px, 10px); - min-height: 40px; - min-width: 40px; - - .identicon.s64 { - font-size: 16px; - } - } - } - - @include media-breakpoint-down(md) { .updated-note { @include gl-mt-3; @include gl-text-right; diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index a3a62b44e98..b145d046fa4 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -31,26 +31,6 @@ $language-filter-max-height: 20rem; } } -.search-sidebar { - @include media-breakpoint-down(lg) { - max-width: 100%; - } - - @include media-breakpoint-down(xl) { - min-width: $search-sidebar-min-width; - max-width: $search-sidebar-min-width; - } - - @include media-breakpoint-up(xl) { - min-width: $search-sidebar-max-width; - max-width: $search-sidebar-max-width; - } - - .language-filter-max-height { - max-height: $language-filter-max-height; - } -} - .issue-filters { .label-filter { list-style: none; diff --git a/app/assets/stylesheets/page_bundles/terms.scss b/app/assets/stylesheets/page_bundles/terms.scss index 139627072be..727cdcf0627 100644 --- a/app/assets/stylesheets/page_bundles/terms.scss +++ b/app/assets/stylesheets/page_bundles/terms.scss @@ -26,16 +26,6 @@ justify-content: space-between; line-height: $line-height-base; - .navbar-collapse { - padding-right: 0; - flex-grow: 0; - flex-basis: auto; - - .navbar-nav { - margin: 0; - } - } - .nav li { float: none; } diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss index 66d828ed87d..5266849bb30 100644 --- a/app/assets/stylesheets/page_bundles/tree.scss +++ b/app/assets/stylesheets/page_bundles/tree.scss @@ -78,10 +78,6 @@ .btn-group { width: 100%; } - - .btn { - margin-top: 10px; - } } } diff --git a/app/assets/stylesheets/page_bundles/users.scss b/app/assets/stylesheets/page_bundles/users.scss index d4cd28504fc..76d60593c8a 100644 --- a/app/assets/stylesheets/page_bundles/users.scss +++ b/app/assets/stylesheets/page_bundles/users.scss @@ -1,48 +1,7 @@ @import 'mixins_and_variables_and_functions'; -.user-search-form { - position: relative; - - @include media-breakpoint-up(sm) { - float: right; - } - - .dropdown { - width: 100%; - margin-top: 5px; - - .dropdown-menu-toggle { - vertical-align: middle; - width: 100%; - } - - @include media-breakpoint-up(sm) { - margin-top: 0; - width: 155px; - } - } - - .form-control { - padding-right: 35px; - } - - .search-control-wrap, - .form-control { - width: 100%; - - @include media-breakpoint-up(sm) { - width: 250px; - } - } -} - .user-search-btn { - position: absolute; - right: 4px; - top: 0; - height: 35px; - padding-left: 10px; - padding-right: 10px; + top: 1px; color: $gray-darkest; background: transparent; border: 0; diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index 81e6b4c1191..ed2c7662a98 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -90,8 +90,6 @@ } a { - color: var(--gray-400, $gray-400); - &:hover, &.active { text-decoration: none; @@ -103,18 +101,16 @@ } .active > .wiki-list { - a, - .wiki-list-expand-button, - .wiki-list-collapse-button { - color: $black; - } + background-color: $gray-50; } .wiki-list { - min-height: $gl-spacing-scale-8; + padding: $gl-spacing-scale-2 $gl-spacing-scale-3; + margin-bottom: $gl-spacing-scale-1; + @include gl-rounded-base; &:hover { - background: $gray-10; + background: $gray-50; .wiki-list-create-child-button { display: block; @@ -148,8 +144,7 @@ margin: 0; } - ul.wiki-pages ul, - ul.wiki-pages li:not(.wiki-directory){ + ul.wiki-pages ul { padding-left: 20px; } @@ -162,16 +157,6 @@ } } -.right-sidebar.wiki-sidebar { - .active > .wiki-list { - a, - .wiki-list-expand-button, - .wiki-list-collapse-button { - color: $white; - } - } -} - ul.wiki-pages-list.content-list { a { color: var(--blue-600, $blue-600); diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index ec73f27ed09..b9ab2450ff9 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -215,3 +215,130 @@ $work-item-sticky-header-height: 52px; font-weight: normal; } } + +// Disclosure hierarchy component, used for Ancestors widget + +$disclosure-hierarchy-chevron-dimension: 1.2rem; + +@mixin hierarchy-active-item-color { + background-color: var(--gray-50, $gray-50); + + &::after { + background-color: var(--gray-50, $gray-50); + } +} + +@mixin hierarchy-path-chevron { + content: ''; + @include gl-absolute; + @include gl-reset-bg; + top: 0.39rem; + right: px-to-rem(-9px); + width: $disclosure-hierarchy-chevron-dimension; + height: $disclosure-hierarchy-chevron-dimension; + transform: rotate(45deg) skew(14deg, 14deg); +} + +.disclosure-hierarchy-button { + @include gl-pl-4; + @include gl-py-3; + @include gl-display-flex; + @include gl-relative; + @include gl-font-sm; + border: 1px solid var(--gray-100, $gray-100); + @include gl-border-r-none; + @include gl-border-l-none; + @include gl-line-height-normal; + padding-right: $grid-size; + max-width: $gl-spacing-scale-20; + background: var(--gray-10, $white); + + @include media-breakpoint-up(sm) { + max-width: $gl-spacing-scale-48; + } + + &::before, + &::after { + @include hierarchy-path-chevron; + border: 1px solid var(--gray-100, $gray-100); + border-color: inherit; + @include gl-border-b-transparent; + @include gl-border-l-transparent; + @include gl-reset-bg; + @include gl-rounded-top-left-small; + @include gl-rounded-bottom-right-small; + } + + &::before { + background: var(--gray-10, $white); + left: -10px; + z-index: 1; + } + + &::after { + z-index: 0; + } + + .disclosure-hierarchy-item:first-child & { + @include gl-pl-3; + border-left: 1px solid var(--gray-100, $gray-100); + @include gl-rounded-top-left-base; + @include gl-rounded-bottom-left-base; + + &::before { + @include gl-display-none; + } + + &:active, + &:focus, + &:focus:active { + // Custom focus + box-shadow: 1px 1px 0 1px $blue-400, 2px -1px 0 1px $blue-400, -1px 1px 0 1px $blue-400, -1px -1px 0 1px $blue-400 !important; + } + } + + .disclosure-hierarchy-item:last-child & { + @include gl-pr-4; + border-right: 1px solid var(--gray-100, $gray-100); + @include gl-rounded-top-right-base; + @include gl-rounded-bottom-right-base; + + &::after { + display: none; + } + } + + &[disabled] { + color: $gl-text-color-disabled; + @include gl-cursor-not-allowed; + } + + &:not([disabled]):hover { + @include gl-border-gray-400; + @include hierarchy-active-item-color; + color: var(--gray-900, $gray-900); + + &::after { + border-left: 1px solid var(--gray-50, $gray-50); + border-bottom: 1px solid var(--gray-50, $gray-50); + z-index: 3; + } + } + + &:active, + &:focus, + &:focus:active { + // Custom focus + box-shadow: 1px 1px 0 1px $blue-400, 2px -1px 0 1px $blue-400 !important; + outline: none; + border-top: 1px solid var(--gray-400, $gray-400); + border-bottom: 1px solid var(--gray-400, $gray-400); + @include hierarchy-active-item-color; + z-index: 2; + @include gl-rounded-small; + + &::before, &::after { + box-shadow: 2px -2px 0 1px $blue-400; + } + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 72ea586979f..f1055590539 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -7,7 +7,7 @@ background: none; word-break: normal; overflow-x: auto; - border-left: 3px solid $white-dark; + border-left: 3px solid $gray-100; color: $gl-text-color-secondary; } @@ -79,7 +79,7 @@ .commits-row { + .commits-row { - border-top: 1px solid $white-normal; + border-top: 1px solid $gray-50; } + .commits-empty { @@ -93,7 +93,7 @@ color: $gl-text-color-secondary; padding: 1px $gl-padding-4; cursor: pointer; - border: 1px solid $border-white-normal; + border: 1px solid $gray-100; border-radius: $border-radius-default; margin-left: 5px; font-size: 12px; diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index f6c79a4eca2..cfb964e6227 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -5,7 +5,7 @@ .event-item { font-size: $gl-font-size; padding: $gl-padding 0 $gl-padding $gl-spacing-scale-8; - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid $gray-50; color: $gl-text-color-secondary; position: relative; line-height: $gl-line-height-20; @@ -158,36 +158,6 @@ } } -@include media-breakpoint-down(xs) { - .event-item { - padding-left: 0; - - .event-user-info { - margin-bottom: $gl-padding-4; - } - - .event-title { - white-space: normal; - overflow: visible; - max-width: 100%; - } - - .system-note-image { - display: none; - } - - .event-body { - margin-top: $gl-padding-4; - margin-right: 0; - padding-left: 0; - } - - .event-item-timestamp { - display: none; - } - } -} - // hide event scope (namespace + project) where it is not necessary .project-activity { .event-scope { diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 2e1bb9b9eac..d01286bd209 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -141,7 +141,7 @@ table.pipeline-project-metrics tr td { top: 5px; bottom: 0; left: -16px; - border-left: 2px solid $border-white-normal; + border-left: 2px solid $border-color; } .group-row { @@ -152,7 +152,7 @@ table.pipeline-project-metrics tr td { display: block; width: 10px; height: 0; - border-top: 2px solid $border-white-normal; + border-top: 2px solid $border-color; position: absolute; top: 30px; left: -16px; @@ -179,7 +179,7 @@ table.pipeline-project-metrics tr td { } &:first-child { - border-top: 1px solid $white-normal; + border-top: 1px solid $gray-50; } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index e82a689fe5d..9748983d1ae 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -29,7 +29,7 @@ .issue-token:hover &, .issue-token-link:focus > & { - background-color: $border-gray-normal; + background-color: $gray-100; } } @@ -41,7 +41,7 @@ &:focus, .issue-token:hover &, .issue-token-link:focus + & { - background-color: $border-gray-normal; + background-color: $gray-100; outline: none; } } @@ -268,3 +268,25 @@ ul.related-merge-requests > li gl-emoji { .issuable-header-slide-leave-to { transform: translateY(-100%); } + +.issuable-sticky-header-visible { + --issuable-sticky-header-height: 40px; +} + +.md-header-preview { + z-index: 1; + position: sticky; + top: calc(#{$calc-application-header-height} + var(--issuable-sticky-header-height, 0px)); +} + +.detail-page-description .md-header { + top: $calc-application-header-height; +} + +.gl-drawer .md-header { + top: 0; +} + +.gl-modal .md-header { + top: -$gl-padding-8; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 38686d5e713..5d644d63666 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -56,7 +56,7 @@ &.is-dropzone-hover { border-color: $green-500; - box-shadow: 0 0 2px $black-transparent, + box-shadow: 0 0 2px $t-gray-a-24, 0 0 4px $green-500-focus; .comment-toolbar, @@ -326,7 +326,6 @@ table { .discussion-reply-holder { .reply-placeholder-text-field { - @include gl-font-monospace; border-radius: $gl-border-radius-base; width: 100%; resize: none; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 8e0fab04ab2..8792c7f9a72 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -264,11 +264,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; display: block; position: relative; - .timeline-discussion-body { - overflow-x: auto; - overflow-y: hidden; - } - .diff-content { overflow: visible; padding: 0; @@ -330,8 +325,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .note-body { padding: 0 $gl-padding-8 $gl-padding-8; - overflow-x: auto; - overflow-y: hidden; .note-text { word-wrap: break-word; @@ -747,8 +740,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .timeline-content { - overflow-x: auto; - overflow-y: hidden; border-radius: $gl-border-radius-base; padding: $gl-padding-8 !important; @include gl-border; @@ -995,7 +986,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .disabled-comment { background-color: $gray-light; border-radius: $border-radius-base; - border: 1px solid $border-gray-normal; + border: 1px solid $border-color; color: $note-disabled-comment-color; padding: $gl-padding-8 0; @@ -1136,10 +1127,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } .user-activity-content { - &::before { - @include vertical-line(80px, 25px); - background: var(--gray-50, $gray-50); - } + @include gl-relative; .system-note-image { @include gl--flex-center; @@ -1153,6 +1141,16 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } +.user-profile-activity { + @include gl-relative; + + &:not(:last-child)::before { + @include vertical-line(16px, 10px); + height: 100%; + background: var(--gray-50, $gray-50); + } +} + //This needs to be deleted when Snippet/Commit comments are convered to Vue // See https://gitlab.com/gitlab-org/gitlab-foss/issues/53918#note_117038785 .unstyled-comments { @@ -1164,3 +1162,17 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; padding: $gl-padding; } } + +.project-activity-item:not(:last-of-type) { + position: relative; + + &::before { + // Avatar width is 32px, connecting line width is 2px. + // To center the line relatively to the avatar it should be positioned 15px from the left: + // (32px (avatar size) - 2px (line thickness)) / 2 = 15px + // stylelint-disable length-zero-no-unit + @include vertical-line(0px, 15px); + top: auto; // Override top to auto align + background: var(--gray-50, $gray-50); + } +} diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 3015cfec34f..315b9c829a7 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -39,7 +39,7 @@ content: ' '; height: 100%; width: 4px; - background-color: $white-dark; + background-color: $gray-100; } position: relative; @@ -57,8 +57,6 @@ header, nav, -nav.navbar-collapse, -nav.navbar-collapse.collapse, .nav-sidebar, .super-sidebar, .profiler-results, diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index e249ecbd10b..91b381462be 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -82,12 +82,12 @@ font-size: $code-font-size; line-height: $code-line-height; white-space: nowrap; - color: $black-transparent; + color: $t-gray-a-24; min-width: 30px; } .diff-line-num:hover { - color: $almost-black; + color: $gray-950; cursor: pointer; } } @@ -158,8 +158,8 @@ border-right: 0; &:hover { - background-color: $white-normal; - border-color: $border-white-normal; + background-color: $gray-50; + border-color: $gray-100; text-decoration: none; } diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 36fa457f244..cb0da7e782d 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -1,15 +1,4 @@ -$gray-10: #1f1e24; -$gray-50: #333238; -$gray-100: #434248; -$gray-200: #535158; -$gray-300: #626168; -$gray-400: #737278; -$gray-500: #89888d; -$gray-600: #a4a3a8; -$gray-700: #bfbfc3; -$gray-800: #dcdcde; -$gray-900: #ececef; -$gray-950: #fbfafd; +@import '@gitlab/ui/dist/tokens/scss/tokens.dark'; $gray-lightest: lighten($gray-10, 1); $gray-light: lighten($gray-10, 2); @@ -19,108 +8,14 @@ $gray-dark: darken($gray-100, 2); $gray-darker: darken($gray-200, 2); $gray-darkest: $gray-700; -// Some of the other $t-gray-a variables are used -// for borders and some other places, so we cannot override -// them. These are used only for box shadows so we can -$t-gray-a-16: rgba($gray-10, 0.16); -$t-gray-a-24: rgba($gray-10, 0.24); +// Used for border and background in a couple instances where inverting between modes is desirable +// once migrated to suitable color values this can be removed +$t-gray-a-08: rgba($gray-950, 0.08); -$black: #fff; $black-normal: $gray-900; -$white: $gray-50; -$white-normal: $gray-50; -$white-dark: $gray-100; - -$green-50: #0a4020; -$green-100: #0d532a; -$green-200: #24663b; -$green-300: #217645; -$green-400: #108548; -$green-500: #2da160; -$green-600: #52b87a; -$green-700: #91d4a8; -$green-800: #c3e6cd; -$green-900: #ecf4ee; -$green-950: #f1fdf6; - -$blue-50: #033464; -$blue-100: #064787; -$blue-200: #0b5cad; -$blue-300: #1068bf; -$blue-400: #1f75cb; -$blue-500: #428fdc; -$blue-600: #63a6e9; -$blue-700: #9dc7f1; -$blue-800: #cbe2f9; -$blue-900: #e9f3fc; -$blue-950: #f2f9ff; - -$orange-50: #5c2900; -$orange-100: #703800; -$orange-200: #8f4700; -$orange-300: #9e5400; -$orange-400: #ab6100; -$orange-500: #c17d10; -$orange-600: #d99530; -$orange-700: #e9be74; -$orange-800: #f5d9a8; -$orange-900: #fdf1dd; -$orange-950: #fff4e1; - -$red-50: #660e00; -$red-100: #8d1300; -$red-200: #ae1800; -$red-300: #c91c00; -$red-400: #dd2b0e; -$red-500: #ec5941; -$red-600: #f57f6c; -$red-700: #fcb5aa; -$red-800: #fdd4cd; -$red-900: #fcf1ef; -$red-950: #fff4f3; - -$indigo-50: #1a1a40; -$indigo-100: #292961; -$indigo-200: #393982; -$indigo-300: #4b4ba3; -$indigo-400: #5b5bbd; -$indigo-500: #6666c4; -$indigo-600: #7c7ccc; -$indigo-700: #a6a6de; -$indigo-800: #d1d1f0; -$indigo-900: #ebebfa; -$indigo-950: #f7f7ff; - -$purple-50: #232150; -$purple-100: #2f2a6b; -$purple-200: #453894; -$purple-300: #5943b6; -$purple-400: #694cc0; -$purple-500: #7b58cf; -$purple-600: #9475db; -$purple-700: #ac93e6; -$purple-800: #cbbbf2; -$purple-900: #e1d8f9; -$purple-950: #f4f0ff; - -$theme-indigo-50: #1a1a40; $border-color: #4f4f4f; -$data-viz-blue-50: #2a2b59; -$data-viz-blue-100: #303470; -$data-viz-blue-200: #374291; -$data-viz-blue-300: #3f51ae; -$data-viz-blue-400: #4e65cd; -$data-viz-blue-500: #617ae2; -$data-viz-blue-600: #7992f5; -$data-viz-blue-700: #97acff; -$data-viz-blue-800: #b7c6ff; -$data-viz-blue-900: #d2dcff; -$data-viz-blue-950: #e9ebff; - -$border-white-normal: $border-color; - $gl-text-color-secondary: $gray-700; $body-bg: $gray-10; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index c0eced48171..3ab3e195b06 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -1,93 +1,10 @@ @import './themes/dark'; +@import '@gitlab/ui/dist/tokens/css/tokens.dark'; @import 'page_bundles/mixins_and_variables_and_functions'; @import './themes/theme_helper'; :root { color-scheme: dark; - --gray-10: #{$gray-10}; - --gray-50: #{$gray-50}; - --gray-100: #{$gray-100}; - --gray-200: #{$gray-200}; - --gray-300: #{$gray-300}; - --gray-400: #{$gray-400}; - --gray-500: #{$gray-500}; - --gray-600: #{$gray-600}; - --gray-700: #{$gray-700}; - --gray-800: #{$gray-800}; - --gray-900: #{$gray-900}; - --gray-950: #{$gray-950}; - - --green-50: #{$green-50}; - --green-100: #{$green-100}; - --green-200: #{$green-200}; - --green-300: #{$green-300}; - --green-400: #{$green-400}; - --green-500: #{$green-500}; - --green-600: #{$green-600}; - --green-700: #{$green-700}; - --green-800: #{$green-800}; - --green-900: #{$green-900}; - --green-950: #{$green-950}; - - --blue-50: #{$blue-50}; - --blue-100: #{$blue-100}; - --blue-200: #{$blue-200}; - --blue-300: #{$blue-300}; - --blue-400: #{$blue-400}; - --blue-500: #{$blue-500}; - --blue-600: #{$blue-600}; - --blue-700: #{$blue-700}; - --blue-800: #{$blue-800}; - --blue-900: #{$blue-900}; - --blue-950: #{$blue-950}; - - --orange-50: #{$orange-50}; - --orange-100: #{$orange-100}; - --orange-200: #{$orange-200}; - --orange-300: #{$orange-300}; - --orange-400: #{$orange-400}; - --orange-500: #{$orange-500}; - --orange-600: #{$orange-600}; - --orange-700: #{$orange-700}; - --orange-800: #{$orange-800}; - --orange-900: #{$orange-900}; - --orange-950: #{$orange-950}; - - --red-50: #{$red-50}; - --red-100: #{$red-100}; - --red-200: #{$red-200}; - --red-300: #{$red-300}; - --red-400: #{$red-400}; - --red-500: #{$red-500}; - --red-600: #{$red-600}; - --red-700: #{$red-700}; - --red-800: #{$red-800}; - --red-900: #{$red-900}; - --red-950: #{$red-950}; - - --indigo-50: #{$indigo-50}; - --indigo-100: #{$indigo-100}; - --indigo-200: #{$indigo-200}; - --indigo-300: #{$indigo-300}; - --indigo-400: #{$indigo-400}; - --indigo-500: #{$indigo-500}; - --indigo-600: #{$indigo-600}; - --indigo-700: #{$indigo-700}; - --indigo-800: #{$indigo-800}; - --indigo-900: #{$indigo-900}; - --indigo-950: #{$indigo-950}; - - --purple-50: #{$purple-50}; - --purple-100: #{$purple-100}; - --purple-200: #{$purple-200}; - --purple-300: #{$purple-300}; - --purple-400: #{$purple-400}; - --purple-500: #{$purple-500}; - --purple-600: #{$purple-600}; - --purple-700: #{$purple-700}; - --purple-800: #{$purple-800}; - --purple-900: #{$purple-900}; - --purple-950: #{$purple-950}; --dark-icon-color-purple-1: #524a68; --dark-icon-color-purple-2: #715bae; @@ -98,8 +15,6 @@ --gl-text-color: #{$gray-900}; --border-color: #{$border-color}; - --white: #{$white}; - --black: #{$black}; --gray-light: #{$gray-50}; --svg-status-bg: #{$white}; @@ -209,13 +124,6 @@ border-color: $gray-800; } -.nav-sidebar, -.toggle-sidebar-button, -.close-nav-button { - background-color: darken($gray-50, 4%); - border-right: 1px solid $gray-50; -} - .gl-avatar:not(.gl-avatar-identicon), .avatar-container, .avatar { @@ -227,83 +135,17 @@ box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity); } -.nav-sidebar { - .sidebar-sub-level-items.fly-out-list { - box-shadow: none; - border: 1px solid $border-color; - } -} - aside.right-sidebar:not(.right-sidebar-merge-requests) { background-color: $gray-10; - border-left-color: $gray-50; } :root.gl-dark { - @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white); - .terms { .logo-text { fill: var(--black); } } - - .navbar.navbar-gitlab { - background-color: var(--gray-50); - box-shadow: 0 1px 0 0 var(--gray-100); - - .navbar-sub-nav, - .navbar-nav { - li { - > a:hover, - > a:focus, - > button:hover, - > button:focus { - color: var(--gl-text-color); - background-color: var(--gray-200); - } - } - - li.active, - li.dropdown.show { - > a, - > button { - color: var(--gl-text-color); - background-color: var(--gray-200); - } - } - } - - .header-search-form { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--border-color) !important; - - &:active, - &:hover { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--blue-200) !important; - } - } - - .search { - form { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--border-color); - - &:active, - &:hover { - background-color: var(--gray-100); - box-shadow: inset 0 0 0 1px var(--blue-200); - } - - .search-input { - color: var(--gl-text-color); - } - } - } - } - .md :not(pre.code) > code { background-color: $gray-200; } diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 749120a0ecb..1a373fbfeda 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -2,18 +2,10 @@ :root { &.ui-blue { - @include gitlab-theme( - $theme-blue-200, - $theme-blue-500, - $theme-blue-700, - $theme-blue-900, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-blue-50, - $theme-blue-200, + $theme-blue-100, $theme-blue-900, $theme-blue-900, ); diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss index 70611e692cd..9a24142f286 100644 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ b/app/assets/stylesheets/themes/theme_gray.scss @@ -2,14 +2,6 @@ :root { &.ui-gray { - @include gitlab-theme( - $gray-200, - $gray-300, - $gray-500, - $gray-900, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $gray-50, diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index ae969873692..a766fdddc78 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -2,18 +2,10 @@ :root { &.ui-green { - @include gitlab-theme( - $theme-green-200, - $theme-green-500, - $theme-green-700, - $theme-green-900, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-green-50, - $theme-green-200, + $theme-green-100, $theme-green-900, $theme-green-900, ); diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index db20034419a..c94a32891f6 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -2,342 +2,35 @@ /** * Styles the GitLab application with a specific color theme */ -@mixin gitlab-theme( - $search-and-nav-links, - $accent, - $border-and-box-shadow, - $navbar-theme-color, - $navbar-theme-contrast-color -) { - // Set custom properties - - --gl-theme-accent: #{$accent}; - - $search-and-nav-links-a20: rgba($search-and-nav-links, 0.2); - $search-and-nav-links-a30: rgba($search-and-nav-links, 0.3); - $search-and-nav-links-a40: rgba($search-and-nav-links, 0.4); - $search-and-nav-links-a80: rgba($search-and-nav-links, 0.8); - - // Header - - .navbar-gitlab:not(.super-sidebar-logged-out) { - background-color: $navbar-theme-color; - - .navbar-collapse { - color: $search-and-nav-links; - } - - .container-fluid { - .navbar-toggler { - border-left: 1px solid lighten($border-and-box-shadow, 10%); - color: $search-and-nav-links; - } - } - - .navbar-sub-nav, - .navbar-nav { - > li { - > a, - > button { - &:hover, - &:focus { - background-color: $search-and-nav-links-a20; - } - } - - &.active, - &.dropdown.show { - > a, - > button { - color: $navbar-theme-color; - background-color: $navbar-theme-contrast-color; - } - } - - &.line-separator { - border-left: 1px solid $search-and-nav-links-a20; - } - } - } - - .navbar-sub-nav { - color: $search-and-nav-links; - } - - .nav { - > li { - color: $search-and-nav-links; - - &.header-search { - color: $gray-900; - } - - > a { - .notification-dot { - border: 2px solid $navbar-theme-color; - } - - &.header-help-dropdown-toggle { - .notification-dot { - background-color: $search-and-nav-links; - } - } - - &.header-user-dropdown-toggle { - .header-user-avatar { - border-color: $search-and-nav-links; - } - } - - &:hover, - &:focus { - @include media-breakpoint-up(sm) { - background-color: $search-and-nav-links-a20; - } - - svg { - fill: currentColor; - } - - .notification-dot { - will-change: border-color, background-color; - border-color: adjust-color($navbar-theme-color, $red: 33, $green: 33, $blue: 33); - } - - &.header-help-dropdown-toggle .notification-dot { - background-color: $white; - } - } - } - - &.active > a, - &.dropdown.show > a { - color: $navbar-theme-color; - background-color: $navbar-theme-contrast-color; - - &:hover { - svg { - fill: $navbar-theme-color; - } - } - - .notification-dot { - border-color: $white; - } - - &.header-help-dropdown-toggle { - .notification-dot { - background-color: $navbar-theme-color; - } - } - } - - .impersonated-user, - .impersonated-user:hover { - svg { - fill: $navbar-theme-color; - } - } - } - } - } - - .navbar .title { - > a { - &:hover, - &:focus { - background-color: $search-and-nav-links-a20; - } - } - } - - .header-search-form { - background-color: $search-and-nav-links-a20 !important; - border-radius: $border-radius-default; - - &:hover { - background-color: $search-and-nav-links-a30 !important; - } - - &.is-focused { - input { - background-color: $white; - color: $gl-text-color !important; - box-shadow: inset 0 0 0 1px $gray-900; - - &:focus { - box-shadow: inset 0 0 0 1px $gray-900, 0 0 0 1px $white, 0 0 0 3px $blue-400; - } - - &::placeholder { - color: $gray-400; - } - } - } - - svg.gl-search-box-by-type-search-icon { - color: $search-and-nav-links-a80; - } - - input { - background-color: transparent; - color: $search-and-nav-links-a80; - box-shadow: inset 0 0 0 1px $search-and-nav-links-a40; - - &::placeholder { - color: $search-and-nav-links-a80; - } - - &:focus, - &:active { - &::placeholder { - color: $gray-400; - } - } - } - - .keyboard-shortcut-helper { - color: $search-and-nav-links; - background-color: $search-and-nav-links-a20; - } - } - - .search { - form { - background-color: $search-and-nav-links-a20; - - &:hover { - background-color: $search-and-nav-links-a30; - } - } - - .search-input::placeholder { - color: $search-and-nav-links-a80; - } - - .search-input-wrap { - .search-icon, - .clear-icon { - fill: $search-and-nav-links-a80; - } - } - - &.search-active { - form { - background-color: $white; - } - - .search-input-wrap { - .search-icon { - fill: $search-and-nav-links-a80; - } - } - } - } - - .search-sidebar { - .nav-link { - &.active, - &:hover { - background-color: rgba($gray-50, 0.8); - color: $gray-900; - } - } - } - - // Sidebar - .nav-sidebar li.active > a { - color: $gray-900; - } - - .nav-sidebar { - .fly-out-top-item { - a, - a:hover, - &.active a, - .fly-out-top-item-container { - background-color: var(--gray-100, $gray-50); - color: var(--gray-900, $gray-900); - } - } - } - - .branch-header-title { - color: $border-and-box-shadow; - } - - .ide-sidebar-link { - &.active { - color: $border-and-box-shadow; - - &.is-right { - box-shadow: inset -3px 0 $border-and-box-shadow; - } - } - } -} - @mixin gitlab-theme-super-sidebar( $theme-color-lightest, $theme-color-light, $theme-color, $theme-color-darkest, ) { - --sidebar-background: #{mix(white, $theme-color-lightest, 50%)}; - --transparent-white-16: rgba(255, 255, 255, 0.16); - --transparent-white-24: rgba(255, 255, 255, 0.24); - .super-sidebar { - background-color: var(--sidebar-background); - } - - .super-sidebar .user-bar { - background-color: $theme-color; - - .counter { - background-color: var(--transparent-white-16) !important; - } - - .brand-logo, - .btn-default-tertiary, - .counter { - color: $theme-color-lightest; - mix-blend-mode: normal; - - &:hover, - &:focus { - background-color: var(--transparent-white-24) !important; - color: $white; - } - - .gl-icon { - color: $theme-color-light; - } - } - } - - .super-sidebar hr { - mix-blend-mode: multiply; - } - - .btn-with-notification { - &:hover, - &:focus { + --super-sidebar-bg: #{mix(white, $theme-color-lightest, 50%)}; + --super-sidebar-user-bar-bg: #{$theme-color}; + --super-sidebar-primary: #{$theme-color}; + --super-sidebar-notification-dot: #{$theme-color-darkest}; + + --super-sidebar-user-bar-button-bg: #{$t-white-a-16}; + --super-sidebar-user-bar-button-color: #{$theme-color-lightest}; + --super-sidebar-user-bar-button-border-color: #{$t-white-a-16}; + --super-sidebar-user-bar-button-hover-bg: #{$t-white-a-24}; + --super-sidebar-user-bar-button-hover-color: #{$white}; + --super-sidebar-user-bar-button-active-bg: #{$t-white-a-36}; + + --super-sidebar-user-bar-button-icon-color: #{$theme-color-light}; + --super-sidebar-user-bar-button-icon-hover-color: #{$theme-color-light}; + --super-sidebar-user-bar-button-icon-mix-blend-mode: screen; + + hr { mix-blend-mode: multiply; } - .notification-dot-info { - background-color: $theme-color-darkest; - border-color: $theme-color-lightest; - + .super-sidebar-context-header { + color: var(--super-sidebar-primary); } } - - .active-indicator { - background-color: $theme-color; - } - - .super-sidebar-context-header { - color: $theme-color; - } } diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index d7e8ddadf46..d0a8d597b59 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -2,18 +2,10 @@ :root { &.ui-indigo { - @include gitlab-theme( - $indigo-200, - $indigo-500, - $indigo-700, - $indigo-900, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-indigo-50, - $theme-indigo-200, + $theme-indigo-100, $theme-indigo-900, $theme-indigo-900, ); diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 430960f563f..e712b6ae859 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -2,18 +2,10 @@ :root { &.ui-light-blue { - @include gitlab-theme( - $theme-light-blue-200, - $theme-light-blue-500, - $theme-light-blue-500, - $theme-light-blue-700, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-light-blue-50, - $theme-light-blue-200, + $theme-light-blue-100, $theme-light-blue-700, $theme-light-blue-900, ); diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index f63da3f22f1..5cb9bee37b0 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -1,102 +1,2 @@ -@import './theme_helper'; - -:root { - &.ui-light-gray { - @include gitlab-theme( - $gray-500, - $gray-700, - $gray-500, - $gray-50, - $gray-500 - ); - - .navbar-gitlab:not(.super-sidebar-logged-out) { - background-color: $gray-50; - box-shadow: 0 1px 0 0 $border-color; - - .logo-text { - fill: #171321; - } - - .navbar-sub-nav, - .navbar-nav { - > li { - > a:hover, - > a:focus, - > button:hover { - color: $gray-900; - } - - &.active > a, - &.active > a:hover, - &.active > button { - color: $white; - } - - > a, - > button { - &:active, - &:focus { - @include gl-focus; - } - } - } - } - - .container-fluid { - .navbar-toggler, - .navbar-toggler:hover { - color: $gray-500; - border-left: 1px solid $gray-100; - } - } - } - - .header-search-form { - background-color: $white !important; - box-shadow: inset 0 0 0 1px $border-color !important; - border-radius: $border-radius-default; - - &:hover { - background-color: $white !important; - box-shadow: inset 0 0 0 1px $blue-200 !important; - } - } - - .search { - form { - background-color: $white; - box-shadow: inset 0 0 0 1px $border-color; - - &:hover { - background-color: $white; - box-shadow: inset 0 0 0 1px $blue-200; - } - } - - .search-input-wrap { - .search-icon { - fill: $gray-100; - } - - .search-input { - color: $gl-text-color; - } - } - } - - .nav-sidebar li.active { - > a { - color: $gray-900; - } - - svg { - fill: $gray-900; - } - } - - .sidebar-top-level-items > li.active .badge.badge-pill { - color: $gray-900; - } - } -} +// "Light gray" is the default unthemed state of the sidebar. +// Nothing to do here. diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 05adc56c36a..44e19b02e36 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -2,18 +2,10 @@ :root { &.ui-light-green { - @include gitlab-theme( - $theme-green-200, - $theme-green-500, - $theme-green-500, - $theme-green-700, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-green-50, - $theme-green-200, + $theme-green-100, $theme-green-700, $theme-green-900, ); diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index 04bcfaf8366..ab299ca9d84 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -2,18 +2,10 @@ :root { &.ui-light-indigo { - @include gitlab-theme( - $indigo-200, - $indigo-500, - $indigo-500, - $indigo-700, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-indigo-50, - $theme-indigo-200, + $theme-indigo-100, $theme-indigo-700, $theme-indigo-900, ); diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index c4952b8e155..499cdace772 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -2,18 +2,10 @@ :root { &.ui-light-red { - @include gitlab-theme( - $theme-light-red-200, - $theme-light-red-500, - $theme-light-red-500, - $theme-light-red-700, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-light-red-50, - $theme-light-red-200, + $theme-light-red-100, $theme-light-red-700, $theme-light-red-900, ); diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index 536963e12ef..9a17f98aa80 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -2,18 +2,10 @@ :root { &.ui-red { - @include gitlab-theme( - $theme-red-200, - $theme-red-500, - $theme-red-700, - $theme-red-900, - $white - ); - .page-with-super-sidebar { @include gitlab-theme-super-sidebar( $theme-red-50, - $theme-red-200, + $theme-red-100, $theme-red-900, $theme-red-900, ); diff --git a/app/assets/stylesheets/tmp_utilities.scss b/app/assets/stylesheets/tmp_utilities.scss deleted file mode 100644 index 96464aa5a39..00000000000 --- a/app/assets/stylesheets/tmp_utilities.scss +++ /dev/null @@ -1,32 +0,0 @@ -/** - * DISCLAIMER - * This is a temporary stylesheet meant to assist in migrating away from desktop-first responsive - * CSS utilities. - * DO NOT add utils in here unless you are actively taking part in in the migration. - * We needed this new file for temporary utils to be defined _after_ the main, non-responsive - * GitLab UI util. - * This file is scheduled to be removed by the end of 2023. - */ - .gl-sm-w-25p { - @include gl-media-breakpoint-up(sm) { - width: 25%; - } -} - -.gl-sm-w-30p { - @include gl-media-breakpoint-up(sm) { - width: 30%; - } -} - -.gl-sm-w-40p { - @include gl-media-breakpoint-up(sm) { - width: 40%; - } -} - -.gl-sm-w-75p { - @include gl-media-breakpoint-up(sm) { - width: 75%; - } -} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 347b8e20ab4..79ea8d3cc70 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -37,7 +37,7 @@ .border-color-default { border-color: $border-color; } .border-radius-default { border-radius: $border-radius-default; } .border-radius-small { border-radius: $border-radius-small; } -.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } +.box-shadow-default { box-shadow: 0 2px 4px 0 $t-gray-a-24; } // Override Bootstrap class with offset for system-header and // performance bar when present diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss index cf7dc79c5f5..6a2f37beed0 100644 --- a/app/assets/stylesheets/vendors/atwho.scss +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -1,27 +1,24 @@ .atwho-view { overflow-y: auto; overflow-x: hidden; - max-width: calc(100% - 6px); + min-width: $gl-new-dropdown-min-width; + max-width: $gl-new-dropdown-max-width; + @include gl-border-b-1; @include gl-border-b-solid; @include gl-border-b-gray-100; @include gl-rounded-lg; @include gl-shadow-md; - .name, - small.aliases, - small.params { - float: left; - } - small.aliases, - small.params { - padding: 2px 5px; + small { + @include gl-font-sm; } small.description { - float: right; - padding: 3px 5px; + display: block; + width: auto; + @include gl-mt-2; } .avatar-inline { @@ -42,24 +39,22 @@ } } - ul > li { - @include clearfix; - white-space: nowrap; - } - // TODO: fallback to global style .atwho-view-ul { - @include gl-p-2; + @include gl-py-2; max-height: $gl-max-dropdown-max-height; li { - @include gl-px-3; - padding-top: $gl-padding-6; - padding-bottom: $gl-padding-6; border: 0; - @include gl-rounded-base; + padding: $gl-padding-6; + + @include gl-my-2; + @include gl-mx-3; + @include gl-rounded-small; + @include gl-line-height-normal; &.cur { + @include gl-focus; background-color: $gray-darker; color: $gl-text-color; @@ -78,16 +73,18 @@ align-items: center; } - .center { - line-height: 14px; - } - strong { color: $gl-text-color; } gl-emoji { @include gl-mr-2; + vertical-align: text-top; + + img { + margin-block: -0.1em; + top: 0.05em; + } } .dropdown-label-box { diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index bdd9d00ca7f..0bb9ed2fe2f 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -3,13 +3,14 @@ module ApplicationCable class Connection < ActionCable::Connection::Base include Logging + include Gitlab::Auth::AuthFinders identified_by :current_user public :request def connect - self.current_user = find_user_from_session_store + self.current_user = find_user_from_bearer_token || find_user_from_session_store end private diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml index ebb88b305dc..ce380d611a0 100644 --- a/app/components/pajamas/banner_component.html.haml +++ b/app/components/pajamas/banner_component.html.haml @@ -1,8 +1,9 @@ -# This is using gl-card classes to match Vue component -# Here's the issue to refactor away from gl-card -# https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2324 + .gl-banner.gl-card.gl-pl-6.gl-pr-8.gl-py-6{ @banner_options, class: banner_class } - .gl-display-flex + .gl-card-body.gl-display-flex.gl-p-0! - if illustration? .gl-banner-illustration = illustration @@ -11,7 +12,7 @@ = image_tag @svg_path, alt: "" .gl-banner-content - %h1.gl-banner-title= title + %h2.gl-banner-title= title = content diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb index 5291db91fb2..4fee13948cb 100644 --- a/app/components/pajamas/banner_component.rb +++ b/app/components/pajamas/banner_component.rb @@ -4,7 +4,6 @@ module Pajamas class BannerComponent < Pajamas::Component # @param [String] button_text # @param [String] button_link - # @param [Boolean] embedded # @param [Symbol] variant # @param [String] svg_path # @param [Hash] banner_options @@ -13,7 +12,6 @@ module Pajamas def initialize( button_text: 'OK', button_link: '#', - embedded: false, variant: :promotion, svg_path: nil, banner_options: {}, @@ -22,7 +20,6 @@ module Pajamas ) @button_text = button_text @button_link = button_link - @embedded = embedded @variant = filter_attribute(variant.to_sym, VARIANT_OPTIONS, default: :promotion) @svg_path = svg_path.to_s @banner_options = banner_options @@ -36,7 +33,7 @@ module Pajamas def banner_class classes = [] - classes.push('gl-border-none') if @embedded + classes.push('gl-bg-gray-10!') unless introduction? classes.push('gl-banner-introduction') if introduction? classes.join(' ') end diff --git a/app/components/pajamas/button_component.html.haml b/app/components/pajamas/button_component.html.haml index 5cf57deb7f1..00aeafca8c1 100644 --- a/app/components/pajamas/button_component.html.haml +++ b/app/components/pajamas/button_component.html.haml @@ -7,7 +7,11 @@ %span.gl-button-text{ class: @button_text_classes } = content -- if link? +- if form? + -# workaround for link_to dropping snowplow tracking. Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/417815 + = button_to @href, { **@button_options, **base_attributes, class: button_class, target: @target, method: @method } do + = content_for :pajamas_button_content +- elsif link? = link_to @href, { **@button_options, **base_attributes, class: button_class, target: @target, method: @method } do = content_for :pajamas_button_content - else diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb index cdfd201bfb8..dc43cee0801 100644 --- a/app/components/pajamas/button_component.rb +++ b/app/components/pajamas/button_component.rb @@ -12,6 +12,7 @@ module Pajamas # @param [Boolean] selected # @param [String] icon # @param [String] href + # @param [Boolean] form # @param [String] target # @param [Symbol] method # @param [Hash] button_options @@ -28,6 +29,7 @@ module Pajamas selected: false, icon: nil, href: nil, + form: false, target: nil, method: nil, button_options: {}, @@ -44,6 +46,7 @@ module Pajamas @selected = selected @icon = icon @href = href + @form = form @target = filter_attribute(target, TARGET_OPTIONS) @method = filter_attribute(method, METHOD_OPTIONS) @button_options = button_options @@ -109,6 +112,10 @@ module Pajamas @href.present? end + def form? + @href.present? && @form.present? + end + def base_attributes attributes = {} diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb index 5754c2a1fa9..5e37777eb61 100644 --- a/app/components/projects/ml/models_index_component.rb +++ b/app/components/projects/ml/models_index_component.rb @@ -26,6 +26,7 @@ module Projects paginator.records.map(&:present).map do |m| { name: m.name, + path: m.path, version: m.latest_version_name, version_count: m.version_count, version_package_path: m.latest_package_path, diff --git a/app/components/projects/ml/show_ml_model_component.rb b/app/components/projects/ml/show_ml_model_component.rb index d349c0a22e9..11a36a78b18 100644 --- a/app/components/projects/ml/show_ml_model_component.rb +++ b/app/components/projects/ml/show_ml_model_component.rb @@ -3,10 +3,11 @@ module Projects module Ml class ShowMlModelComponent < ViewComponent::Base - attr_reader :model + attr_reader :model, :current_user - def initialize(model:) + def initialize(model:, current_user:) @model = model.present + @current_user = current_user end private @@ -17,9 +18,10 @@ module Projects id: model.id, name: model.name, path: model.path, - description: "This is a placeholder for the short description", + description: model.description, latest_version: latest_version_view_model, - version_count: model.version_count + version_count: model.version_count, + candidate_count: model.candidate_count } } @@ -29,8 +31,14 @@ module Projects def latest_version_view_model return unless model.latest_version + model_version = model.latest_version + { - version: model.latest_version.version + version: model_version.version, + description: model_version.description, + project_path: project_path(model_version.project), + package_id: model_version.package_id, + **::Ml::CandidateDetailsPresenter.new(model_version.candidate, current_user).present } end end diff --git a/app/components/projects/ml/show_ml_model_version_component.rb b/app/components/projects/ml/show_ml_model_version_component.rb index ae81642a891..a4c641f6d66 100644 --- a/app/components/projects/ml/show_ml_model_version_component.rb +++ b/app/components/projects/ml/show_ml_model_version_component.rb @@ -3,11 +3,12 @@ module Projects module Ml class ShowMlModelVersionComponent < ViewComponent::Base - attr_reader :model_version, :model + attr_reader :model_version, :model, :current_user - def initialize(model_version:) + def initialize(model_version:, current_user:) @model_version = model_version.present @model = model_version.model.present + @current_user = current_user end private @@ -18,15 +19,23 @@ module Projects id: model_version.id, version: model_version.version, path: model_version.path, + description: model_version.description, + project_path: project_path(model_version.project), + package_id: model_version.package_id, model: { name: model.name, path: model.path - } + }, + **candidate_data } } Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) end + + def candidate_data + ::Ml::CandidateDetailsPresenter.new(model_version.candidate, current_user).present + end end end end diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb index 4a7706db94e..a187e43b3df 100644 --- a/app/controllers/acme_challenges_controller.rb +++ b/app/controllers/acme_challenges_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class AcmeChallengesController < ActionController::Base +class AcmeChallengesController < BaseActionController def show if acme_order render plain: acme_order.challenge_file_content, content_type: 'text/plain' @@ -16,4 +15,3 @@ class AcmeChallengesController < ActionController::Base @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token]) end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb index f9c2b14fe77..70cf881c857 100644 --- a/app/controllers/activity_pub/application_controller.rb +++ b/app/controllers/activity_pub/application_controller.rb @@ -8,6 +8,8 @@ module ActivityPub skip_before_action :authenticate_user! after_action :set_content_type + protect_from_forgery with: :null_session + def can?(object, action, subject = :global) Ability.allowed?(object, action, subject) end diff --git a/app/controllers/activity_pub/projects/releases_controller.rb b/app/controllers/activity_pub/projects/releases_controller.rb index 7c4c2a0322b..eeff96a5ef7 100644 --- a/app/controllers/activity_pub/projects/releases_controller.rb +++ b/app/controllers/activity_pub/projects/releases_controller.rb @@ -5,15 +5,27 @@ module ActivityPub class ReleasesController < ApplicationController feature_category :release_orchestration + before_action :enforce_payload, only: :inbox + def index opts = { - inbox: nil, + inbox: inbox_project_releases_url(@project), outbox: outbox_project_releases_url(@project) } render json: ActivityPub::ReleasesActorSerializer.new.represent(@project, opts) end + def inbox + service = inbox_service + success = service ? service.execute : true + + response = { success: success } + response[:errors] = service.errors unless success + + render json: response + end + def outbox serializer = ActivityPub::ReleasesOutboxSerializer.new.with_pagination(request, response) render json: serializer.represent(releases) @@ -24,6 +36,39 @@ module ActivityPub def releases(params = {}) ReleasesFinder.new(@project, current_user, params).execute end + + def enforce_payload + return if payload + + head :unprocessable_entity + false + end + + def payload + @payload ||= begin + Gitlab::Json.parse(request.body.read) + rescue JSON::ParserError + nil + end + end + + def follow? + payload['type'] == 'Follow' + end + + def unfollow? + undo = payload['type'] == 'Undo' + object = payload['object'] + follow = object.present? && object.is_a?(Hash) && object['type'] == 'Follow' + undo && follow + end + + def inbox_service + return ReleasesFollowService.new(project, payload) if follow? + return ReleasesUnfollowService.new(project, payload) if unfollow? + + nil + end end end end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8cf0ab60fd3..cd099173718 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -164,10 +164,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController params.delete(:domain_denylist_raw) if params[:domain_denylist] params.delete(:domain_allowlist_raw) if params[:domain_allowlist] - if params[:application_setting].key?(:user_email_lookup_limit) - params[:application_setting][:search_rate_limit] ||= params[:application_setting][:user_email_lookup_limit] - end - params[:application_setting].permit(visible_application_setting_attributes) end @@ -183,6 +179,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController *::ApplicationSettingsHelper.visible_attributes, *::ApplicationSettingsHelper.external_authorization_service_attributes, *ApplicationSetting.kroki_formats_attributes.keys.map { |key| "kroki_formats_#{key}".to_sym }, + :can_create_organization, :lets_encrypt_notification_email, :lets_encrypt_terms_of_service_accepted, :domain_denylist_file, diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb index 052c8821588..ead02a7aa8c 100644 --- a/app/controllers/admin/clusters_controller.rb +++ b/app/controllers/admin/clusters_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Admin::ClustersController < Clusters::ClustersController +class Admin::ClustersController < ::Clusters::ClustersController include EnforcesAdminAuthentication before_action :ensure_feature_enabled! diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb index b24b25446b0..a9b34bf56f6 100644 --- a/app/controllers/admin/deploy_keys_controller.rb +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -55,7 +55,7 @@ class Admin::DeployKeysController < Admin::ApplicationController end def create_params - params.require(:deploy_key).permit(:key, :title) + params.require(:deploy_key).permit(:key, :title, :expires_at) end def update_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6739fc57a1f..fca3bb3460f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ require 'gon' require 'fogbugz' -class ApplicationController < ActionController::Base +class ApplicationController < BaseActionController include Gitlab::GonHelper include Gitlab::NoCacheHeaders include GitlabRoutingHelper @@ -25,7 +25,6 @@ class ApplicationController < ActionController::Base include FlocOptOut include CheckRateLimit include RequestPayloadLogger - extend ContentSecurityPolicyPatch before_action :limit_session_time, if: -> { !current_user } before_action :authenticate_user!, except: [:route_not_found] @@ -113,33 +112,6 @@ class ApplicationController < ActionController::Base render plain: e.message, status: :service_unavailable end - content_security_policy do |p| - next if p.directives.blank? - - if Rails.env.development? && Feature.enabled?(:vite) - vite_host = ViteRuby.instance.config.host - vite_port = ViteRuby.instance.config.port - vite_origin = "#{vite_host}:#{vite_port}" - http_origin = "http://#{vite_origin}" - ws_origin = "ws://#{vite_origin}" - wss_origin = "wss://#{vite_origin}" - gitlab_ws_origin = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, 'vite-dev/') - http_path = Gitlab::Utils.append_path(http_origin, 'vite-dev/') - - connect_sources = p.directives['connect-src'] - p.connect_src(*(Array.wrap(connect_sources) | [ws_origin, wss_origin, http_path])) - - worker_sources = p.directives['worker-src'] - p.worker_src(*(Array.wrap(worker_sources) | [gitlab_ws_origin, http_path])) - end - - next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank? - - default_connect_src = p.directives['connect-src'] || p.directives['default-src'] - connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.snowplow_collector_hostname] - p.connect_src(*connect_src_values) - end - def redirect_back_or_default(default: root_path, options: {}) redirect_back(fallback_location: default, **options) end @@ -325,7 +297,7 @@ class ApplicationController < ActionController::Base return if current_user.nil? if current_user.password_expired? && current_user.allow_password_authentication? - redirect_to new_profile_password_path + redirect_to new_user_settings_password_path end end diff --git a/app/controllers/base_action_controller.rb b/app/controllers/base_action_controller.rb new file mode 100644 index 00000000000..e251c44f63d --- /dev/null +++ b/app/controllers/base_action_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# GitLab lightweight base action controller +# +# This class should be limited to content that +# is desired/required for *all* controllers in +# GitLab. +# +# Most controllers inherit from `ApplicationController`. +# Some controllers don't want or need all of that +# logic and instead inherit from `ActionController::Base`. +# This makes it difficult to set security headers and +# handle other critical logic across *all* controllers. +# +# Between this controller and `ApplicationController` +# no controller should ever inherit directly from +# `ActionController::Base` +# +# rubocop:disable Rails/ApplicationController -- This class is specifically meant as a base class for controllers that +# don't inherit from ApplicationController +# rubocop:disable Gitlab/NamespacedClass -- Base controllers live in the global namespace +class BaseActionController < ActionController::Base + extend ContentSecurityPolicyPatch + + content_security_policy do |p| + next if p.directives.blank? + + if helpers.vite_enabled? + vite_port = ViteRuby.instance.config.port + vite_origin = "#{Gitlab.config.gitlab.host}:#{vite_port}" + http_origin = "http://#{vite_origin}" + ws_origin = "ws://#{vite_origin}" + wss_origin = "wss://#{vite_origin}" + gitlab_ws_origin = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, 'vite-dev/') + http_path = Gitlab::Utils.append_path(http_origin, 'vite-dev/') + + connect_sources = p.directives['connect-src'] + p.connect_src(*(Array.wrap(connect_sources) | [ws_origin, wss_origin, http_path])) + + worker_sources = p.directives['worker-src'] + p.worker_src(*(Array.wrap(worker_sources) | [gitlab_ws_origin, http_path])) + end + + next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank? + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.snowplow_collector_hostname] + p.connect_src(*connect_src_values) + end +end +# rubocop:enable Gitlab/NamespacedClass +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index 7328b793b09..b61a8c5ff12 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class ChaosController < ActionController::Base +class ChaosController < BaseActionController before_action :validate_chaos_secret, unless: :development_or_test? def leakmem @@ -95,4 +94,3 @@ class ChaosController < ActionController::Base Rails.env.development? || Rails.env.test? end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/clusters/agents/dashboard_controller.rb b/app/controllers/clusters/agents/dashboard_controller.rb index 1f72aaa4775..7016ebacfba 100644 --- a/app/controllers/clusters/agents/dashboard_controller.rb +++ b/app/controllers/clusters/agents/dashboard_controller.rb @@ -6,15 +6,15 @@ module Clusters include KasCookie before_action :check_feature_flag! - before_action :find_agent - before_action :authorize_read_cluster_agent! + before_action :find_agent, only: [:show], if: -> { current_user } + before_action :authorize_read_cluster_agent!, only: [:show], if: -> { current_user } before_action :set_kas_cookie, only: [:show], if: -> { current_user } feature_category :deployment_management - def show - head :ok - end + def index; end + + def show; end private diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb index e7b76b87ad9..495b6463383 100644 --- a/app/controllers/clusters/base_controller.rb +++ b/app/controllers/clusters/base_controller.rb @@ -4,6 +4,7 @@ class Clusters::BaseController < ApplicationController include RoutableActions skip_before_action :authenticate_user! + before_action :clusterable before_action :authorize_admin_cluster!, except: [:show, :index, :new, :authorize_aws_role, :update] helper_method :clusterable @@ -41,6 +42,11 @@ class Clusters::BaseController < ApplicationController access_denied! unless can?(current_user, :read_prometheus, clusterable) end + # For Group/Clusters and Project/Clusters, the clusterable object (group or project) + # is fetched through `find_routable!`, which calls a `render_404` if the user does not have access to the object + # The `clusterable` method will need to be in its own before_action call before the `authorize_*` calls + # so that the call stack will not proceed to the `authorize_*` calls + # and instead just render a not found page after the `clusterable` call def clusterable raise NotImplementedError end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index b012a4e003e..917007144fa 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Clusters::ClustersController < Clusters::BaseController +class Clusters::ClustersController < ::Clusters::BaseController include RoutableActions before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache] diff --git a/app/controllers/concerns/autocomplete_sources/expires_in.rb b/app/controllers/concerns/autocomplete_sources/expires_in.rb new file mode 100644 index 00000000000..d61cd88bd91 --- /dev/null +++ b/app/controllers/concerns/autocomplete_sources/expires_in.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module AutocompleteSources + module ExpiresIn + extend ActiveSupport::Concern + + AUTOCOMPLETE_EXPIRES_IN = 3.minutes + AUTOCOMPLETE_CACHED_ACTIONS = [:members, :commands, :labels].freeze + + included do + before_action :set_expires_in, only: AUTOCOMPLETE_CACHED_ACTIONS + end + + private + + def set_expires_in + case action_name.to_sym + when :members + expires_in AUTOCOMPLETE_EXPIRES_IN if Feature.enabled?(:cache_autocomplete_sources_members, current_user) + when :commands + expires_in AUTOCOMPLETE_EXPIRES_IN if Feature.enabled?(:cache_autocomplete_sources_commands, current_user) + when :labels + expires_in AUTOCOMPLETE_EXPIRES_IN if Feature.enabled?(:cache_autocomplete_sources_labels, current_user) + end + end + end +end diff --git a/app/controllers/concerns/import/github_oauth.rb b/app/controllers/concerns/import/github_oauth.rb index ae5a0401155..fa6162254dc 100644 --- a/app/controllers/concerns/import/github_oauth.rb +++ b/app/controllers/concerns/import/github_oauth.rb @@ -56,7 +56,11 @@ module Import session[:auth_on_failure_path] = "#{new_project_path}#import_project" oauth_client.auth_code.authorize_url( redirect_uri: callback_import_url, - scope: 'repo, user, user:email', + # read:org only required for collaborator import, which is optional, + # but at the time of this OAuth request we do not know which optional + # configuration the user will select because the options are only shown + # after authenticating + scope: 'repo, read:org', state: state ) end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index b4f5589a059..042adc8479e 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -4,6 +4,11 @@ module MembershipActions include MembersPresentation extend ActiveSupport::Concern + included do + before_action :authenticate_user!, only: :request_access + before_action :already_a_member!, only: :request_access + end + def update member = members_and_requesters.find(params[:id]) result = Members::UpdateService @@ -166,6 +171,20 @@ module MembershipActions end end end + + def authenticate_user! + return if current_user + + redirect_to new_user_session_path + end + + def already_a_member! + member = members_and_requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord + return if member.nil? + + message = member.request? ? _('You have already requested access.') : _('You already have access.') + redirect_to polymorphic_path(membershipable), notice: message + end end MembershipActions.prepend_mod_with('MembershipActions') diff --git a/app/controllers/concerns/onboarding/redirectable.rb b/app/controllers/concerns/onboarding/redirectable.rb index 7e669db9199..15c1847ebe4 100644 --- a/app/controllers/concerns/onboarding/redirectable.rb +++ b/app/controllers/concerns/onboarding/redirectable.rb @@ -9,7 +9,7 @@ module Onboarding def after_sign_up_path if onboarding_status.single_invite? flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) - onboarding_status.last_invited_member_source.activity_path + polymorphic_path(onboarding_status.last_invited_member_source) else # Invites will come here if there is more than 1. path_for_signed_in_user @@ -17,13 +17,13 @@ module Onboarding end def path_for_signed_in_user - stored_location_for(:user) || last_member_activity_path + stored_location_for(:user) || last_member_source_path end - def last_member_activity_path + def last_member_source_path return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? - onboarding_status.last_invited_member_source.activity_path + polymorphic_path(onboarding_status.last_invited_member_source) end end end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index d4610267897..e148f5d063a 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -14,7 +14,7 @@ module ProductAnalyticsTracking end def track_internal_event(*controller_actions, name:, conditions: nil) - custom_conditions = [:trackable_html_request?, :authenticated?, *conditions] + custom_conditions = [:trackable_html_request?, *conditions] after_action only: controller_actions, if: custom_conditions do Gitlab::InternalEvents.track_event( @@ -70,8 +70,4 @@ module ProductAnalyticsTracking cookies[:visitor_id] = { value: uuid, expires: 24.months } uuid end - - def authenticated? - current_user.present? - end end diff --git a/app/controllers/explore/catalog_controller.rb b/app/controllers/explore/catalog_controller.rb index 3cd3771129e..d384ad10c86 100644 --- a/app/controllers/explore/catalog_controller.rb +++ b/app/controllers/explore/catalog_controller.rb @@ -2,8 +2,14 @@ module Explore class CatalogController < Explore::ApplicationController + include ProductAnalyticsTracking + feature_category :pipeline_composition - before_action :check_feature_flag + before_action :check_resource_access, only: :show + track_internal_event :index, name: 'unique_users_visiting_ci_catalog' + before_action do + push_frontend_feature_flag(:ci_catalog_components_tab, current_user) + end def show; end @@ -13,8 +19,20 @@ module Explore private - def check_feature_flag - render_404 unless Feature.enabled?(:global_ci_catalog, current_user) + def check_resource_access + render_404 unless catalog_resource.present? + end + + def catalog_resource + ::Ci::Catalog::Listing.new(current_user).find_resource(full_path: params[:full_path]) + end + + def tracking_namespace_source + current_user.namespace + end + + def tracking_project_source + nil end end end diff --git a/app/controllers/external_redirect/external_redirect_controller.rb b/app/controllers/external_redirect/external_redirect_controller.rb index 532196157b7..8d5f07ad3eb 100644 --- a/app/controllers/external_redirect/external_redirect_controller.rb +++ b/app/controllers/external_redirect/external_redirect_controller.rb @@ -11,7 +11,6 @@ module ExternalRedirect redirect_to url_param else render layout: 'fullscreen', locals: { - minimal: true, url: url_param } end @@ -29,8 +28,13 @@ module ExternalRedirect uri_data.site == Gitlab.config.gitlab.url end + def should_handle_url?(url) + # note: To avoid lots of redirects, don't allow url to point to self. + ::Gitlab::UrlSanitizer.valid_web?(url) && !url.starts_with?(request.base_url + request.path) + end + def check_url_param - render_404 unless ::Gitlab::UrlSanitizer.valid_web?(url_param) + render_404 unless should_handle_url?(url_param) end end end diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 86bf65f4723..7a490b34511 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Groups::AutocompleteSourcesController < Groups::ApplicationController + include AutocompleteSources::ExpiresIn + feature_category :groups_and_projects, [:members] feature_category :team_planning, [:issues, :labels, :milestones, :commands] feature_category :code_review_workflow, [:merge_requests] @@ -8,11 +10,6 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :merge_requests, :members] def members - if Feature.enabled?(:cache_autocomplete_sources_members, current_user) - # Cache the response on the frontend - expires_in 3.minutes - end - render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 6bb807be1c4..7cc0e6a8558 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -8,6 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController before_action do push_frontend_feature_flag(:board_multi_select, group) push_frontend_feature_flag(:apollo_boards, group) + push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, group) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.control {} e.candidate {} diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb index 2fe9faa252f..5b1508f44a2 100644 --- a/app/controllers/groups/clusters_controller.rb +++ b/app/controllers/groups/clusters_controller.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -class Groups::ClustersController < Clusters::ClustersController +class Groups::ClustersController < ::Clusters::ClustersController include ControllerWithCrossProjectAccessCheck before_action :ensure_feature_enabled! - prepend_before_action :group requires_cross_project_access layout 'group' @@ -12,7 +11,7 @@ class Groups::ClustersController < Clusters::ClustersController private def clusterable - @clusterable ||= ClusterablePresenter.fabricate(group, current_user: current_user) + @clusterable ||= group && ClusterablePresenter.fabricate(group, current_user: current_user) end def group diff --git a/app/controllers/groups/work_items_controller.rb b/app/controllers/groups/work_items_controller.rb index ece279da778..bfb5b74d2a5 100644 --- a/app/controllers/groups/work_items_controller.rb +++ b/app/controllers/groups/work_items_controller.rb @@ -20,3 +20,5 @@ module Groups end end end + +Groups::WorkItemsController.prepend_mod diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 1381999ab4c..2b2db2f950c 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class HealthController < ActionController::Base +class HealthController < BaseActionController protect_from_forgery with: :exception, prepend: true include RequiresAllowlistedMonitoringClient @@ -40,4 +39,3 @@ class HealthController < ActionController::Base render json: result.json, status: result.http_status end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 4cc943ac252..4a4d41f3e6f 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -5,7 +5,8 @@ class IdeController < ApplicationController include StaticObjectExternalStorageCSP include Gitlab::Utils::StrongMemoize - before_action :authorize_read_project! + before_action :authorize_read_project!, only: [:index] + before_action :ensure_web_ide_oauth_application!, only: [:index] before_action do push_frontend_feature_flag(:build_service_proxy) @@ -24,7 +25,17 @@ class IdeController < ApplicationController @fork_info = fork_info(project, params[:branch]) end - render layout: 'fullscreen', locals: { minimal: helpers.use_new_web_ide? } + render layout: helpers.use_new_web_ide? ? 'fullscreen' : 'application' + end + + def oauth_redirect + return render_404 unless ::Gitlab::WebIde::DefaultOauthApplication.feature_enabled?(current_user) + # TODO - It's **possible** we end up here and no oauth application has been set up. + # We need to have better handling of these edge cases. Here's a follow-up issue: + # https://gitlab.com/gitlab-org/gitlab/-/issues/433322 + return render_404 unless ::Gitlab::WebIde::DefaultOauthApplication.oauth_application + + render layout: 'fullscreen', locals: { minimal: true } end private @@ -33,6 +44,12 @@ class IdeController < ApplicationController render_404 unless can?(current_user, :read_project, project) end + def ensure_web_ide_oauth_application! + return unless ::Gitlab::WebIde::DefaultOauthApplication.feature_enabled?(current_user) + + ::Gitlab::WebIde::DefaultOauthApplication.ensure_oauth_application! + end + def fork_info(project, branch) return if can?(current_user, :push_code, project) diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index bc425323d6f..e211ea70a56 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -76,7 +76,7 @@ class Import::BulkImportsController < ApplicationController def realtime_changes Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) - render json: current_user_bulk_imports.to_json(only: [:id], methods: [:status_name]) + render json: current_user_bulk_imports.to_json(only: [:id], methods: [:status_name, :has_failures]) end private diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index c058329680a..fcd87f46f67 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -125,14 +125,14 @@ class InvitesController < ApplicationController name: member.source.full_name, url: project_url(member.source), title: _("project"), - path: member.source.activity_path + path: project_path(member.source) } when Group { name: member.source.name, url: group_url(member.source), title: _("group"), - path: member.source.activity_path + path: group_path(member.source) } end end diff --git a/app/controllers/jwks_controller.rb b/app/controllers/jwks_controller.rb index d3a8d3dafea..2e030cf46c4 100644 --- a/app/controllers/jwks_controller.rb +++ b/app/controllers/jwks_controller.rb @@ -2,6 +2,10 @@ class JwksController < Doorkeeper::OpenidConnect::DiscoveryController def index + if ::Feature.enabled?(:cache_control_headers_for_openid_jwks) + expires_in 24.hours, public: true, must_revalidate: true, 'no-transform': true + end + render json: { keys: payload } end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 83409c7e096..e6e232cfbc3 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -33,6 +33,7 @@ class JwtController < ApplicationController @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_only_authentication_abilities) authenticate_with_http_basic do |login, password| + @raw_token = password @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, request: request) if @authentication_result.failed? @@ -80,6 +81,7 @@ class JwtController < ApplicationController def additional_params { scopes: scopes_param, + raw_token: @raw_token, deploy_token: @authentication_result.deploy_token, auth_type: @authentication_result.type }.compact diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 9f41c092fa0..61851fd1c60 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class MetricsController < ActionController::Base +class MetricsController < BaseActionController include RequiresAllowlistedMonitoringClient protect_from_forgery with: :exception, prepend: true @@ -36,4 +35,3 @@ class MetricsController < ActionController::Base ) end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 6fc2eb6bc45..42c65a845c6 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -19,7 +19,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio Doorkeeper::Application.revoke_tokens_and_grants_for(params[:id], current_resource_owner) end - redirect_to applications_profile_url, + redirect_to user_settings_applications_url, status: :found, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy]) end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index a97516fddff..907ece1a06e 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -8,6 +8,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include KnownSignIn include AcceptsPendingInvitations include Onboarding::Redirectable + include InternalRedirect + + ACTIVE_SINCE_KEY = 'active_since' after_action :verify_known_sign_in @@ -113,14 +116,21 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController super end + def store_redirect_to + # overridden in EE + end + def omniauth_flow(auth_module, identity_linker: nil) if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence store_redirect_fragment(fragment) end + store_redirect_to + if current_user return render_403 unless link_provider_allowed?(oauth['provider']) + set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym) track_event(current_user, oauth['provider'], 'succeeded') if Gitlab::CurrentSettings.admin_mode @@ -167,6 +177,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end end + # Overrided in EE + def set_session_active_since(id); end + def sign_in_user_flow(auth_user_class) auth_user = build_auth_user(auth_user_class) new_user = auth_user.new? @@ -181,6 +194,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: @user) if new_user set_remember_me(@user) + set_session_active_since(oauth['provider']) if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym) if @user.two_factor_enabled? && !auth_user.bypass_two_factor? prompt_for_two_factor(@user) diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb index 8a99b6804ae..9cc33ec0447 100644 --- a/app/controllers/organizations/application_controller.rb +++ b/app/controllers/organizations/application_controller.rb @@ -28,6 +28,10 @@ module Organizations access_denied! unless can?(current_user, :read_organization, organization) end + def authorize_read_organization_user! + access_denied! unless can?(current_user, :read_organization_user, organization) + end + def authorize_admin_organization! access_denied! unless can?(current_user, :admin_organization, organization) end diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 3085f0c07d1..9f09627b1e4 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -4,7 +4,7 @@ module Organizations class OrganizationsController < ApplicationController feature_category :cell - skip_before_action :authenticate_user!, except: [:index, :new] + skip_before_action :authenticate_user!, except: [:index, :new, :users] def index; end @@ -21,7 +21,7 @@ module Organizations end def users - authorize_read_organization! + authorize_read_organization_user! end end end diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb deleted file mode 100644 index 5a86179b89f..00000000000 --- a/app/controllers/profiles/active_sessions_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class Profiles::ActiveSessionsController < Profiles::ApplicationController - feature_category :system_access - - def index - @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) - end - - def destroy - # params[:id] can be an Rack::Session::SessionId#private_id - ActiveSession.destroy_session(current_user, params[:id]) - current_user.forget_me! - - respond_to do |format| - format.html { redirect_to profile_active_sessions_url, status: :found } - format.js { head :ok } - end - end -end diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb deleted file mode 100644 index 7a0dfbbba0d..00000000000 --- a/app/controllers/profiles/passwords_controller.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -class Profiles::PasswordsController < Profiles::ApplicationController - include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent - - skip_before_action :check_password_expiration, only: [:new, :create] - skip_before_action :check_two_factor_requirement, only: [:new, :create] - - before_action :set_user - before_action :authorize_change_password! - - layout :determine_layout - - feature_category :system_access - - def new - end - - def create - unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) - redirect_to new_profile_password_path, alert: _('You must provide a valid current password') - return - end - - result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute - - if result[:status] == :success - Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute - - redirect_to root_path, notice: _('Password successfully changed') - else - track_weak_password_error(@user, self.class.name, 'create') - render :new - end - end - - def edit - end - - def update - unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) - handle_invalid_current_password_attempt! - - redirect_to edit_profile_password_path, alert: _('You must provide a valid current password') - return - end - - result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute - - if result[:status] == :success - flash[:notice] = _('Password was successfully updated. Please sign in again.') - redirect_to new_user_session_path - else - track_weak_password_error(@user, self.class.name, 'update') - @user.reset - render 'edit' - end - end - - def reset - current_user.send_reset_password_instructions - redirect_to edit_profile_password_path, notice: _('We sent you an email with reset password instructions') - end - - private - - def set_user - @user = current_user - end - - def determine_layout - if [:new, :create].include?(action_name.to_sym) - 'application' - else - 'profile' - end - end - - def authorize_change_password! - render_404 unless @user.allow_password_authentication? - end - - def handle_invalid_current_password_attempt! - Gitlab::AppLogger.info(message: 'Invalid current password when attempting to update user password', username: @user.username, ip: request.remote_ip) - - @user.increment_failed_attempts! - end - - def user_params - params.require(:user).permit(:password, :new_password, :password_confirmation) - end - - def password_attributes - { - password: user_params[:new_password], - password_confirmation: user_params[:password_confirmation], - password_automatically_set: false - } - end -end - -Profiles::PasswordsController.prepend_mod diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb deleted file mode 100644 index 4b6e2f768fa..00000000000 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -class Profiles::PersonalAccessTokensController < Profiles::ApplicationController - include RenderAccessTokens - - feature_category :system_access - - before_action :check_personal_access_tokens_enabled - - def index - set_index_vars - scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil? - @personal_access_token = finder.build( - name: params[:name], - scopes: scopes - ) - - respond_to do |format| - format.html - format.json do - render json: @active_access_tokens - end - end - end - - def create - result = ::PersonalAccessTokens::CreateService.new( - current_user: current_user, - target_user: current_user, - params: personal_access_token_params, - concatenate_errors: false - ).execute - - @personal_access_token = result.payload[:personal_access_token] - - if result.success? - render json: { new_token: @personal_access_token.token, - active_access_tokens: active_access_tokens }, status: :ok - else - render json: { errors: result.errors }, status: :unprocessable_entity - end - end - - def revoke - @personal_access_token = finder.find(params[:id]) - service = PersonalAccessTokens::RevokeService.new(current_user, token: @personal_access_token).execute - service.success? ? flash[:notice] = service.message : flash[:alert] = service.message - - redirect_to profile_personal_access_tokens_path - end - - private - - def finder(options = {}) - PersonalAccessTokensFinder.new({ user: current_user, impersonation: false }.merge(options)) - end - - def personal_access_token_params - params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) - end - - def set_index_vars - @scopes = Gitlab::Auth.available_scopes_for(current_user) - @active_access_tokens = active_access_tokens - end - - def represent(tokens) - ::PersonalAccessTokenSerializer.new.represent(tokens) - end - - def check_personal_access_tokens_enabled - render_404 if Gitlab::CurrentSettings.personal_access_tokens_disabled? - end -end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 7059e2a0371..4c93c738484 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -41,6 +41,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :color_scheme_id, :diffs_deletion_color, :diffs_addition_color, + :home_organization_id, :layout, :dashboard, :project_view, diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index cb29f0f3539..39a070b6405 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -14,7 +14,6 @@ class ProfilesController < Profiles::ApplicationController feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token, :reset_static_object_token, :update_username] - feature_category :system_access, [:audit_log] urgency :low, [:show, :update] def show @@ -43,7 +42,7 @@ class ProfilesController < Profiles::ApplicationController flash[:notice] = s_("Profiles|Incoming email token was successfully reset") - redirect_to profile_personal_access_tokens_path + redirect_to user_settings_personal_access_tokens_path end def reset_feed_token @@ -53,7 +52,7 @@ class ProfilesController < Profiles::ApplicationController flash[:notice] = s_('Profiles|Feed token was successfully reset') - redirect_to profile_personal_access_tokens_path + redirect_to user_settings_personal_access_tokens_path end def reset_static_object_token @@ -61,20 +60,10 @@ class ProfilesController < Profiles::ApplicationController user.reset_static_object_token! end - redirect_to profile_personal_access_tokens_path, + redirect_to user_settings_personal_access_tokens_path, notice: s_('Profiles|Static object token was successfully reset') end - # rubocop: disable CodeReuse/ActiveRecord - def audit_log - @events = AuthenticationEvent.where(user: current_user) - .order("created_at DESC") - .page(params[:page]) - - Gitlab::Tracking.event(self.class.name, 'search_audit_event', user: current_user) - end - # rubocop: enable CodeReuse/ActiveRecord - def update_username result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 60c8fe97e81..ff3484d3020 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::AutocompleteSourcesController < Projects::ApplicationController + include AutocompleteSources::ExpiresIn + before_action :authorize_read_milestone!, only: :milestones before_action :authorize_read_crm_contact!, only: :contacts @@ -13,11 +15,6 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :contacts] def members - if Feature.enabled?(:cache_autocomplete_sources_members, current_user) - # Cache the response on the frontend - expires_in 3.minutes - end - render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7371902a6bd..7851e2ac80b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -52,6 +52,7 @@ class Projects::BlobController < Projects::ApplicationController push_frontend_feature_flag(:blob_blame_info, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) + push_frontend_feature_flag(:encoding_logs_tree) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 84872d1e978..fd853b5aaed 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -8,6 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:board_multi_select, project) push_frontend_feature_flag(:apollo_boards, project) + push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project) experiment(:prominent_create_board_btn, subject: current_user) do |e| e.control {} e.candidate {} diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 6e7f764c5c1..62a5baccc62 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -4,7 +4,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! before_action do push_frontend_feature_flag(:ci_job_assistant_drawer, @project) - push_frontend_feature_flag(:ai_ci_config_generator, @user) push_frontend_feature_flag(:ci_graphql_pipeline_mini_graph, @project) end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index b781365b3c3..dd969efa49b 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -class Projects::ClustersController < Clusters::ClustersController - prepend_before_action :project +class Projects::ClustersController < ::Clusters::ClustersController before_action :repository before_action do @@ -13,7 +12,7 @@ class Projects::ClustersController < Clusters::ClustersController private def clusterable - @clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user) + @clusterable ||= project && ClusterablePresenter.fabricate(project, current_user: current_user) end def project diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 9cdbd2a30f6..66ce501f9f0 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -22,6 +22,34 @@ class Projects::DeployKeysController < Projects::ApplicationController end end + def enabled_keys + respond_to do |format| + format.json do + enabled_keys = find_keys(filter: :enabled_keys) + render json: { keys: serialize(enabled_keys) } + end + end + end + + def available_project_keys + respond_to do |format| + format.json do + available_project_keys = find_keys(filter: :available_project_keys) + render json: { keys: serialize(available_project_keys) } + end + end + end + + def available_public_keys + respond_to do |format| + format.json do + available_public_keys = find_keys(filter: :available_public_keys) + + render json: { keys: serialize(available_public_keys) } + end + end + end + def new redirect_to_repository end @@ -108,4 +136,17 @@ class Projects::DeployKeysController < Projects::ApplicationController def redirect_to_repository redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings') end + + def find_keys(params) + DeployKeys::DeployKeysFinder.new(@project, current_user, params) + .execute + end + + def serialize(keys) + opts = { user: current_user, project: project } + + DeployKeys::DeployKeySerializer.new + .with_pagination(request, response) + .represent(keys, opts) + end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4b2749dc716..8cdd6efa7c5 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -14,6 +14,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:k8s_watch_api, project) end + before_action only: [:folder] do + push_frontend_feature_flag(:environments_folder_new_look, project) + end + before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] diff --git a/app/controllers/projects/gcp/artifact_registry/base_controller.rb b/app/controllers/projects/gcp/artifact_registry/base_controller.rb new file mode 100644 index 00000000000..4084427f3e5 --- /dev/null +++ b/app/controllers/projects/gcp/artifact_registry/base_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Projects + module Gcp + module ArtifactRegistry + class BaseController < ::Projects::ApplicationController + before_action :ensure_feature_flag + before_action :ensure_saas + before_action :authorize_read_container_image! + before_action :ensure_private_project + + feature_category :container_registry + urgency :low + + private + + def ensure_feature_flag + return if Feature.enabled?(:gcp_technical_demo, project) + + @error = 'Feature flag disabled' + + render + end + + def ensure_saas + return if Gitlab.com_except_jh? # rubocop: disable Gitlab/AvoidGitlabInstanceChecks -- demo requirement + + @error = "Can't run here" + + render + end + + def ensure_private_project + return if project.private? + + @error = 'Can only run on private projects' + + render + end + end + end + end +end diff --git a/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb new file mode 100644 index 00000000000..b88b86975a4 --- /dev/null +++ b/app/controllers/projects/gcp/artifact_registry/docker_images_controller.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Projects + module Gcp + module ArtifactRegistry + class DockerImagesController < Projects::Gcp::ArtifactRegistry::BaseController + before_action :require_gcp_params + before_action :handle_pagination + + REPO_NAME_REGEX = %r{/repositories/(.*)/dockerImages/} + + def index + result = service.execute(page_token: params[:page_token]) + + if result.success? + @docker_images = process_docker_images(result.payload[:images] || []) + @next_page_token = result.payload[:next_page_token] + @artifact_repository_name = artifact_repository_name + @error = @docker_images.blank? ? 'No docker images' : false + else + @error = result.message + end + end + + private + + def service + ::Integrations::GoogleCloudPlatform::ArtifactRegistry::ListDockerImagesService.new( + project: @project, + current_user: current_user, + params: { + gcp_project_id: gcp_project_id, + gcp_location: gcp_location, + gcp_repository: gcp_ar_repository, + gcp_wlif: gcp_wlif_url + } + ) + end + + def process_docker_images(raw_images) + raw_images.map { |r| process_docker_image(r) } + end + + def process_docker_image(raw_image) + DockerImage.new( + name: raw_image[:name], + uri: raw_image[:uri], + tags: raw_image[:tags], + image_size_bytes: raw_image[:size_bytes], + media_type: raw_image[:media_type], + upload_time: raw_image[:uploaded_at], + build_time: raw_image[:built_at], + update_time: raw_image[:updated_at] + ) + end + + def artifact_repository_name + return unless @docker_images.present? + + (@docker_images.first.name || '')[REPO_NAME_REGEX, 1] + end + + def handle_pagination + @page = Integer(params[:page] || 1) + @page_tokens = {} + @previous_page_token = nil + + if params[:page_tokens] + @page_tokens = ::Gitlab::Json.parse(Base64.decode64(params[:page_tokens])) + @previous_page_token = @page_tokens[(@page - 1).to_s] + end + + @page_tokens[@page.to_s] = params[:page_token] + @page_tokens = Base64.encode64(::Gitlab::Json.dump(@page_tokens.compact)) + end + + def require_gcp_params + return unless gcp_project_id.blank? || gcp_location.blank? || gcp_ar_repository.blank? || gcp_wlif_url.blank? + + redirect_to new_namespace_project_gcp_artifact_registry_setup_path + end + + def gcp_project_id + params[:gcp_project_id] + end + + def gcp_location + params[:gcp_location] + end + + def gcp_ar_repository + params[:gcp_ar_repository] + end + + def gcp_wlif_url + params[:gcp_wlif_url] + end + + class DockerImage + include ActiveModel::API + + attr_accessor :name, :uri, :tags, :image_size_bytes, :upload_time, :media_type, :build_time, :update_time + + SHORT_NAME_REGEX = %r{dockerImages/(.*)$} + + def short_name + (name || '')[SHORT_NAME_REGEX, 1] + end + + def updated_at + return unless update_time + + Time.zone.parse(update_time) + end + + def built_at + return unless build_time + + Time.zone.parse(build_time) + end + + def uploaded_at + return unless upload_time + + Time.zone.parse(upload_time) + end + end + end + end + end +end diff --git a/app/controllers/projects/gcp/artifact_registry/setup_controller.rb b/app/controllers/projects/gcp/artifact_registry/setup_controller.rb new file mode 100644 index 00000000000..e90304ce593 --- /dev/null +++ b/app/controllers/projects/gcp/artifact_registry/setup_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Projects + module Gcp + module ArtifactRegistry + class SetupController < ::Projects::Gcp::ArtifactRegistry::BaseController + def new; end + end + end + end +end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 5f8bf423219..855b9824cf2 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -3,7 +3,7 @@ class Projects::GroupLinksController < Projects::ApplicationController layout 'project_settings' before_action :authorize_admin_project!, except: [:destroy] - before_action :authorize_admin_project_group_link!, only: [:destroy] + before_action :authorize_manage_destroy!, only: [:destroy] before_action :authorize_admin_project_member!, only: [:update] feature_category :groups_and_projects @@ -20,8 +20,8 @@ class Projects::GroupLinksController < Projects::ApplicationController else render json: {} end - elsif result.reason == :not_found - render json: { message: result.message }, status: :not_found + else + render json: { message: result.message }, status: result.reason end end @@ -47,7 +47,7 @@ class Projects::GroupLinksController < Projects::ApplicationController end format.js do - render json: { message: result.message }, status: :not_found if result.reason == :not_found + render json: { message: result.message }, status: result.reason end end end @@ -55,8 +55,8 @@ class Projects::GroupLinksController < Projects::ApplicationController protected - def authorize_admin_project_group_link! - render_404 unless can?(current_user, :admin_project_group_link, group_link) + def authorize_manage_destroy! + render_404 unless can?(current_user, :manage_destroy, group_link) end def group_link diff --git a/app/controllers/projects/integrations/shimos_controller.rb b/app/controllers/projects/integrations/shimos_controller.rb deleted file mode 100644 index 6c8313d0805..00000000000 --- a/app/controllers/projects/integrations/shimos_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# 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 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 a6444dc038c..d0eabf8d837 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -49,6 +49,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:service_desk_ticket) push_frontend_feature_flag(:issues_list_drawer, project) push_frontend_feature_flag(:linked_work_items, project) + push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project) end before_action only: [:index, :show] do @@ -67,6 +68,7 @@ class Projects::IssuesController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_frontend_feature_flag(:moved_mr_sidebar, project) + push_frontend_feature_flag(:display_work_item_epic_issue_sidebar, project) push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?) push_frontend_feature_flag(:notifications_todos_buttons, current_user) end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index d5a7f25d4ce..4062e625e07 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -21,7 +21,6 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize - before_action :push_job_log_jump_to_failures, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] before_action :push_ai_build_failure_cause, only: [:show] layout 'project' @@ -277,10 +276,6 @@ class Projects::JobsController < Projects::ApplicationController ::Gitlab::Workhorse.channel_websocket(service) end - def push_job_log_jump_to_failures - push_frontend_feature_flag(:job_log_jump_to_failures, @project) - end - def push_ai_build_failure_cause push_frontend_feature_flag(:ai_build_failure_cause, @project) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 5bd0063ab95..b269d41fa77 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -32,8 +32,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: disable Metrics/AbcSize def diffs_batch + collapse_generated = Feature.enabled?(:collapse_generated_diff_files, project) + diff_options_hash = diff_options diff_options_hash[:paths] = params[:paths] if params[:paths] + diff_options_hash[:collapse_generated] = collapse_generated diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options_hash) @@ -59,7 +62,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic params[:expanded], params[:page], params[:per_page], - options[:merge_ref_head_diff] + options[:merge_ref_head_diff], + collapse_generated ] expires_in(1.day) if cache_with_max_age? diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index eb7505bd81f..0899e303305 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -41,18 +41,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:sast_reports_in_inline_diff, project) push_frontend_feature_flag(:mr_experience_survey, project) - push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) push_frontend_feature_flag(:ci_job_failures_in_mr, project) push_frontend_feature_flag(:mr_pipelines_graphql, project) push_frontend_feature_flag(:notifications_todos_buttons, current_user) - push_frontend_feature_flag(:widget_pipeline_pass_subscription_update, project) push_frontend_feature_flag(:mr_request_changes, current_user) - end - - before_action only: [:edit] do - if can?(current_user, :fill_in_merge_request_template, project) - push_frontend_feature_flag(:fill_in_mr_template, project) - end + push_frontend_feature_flag(:merge_blocked_component, current_user) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] @@ -345,15 +338,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def discussions - if Feature.enabled?(:only_highlight_discussions_requested, project) - super do |discussion_notes| - note_ids = discussion_notes.flat_map { |x| x.notes.collect(&:id) } - merge_request.discussions_diffs.load_highlight(diff_note_ids: note_ids) - end - else - merge_request.discussions_diffs.load_highlight - - super + super do |discussion_notes| + note_ids = discussion_notes.flat_map { |x| x.notes.collect(&:id) } + merge_request.discussions_diffs.load_highlight(diff_note_ids: note_ids) end end @@ -461,7 +448,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @update_current_user_path = expose_path(api_v4_user_preferences_path) @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request) - @diffs_batch_cache_key = @merge_request.merge_head_diff&.id if merge_request.diffs_batch_cache_with_max_age? + + if merge_request.diffs_batch_cache_with_max_age? + @diffs_batch_cache_key = @merge_request.merge_head_diff&.patch_id_sha + end set_pipeline_variables @@ -471,11 +461,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def get_diffs_count - if show_only_context_commits? - @merge_request.context_commits_diff.raw_diffs.size - else - @merge_request.diff_size - end + return @merge_request.context_commits_diff.raw_diffs.size if show_only_context_commits? + return @merge_request.merge_request_diffs.find_by_id(params[:diff_id])&.size if params[:diff_id] + + @merge_request.diff_size end def merge_request_update_params @@ -598,7 +587,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo when :parsed render json: Gitlab::Json.dump(report_comparison[:data]), status: :ok when :error - render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request + render json: { + errors: [report_comparison[:status_reason]], + status_reason: report_comparison[:status_reason] + }, + status: :bad_request else raise "Failed to build comparison response as comparison yielded unknown status '#{report_comparison[:status]}'" end @@ -629,7 +622,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo params = request .query_parameters .merge(view: 'inline', diff_head: true, w: show_whitespace, page: '0', per_page: per_page) - params[:ck] = merge_request.merge_head_diff&.id if merge_request.diffs_batch_cache_with_max_age? + params[:ck] = merge_request.merge_head_diff&.patch_id_sha if merge_request.diffs_batch_cache_with_max_age? diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params) end @@ -638,16 +631,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo Date.strptime(date, "%Y-%m-%d")&.to_time&.to_i if date rescue Date::Error, TypeError end - - def summarize_my_code_review_enabled? - namespace = project&.group&.root_ancestor - return false if namespace.nil? - - Feature.enabled?(:summarize_my_code_review, current_user) && - namespace.group_namespace? && - namespace.licensed_feature_available?(:summarize_my_mr_code_review) && - Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review) - end end Projects::MergeRequestsController.prepend_mod_with('Projects::MergeRequestsController') diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index 12111c45fde..9905e454acb 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -9,9 +9,7 @@ module Projects feature_category :mlops - def show - @include_ci_info = @candidate.from_ci? && can?(current_user, :read_build, @candidate.ci_build) - end + def show; end def destroy @experiment = @candidate.experiment diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cd2db2dad2c..516aa70cf89 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -166,6 +166,8 @@ class Projects::PipelinesController < Projects::ApplicationController @stage = pipeline.stage(params[:stage]) return not_found unless @stage + return unless stage_stale? + render json: StageSerializer .new(project: @project, current_user: @current_user) .represent(@stage, details: true, retried: params[:retried]) @@ -263,6 +265,14 @@ class Projects::PipelinesController < Projects::ApplicationController redirect_to url_for(safe_params.except(:scope).merge(status: safe_params[:scope])), status: :moved_permanently end + def stage_stale? + return true if Feature.disabled?(:pipeline_stage_set_last_modified, @current_user) + + last_modified = [@stage.updated_at.utc, @stage.statuses.maximum(:updated_at)].max + + stale?(last_modified: last_modified, etag: @stage) + end + # rubocop: disable CodeReuse/ActiveRecord def pipeline return @pipeline if defined?(@pipeline) diff --git a/app/controllers/projects/service_desk/custom_email_controller.rb b/app/controllers/projects/service_desk/custom_email_controller.rb index fb5e87f9a97..7c1623cfcd1 100644 --- a/app/controllers/projects/service_desk/custom_email_controller.rb +++ b/app/controllers/projects/service_desk/custom_email_controller.rb @@ -3,7 +3,6 @@ module Projects module ServiceDesk class CustomEmailController < Projects::ApplicationController - before_action :check_feature_flag_enabled before_action :authorize_admin_project! feature_category :service_desk @@ -75,10 +74,6 @@ module Projects error_message: error_message } end - - def check_feature_flag_enabled - render_404 unless Feature.enabled?(:service_desk_custom_email, @project) - end end end end diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb index 70cb439c4f3..a53e8859ee6 100644 --- a/app/controllers/projects/service_desk_controller.rb +++ b/app/controllers/projects/service_desk_controller.rb @@ -29,7 +29,13 @@ class Projects::ServiceDeskController < Projects::ApplicationController end def allowed_update_attributes - %i[issue_template_key outgoing_name project_key add_external_participants_from_cc] + %i[ + issue_template_key + outgoing_name + project_key + reopen_issue_on_external_participant_note + add_external_participants_from_cc + ] end def service_desk_attributes @@ -42,6 +48,7 @@ class Projects::ServiceDeskController < Projects::ApplicationController template_file_missing: service_desk_settings&.issue_template_missing?, outgoing_name: service_desk_settings&.outgoing_name, project_key: service_desk_settings&.project_key, + reopen_issue_on_external_participant_note: service_desk_settings&.reopen_issue_on_external_participant_note, add_external_participants_from_cc: service_desk_settings&.add_external_participants_from_cc } end diff --git a/app/controllers/projects/settings/merge_requests_controller.rb b/app/controllers/projects/settings/merge_requests_controller.rb index f09e324f574..2724e2d9eec 100644 --- a/app/controllers/projects/settings/merge_requests_controller.rb +++ b/app/controllers/projects/settings/merge_requests_controller.rb @@ -52,6 +52,7 @@ module Projects :resolve_outdated_diff_discussions, :only_allow_merge_if_all_discussions_are_resolved, :only_allow_merge_if_pipeline_succeeds, + :allow_merge_without_pipeline, :printing_merge_request_link_enabled, :remove_source_branch_after_merge, :merge_method, diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index cfcc27edf3e..1bbf272e8f9 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -22,6 +22,7 @@ class Projects::TreeController < Projects::ApplicationController push_frontend_feature_flag(:blob_blame_info, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) + push_frontend_feature_flag(:encoding_logs_tree) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index cee56dca538..1152bdcf058 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -42,8 +42,8 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) - push_frontend_feature_flag(:service_desk_custom_email, @project) push_frontend_feature_flag(:issue_email_participants, @project) + push_frontend_feature_flag(:encoding_logs_tree) # TODO: We need to remove the FF eventually when we rollout page_specific_styles push_frontend_feature_flag(:page_specific_styles, current_user) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) @@ -398,6 +398,7 @@ class ProjectsController < Projects::ApplicationController if can?(current_user, :read_code, @project) return render 'projects/no_repo' unless @project.repository_exists? + return render 'projects/missing_default_branch', status: :service_unavailable if @ref == '' render 'projects/empty' if @project.empty_repo? else @@ -442,6 +443,7 @@ class ProjectsController < Projects::ApplicationController params.require(:project) .permit(project_params_attributes + attributes) .merge(import_url_params) + .merge(object_format_params) end def project_feature_attributes @@ -465,6 +467,7 @@ class ProjectsController < Projects::ApplicationController monitor_access_level infrastructure_access_level model_experiments_access_level + model_registry_access_level ] end @@ -497,6 +500,7 @@ class ProjectsController < Projects::ApplicationController :name, :only_allow_merge_if_all_discussions_are_resolved, :only_allow_merge_if_pipeline_succeeds, + :allow_merge_without_pipeline, :path, :printing_merge_request_link_enabled, :public_builds, @@ -529,6 +533,12 @@ class ProjectsController < Projects::ApplicationController {} end + def object_format_params + return {} unless Gitlab::Utils.to_boolean(params.dig(:project, :use_sha256_repository)) + + { repository_object_format: Repository::FORMAT_SHA256 } + end + def active_new_project_tab project_params[:import_url].present? ? 'import' : 'blank' end @@ -552,6 +562,9 @@ class ProjectsController < Projects::ApplicationController # Override get_id from ExtractsPath in this case is just the root of the default branch. def get_id project.repository.root_ref + rescue Gitlab::Git::CommandError + # Empty string is intentional and prevent the @ref reload + '' end def build_canonical_path(project) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 72636a89433..ed0f1687420 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -23,6 +23,7 @@ class RegistrationsController < Devise::RegistrationsController before_action :load_recaptcha, only: :new before_action only: [:create] do check_rate_limit!(:user_sign_up, scope: request.ip) + invite_email # set for failure path so we still remember we are invite in form end feature_category :instance_resiliency @@ -271,10 +272,15 @@ class RegistrationsController < Devise::RegistrationsController def set_invite_params if resource.email.blank? && params[:invite_email].present? - resource.email = @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) + resource.email = invite_email end end + def invite_email + ActionController::Base.helpers.sanitize(params[:invite_email]) + end + strong_memoize_attr :invite_email + def user_invited? !!member_id end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index b639a9dda3f..64d9db41a1b 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -27,7 +27,10 @@ class SearchController < ApplicationController around_action :allow_gitaly_ref_name_caching - before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch + before_action :block_all_anonymous_searches, + :block_anonymous_global_searches, + :check_scope_global_search_enabled, + except: :opensearch skip_before_action :authenticate_user! requires_cross_project_access if: -> do @@ -191,17 +194,7 @@ class SearchController < ApplicationController # Merging to :metadata will ensure these are logged as top level keys payload[:metadata] ||= {} - payload[:metadata]['meta.search.group_id'] = params[:group_id] - payload[:metadata]['meta.search.project_id'] = params[:project_id] - payload[:metadata]['meta.search.scope'] = params[:scope] || @scope - 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] - payload[:metadata]['meta.search.project_ids'] = params[:project_ids] - payload[:metadata]['meta.search.filters.language'] = params[:language] - payload[:metadata]['meta.search.type'] = @search_type if @search_type.present? - payload[:metadata]['meta.search.level'] = @search_level if @search_level.present? - payload[:metadata][:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present? + payload[:metadata].merge!(payload_metadata) if search_service.abuse_detected? payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain) @@ -209,6 +202,23 @@ class SearchController < ApplicationController end end + def payload_metadata + {}.tap do |metadata| + metadata['meta.search.group_id'] = params[:group_id] + metadata['meta.search.project_id'] = params[:project_id] + metadata['meta.search.scope'] = params[:scope] || @scope + metadata['meta.search.page'] = params[:page] || '1' + metadata['meta.search.filters.confidential'] = params[:confidential] + metadata['meta.search.filters.state'] = params[:state] + metadata['meta.search.force_search_results'] = params[:force_search_results] + metadata['meta.search.project_ids'] = params[:project_ids] + metadata['meta.search.filters.language'] = params[:language] + metadata['meta.search.type'] = @search_type if @search_type.present? + metadata['meta.search.level'] = @search_level if @search_level.present? + metadata[:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present? + end + end + def block_anonymous_global_searches return unless search_service.global_search? return if current_user @@ -219,6 +229,14 @@ class SearchController < ApplicationController redirect_to new_user_session_path, alert: _('You must be logged in to search across all of GitLab') end + def block_all_anonymous_searches + return if current_user || ::Feature.enabled?(:allow_anonymous_searches, type: :ops) + + store_location_for(:user, request.fullpath) + + redirect_to new_user_session_path, alert: _('You must be logged in to search') + end + def check_scope_global_search_enabled return unless search_service.global_search? diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 595d79abcf2..a8a09bd6ac6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -100,7 +100,7 @@ class SessionsController < Devise::SessionsController def after_pending_invitations_hook member = resource.members.last - store_location_for(:user, member.source.activity_path) if member + store_location_for(:user, polymorphic_path(member.source)) if member end def captcha_enabled? diff --git a/app/controllers/user_settings/active_sessions_controller.rb b/app/controllers/user_settings/active_sessions_controller.rb new file mode 100644 index 00000000000..bfc969d0ff8 --- /dev/null +++ b/app/controllers/user_settings/active_sessions_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module UserSettings + class ActiveSessionsController < ApplicationController + feature_category :system_access + + def index + @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) + end + + def destroy + # params[:id] can be an Rack::Session::SessionId#private_id + ActiveSession.destroy_session(current_user, params[:id]) + current_user.forget_me! + + respond_to do |format| + format.html { redirect_to user_settings_active_sessions_url, status: :found } + format.js { head :ok } + end + end + end +end diff --git a/app/controllers/user_settings/application_controller.rb b/app/controllers/user_settings/application_controller.rb new file mode 100644 index 00000000000..d495c604bf3 --- /dev/null +++ b/app/controllers/user_settings/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module UserSettings + class ApplicationController < ::ApplicationController + layout 'profile' + end +end diff --git a/app/controllers/user_settings/passwords_controller.rb b/app/controllers/user_settings/passwords_controller.rb new file mode 100644 index 00000000000..d68ddf90d49 --- /dev/null +++ b/app/controllers/user_settings/passwords_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module UserSettings + class PasswordsController < ApplicationController + include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent + + skip_before_action :check_password_expiration, only: [:new, :create] + skip_before_action :check_two_factor_requirement, only: [:new, :create] + + before_action :set_user + before_action :authorize_change_password! + + layout :determine_layout + + feature_category :system_access + + def new; end + + def create + unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) + redirect_to new_user_settings_password_path, alert: _('You must provide a valid current password') + return + end + + result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute + + if result[:status] == :success + Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute + + redirect_to root_path, notice: _('Password successfully changed') + else + track_weak_password_error(@user, self.class.name, 'create') + render :new + end + end + + def edit; end + + def update + unless @user.password_automatically_set || @user.valid_password?(user_params[:password]) + handle_invalid_current_password_attempt! + + redirect_to edit_user_settings_password_path, alert: _('You must provide a valid current password') + return + end + + result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute + + if result[:status] == :success + flash[:notice] = _('Password was successfully updated. Please sign in again.') + redirect_to new_user_session_path + else + track_weak_password_error(@user, self.class.name, 'update') + @user.reset + render 'edit' + end + end + + def reset + current_user.send_reset_password_instructions + redirect_to edit_user_settings_password_path, notice: _('We sent you an email with reset password instructions') + end + + private + + def set_user + @user = current_user + end + + def determine_layout + if [:new, :create].include?(action_name.to_sym) + 'application' + else + 'profile' + end + end + + def authorize_change_password! + render_404 unless @user.allow_password_authentication? + end + + def handle_invalid_current_password_attempt! + Gitlab::AppLogger.info(message: 'Invalid current password when attempting to update user password', + username: @user.username, ip: request.remote_ip) + + @user.increment_failed_attempts! + end + + def user_params + params.require(:user).permit(:password, :new_password, :password_confirmation) + end + + def password_attributes + { + password: user_params[:new_password], + password_confirmation: user_params[:password_confirmation], + password_automatically_set: false + } + end + end +end + +UserSettings::PasswordsController.prepend_mod diff --git a/app/controllers/user_settings/personal_access_tokens_controller.rb b/app/controllers/user_settings/personal_access_tokens_controller.rb new file mode 100644 index 00000000000..a8e9a328c26 --- /dev/null +++ b/app/controllers/user_settings/personal_access_tokens_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module UserSettings + class PersonalAccessTokensController < ApplicationController + include RenderAccessTokens + + feature_category :system_access + + before_action :check_personal_access_tokens_enabled + + def index + set_index_vars + scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil? + @personal_access_token = finder.build( + name: params[:name], + scopes: scopes + ) + + respond_to do |format| + format.html + format.json do + render json: @active_access_tokens + end + end + end + + def create + result = ::PersonalAccessTokens::CreateService.new( + current_user: current_user, + target_user: current_user, + params: personal_access_token_params, + concatenate_errors: false + ).execute + + @personal_access_token = result.payload[:personal_access_token] + + if result.success? + render json: { new_token: @personal_access_token.token, + active_access_tokens: active_access_tokens }, status: :ok + else + render json: { errors: result.errors }, status: :unprocessable_entity + end + end + + def revoke + @personal_access_token = finder.find(params[:id]) + service = PersonalAccessTokens::RevokeService.new(current_user, token: @personal_access_token).execute + service.success? ? flash[:notice] = service.message : flash[:alert] = service.message + + redirect_to user_settings_personal_access_tokens_path + end + + private + + def finder(options = {}) + PersonalAccessTokensFinder.new({ user: current_user, impersonation: false }.merge(options)) + end + + def personal_access_token_params + params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) + end + + def set_index_vars + @scopes = Gitlab::Auth.available_scopes_for(current_user) + @active_access_tokens = active_access_tokens + end + + def represent(tokens) + ::PersonalAccessTokenSerializer.new.represent(tokens) + end + + def check_personal_access_tokens_enabled + render_404 if Gitlab::CurrentSettings.personal_access_tokens_disabled? + end + end +end diff --git a/app/controllers/user_settings/user_settings_controller.rb b/app/controllers/user_settings/user_settings_controller.rb new file mode 100644 index 00000000000..3535a30095a --- /dev/null +++ b/app/controllers/user_settings/user_settings_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module UserSettings + class UserSettingsController < ApplicationController + feature_category :system_access + + def authentication_log + @events = AuthenticationEvent.for_user(current_user) + .order_by_created_at_desc + .page(params[:page]) + + Gitlab::Tracking.event(self.class.name, 'search_audit_event', user: current_user) + end + end +end diff --git a/app/controllers/web_ide/remote_ide_controller.rb b/app/controllers/web_ide/remote_ide_controller.rb index 90652a1b6e2..8392e7a190c 100644 --- a/app/controllers/web_ide/remote_ide_controller.rb +++ b/app/controllers/web_ide/remote_ide_controller.rb @@ -17,7 +17,7 @@ module WebIde def index return render_404 unless Feature.enabled?(:vscode_web_ide, current_user) - render layout: 'fullscreen', locals: { minimal: true, data: root_element_data } + render layout: 'fullscreen', locals: { data: root_element_data } end private diff --git a/app/controllers/well_known_controller.rb b/app/controllers/well_known_controller.rb new file mode 100644 index 00000000000..9ca38a81b11 --- /dev/null +++ b/app/controllers/well_known_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# This controller implements /.well-known paths that have no better home. +# +# Other controllers also implement /.well-known/* paths. They can be +# discovered by running `rails routes | grep "well-known"`. +class WellKnownController < ApplicationController # rubocop:disable Gitlab/NamespacedClass -- No relevant product domain exists + skip_before_action :authenticate_user!, :check_two_factor_requirement + feature_category :compliance_management, [:security_txt] + + def security_txt + content = Gitlab::CurrentSettings.current_application_settings.security_txt_content + if content.present? + render plain: content + else + route_not_found + end + end +end diff --git a/app/events/project_authorizations/authorizations_removed_event.rb b/app/events/project_authorizations/authorizations_removed_event.rb new file mode 100644 index 00000000000..ead7daafe57 --- /dev/null +++ b/app/events/project_authorizations/authorizations_removed_event.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ProjectAuthorizations + class AuthorizationsRemovedEvent < ::Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[project_id user_ids], + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'user_ids' => { 'type' => 'array' } + } + } + end + end +end diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 60bf47c2f12..cfbd4eb6b75 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -3,6 +3,18 @@ class ApplicationExperiment < Gitlab::Experiment control { nil } # provide a default control for anonymous experiments + # We have experiments in ce/foss code even though they will never be available + # for ce/foss instances. + # We do that since we currently only experiment on the ee with SaaS instance. + # However, if the experiment is successful, we may commit the final code to ce/foss + # if the feature we are experimenting on is not a licensed or SaaS feature. + # + # This follows the https://docs.gitlab.com/ee/development/ee_features.html + # guidelines and therefore we have hardcoded `false` here. + def self.available? + false + end + def control_behavior # define a default nil control behavior so we can omit it when not needed end @@ -44,3 +56,5 @@ class ApplicationExperiment < Gitlab::Experiment actor.respond_to?(:id) ? actor : context.try(:user) end end + +ApplicationExperiment.prepend_mod diff --git a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt deleted file mode 100644 index 4f9c00980e1..00000000000 --- a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt +++ /dev/null @@ -1,106 +0,0 @@ -# <%= @project.name %> - -<%= @project.description %> - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](<%= redirect("https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file") %>) or [upload](<%= redirect("https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file") %>) files -- [ ] [Add files using the command line](<%= redirect("https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line") %>) or push an existing Git repository with the following command: - -```shell -cd existing_repo -git remote add origin <%= @project.http_url_to_repo %> -git branch -M <%= @project.default_branch_or_main %> -git push -uf origin <%= @project.default_branch_or_main %> -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](<%= redirect(project_settings_integrations_url(@project)) %>) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](<%= redirect("https://docs.gitlab.com/ee/user/project/members/") %>) -- [ ] [Create a new merge request](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html") %>) -- [ ] [Automatically close issues from merge requests](<%= redirect("https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically") %>) -- [ ] [Enable merge request approvals](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/approvals/") %>) -- [ ] [Set auto-merge](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](<%= redirect("https://docs.gitlab.com/ee/ci/quick_start/index.html") %>) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](<%= redirect("https://docs.gitlab.com/ee/user/application_security/sast/") %>) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](<%= redirect("https://docs.gitlab.com/ee/topics/autodevops/requirements.html") %>) -- [ ] [Use pull-based deployments for improved Kubernetes management](<%= redirect("https://docs.gitlab.com/ee/user/clusters/agent/") %>) -- [ ] [Set up protected environments](<%= redirect("https://docs.gitlab.com/ee/ci/environments/protected_environments.html") %>) - ---- - -## Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com) for this template. - -### Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -### Name - -Choose a self-explaining name for your project. - -### Description - -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -### Badges - -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -### Visuals - -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -### Installation - -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -### Usage - -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -### Support - -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -### Roadmap - -If you have ideas for releases in the future, it is a good idea to list them in the README. - -### Contributing - -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -### Authors and acknowledgment - -Show your appreciation to those who have contributed to the project. - -### License - -For open source projects, say how it is licensed. - -### Project status - -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index dc7b9f6a0ce..8f90ce40bb4 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true class BranchesFinder < GitRefsFinder - def initialize(repository, params = {}) - super(repository, params) - end - def execute(gitaly_pagination: false) if gitaly_pagination && names.blank? && search.blank? && regex.blank? - repository.branches_sorted_by(sort, pagination_params) + repository.branches_sorted_by(sort, pagination_params).tap do |branches| + set_next_cursor(branches) + end else branches = repository.branches_sorted_by(sort) branches = by_search(branches) diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index 585b35981a6..3f0e849d7ef 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -13,6 +13,21 @@ module Packages project.packages.installable end + # /!\ This function doesn't check user permissions + # at the package level. + def packages_for(user, within_group:) + return ::Packages::Package.none unless within_group + return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group) + + projects = if user.is_a?(DeployToken) + user.accessible_projects + else + within_group.all_projects + end + + ::Packages::Package.for_projects(projects).installable + end + def packages_visible_to_user(user, within_group:, with_package_registry_enabled: false) return ::Packages::Package.none unless within_group return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group) diff --git a/app/finders/deploy_keys/deploy_keys_finder.rb b/app/finders/deploy_keys/deploy_keys_finder.rb new file mode 100644 index 00000000000..5924a656801 --- /dev/null +++ b/app/finders/deploy_keys/deploy_keys_finder.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module DeployKeys + class DeployKeysFinder + attr_reader :project, :current_user, :params + + def initialize(project, current_user, params = {}) + @project = project + @current_user = current_user + @params = params + end + + def execute + return empty unless can_admin_project? + + case params[:filter] + when :enabled_keys + enabled_keys + when :available_project_keys + available_project_keys + when :available_public_keys + available_public_keys + else + empty + end + end + + private + + def enabled_keys + project.deploy_keys.with_projects + end + + def available_project_keys + current_user.project_deploy_keys.with_projects.not_in(enabled_keys) + end + + def available_public_keys + DeployKey.are_public.with_projects.not_in(enabled_keys) + end + + def empty + DeployKey.none + end + + def can_admin_project? + current_user.can?(:admin_project, project) + end + end +end diff --git a/app/finders/design_management/designs_finder.rb b/app/finders/design_management/designs_finder.rb index 857b828dc47..4793d5e0073 100644 --- a/app/finders/design_management/designs_finder.rb +++ b/app/finders/design_management/designs_finder.rb @@ -30,7 +30,7 @@ module DesignManagement attr_reader :issue, :current_user, :params def init_collection - return ::DesignManagement::Design.none unless can?(current_user, :read_design, issue) + return DesignManagement::Design.none unless can?(current_user, :read_design, issue) issue.designs end @@ -43,14 +43,14 @@ module DesignManagement def by_filename(items) return items if params[:filenames].nil? - return ::DesignManagement::Design.none if params[:filenames].empty? + return DesignManagement::Design.none if params[:filenames].empty? items.with_filename(params[:filenames]) end def by_id(items) return items if params[:ids].nil? - return ::DesignManagement::Design.none if params[:ids].empty? + return DesignManagement::Design.none if params[:ids].empty? items.id_in(params[:ids]) end diff --git a/app/finders/design_management/versions_finder.rb b/app/finders/design_management/versions_finder.rb index c4aefd3078e..8769cfdcec0 100644 --- a/app/finders/design_management/versions_finder.rb +++ b/app/finders/design_management/versions_finder.rb @@ -25,7 +25,7 @@ module DesignManagement def execute unless Ability.allowed?(current_user, :read_design, design_or_collection) - return ::DesignManagement::Version.none + return DesignManagement::Version.none end items = design_or_collection.versions diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 7bccfe453ab..4ed447a90ce 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -61,7 +61,6 @@ class EventsFinder def by_current_user_access(events) events.merge(Project.public_or_visible_to_user(current_user)) .joins(:project) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb index 3c8d53051d6..521b4aa171f 100644 --- a/app/finders/git_refs_finder.rb +++ b/app/finders/git_refs_finder.rb @@ -3,9 +3,12 @@ class GitRefsFinder include Gitlab::Utils::StrongMemoize + attr_reader :next_cursor + def initialize(repository, params = {}) @repository = repository @params = params + @next_cursor = nil end protected @@ -54,4 +57,13 @@ class GitRefsFinder def unescape_regex_operators(regex_string) regex_string.sub('\^', '^').gsub('\*', '.*?').sub('\$', '$') end + + def set_next_cursor(records) + return if records.blank? + + # TODO: Gitaly should be responsible for a cursor generation + # Follow-up for branches: https://gitlab.com/gitlab-org/gitlab/-/issues/431903 + # Follow-up for tags: https://gitlab.com/gitlab-org/gitlab/-/issues/431904 + @next_cursor = records.last.name + end end diff --git a/app/finders/groups/custom_emoji_finder.rb b/app/finders/groups/custom_emoji_finder.rb new file mode 100644 index 00000000000..80a4e948f8b --- /dev/null +++ b/app/finders/groups/custom_emoji_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Groups + class CustomEmojiFinder < Base + include FinderWithGroupHierarchy + include Gitlab::Utils::StrongMemoize + + def initialize(group, params = {}) + @group = group + @params = params + @skip_authorization = true + end + + def execute + return CustomEmoji.none if Feature.disabled?(:custom_emoji, group) + + return CustomEmoji.for_resource(group) unless params[:include_ancestor_groups] + + CustomEmoji.for_namespaces(group_ids_for(group)) + end + + private + + attr_reader :group, :params, :skip_authorization + end +end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 074eb9add0f..9cc27a3096d 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -17,6 +17,7 @@ # include_parent_descendants: boolean (defaults to false) - includes descendant groups when # filtering by parent. The parent param must be present. # include_ancestors: boolean (defaults to true) +# organization: Scope the groups to the Organizations::Organization # # Users with full private access can see all groups. The `owned` and `parent` # params can be used to restrict the groups that are returned. @@ -44,6 +45,7 @@ class GroupsFinder < UnionFinder attr_reader :current_user, :params def filter_groups(groups) + groups = by_organization(groups) groups = by_parent(groups) groups = by_custom_attributes(groups) groups = filter_group_ids(groups) @@ -93,6 +95,13 @@ class GroupsFinder < UnionFinder groups.id_in(params[:filter_group_ids]) end + def by_organization(groups) + organization = params[:organization] + return groups unless organization + + groups.in_organization(organization) + end + # rubocop: disable CodeReuse/ActiveRecord def by_parent(groups) return groups unless params[:parent] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 93b7292bb69..2b4e4592020 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -130,9 +130,7 @@ class IssuableFinder end def filter_items(items) - # Selection by group is already covered by `by_project` and `projects` for project-based issuables - # Group-based issuables have their own group filter methods - items = by_project(items) + items = by_parent(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -313,7 +311,7 @@ class IssuableFinder end # rubocop: disable CodeReuse/ActiveRecord - def by_project(items) + def by_parent(items) # When finding issues for multiple projects it's more efficient # to use a JOIN instead of running a sub-query # See https://gitlab.com/gitlab-org/gitlab/-/commit/8591cc02be6b12ed60f763a5e0147f2cbbca99e1 diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 0ba93a76342..2297c0569d9 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -109,6 +109,21 @@ class IssuesFinder < IssuableFinder super.with_projects_matching_search_data end + override :by_parent + def by_parent(items) + return super unless include_namespace_level_work_items? + + items.in_namespaces( + Namespace.from_union( + [ + Group.id_in(params.group).select(:id), + params.projects.select(:project_namespace_id) + ], + remove_duplicates: false + ) + ) + end + def by_confidential(items) return items if params[:confidential].nil? @@ -157,6 +172,12 @@ class IssuesFinder < IssuableFinder def model_class Issue end + + def include_namespace_level_work_items? + params.group? && + Array(params[:issue_types]).map(&:to_s).include?('epic') && + Feature.enabled?(:namespace_level_work_items, params.group) + end end IssuesFinder.prepend_mod_with('IssuesFinder') diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 6348bceb157..5b4b83652df 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -90,10 +90,8 @@ class MembersFinder # enumerate the columns here since we are enumerating them in the union and want to be immune to # column caching issues when adding/removing columns - members = Member.select(*Member.column_names) - .includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord - # The left join with the table users in the method distinct_on needs to be resolved - members.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") + Member.select(*Member.column_names) + .preload(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord -- TODO: Usage of `from` forces us to use this. end def distinct_on(union) @@ -102,8 +100,7 @@ class MembersFinder <<~SQL SELECT DISTINCT ON (user_id, invite_email) #{member_columns} FROM (#{union.to_sql}) AS #{member_union_table} - LEFT JOIN users on users.id = member_union.user_id - LEFT JOIN project_authorizations on project_authorizations.user_id = users.id + LEFT JOIN project_authorizations on project_authorizations.user_id = member_union.user_id AND project_authorizations.project_id = #{project.id} ORDER BY user_id, diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb index 9ffd623338f..820fb6ea291 100644 --- a/app/finders/milestones_finder.rb +++ b/app/finders/milestones_finder.rb @@ -27,9 +27,8 @@ class MilestonesFinder def execute items = Milestone.all - items = by_ids(items) + items = by_ids_or_title(items) items = by_groups_and_projects(items) - items = by_title(items) items = by_search_title(items) items = by_search(items) items = by_state(items) @@ -43,26 +42,18 @@ class MilestonesFinder private - def by_ids(items) - return items unless params[:ids].present? + def by_ids_or_title(items) + return items if params[:ids].blank? && params[:title].blank? + return items.id_in(params[:ids]) if params[:ids].present? && params[:title].blank? + return items.with_title(params[:title]) if params[:ids].blank? && params[:title].present? - items.id_in(params[:ids]) + items.with_ids_or_title(ids: params[:ids], title: params[:title]) end def by_groups_and_projects(items) items.for_projects_and_groups(params[:project_ids], params[:group_ids]) end - # rubocop: disable CodeReuse/ActiveRecord - def by_title(items) - if params[:title] - items.where(title: params[:title]) - else - items - end - end - # rubocop: enable CodeReuse/ActiveRecord - def by_search_title(items) if params[:search_title].present? items.search_title(params[:search_title]) @@ -96,7 +87,7 @@ class MilestonesFinder end def by_iids(items) - return items unless params[:iids].present? && !params[:include_parent_milestones] + return items unless params[:iids].present? && !params[:include_ancestors] items.by_iid(params[:iids]) end diff --git a/app/finders/organizations/groups_finder.rb b/app/finders/organizations/groups_finder.rb deleted file mode 100644 index 2b59a3106a3..00000000000 --- a/app/finders/organizations/groups_finder.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# Organizations::GroupsFinder -# -# Used to find Groups within an Organization -module Organizations - class GroupsFinder - # @param organization [Organizations::Organization] - # @param current_user [User] - # @param params [{ sort: { field: [String], direction: [String] }, search: [String] }] - def initialize(organization:, current_user:, params: {}) - @organization = organization - @current_user = current_user - @params = params - end - - def execute - return Group.none if organization.nil? || !authorized? - - filter_groups(all_accessible_groups) - .then { |groups| sort(groups) } - .then(&:with_route) - end - - private - - attr_reader :organization, :params, :current_user - - def all_accessible_groups - current_user.authorized_groups.in_organization(organization) - end - - def filter_groups(groups) - by_search(groups) - end - - def by_search(groups) - return groups unless params[:search].present? - - groups.search(params[:search]) - end - - def sort(groups) - return default_sort_order(groups) if params[:sort].blank? - - field = params[:sort][:field] - direction = params[:sort][:direction] - groups.reorder(field => direction) # rubocop: disable CodeReuse/ActiveRecord - end - - def default_sort_order(groups) - groups.sort_by_attribute('name_asc') - end - - def authorized? - Ability.allowed?(current_user, :read_organization, organization) - end - end -end diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb index 03855afb6e4..b288611914f 100644 --- a/app/finders/packages/maven/package_finder.rb +++ b/app/finders/packages/maven/package_finder.rb @@ -3,6 +3,8 @@ module Packages module Maven class PackageFinder < ::Packages::GroupOrProjectPackageFinder + extend ::Gitlab::Utils::Override + def execute packages end @@ -15,6 +17,15 @@ module Packages matching_packages end + + override :group_packages + def group_packages + if Feature.enabled?(:maven_remove_permissions_check_from_finder, @project_or_group) + packages_for(@current_user, within_group: @project_or_group) + else + super + end + end end end end diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb index 57e0620c7a7..024b353c920 100644 --- a/app/finders/projects/ml/model_finder.rb +++ b/app/finders/projects/ml/model_finder.rb @@ -5,7 +5,7 @@ module Projects class ModelFinder include Gitlab::Utils::StrongMemoize - VALID_ORDER_BY = %w[name created_at id].freeze + VALID_ORDER_BY = %w[name created_at updated_at id].freeze VALID_SORT = %w[asc desc].freeze def initialize(project, params = {}) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 1aa5245590e..2a781c037f6 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -29,6 +29,7 @@ # repository_storage: string # not_aimed_for_deletion: boolean # full_paths: string[] +# organization_id: int # class ProjectsFinder < UnionFinder include CustomAttributesFilter @@ -95,6 +96,7 @@ class ProjectsFinder < UnionFinder collection = by_language(collection) collection = by_feature_availability(collection) collection = by_updated_at(collection) + collection = by_organization_id(collection) by_repository_storage(collection) end @@ -293,6 +295,10 @@ class ProjectsFinder < UnionFinder items end + def by_organization_id(items) + params[:organization_id].present? ? items.in_organization(params[:organization_id]) : items + end + def finder_params return {} unless min_access_level? diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index c7d35f62673..fcc216f213b 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -27,11 +27,11 @@ class ReleasesFinder private def get_releases - Release.where(project_id: authorized_projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord + Release.for_projects(authorized_projects).tagged end def get_latest_releases - Release.latest_for_projects(authorized_projects, order_by: params[:order_by_for_latest]).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord + Release.latest_for_projects(authorized_projects, order_by: params[:order_by_for_latest]).tagged end def authorized_projects @@ -43,14 +43,6 @@ class ReleasesFinder end strong_memoize_attr :authorized_projects - # rubocop: disable CodeReuse/ActiveRecord - def by_tag(releases) - return releases unless params[:tag].present? - - releases.where(tag: params[:tag]) - end - # rubocop: enable CodeReuse/ActiveRecord - def order_releases(releases) releases.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") end @@ -58,4 +50,10 @@ class ReleasesFinder def authorized?(project) Ability.allowed?(current_user, :read_release, project) end + + def by_tag(releases) + return releases unless params[:tag].present? + + releases.by_tag(params[:tag]) + end end diff --git a/app/finders/repositories/tree_finder.rb b/app/finders/repositories/tree_finder.rb index 2a8971d4d86..8280908ff42 100644 --- a/app/finders/repositories/tree_finder.rb +++ b/app/finders/repositories/tree_finder.rb @@ -4,10 +4,13 @@ module Repositories class TreeFinder CommitMissingError = Class.new(StandardError) + attr_reader :next_cursor + def initialize(project, params = {}) @project = project @repository = project.repository @params = params + @next_cursor = nil end def execute(gitaly_pagination: false) @@ -16,7 +19,11 @@ module Repositories request_params = { recursive: recursive, rescue_not_found: rescue_not_found } request_params[:pagination_params] = pagination_params if gitaly_pagination - repository.tree(commit.id, path, **request_params).sorted_entries + tree = repository.tree(commit.id, path, **request_params) + + @next_cursor = tree.cursor&.next_cursor if gitaly_pagination + + tree.sorted_entries end def total diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index 52b1fff4883..a25d17dbaf4 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -8,8 +8,9 @@ class TagsFinder < GitRefsFinder repository.tags_sorted_by(sort) end - by_search(tags) - + by_search(tags).tap do |records| + set_next_cursor(records) if gitaly_pagination + end rescue ArgumentError => e raise Gitlab::Git::InvalidPageToken, "Invalid page token: #{page_token}" if e.message.include?('page token') diff --git a/app/finders/timelogs/timelogs_finder.rb b/app/finders/timelogs/timelogs_finder.rb new file mode 100644 index 00000000000..610dba43317 --- /dev/null +++ b/app/finders/timelogs/timelogs_finder.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Timelogs + class TimelogsFinder + attr_reader :issuable, :params + + def initialize(issuable, params = {}) + @issuable = issuable + @params = params + end + + def execute + timelogs = issuable&.timelogs || Timelog.all + timelogs = by_time(timelogs) + timelogs = by_user(timelogs) + timelogs = by_group(timelogs) + timelogs = by_project(timelogs) + apply_sorting(timelogs) + end + + private + + def by_time(timelogs) + return timelogs unless params[:start_time] || params[:end_time] + + validate_time_difference! + + timelogs = timelogs.at_or_after(params[:start_time]) if params[:start_time] + timelogs = timelogs.at_or_before(params[:end_time]) if params[:end_time] + + timelogs + end + + def by_user(timelogs) + return timelogs unless params[:username] + + user = User.find_by_username(params[:username]) + timelogs.for_user(user) + end + + def by_group(timelogs) + return timelogs unless params[:group_id] + + group = Group.find_by_id(params[:group_id]) + raise(ActiveRecord::RecordNotFound, "Group with id '#{params[:group_id]}' could not be found") unless group + + timelogs.in_group(group) + end + + def by_project(timelogs) + return timelogs unless params[:project_id] + + timelogs.in_project(params[:project_id]) + end + + def apply_sorting(timelogs) + return timelogs unless params[:sort] + + timelogs.sort_by_field(params[:sort]) + end + + def validate_time_difference! + return unless end_time_before_start_time? + + raise ArgumentError, 'Start argument must be before End argument' + end + + def end_time_before_start_time? + times_provided? && params[:end_time] < params[:start_time] + end + + def times_provided? + params[:start_time] && params[:end_time] + end + end +end diff --git a/app/finders/work_items/namespace_work_items_finder.rb b/app/finders/work_items/namespace_work_items_finder.rb index da6437e0907..95075e0ca0d 100644 --- a/app/finders/work_items/namespace_work_items_finder.rb +++ b/app/finders/work_items/namespace_work_items_finder.rb @@ -54,8 +54,8 @@ module WorkItems end strong_memoize_attr :namespace - override :by_project - def by_project(items) + override :by_parent + def by_parent(items) items end end diff --git a/app/graphql/mutations/achievements/award.rb b/app/graphql/mutations/achievements/award.rb index b486049594d..71a46a04a1c 100644 --- a/app/graphql/mutations/achievements/award.rb +++ b/app/graphql/mutations/achievements/award.rb @@ -29,10 +29,6 @@ module Mutations result = ::Achievements::AwardService.new(current_user, achievement.id, recipient_id).execute { user_achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) - end end end end diff --git a/app/graphql/mutations/achievements/create.rb b/app/graphql/mutations/achievements/create.rb index 310a653c705..497eaee9b70 100644 --- a/app/graphql/mutations/achievements/create.rb +++ b/app/graphql/mutations/achievements/create.rb @@ -41,10 +41,6 @@ module Mutations params: args).execute { achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Namespace) - end end end end diff --git a/app/graphql/mutations/achievements/delete.rb b/app/graphql/mutations/achievements/delete.rb index 0b510b44b4e..fc00261a176 100644 --- a/app/graphql/mutations/achievements/delete.rb +++ b/app/graphql/mutations/achievements/delete.rb @@ -24,10 +24,6 @@ module Mutations result = ::Achievements::DestroyService.new(current_user, achievement).execute { achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) - end end end end diff --git a/app/graphql/mutations/achievements/delete_user_achievement.rb b/app/graphql/mutations/achievements/delete_user_achievement.rb index f1527c2981a..be4c5a1d5e2 100644 --- a/app/graphql/mutations/achievements/delete_user_achievement.rb +++ b/app/graphql/mutations/achievements/delete_user_achievement.rb @@ -24,10 +24,6 @@ module Mutations result = ::Achievements::DestroyUserAchievementService.new(current_user, user_achievement).execute { user_achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement) - end end end end diff --git a/app/graphql/mutations/achievements/revoke.rb b/app/graphql/mutations/achievements/revoke.rb index 9d21b1c3741..ac5b38cefbf 100644 --- a/app/graphql/mutations/achievements/revoke.rb +++ b/app/graphql/mutations/achievements/revoke.rb @@ -24,10 +24,6 @@ module Mutations result = ::Achievements::RevokeService.new(current_user, user_achievement).execute { user_achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement) - end end end end diff --git a/app/graphql/mutations/achievements/update.rb b/app/graphql/mutations/achievements/update.rb index 2a9e6580629..8bb95ac41f3 100644 --- a/app/graphql/mutations/achievements/update.rb +++ b/app/graphql/mutations/achievements/update.rb @@ -37,10 +37,6 @@ module Mutations result = ::Achievements::UpdateService.new(current_user, achievement, args).execute { achievement: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) - end end end end diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb index 9434ac1637e..760005ae249 100644 --- a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb +++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb @@ -13,10 +13,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.object_from_id(id, expected_class: ::AlertManagement::HttpIntegration) - end - def response(result) { integration: result.payload[:integration], diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb index 28729ec70cd..19fb514d3a5 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb @@ -13,10 +13,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.object_from_id(id, expected_class: ::Integrations::Prometheus) - end - def response(integration, result) { integration: integration, diff --git a/app/graphql/mutations/boards/destroy.rb b/app/graphql/mutations/boards/destroy.rb index 61e0c95f8d3..abdffaaeb90 100644 --- a/app/graphql/mutations/boards/destroy.rb +++ b/app/graphql/mutations/boards/destroy.rb @@ -26,12 +26,6 @@ module Mutations errors: response.errors } end - - private - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Board) - end end end end diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb index 590a905ab7b..a80a6af9e2f 100644 --- a/app/graphql/mutations/boards/lists/create.rb +++ b/app/graphql/mutations/boards/lists/create.rb @@ -19,10 +19,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Board) - end - def create_list(board, params) create_list_service = ::Boards::Lists::CreateService.new(board.resource_parent, current_user, params) diff --git a/app/graphql/mutations/branch_rules/update.rb b/app/graphql/mutations/branch_rules/update.rb new file mode 100644 index 00000000000..c10e11970eb --- /dev/null +++ b/app/graphql/mutations/branch_rules/update.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Mutations + module BranchRules + class Update < BaseMutation + graphql_name 'BranchRuleUpdate' + + include FindsProject + + authorize :admin_project + + argument :id, ::Types::GlobalIDType[::ProtectedBranch], + required: true, + description: 'Global ID of the protected branch.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Branch name, with wildcards, for the branch rules.' + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path to the project that the branch is associated with.' + + field :branch_rule, + Types::Projects::BranchRuleType, + null: true, + description: 'Branch rule after mutation.' + + def resolve(id:, project_path:, name:) + protected_branch = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(id, + expected_type: ::ProtectedBranch)) + raise_resource_not_available_error! unless protected_branch + + project = authorized_find!(project_path) + + protected_branch = ::ProtectedBranches::UpdateService.new(project, current_user, + { name: name }).execute(protected_branch) + + if protected_branch.errors.empty? + { + branch_rule: ::Projects::BranchRule.new(project, protected_branch), + errors: [] + } + else + { errors: errors_on_object(protected_branch) } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/catalog/resources/base.rb b/app/graphql/mutations/ci/catalog/resources/base.rb new file mode 100644 index 00000000000..4ff245d52ec --- /dev/null +++ b/app/graphql/mutations/ci/catalog/resources/base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Catalog + module Resources + class Base < BaseMutation + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project path belonging to the catalog resource.' + + def find_object(project_path:) + Project.find_by_full_path(project_path) + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/catalog/resources/create.rb b/app/graphql/mutations/ci/catalog/resources/create.rb index 7f934e101c8..34d60f780ca 100644 --- a/app/graphql/mutations/ci/catalog/resources/create.rb +++ b/app/graphql/mutations/ci/catalog/resources/create.rb @@ -4,13 +4,9 @@ module Mutations module Ci module Catalog module Resources - class Create < BaseMutation + class Create < Base graphql_name 'CatalogResourcesCreate' - argument :project_path, GraphQL::Types::ID, - required: true, - description: 'Project to convert to a catalog resource.' - authorize :add_catalog_resource def resolve(project_path:) @@ -23,12 +19,6 @@ module Mutations errors: errors } end - - private - - def find_object(project_path:) - Project.find_by_full_path(project_path) - end end end end diff --git a/app/graphql/mutations/ci/catalog/resources/destroy.rb b/app/graphql/mutations/ci/catalog/resources/destroy.rb new file mode 100644 index 00000000000..d33a8c1ef70 --- /dev/null +++ b/app/graphql/mutations/ci/catalog/resources/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Catalog + module Resources + class Destroy < Base + graphql_name 'CatalogResourcesDestroy' + + authorize :add_catalog_resource + + def resolve(project_path:) + project = authorized_find!(project_path: project_path) + catalog_resource = project.catalog_resource + + response = ::Ci::Catalog::Resources::DestroyService.new(project, current_user).execute(catalog_resource) + + errors = response.success? ? [] : [response.message] + + { + errors: errors + } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/catalog/resources/unpublish.rb b/app/graphql/mutations/ci/catalog/resources/unpublish.rb deleted file mode 100644 index e45e9646147..00000000000 --- a/app/graphql/mutations/ci/catalog/resources/unpublish.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module Ci - module Catalog - module Resources - class Unpublish < BaseMutation - graphql_name 'CatalogResourceUnpublish' - - authorize :add_catalog_resource - - argument :id, ::Types::GlobalIDType[::Ci::Catalog::Resource], - required: true, - description: 'Global ID of the catalog resource to unpublish.' - - def resolve(id:) - catalog_resource = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id)) - authorize!(catalog_resource&.project) - - catalog_resource.unpublish! - - { - errors: [] - } - end - end - end - end - end -end diff --git a/app/graphql/mutations/container_registry/protection/rule/create.rb b/app/graphql/mutations/container_registry/protection/rule/create.rb index cf8416480a2..5b01d13d8cb 100644 --- a/app/graphql/mutations/container_registry/protection/rule/create.rb +++ b/app/graphql/mutations/container_registry/protection/rule/create.rb @@ -18,12 +18,12 @@ module Mutations required: true, description: 'Full path of the project where a protection rule is located.' - argument :container_path_pattern, + argument :repository_path_pattern, GraphQL::Types::String, required: true, description: - 'ContainerRegistryname protected by the protection rule. For example `@my-scope/my-container-*`. ' \ - 'Wildcard character `*` allowed.' + 'Container repository path pattern protected by the protection rule. ' \ + 'For example `my-project/my-container-*`. Wildcard character `*` allowed.' argument :push_protected_up_to_access_level, Types::ContainerRegistry::Protection::RuleAccessLevelEnum, diff --git a/app/graphql/mutations/container_registry/protection/rule/delete.rb b/app/graphql/mutations/container_registry/protection/rule/delete.rb new file mode 100644 index 00000000000..b1673b7c43e --- /dev/null +++ b/app/graphql/mutations/container_registry/protection/rule/delete.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module ContainerRegistry + module Protection + module Rule + class Delete < ::Mutations::BaseMutation + graphql_name 'DeleteContainerRegistryProtectionRule' + description 'Deletes a container registry protection rule. ' \ + 'Available only when feature flag `container_registry_protected_containers` is enabled.' + + authorize :admin_container_image + + argument :id, + ::Types::GlobalIDType[::ContainerRegistry::Protection::Rule], + required: true, + description: 'Global ID of the container registry protection rule to delete.' + + field :container_registry_protection_rule, + Types::ContainerRegistry::Protection::RuleType, + null: true, + description: 'Container registry protection rule that was deleted successfully.' + + def resolve(id:, **_kwargs) + if Feature.disabled?(:container_registry_protected_containers) + raise_resource_not_available_error!("'container_registry_protected_containers' feature flag is disabled") + end + + container_registry_protection_rule = authorized_find!(id: id) + + response = ::ContainerRegistry::Protection::DeleteRuleService.new(container_registry_protection_rule, + current_user: current_user).execute + + { container_registry_protection_rule: response.payload[:container_registry_protection_rule], + errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/container_registry/protection/rule/update.rb b/app/graphql/mutations/container_registry/protection/rule/update.rb new file mode 100644 index 00000000000..b4464e5b5f4 --- /dev/null +++ b/app/graphql/mutations/container_registry/protection/rule/update.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Mutations + module ContainerRegistry + module Protection + module Rule + class Update < ::Mutations::BaseMutation + graphql_name 'UpdateContainerRegistryProtectionRule' + description 'Updates a container registry protection rule to restrict access to project containers. ' \ + 'You can prevent users without certain roles from altering containers. ' \ + 'Available only when feature flag `container_registry_protected_containers` is enabled.' + + authorize :admin_container_image + + argument :id, + ::Types::GlobalIDType[::ContainerRegistry::Protection::Rule], + required: true, + description: 'Global ID of the container registry protection rule to be updated.' + + argument :repository_path_pattern, + GraphQL::Types::String, + required: false, + validates: { allow_blank: false }, + description: + 'Container\'s repository path pattern of the protection rule. ' \ + 'For example, `my-scope/my-project/container-dev-*`. ' \ + 'Wildcard character `*` allowed.' + + argument :delete_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + required: false, + validates: { allow_blank: false }, + description: + 'Maximum GitLab access level prevented from deleting a container. ' \ + 'For example, `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + argument :push_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + required: false, + validates: { allow_blank: false }, + description: + 'Maximum GitLab access level prevented from pushing a container. ' \ + 'For example, `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :container_registry_protection_rule, + Types::ContainerRegistry::Protection::RuleType, + null: true, + description: 'Container registry protection rule after mutation.' + + def resolve(id:, **kwargs) + container_registry_protection_rule = authorized_find!(id: id) + + if Feature.disabled?(:container_registry_protected_containers, container_registry_protection_rule.project) + raise_resource_not_available_error!("'container_registry_protected_containers' feature flag is disabled") + end + + response = ::ContainerRegistry::Protection::UpdateRuleService.new(container_registry_protection_rule, + current_user: current_user, params: kwargs).execute + + { container_registry_protection_rule: response.payload[:container_registry_protection_rule], + errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/custom_emoji/destroy.rb b/app/graphql/mutations/custom_emoji/destroy.rb index 64e3f2ed7d3..0dd989ad841 100644 --- a/app/graphql/mutations/custom_emoji/destroy.rb +++ b/app/graphql/mutations/custom_emoji/destroy.rb @@ -29,12 +29,6 @@ module Mutations custom_emoji: custom_emoji } end - - private - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::CustomEmoji) - end end end end diff --git a/app/graphql/mutations/customer_relations/contacts/create.rb b/app/graphql/mutations/customer_relations/contacts/create.rb index 5b4063fb89a..e3a8eba14c8 100644 --- a/app/graphql/mutations/customer_relations/contacts/create.rb +++ b/app/graphql/mutations/customer_relations/contacts/create.rb @@ -43,10 +43,6 @@ module Mutations result = ::CustomerRelations::Contacts::CreateService.new(group: group, current_user: current_user, params: args).execute { contact: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Group) - end end end end diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb index 43c50a9fb30..9be66830640 100644 --- a/app/graphql/mutations/customer_relations/organizations/create.rb +++ b/app/graphql/mutations/customer_relations/organizations/create.rb @@ -41,10 +41,6 @@ module Mutations result = ::CustomerRelations::Organizations::CreateService.new(group: group, current_user: current_user, params: args).execute { organization: result.payload, errors: result.errors } end - - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Group) - end end end end diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index 97c16ee79fe..813c5687642 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -81,6 +81,11 @@ module Mutations required: false, description: copy_field_description(Types::Namespace::PackageSettingsType, :lock_pypi_package_requests_forwarding) + argument :nuget_symbol_server_enabled, + GraphQL::Types::Boolean, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :nuget_symbol_server_enabled) + field :package_settings, Types::Namespace::PackageSettingsType, null: true, diff --git a/app/graphql/mutations/organizations/base.rb b/app/graphql/mutations/organizations/base.rb new file mode 100644 index 00000000000..112eb12f0d7 --- /dev/null +++ b/app/graphql/mutations/organizations/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module Organizations + class Base < BaseMutation + field :organization, + ::Types::Organizations::OrganizationType, + null: true, + description: 'Organization after mutation.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of the organization.' + + argument :avatar, ApolloUploadServer::Upload, + required: false, + description: 'Avatar for the organization.' + end + end +end diff --git a/app/graphql/mutations/organizations/create.rb b/app/graphql/mutations/organizations/create.rb index 0d1b204a4c1..e64c964e8b2 100644 --- a/app/graphql/mutations/organizations/create.rb +++ b/app/graphql/mutations/organizations/create.rb @@ -2,16 +2,11 @@ module Mutations module Organizations - class Create < BaseMutation + class Create < Base graphql_name 'OrganizationCreate' authorize :create_organization - field :organization, - ::Types::Organizations::OrganizationType, - null: true, - description: 'Organization created.' - argument :name, GraphQL::Types::String, required: true, description: 'Name for the organization.' @@ -28,7 +23,7 @@ module Mutations params: args ).execute - { organization: result.payload, errors: result.errors } + { organization: result.payload[:organization], errors: result.errors } end end end diff --git a/app/graphql/mutations/organizations/update.rb b/app/graphql/mutations/organizations/update.rb new file mode 100644 index 00000000000..929f2605735 --- /dev/null +++ b/app/graphql/mutations/organizations/update.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Organizations + class Update < Base + graphql_name 'OrganizationUpdate' + + authorize :admin_organization + + argument :id, + Types::GlobalIDType[::Organizations::Organization], + required: true, + description: 'ID of the organization to mutate.' + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name for the organization.' + + argument :path, GraphQL::Types::String, + required: false, + description: 'Path for the organization.' + + def resolve(id:, **args) + organization = authorized_find!(id: id) + + result = ::Organizations::UpdateService.new( + organization, + current_user: current_user, + params: args + ).execute + + { organization: result.payload[:organization], errors: result.errors } + end + end + end +end diff --git a/app/graphql/mutations/packages/protection/rule/update.rb b/app/graphql/mutations/packages/protection/rule/update.rb new file mode 100644 index 00000000000..dc1f78e6822 --- /dev/null +++ b/app/graphql/mutations/packages/protection/rule/update.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Mutations + module Packages + module Protection + module Rule + class Update < ::Mutations::BaseMutation + graphql_name 'UpdatePackagesProtectionRule' + description 'Updates a package protection rule to restrict access to project packages. ' \ + 'You can prevent users without certain permissions from altering packages. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' + + authorize :admin_package + + argument :id, + ::Types::GlobalIDType[::Packages::Protection::Rule], + required: true, + description: 'Global ID of the package protection rule to be updated.' + + argument :package_name_pattern, + GraphQL::Types::String, + required: false, + validates: { allow_blank: false }, + description: + 'Package name protected by the protection rule. For example, `@my-scope/my-package-*`. ' \ + 'Wildcard character `*` allowed.' + + argument :package_type, + Types::Packages::Protection::RulePackageTypeEnum, + required: false, + validates: { allow_blank: false }, + description: 'Package type protected by the protection rule. For example, `NPM`.' + + argument :push_protected_up_to_access_level, + Types::Packages::Protection::RuleAccessLevelEnum, + required: false, + validates: { allow_blank: false }, + description: + 'Maximum GitLab access level unable to push a package. For example, `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :package_protection_rule, + Types::Packages::Protection::RuleType, + null: true, + description: 'Packages protection rule after mutation.' + + def resolve(id:, **kwargs) + package_protection_rule = authorized_find!(id: id) + + if Feature.disabled?(:packages_protected_packages, package_protection_rule.project) + raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled") + end + + response = ::Packages::Protection::UpdateRuleService.new(package_protection_rule, + current_user: current_user, params: kwargs).execute + + { package_protection_rule: response.payload[:package_protection_rule], errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/projects/star.rb b/app/graphql/mutations/projects/star.rb new file mode 100644 index 00000000000..e4b64235c9a --- /dev/null +++ b/app/graphql/mutations/projects/star.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Projects + class Star < BaseMutation + graphql_name 'StarProject' + + authorize :read_project + + argument :project_id, + ::Types::GlobalIDType[::Project], + required: true, + description: 'Full path of the project to star or unstar.' + + argument :starred, + GraphQL::Types::Boolean, + required: true, + description: 'Indicates whether to star or unstar the project.' + + field :count, + GraphQL::Types::String, + null: false, + description: 'Number of stars for the project.' + + def resolve(project_id:, starred:) + project = authorized_find!(id: project_id) + + if current_user.starred?(project) != starred + current_user.toggle_star(project) + project.reset + end + + { + count: project.star_count + } + end + end + end +end diff --git a/app/graphql/mutations/saved_replies/base.rb b/app/graphql/mutations/saved_replies/base.rb index 79761645eb7..d89cb2d06f0 100644 --- a/app/graphql/mutations/saved_replies/base.rb +++ b/app/graphql/mutations/saved_replies/base.rb @@ -22,10 +22,6 @@ module Mutations } end end - - def find_object(id) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/saved_replies/destroy.rb b/app/graphql/mutations/saved_replies/destroy.rb index 655ed9cb798..14223c06d0c 100644 --- a/app/graphql/mutations/saved_replies/destroy.rb +++ b/app/graphql/mutations/saved_replies/destroy.rb @@ -12,7 +12,7 @@ module Mutations description: copy_field_description(Types::SavedReplyType, :id) def resolve(id:) - saved_reply = authorized_find!(id) + saved_reply = authorized_find!(id: id) result = ::Users::SavedReplies::DestroyService.new(saved_reply: saved_reply).execute present_result(result) end diff --git a/app/graphql/mutations/saved_replies/update.rb b/app/graphql/mutations/saved_replies/update.rb index f5dc81614d2..72da66736e9 100644 --- a/app/graphql/mutations/saved_replies/update.rb +++ b/app/graphql/mutations/saved_replies/update.rb @@ -20,7 +20,7 @@ module Mutations description: copy_field_description(Types::SavedReplyType, :content) def resolve(id:, name:, content:) - saved_reply = authorized_find!(id) + saved_reply = authorized_find!(id: id) result = ::Users::SavedReplies::UpdateService.new(saved_reply: saved_reply, name: name, content: content).execute present_result(result) end diff --git a/app/graphql/mutations/snippets/base.rb b/app/graphql/mutations/snippets/base.rb index acaa7b80843..01441926396 100644 --- a/app/graphql/mutations/snippets/base.rb +++ b/app/graphql/mutations/snippets/base.rb @@ -10,10 +10,6 @@ module Mutations private - def find_object(id:) - GitlabSchema.object_from_id(id, expected_type: ::Snippet) - end - def authorized_resource?(snippet) return false if snippet.nil? diff --git a/app/graphql/mutations/user_preferences/update.rb b/app/graphql/mutations/user_preferences/update.rb index 16c7b37532c..111bd258775 100644 --- a/app/graphql/mutations/user_preferences/update.rb +++ b/app/graphql/mutations/user_preferences/update.rb @@ -5,9 +5,17 @@ module Mutations class Update < BaseMutation graphql_name 'UserPreferencesUpdate' + NON_NULLABLE_ARGS = [ + :use_web_ide_extension_marketplace, + :visibility_pipeline_id_type + ].freeze + argument :issues_sort, Types::IssueSortEnum, required: false, description: 'Sort order for issue lists.' + argument :use_web_ide_extension_marketplace, GraphQL::Types::Boolean, + required: false, + description: 'Whether Web IDE Extension Marketplace is enabled for the user.' argument :visibility_pipeline_id_type, Types::VisibilityPipelineIdTypeEnum, required: false, description: 'Determines whether the pipeline list shows ID or IID.' @@ -18,6 +26,7 @@ module Mutations description: 'User preferences after mutation.' def resolve(**attributes) + attributes.delete_if { |key, value| NON_NULLABLE_ARGS.include?(key) && value.nil? } user_preferences = current_user.user_preference user_preferences.update(attributes) diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb index b1936027fdc..c4d4927fde2 100644 --- a/app/graphql/mutations/work_items/convert.rb +++ b/app/graphql/mutations/work_items/convert.rb @@ -59,10 +59,6 @@ module Mutations message = format(_('You are not allowed to change the Work Item type to %{name}.'), name: work_item_type.name) raise_resource_not_available_error! message end - - def find_object(id:) - GitlabSchema.find_by_gid(id) - end end end end diff --git a/app/graphql/mutations/work_items/delete_task.rb b/app/graphql/mutations/work_items/delete_task.rb deleted file mode 100644 index b13d7e2e3bf..00000000000 --- a/app/graphql/mutations/work_items/delete_task.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Mutations - module WorkItems - class DeleteTask < BaseMutation - graphql_name 'WorkItemDeleteTask' - - description "Deletes a task in a work item's description." - - authorize :update_work_item - - argument :id, ::Types::GlobalIDType[::WorkItem], - required: true, - description: 'Global ID of the work item.' - argument :lock_version, GraphQL::Types::Int, - required: true, - description: 'Current lock version of the work item containing the task in the description.' - argument :task_data, ::Types::WorkItems::DeletedTaskInputType, - required: true, - description: 'Arguments necessary to delete a task from a work item\'s description.', - prepare: ->(attributes, _ctx) { attributes.to_h } - - field :work_item, Types::WorkItemType, - null: true, - description: 'Updated work item.' - - def resolve(id:, lock_version:, task_data:) - work_item = authorized_find!(id: id) - task_data[:task] = authorized_find_task!(task_data[:id]) - - result = ::WorkItems::DeleteTaskService.new( - work_item: work_item, - current_user: current_user, - lock_version: lock_version, - task_params: task_data - ).execute - - response = { errors: result.errors } - response[:work_item] = work_item if result.success? - - response - end - - private - - def authorized_find_task!(task_id) - task = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(task_id)) - - if current_user.can?(:delete_work_item, task) - task - else - # Fail early if user cannot delete task - raise_resource_not_available_error! - end - end - end - end -end diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql index 8712a6f4b01..8a2b314b994 100644 --- a/app/graphql/queries/snippet/snippet.query.graphql +++ b/app/graphql/queries/snippet/snippet.query.graphql @@ -13,6 +13,7 @@ query GetSnippetQuery($ids: [SnippetID!]) { webUrl httpUrlToRepo sshUrlToRepo + hidden blobs { __typename hasUnretrievableBlobs diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb index 82d38ff89d9..565638903e8 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb @@ -32,6 +32,25 @@ module Resolvers super end + + # :project level: no customization, returning the original resolver + # :group level: add the project_ids argument + def self.[](context = :project) + case context + when :project + self + when :group + Class.new(self) do + argument :project_ids, [GraphQL::Types::ID], + required: false, + description: 'Project IDs within the group hierarchy.' + + define_method :finder_params do + { group_id: object.id, include_subgroups: true } + end + end + end + end end end end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb index 768265752d5..587077d7fd4 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb @@ -25,25 +25,6 @@ module Resolvers def finder_params { project_id: object.project.id } end - - # :project level: no customization, returning the original resolver - # :group level: add the project_ids argument - def self.[](context = :project) - case context - when :project - self - when :group - Class.new(self) do - argument :project_ids, [GraphQL::Types::ID], - required: false, - description: 'Project IDs within the group hierarchy.' - - define_method :finder_params do - { group_id: object.id, include_subgroups: true } - end - end - end - end end end end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_merge_request_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_merge_request_resolver.rb new file mode 100644 index 00000000000..81b6f1f4e23 --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_merge_request_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + class BaseMergeRequestResolver < BaseCountResolver + type Types::Analytics::CycleAnalytics::MetricType, null: true + + argument :assignee_usernames, [GraphQL::Types::String], + required: false, + description: 'Usernames of users assigned to the merge request.' + + argument :author_username, GraphQL::Types::String, + required: false, + description: 'Username of the author of the merge request.' + + argument :milestone_title, GraphQL::Types::String, + required: false, + description: 'Milestone applied to the merge request.' + + argument :label_names, [GraphQL::Types::String], + required: false, + description: 'Labels applied to the merge request.' + end + end + end +end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb index 2d722b02bf1..95080110699 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb @@ -28,22 +28,6 @@ module Resolvers finder.execute.count end - - # :project level: no customization, returning the original resolver - # :group level: add the project_ids argument - def self.[](context = :project) - case context - when :project - self - when :group - Class.new(self) do - argument :project_ids, [GraphQL::Types::ID], - required: false, - description: 'Project IDs within the group hierarchy.' - end - - end - end end end end diff --git a/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb new file mode 100644 index 00000000000..7f82c3dbada --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/stages_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + class StagesResolver < BaseResolver + type [Types::Analytics::CycleAnalytics::ValueStreams::StageType], null: true + + def resolve + list_stages({ value_stream: object }) + end + + private + + def list_stages(list_service_params) + ::Analytics::CycleAnalytics::Stages::ListService.new( + parent: namespace, + current_user: current_user, + params: list_service_params + ).execute[:stages] + end + + def namespace + object.project.project_namespace + end + end + end + end +end + +Resolvers::Analytics::CycleAnalytics::StagesResolver.prepend_mod diff --git a/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb new file mode 100644 index 00000000000..f3e3da86169 --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/value_streams_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + class ValueStreamsResolver < BaseResolver + type Types::Analytics::CycleAnalytics::ValueStreamType.connection_type, null: true + + def resolve + # FOSS only have default value stream available + [ + ::Analytics::CycleAnalytics::ValueStream.build_default_value_stream(object.project_namespace) + ] + end + end + end + end +end + +Resolvers::Analytics::CycleAnalytics::ValueStreamsResolver.prepend_mod diff --git a/app/graphql/resolvers/blame_resolver.rb b/app/graphql/resolvers/blame_resolver.rb index f8b985e6582..d411d025255 100644 --- a/app/graphql/resolvers/blame_resolver.rb +++ b/app/graphql/resolvers/blame_resolver.rb @@ -14,7 +14,7 @@ module Resolvers argument :to_line, GraphQL::Types::Int, required: false, default_value: 1, - description: 'Range ending on the line. Cannot be less than 1 or less than `to_line`.' + description: 'Range ending on the line. Cannot be smaller than `from_line` or greater than `from_line` + 100.' alias_method :blob, :object @@ -48,15 +48,18 @@ module Resolvers end def validate_line_params!(args) - if args[:from_line] <= 0 || args[:to_line] <= 0 - raise Gitlab::Graphql::Errors::ArgumentError, - '`from_line` and `to_line` must be greater than or equal to 1' - end + raise_greater_than_one unless args[:from_line] >= 1 + raise_greater_than_one unless args[:to_line] >= 1 - return unless args[:from_line] > args[:to_line] + return unless args[:to_line] < args[:from_line] || args[:to_line] >= args[:from_line] + 100 raise Gitlab::Graphql::Errors::ArgumentError, - '`to_line` must be greater than or equal to `from_line`' + '`to_line` must be greater than or equal to `from_line` and smaller than `from_line` + 100' + end + + def raise_greater_than_one + raise Gitlab::Graphql::Errors::ArgumentError, + '`from_line` and `to_line` must be greater than or equal to 1' end end end diff --git a/app/graphql/resolvers/ci/catalog/resource_resolver.rb b/app/graphql/resolvers/ci/catalog/resource_resolver.rb index 4b722bd3ec7..3ea730a5768 100644 --- a/app/graphql/resolvers/ci/catalog/resource_resolver.rb +++ b/app/graphql/resolvers/ci/catalog/resource_resolver.rb @@ -6,8 +6,6 @@ module Resolvers class ResourceResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource - authorize :read_code - type ::Types::Ci::Catalog::ResourceType, null: true argument :id, ::Types::GlobalIDType[::Ci::Catalog::Resource], @@ -28,19 +26,15 @@ module Resolvers end def resolve(id: nil, full_path: nil) - if full_path.present? - project = Project.find_by_full_path(full_path) - authorize!(project) - - raise_resource_not_available_error! unless project.catalog_resource + catalog_resource = if full_path.present? + ::Ci::Catalog::Listing.new(current_user).find_resource(full_path: full_path) + else + ::Ci::Catalog::Listing.new(current_user).find_resource(id: id.model_id) + end - project.catalog_resource - else - catalog_resource = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id)) - authorize!(catalog_resource&.project) + raise_resource_not_available_error! unless catalog_resource - catalog_resource - end + catalog_resource end end end diff --git a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb new file mode 100644 index 00000000000..9332076a493 --- /dev/null +++ b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + module Catalog + module Resources + class VersionsResolver < BaseResolver + type Types::Ci::Catalog::Resources::VersionType.connection_type, null: true + + # This allows a maximum of 1 call to the field that uses this resolver. If the + # field is evaluated on more than one node, it causes performance degradation. + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + + argument :sort, Types::Ci::Catalog::Resources::VersionSortEnum, + required: false, + description: 'Sort versions by given criteria.' + + def resolve(sort: nil) + ::Ci::Catalog::Resources::VersionsFinder.new(object, current_user, sort: sort).execute + end + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/catalog/resources_resolver.rb b/app/graphql/resolvers/ci/catalog/resources_resolver.rb index c6904dcd7f6..ec415cf25c1 100644 --- a/app/graphql/resolvers/ci/catalog/resources_resolver.rb +++ b/app/graphql/resolvers/ci/catalog/resources_resolver.rb @@ -27,17 +27,13 @@ module Resolvers description: 'Project with the namespace catalog.' def resolve_with_lookahead(scope:, project_path: nil, search: nil, sort: nil) - if project_path.present? - project = Project.find_by_full_path(project_path) - - apply_lookahead( - ::Ci::Catalog::Listing - .new(context[:current_user]) - .resources(namespace: project.root_namespace, sort: sort, search: search) - ) - elsif scope == :all - apply_lookahead(::Ci::Catalog::Listing.new(context[:current_user]).resources(sort: sort, search: search)) - end + project = Project.find_by_full_path(project_path) + + apply_lookahead( + ::Ci::Catalog::Listing + .new(context[:current_user]) + .resources(namespace: project&.root_namespace, sort: sort, search: search, scope: scope) + ) end private diff --git a/app/graphql/resolvers/ci/catalog/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/versions_resolver.rb deleted file mode 100644 index 046adeb7a67..00000000000 --- a/app/graphql/resolvers/ci/catalog/versions_resolver.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - module Ci - module Catalog - class VersionsResolver < ::Resolvers::ReleasesResolver - type Types::ReleaseType.connection_type, null: true - - # This allows a maximum of 1 call to the field that uses this resolver. If the - # field is evaluated on more than one node, it causes performance degradation. - extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 - - private - - def get_project - object.respond_to?(:project) ? object.project : object - end - - # Override the aliased method in ReleasesResolver - alias_method :project, :get_project - end - end - end -end diff --git a/app/graphql/resolvers/ci/runner_groups_resolver.rb b/app/graphql/resolvers/ci/runner_groups_resolver.rb index c1d9bcbb9bb..2928631c705 100644 --- a/app/graphql/resolvers/ci/runner_groups_resolver.rb +++ b/app/graphql/resolvers/ci/runner_groups_resolver.rb @@ -6,7 +6,7 @@ module Resolvers include Gitlab::Graphql::Authorize::AuthorizeResource include ResolvesGroups - type 'Types::GroupConnection', null: true + type Types::GroupType.connection_type, null: true authorize :read_runner authorizes_object! diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb index bc5006ae06c..50adf98fa07 100644 --- a/app/graphql/resolvers/container_repository_tags_resolver.rb +++ b/app/graphql/resolvers/container_repository_tags_resolver.rb @@ -17,7 +17,7 @@ module Resolvers alias_method :container_repository, :object def resolve(sort:, **filters) - if container_repository.migrated? && Feature.enabled?(:use_repository_list_tags_on_graphql, container_repository.project) + if container_repository.migrated? page_size = [filters[:first], filters[:last]].map(&:to_i).max result = container_repository.tags_page( diff --git a/app/graphql/resolvers/custom_emoji_resolver.rb b/app/graphql/resolvers/custom_emoji_resolver.rb new file mode 100644 index 00000000000..1e39fafe486 --- /dev/null +++ b/app/graphql/resolvers/custom_emoji_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class CustomEmojiResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + + authorize :read_custom_emoji + + argument :include_ancestor_groups, + GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Includes custom emoji from parent groups.' + + type Types::CustomEmojiType, null: true + + def resolve(**args) + Groups::CustomEmojiFinder.new(object, args).execute + end + end +end diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb index 5a6a3d678b9..360781806a4 100644 --- a/app/graphql/resolvers/group_issues_resolver.rb +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -10,7 +10,7 @@ module Resolvers include GroupIssuableResolver before_connection_authorization do |nodes, _| - projects = nodes.map(&:project) + projects = nodes.filter_map(&:project) ActiveRecord::Associations::Preloader.new(records: projects, associations: project_associations).call end diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb index 9242be7f684..d9a664b6ec2 100644 --- a/app/graphql/resolvers/group_milestones_resolver.rb +++ b/app/graphql/resolvers/group_milestones_resolver.rb @@ -2,6 +2,8 @@ module Resolvers class GroupMilestonesResolver < MilestonesResolver + include ::API::Concerns::Milestones::GroupProjectParams + argument :include_ancestors, GraphQL::Types::Boolean, required: false, description: 'Include milestones from all parent groups.' @@ -14,36 +16,7 @@ module Resolvers private def parent_id_parameters(args) - include_ancestors = args[:include_ancestors].present? - include_descendants = args[:include_descendants].present? - return { group_ids: parent.id } unless include_ancestors || include_descendants - - group_ids = if include_ancestors && include_descendants - parent.self_and_hierarchy - elsif include_ancestors - parent.self_and_ancestors - else - parent.self_and_descendants - end - - project_ids = if include_descendants - group_projects.with_issues_or_mrs_available_for_user(current_user) - else - nil - end - - { - group_ids: group_ids.public_or_visible_to_user(current_user).select(:id), - project_ids: project_ids - } - end - - def group_projects - GroupProjectsFinder.new( - group: parent, - current_user: current_user, - options: { include_subgroups: true } - ).execute + group_finder_params(parent, args) end def preloads diff --git a/app/graphql/resolvers/groups_resolver.rb b/app/graphql/resolvers/groups_resolver.rb index 902b5279364..f31f7368eeb 100644 --- a/app/graphql/resolvers/groups_resolver.rb +++ b/app/graphql/resolvers/groups_resolver.rb @@ -4,7 +4,7 @@ module Resolvers class GroupsResolver < BaseResolver include ResolvesGroups - type "Types::GroupConnection", null: true + type Types::GroupType.connection_type, null: true argument :search, GraphQL::Types::String, required: false, diff --git a/app/graphql/resolvers/issues/base_parent_resolver.rb b/app/graphql/resolvers/issues/base_parent_resolver.rb index 78ef4132baf..6b3426c5538 100644 --- a/app/graphql/resolvers/issues/base_parent_resolver.rb +++ b/app/graphql/resolvers/issues/base_parent_resolver.rb @@ -15,8 +15,7 @@ module Resolvers raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE } - # see app/graphql/types/issue_connection.rb - type 'Types::IssueConnection', null: true + type Types::IssueType.connection_type, null: true def resolve_with_lookahead(**args) return Issue.none if resource_parent.nil? diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index bc0e7334303..1b43903a508 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -21,8 +21,7 @@ module Resolvers raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE } - # see app/graphql/types/issue_connection.rb - type 'Types::IssueConnection', null: true + type Types::IssueType.connection_type, null: true before_connection_authorization do |nodes, current_user| projects = nodes.map(&:project) diff --git a/app/graphql/resolvers/kas/agent_connections_resolver.rb b/app/graphql/resolvers/kas/agent_connections_resolver.rb index cf1a47aac75..282efb4161e 100644 --- a/app/graphql/resolvers/kas/agent_connections_resolver.rb +++ b/app/graphql/resolvers/kas/agent_connections_resolver.rb @@ -12,8 +12,8 @@ module Resolvers def resolve return [] unless can_read_connected_agents? - BatchLoader::GraphQL.for(agent.id).batch(key: project, default_value: []) do |agent_ids, loader| - agents = get_connected_agents.group_by(&:agent_id).slice(*agent_ids) + BatchLoader::GraphQL.for(agent.id).batch(default_value: []) do |agent_ids, loader| + agents = get_connected_agents(agent_ids).group_by(&:agent_id) agents.each do |agent_id, connections| loader.call(agent_id, connections) @@ -27,8 +27,8 @@ module Resolvers current_user.can?(:admin_cluster, project) end - def get_connected_agents - kas_client.get_connected_agents(project: project) + def get_connected_agents(agent_ids) + kas_client.get_connected_agents_by_agent_ids(agent_ids: agent_ids) rescue GRPC::BadStatus, Gitlab::Kas::Client::ConfigurationError => e raise Gitlab::Graphql::Errors::ResourceNotAvailable, e.class.name end diff --git a/app/graphql/resolvers/ml/model_detail_resolver.rb b/app/graphql/resolvers/ml/model_detail_resolver.rb new file mode 100644 index 00000000000..01c025c1d8a --- /dev/null +++ b/app/graphql/resolvers/ml/model_detail_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Ml + class ModelDetailResolver < Resolvers::BaseResolver + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + + type ::Types::Ml::ModelType, null: true + + argument :id, ::Types::GlobalIDType[::Ml::Model], + required: true, + description: 'ID of the model.' + + def resolve(id:) + Gitlab::Graphql::Lazy.with_value(find_object(id: id)) do |ml_model| + ml_model if current_user.can?(:read_model_registry, ml_model&.project) + end + end + + private + + def find_object(id:) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/resolvers/namespaces/work_item_state_counts_resolver.rb b/app/graphql/resolvers/namespaces/work_item_state_counts_resolver.rb new file mode 100644 index 00000000000..099b509f77f --- /dev/null +++ b/app/graphql/resolvers/namespaces/work_item_state_counts_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + module Namespaces + class WorkItemStateCountsResolver < WorkItemsResolver + type Types::WorkItemStateCountsType, null: true + + def ready?(**args) + # The search filter is not supported for work times at the namespace level. + # See https://gitlab.com/gitlab-org/gitlab/-/work_items/393126 + if args[:search] + raise Gitlab::Graphql::Errors::ArgumentError, + 'Searching is not available for work items at the namespace level yet' + end + + super + end + + def resolve(**args) + return if resource_parent.nil? + + Gitlab::IssuablesCountForState.new( + finder(args), + resource_parent, + store_in_redis_cache: true + ) + end + end + end +end diff --git a/app/graphql/resolvers/organizations/groups_resolver.rb b/app/graphql/resolvers/organizations/groups_resolver.rb index 0f50713b9b4..552a14fc13f 100644 --- a/app/graphql/resolvers/organizations/groups_resolver.rb +++ b/app/graphql/resolvers/organizations/groups_resolver.rb @@ -25,12 +25,18 @@ module Resolvers private + alias_method :organization, :object + def resolve_groups(**args) - return Group.none if Feature.disabled?(:resolve_organization_groups, context[:current_user]) + return Group.none if Feature.disabled?(:resolve_organization_groups, current_user) + + extra_args = { organization: organization, include_ancestors: false, all_available: false } + groups = GroupsFinder.new(current_user, args.merge(extra_args)).execute - ::Organizations::GroupsFinder - .new(organization: object, current_user: context[:current_user], params: args) - .execute + args[:sort] ||= { field: 'name', direction: :asc } + field = args[:sort][:field] + direction = args[:sort][:direction] + groups.sort_by_attribute("#{field}_#{direction}") end end end diff --git a/app/graphql/resolvers/project_milestones_resolver.rb b/app/graphql/resolvers/project_milestones_resolver.rb index cb4e9a5cdf7..0a078836a6a 100644 --- a/app/graphql/resolvers/project_milestones_resolver.rb +++ b/app/graphql/resolvers/project_milestones_resolver.rb @@ -2,6 +2,8 @@ module Resolvers class ProjectMilestonesResolver < MilestonesResolver + include ::API::Concerns::Milestones::GroupProjectParams + argument :include_ancestors, GraphQL::Types::Boolean, required: false, description: "Also return milestones in the project's parent group and its ancestors." @@ -11,12 +13,7 @@ module Resolvers private def parent_id_parameters(args) - return { project_ids: parent.id } unless args[:include_ancestors].present? && parent.group.present? - - { - group_ids: parent.group.self_and_ancestors.select(:id), - project_ids: parent.id - } + project_finder_params(parent, args) end end end diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb index 4f52db6801d..c2be582742b 100644 --- a/app/graphql/resolvers/timelog_resolver.rb +++ b/app/graphql/resolvers/timelog_resolver.rb @@ -3,6 +3,7 @@ module Resolvers class TimelogResolver < BaseResolver include LooksAhead + include Gitlab::Graphql::Authorize::AuthorizeResource type ::Types::TimelogType.connection_type, null: false @@ -42,21 +43,30 @@ module Resolvers def resolve_with_lookahead(**args) validate_args!(object, args) - timelogs = object&.timelogs || Timelog.all - args = parse_datetime_args(args) - timelogs = apply_user_filter(timelogs, args) - timelogs = apply_project_filter(timelogs, args) - timelogs = apply_time_filter(timelogs, args) - timelogs = apply_group_filter(timelogs, args) - timelogs = apply_sorting(timelogs, args) + timelogs = Timelogs::TimelogsFinder.new(object, finder_params(args)).execute apply_lookahead(timelogs) + rescue ArgumentError => e + raise_argument_error(e.message) + rescue ActiveRecord::RecordNotFound + raise_resource_not_available_error! end private + def finder_params(args) + { + username: args[:username], + start_time: args[:start_time], + end_time: args[:end_time], + group_id: args[:group_id]&.model_id, + project_id: args[:project_id]&.model_id, + sort: args[:sort] + } + end + def preloads { note: [:note] @@ -95,58 +105,6 @@ module Resolvers args[:start_time] && args[:end_time] end - def validate_time_difference!(args) - return unless end_time_before_start_time?(args) - - raise_argument_error('Start argument must be before End argument') - end - - def end_time_before_start_time?(args) - times_provided?(args) && args[:end_time] < args[:start_time] - end - - def apply_project_filter(timelogs, args) - return timelogs unless args[:project_id] - - timelogs.in_project(args[:project_id].model_id) - end - - def apply_group_filter(timelogs, args) - return timelogs unless args[:group_id] - - group = Group.find_by_id(args[:group_id].model_id) - timelogs.in_group(group) - end - - def apply_user_filter(timelogs, args) - return timelogs unless args[:username] - - user = UserFinder.new(args[:username]).find_by_username - timelogs.for_user(user) - end - - def apply_time_filter(timelogs, args) - return timelogs unless args[:start_time] || args[:end_time] - - validate_time_difference!(args) - - if args[:start_time] - timelogs = timelogs.at_or_after(args[:start_time]) - end - - if args[:end_time] - timelogs = timelogs.at_or_before(args[:end_time]) - end - - timelogs - end - - def apply_sorting(timelogs, args) - return timelogs unless args[:sort] - - timelogs.sort_by_field(args[:sort]) - end - def raise_argument_error(message) raise Gitlab::Graphql::Errors::ArgumentError, message end diff --git a/app/graphql/resolvers/users/frecent_groups_resolver.rb b/app/graphql/resolvers/users/frecent_groups_resolver.rb index 2fc757e31ab..f6b43297898 100644 --- a/app/graphql/resolvers/users/frecent_groups_resolver.rb +++ b/app/graphql/resolvers/users/frecent_groups_resolver.rb @@ -10,12 +10,6 @@ module Resolvers def resolve return unless current_user.present? - if Feature.disabled?(:frecent_namespaces_suggestions, current_user) - raise_resource_not_available_error!("'frecent_namespaces_suggestions' feature flag is disabled") - end - - return unless Feature.enabled?(:frecent_namespaces_suggestions, current_user) - ::Users::GroupVisit.frecent_groups(user_id: current_user.id) end end diff --git a/app/graphql/resolvers/users/frecent_projects_resolver.rb b/app/graphql/resolvers/users/frecent_projects_resolver.rb index 397d4ca0cfd..9508195800a 100644 --- a/app/graphql/resolvers/users/frecent_projects_resolver.rb +++ b/app/graphql/resolvers/users/frecent_projects_resolver.rb @@ -10,10 +10,6 @@ module Resolvers def resolve return unless current_user.present? - if Feature.disabled?(:frecent_namespaces_suggestions, current_user) - raise_resource_not_available_error!("'frecent_namespaces_suggestions' feature flag is disabled") - end - ::Users::ProjectVisit.frecent_projects(user_id: current_user.id) end end diff --git a/app/graphql/resolvers/work_item_references_resolver.rb b/app/graphql/resolvers/work_item_references_resolver.rb new file mode 100644 index 00000000000..4aa071519db --- /dev/null +++ b/app/graphql/resolvers/work_item_references_resolver.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Resolvers + class WorkItemReferencesResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + include Gitlab::Graphql::Authorize::AuthorizeResource + + REFERENCES_LIMIT = 10 + + authorize :read_work_item + + type ::Types::WorkItemType.connection_type, null: true + + argument :context_namespace_path, GraphQL::Types::ID, + required: false, + description: 'Full path of the context namespace (project or group).' + + argument :refs, [GraphQL::Types::String], required: true, + description: 'Work item references. Can be either a short reference or URL.' + + def ready?(**args) + if args[:refs].size > REFERENCES_LIMIT + raise Gitlab::Graphql::Errors::ArgumentError, + format( + _('Number of references exceeds the limit. ' \ + 'Please provide no more than %{refs_limit} references at the same time.'), + refs_limit: REFERENCES_LIMIT + ) + end + + super + end + + def resolve_with_lookahead(context_namespace_path: nil, refs: []) + return WorkItem.none if refs.empty? + + @container = authorized_find!(context_namespace_path) + # Only ::Project is supported at the moment, future iterations will include ::Group. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/432555 + return WorkItem.none if container.is_a?(::Group) + + apply_lookahead(find_work_items(refs)) + end + + private + + attr_reader :container + + # rubocop: disable CodeReuse/ActiveRecord -- #references is not an ActiveRecord method + def find_work_items(references) + links, short_references = references.partition { |r| r.include?('/work_items/') } + + item_ids = references_extractor(short_references).references(:issue, ids_only: true) + item_ids << references_extractor(links).references(:work_item, ids_only: true) if links.any? + + WorkItem.id_in(item_ids.flatten) + end + # rubocop: enable CodeReuse/ActiveRecord + + def references_extractor(refs) + extractor = ::Gitlab::ReferenceExtractor.new(container, context[:current_user]) + extractor.analyze(refs.join(' '), {}) + + extractor + end + + def find_object(full_path) + Routable.find_by_full_path(full_path) + end + end +end diff --git a/app/graphql/resolvers/work_item_state_counts_resolver.rb b/app/graphql/resolvers/work_item_state_counts_resolver.rb new file mode 100644 index 00000000000..93551a57694 --- /dev/null +++ b/app/graphql/resolvers/work_item_state_counts_resolver.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Resolvers + class WorkItemStateCountsResolver < WorkItemsResolver + type Types::WorkItemStateCountsType, null: true + + def resolve(**args) + return if resource_parent.nil? + + work_item_finder = finder(prepare_finder_params(args)) + work_item_finder.parent_param = resource_parent + + Gitlab::IssuablesCountForState.new(work_item_finder, resource_parent) + end + end +end diff --git a/app/graphql/resolvers/work_items/ancestors_resolver.rb b/app/graphql/resolvers/work_items/ancestors_resolver.rb index 33adbfc9c86..efd01700a31 100644 --- a/app/graphql/resolvers/work_items/ancestors_resolver.rb +++ b/app/graphql/resolvers/work_items/ancestors_resolver.rb @@ -38,6 +38,8 @@ module Resolvers end def preload_resource_parents(work_items) + return unless current_user + projects = work_items.filter_map(&:project) namespaces = work_items.filter_map(&:namespace) group_namespaces = namespaces.select { |n| n.type == ::Group.sti_name } @@ -46,7 +48,11 @@ module Resolvers return unless projects.any? ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute - ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:namespace), current_user).execute + + if group_namespaces.any? + ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:namespace), current_user).execute + end + ActiveRecord::Associations::Preloader.new(records: projects, associations: [:namespace]).call end diff --git a/app/graphql/resolvers/work_items/types_resolver.rb b/app/graphql/resolvers/work_items/types_resolver.rb index 2508125d392..11db096b5ba 100644 --- a/app/graphql/resolvers/work_items/types_resolver.rb +++ b/app/graphql/resolvers/work_items/types_resolver.rb @@ -3,6 +3,8 @@ module Resolvers module WorkItems class TypesResolver < BaseResolver + include LooksAhead + type Types::WorkItems::TypeType.connection_type, null: true argument :taskable, ::GraphQL::Types::Boolean, @@ -10,13 +12,29 @@ module Resolvers description: 'If `true`, only taskable work item types will be returned.' \ ' Argument is experimental and can be removed in the future without notice.' - def resolve(taskable: nil) + def resolve_with_lookahead(taskable: nil) + context.scoped_set!(:resource_parent, object) + # This will require a finder in the future when groups/projects get their work item types # All groups/projects use the default types for now base_scope = ::WorkItems::Type.default base_scope = base_scope.by_type(:task) if taskable - base_scope.order_by_name_asc + apply_lookahead(base_scope.order_by_name_asc) + end + + private + + def preloads + { + widget_definitions: :enabled_widget_definitions + } + end + + def nested_preloads + { + widget_definitions: { allowed_child_types: :allowed_child_types_by_name } + } end end end diff --git a/app/graphql/types/abuse_report_type.rb b/app/graphql/types/abuse_report_type.rb index 2532530cfa9..dc40800af94 100644 --- a/app/graphql/types/abuse_report_type.rb +++ b/app/graphql/types/abuse_report_type.rb @@ -10,8 +10,6 @@ module Types authorize :read_abuse_report - expose_permissions Types::PermissionTypes::AbuseReport - field :id, Types::GlobalIDType[::AbuseReport], null: false, description: 'Global ID of the abuse report.' diff --git a/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb b/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb index 16ce9b82718..900d2873789 100644 --- a/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb +++ b/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb @@ -26,6 +26,11 @@ module Types null: true, description: 'Project the value stream belongs to, returns empty if it belongs to a group.', alpha: { milestone: '15.6' } + + field :stages, + null: true, + resolver: Resolvers::Analytics::CycleAnalytics::StagesResolver, + description: 'Value Stream stages.' end end end diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_event_enum.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_event_enum.rb new file mode 100644 index 00000000000..f7fd1121e4a --- /dev/null +++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_event_enum.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Analytics + module CycleAnalytics + module ValueStreams + class StageEventEnum < BaseEnum + graphql_name 'ValueStreamStageEvent' + description 'Stage event identifiers' + + Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum.each do |key, value| + value(key.to_s.upcase, description: "#{key.to_s.humanize} event.", value: value) + end + end + end + end + end +end diff --git a/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb new file mode 100644 index 00000000000..c8fdf8513be --- /dev/null +++ b/app/graphql/types/analytics/cycle_analytics/value_streams/stage_type.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Types + module Analytics + module CycleAnalytics + module ValueStreams + # rubocop: disable Graphql/AuthorizeTypes -- # Already authorized in parent value stream type. + class StageType < BaseObject + graphql_name 'ValueStreamStage' + + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the stage.' + + field :hidden, + GraphQL::Types::Boolean, + null: false, + description: 'Whether the stage is hidden.' + + field :custom, + GraphQL::Types::Boolean, + null: false, + description: 'Whether the stage is customized.' + + field :start_event_identifier, + StageEventEnum, + null: false, + description: 'Start event identifier.' + + field :end_event_identifier, + StageEventEnum, + null: false, + description: 'End event identifier.' + + def start_event_identifier + events_enum[object.start_event_identifier] + end + + def end_event_identifier + events_enum[object.end_event_identifier] + end + + def events_enum + Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum.with_indifferent_access + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end + end +end + +Types::Analytics::CycleAnalytics::ValueStreams::StageType.prepend_mod diff --git a/app/graphql/types/ci/catalog/resource_scope_enum.rb b/app/graphql/types/ci/catalog/resource_scope_enum.rb index b825c3a7925..728670ba913 100644 --- a/app/graphql/types/ci/catalog/resource_scope_enum.rb +++ b/app/graphql/types/ci/catalog/resource_scope_enum.rb @@ -8,6 +8,7 @@ module Types description 'Values for scoping catalog resources' value 'ALL', 'All catalog resources visible to the current user.', value: :all + value 'NAMESPACES', 'Catalog resources belonging to authorized namespaces of the user.', value: :namespaces end end end diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb index 119313ae52b..44dec23b347 100644 --- a/app/graphql/types/ci/catalog/resource_type.rb +++ b/app/graphql/types/ci/catalog/resource_type.rb @@ -32,13 +32,14 @@ module Types field :web_path, GraphQL::Types::String, null: true, description: 'Web path of the catalog resource.', alpha: { milestone: '16.1' } - field :versions, Types::ReleaseType.connection_type, null: true, + field :versions, Types::Ci::Catalog::Resources::VersionType.connection_type, null: true, description: 'Versions of the catalog resource. This field can only be ' \ 'resolved for one catalog resource in any single request.', - resolver: Resolvers::Ci::Catalog::VersionsResolver, + resolver: Resolvers::Ci::Catalog::Resources::VersionsResolver, alpha: { milestone: '16.2' } - field :latest_version, Types::ReleaseType, null: true, description: 'Latest version of the catalog resource.', + field :latest_version, Types::Ci::Catalog::Resources::VersionType, null: true, + description: 'Latest version of the catalog resource.', alpha: { milestone: '16.1' } field :latest_released_at, Types::TimeType, null: true, @@ -49,14 +50,6 @@ module Types description: 'Number of times the catalog resource has been starred.', alpha: { milestone: '16.1' } - field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, - description: 'Number of times the catalog resource has been forked.', - alpha: { milestone: '16.1' } - - field :root_namespace, Types::NamespaceType, null: true, - description: 'Root namespace of the catalog resource.', - alpha: { milestone: '16.1' } - markdown_field :readme_html, null: false, alpha: { milestone: '16.1' } @@ -73,42 +66,16 @@ module Types end def latest_version - BatchLoader::GraphQL.for(object.project).batch do |projects, loader| - latest_releases = ReleasesFinder.new(projects, current_user, latest: true).execute + BatchLoader::GraphQL.for(object).batch do |catalog_resources, loader| + latest_versions = ::Ci::Catalog::Resources::VersionsFinder.new( + catalog_resources, current_user, latest: true).execute - latest_releases.index_by(&:project).each do |project, latest_release| - loader.call(project, latest_release) + latest_versions.index_by(&:catalog_resource).each do |catalog_resource, latest_version| + loader.call(catalog_resource, latest_version) end end end - def forks_count - BatchLoader::GraphQL.wrap(object.forks_count) - end - - def root_namespace - BatchLoader::GraphQL.for(object.project_id).batch do |project_ids, loader| - projects = Project.id_in(project_ids) - - # This preloader uses traversal_ids to obtain Group-type root namespaces. - # It also preloads each project's immediate parent namespace, which effectively - # preloads the User-type root namespaces since they cannot be nested (parent == root). - Preloaders::ProjectRootAncestorPreloader.new(projects, :group).execute - root_namespaces = projects.map(&:root_ancestor) - - # NamespaceType requires the `:read_namespace` ability. We must preload the policy for - # Group-type namespaces to avoid N+1 queries caused by the authorization requests. - group_root_namespaces = root_namespaces.select { |n| n.type == ::Group.sti_name } - Preloaders::GroupPolicyPreloader.new(group_root_namespaces, current_user).execute - - # For User-type namespaces, the authorization request requires preloading the owner objects. - user_root_namespaces = root_namespaces.select { |n| n.type == ::Namespaces::UserNamespace.sti_name } - ActiveRecord::Associations::Preloader.new(records: user_root_namespaces, associations: :owner).call - - projects.each { |project| loader.call(project.id, project.root_ancestor) } - end - end - def readme_html_resolver markdown_context = context.to_h.dup.merge(project: object.project) ::MarkupHelper.markdown(object.project.repository.readme&.data, markdown_context) diff --git a/app/graphql/types/ci/catalog/resources/component_type.rb b/app/graphql/types/ci/catalog/resources/component_type.rb new file mode 100644 index 00000000000..3b4771446cb --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/component_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + # rubocop: disable Graphql/AuthorizeTypes -- Authorization is handled by VersionType + class ComponentType < BaseObject + graphql_name 'CiCatalogResourceComponent' + + field :id, ::Types::GlobalIDType[::Ci::Catalog::Resources::Component], null: false, + description: 'ID of the component.', + alpha: { milestone: '16.7' } + + field :name, GraphQL::Types::String, null: true, + description: 'Name of the component.', + alpha: { milestone: '16.7' } + + field :path, GraphQL::Types::String, null: true, + description: 'Path used to include the component.', + alpha: { milestone: '16.7' } + + field :inputs, [Types::Ci::Catalog::Resources::Components::InputType], null: true, + description: 'Inputs for the component.', + alpha: { milestone: '16.7' } + + def inputs + object.inputs.map do |key, value| + { + name: key, + required: !value&.key?('default'), + default: value&.dig('default') + } + end + end + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/ci/catalog/resources/components/input_type.rb b/app/graphql/types/ci/catalog/resources/components/input_type.rb new file mode 100644 index 00000000000..4b20c564ea7 --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/components/input_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + module Components + # rubocop: disable Graphql/AuthorizeTypes -- Authorization hanlded by ComponentType -> VersionType + class InputType < BaseObject + graphql_name 'CiCatalogResourceComponentInput' + + field :name, GraphQL::Types::String, null: true, + description: 'Name of the input.', + alpha: { milestone: '16.7' } + + field :default, GraphQL::Types::String, null: true, + description: 'Default value for the input.', + alpha: { milestone: '16.7' } + + field :required, GraphQL::Types::Boolean, null: true, + description: 'Indicates if an input is required.', + alpha: { milestone: '16.7' } + end + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/ci/catalog/resources/version_sort_enum.rb b/app/graphql/types/ci/catalog/resources/version_sort_enum.rb new file mode 100644 index 00000000000..c5a5f46605a --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/version_sort_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + class VersionSortEnum < Types::ReleaseSortEnum + graphql_name 'CiCatalogResourceVersionSort' + description 'Values for sorting catalog resource versions' + end + end + end + end +end diff --git a/app/graphql/types/ci/catalog/resources/version_type.rb b/app/graphql/types/ci/catalog/resources/version_type.rb new file mode 100644 index 00000000000..689f649afc5 --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/version_type.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + # rubocop: disable Graphql/AuthorizeTypes -- Authorization is handled by Ci::Catalog::Resources::VersionsFinder in the resolver. + class VersionType < BaseObject + graphql_name 'CiCatalogResourceVersion' + + connection_type_class Types::CountableConnectionType + + field :id, ::Types::GlobalIDType[::Ci::Catalog::Resources::Version], null: false, + description: 'Global ID of the version.', + alpha: { milestone: '16.7' } + + field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the version was created.', + alpha: { milestone: '16.7' } + + field :released_at, Types::TimeType, null: true, description: 'Timestamp of when the version was released.', + alpha: { milestone: '16.7' } + + field :tag_name, GraphQL::Types::String, null: true, method: :name, + description: 'Name of the tag associated with the version.', + alpha: { milestone: '16.7' } + + field :tag_path, GraphQL::Types::String, null: true, + description: 'Relative web path to the tag associated with the version.', + alpha: { milestone: '16.7' } + + field :author, Types::UserType, null: true, description: 'User that created the version.', + alpha: { milestone: '16.7' } + + field :commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, + description: 'Commit associated with the version.', + alpha: { milestone: '16.7' } + + field :components, Types::Ci::Catalog::Resources::ComponentType.connection_type, null: true, + description: 'Components belonging to the catalog resource.', + alpha: { milestone: '16.7' } + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end + + def tag_path + Gitlab::Routing.url_helpers.project_tag_path(object.project, object.name) + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end + end +end diff --git a/app/graphql/types/container_registry/protection/rule_type.rb b/app/graphql/types/container_registry/protection/rule_type.rb index 387f0202d2d..b80439b4de2 100644 --- a/app/graphql/types/container_registry/protection/rule_type.rb +++ b/app/graphql/types/container_registry/protection/rule_type.rb @@ -15,12 +15,12 @@ module Types null: false, description: 'ID of the container registry protection rule.' - field :container_path_pattern, + field :repository_path_pattern, GraphQL::Types::String, null: false, description: 'Container repository path pattern protected by the protection rule. ' \ - 'For example `@my-scope/my-container-*`. Wildcard character `*` allowed.' + 'For example `my-project/my-container-*`. Wildcard character `*` allowed.' field :push_protected_up_to_access_level, Types::ContainerRegistry::Protection::RuleAccessLevelEnum, diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb index d9665175449..cf8796410d3 100644 --- a/app/graphql/types/container_repository_tag_type.rb +++ b/app/graphql/types/container_repository_tag_type.rb @@ -8,7 +8,15 @@ module Types authorize :read_container_image - field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete this tag.' + expose_permissions Types::PermissionTypes::ContainerRepositoryTag + + field :can_delete, GraphQL::Types::Boolean, + null: false, + deprecated: { + reason: 'Use `userPermissions` field. See `ContainerRepositoryTagPermissions` type', + milestone: '16.7' + }, + description: 'Can the current user delete this tag.' field :created_at, Types::TimeType, null: true, description: 'Timestamp when the tag was created.' field :digest, GraphQL::Types::String, null: true, description: 'Digest of the tag.' field :location, GraphQL::Types::String, null: false, description: 'URL of the tag.' diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index dfa599e798c..c2a7eae5f94 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -8,7 +8,15 @@ module Types authorize :read_container_image - field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete the container repository.' + expose_permissions Types::PermissionTypes::ContainerRepository + + field :can_delete, GraphQL::Types::Boolean, + null: false, + deprecated: { + reason: 'Use `userPermissions` field. See `ContainerRepositoryPermissions` type', + milestone: '16.7' + }, + description: 'Can the current user delete the container repository.' field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.' field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.' field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.' diff --git a/app/graphql/types/current_user_type.rb b/app/graphql/types/current_user_type.rb new file mode 100644 index 00000000000..d5ecdeba9e2 --- /dev/null +++ b/app/graphql/types/current_user_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + # rubocop:disable Graphql/AuthorizeTypes -- This is not necessary because the superclass declares the authorization + class CurrentUserType < ::Types::UserType + graphql_name 'CurrentUser' + description 'The currently authenticated GitLab user.' + end + # rubocop:enable Graphql/AuthorizeTypes +end + +::Types::CurrentUserType.prepend_mod diff --git a/app/graphql/types/group_connection.rb b/app/graphql/types/group_connection.rb deleted file mode 100644 index e4332e24302..00000000000 --- a/app/graphql/types/group_connection.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# Normally this wouldn't be needed and we could use -# -# type Types::GroupType.connection_type, null: true -# -# in a resolver. However we can end up with cyclic definitions. -# Running the spec locally can result in errors like -# -# NameError: uninitialized constant Types::GroupType -# -# or other errors. To fix this, we created this file and use -# -# type "Types::GroupConnection", null: true -# -# which gives a delayed resolution, and the proper connection type. -# -# See gitlab/app/graphql/types/ci/runner_type.rb -# Reference: https://github.com/rmosolgo/graphql-ruby/issues/3974#issuecomment-1084444214 -# and https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#testing-tips-and-tricks -# -Types::GroupConnection = Types::GroupType.connection_type diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 74e7f256b44..7234948033b 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -21,7 +21,8 @@ module Types field :custom_emoji, type: Types::CustomEmojiType.connection_type, null: true, - description: 'Custom emoji within this namespace.', + resolver: Resolvers::CustomEmojiResolver, + description: 'Custom emoji in this namespace.', alpha: { milestone: '13.6' } field :share_with_group_lock, @@ -274,6 +275,14 @@ module Types description: 'Find a work item by IID directly associated with the group. Returns `null` if the ' \ '`namespace_level_work_items` feature flag is disabled.' + field :work_item_state_counts, + Types::WorkItemStateCountsType, + null: true, + alpha: { milestone: '16.7' }, + description: 'Counts of work items by state for the namespace. Returns `null` if the ' \ + '`namespace_level_work_items` feature flag is disabled.', + resolver: Resolvers::Namespaces::WorkItemStateCountsResolver + field :autocomplete_users, null: true, resolver: Resolvers::AutocompleteUsersResolver, @@ -330,10 +339,6 @@ module Types group.dependency_proxy_setting || group.create_dependency_proxy_setting end - def custom_emoji - object.custom_emoji if Feature.enabled?(:custom_emoji) - end - private def group diff --git a/app/graphql/types/issue_connection.rb b/app/graphql/types/issue_connection.rb deleted file mode 100644 index 2f07888b43e..00000000000 --- a/app/graphql/types/issue_connection.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# Normally this wouldn't be needed and we could use -# -# type Types::IssueType.connection_type, null: true -# -# in a resolver. However we can end up with cyclic definitions. -# Running the spec locally can result in errors like -# -# NameError: uninitialized constant Resolvers::GroupIssuesResolver -# -# or other errors. To fix this, we created this file and use -# -# type "Types::IssueConnection", null: true -# -# which gives a delayed resolution, and the proper connection type. -# -# See app/graphql/resolvers/base_issues_resolver.rb -# Reference: https://github.com/rmosolgo/graphql-ruby/issues/3974#issuecomment-1084444214 -# and https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#testing-tips-and-tricks -# -Types::IssueConnection = Types::IssueType.connection_type diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 7c7d559e05d..76590f95687 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -91,13 +91,13 @@ module Types description: 'Web URL of the issue.' 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.', + method: :parent_emails_disabled?, + description: 'Indicates if the parent project or group has email notifications disabled: `true` if email notifications are disabled.', deprecated: { reason: 'Use `emails_enabled`', milestone: '16.3' } field :emails_enabled, GraphQL::Types::Boolean, null: false, - method: :project_emails_enabled?, - description: 'Indicates if a project has email notifications disabled: `false` if email notifications are disabled.' + method: :parent_emails_enabled?, + description: 'Indicates if the parent project or group has email notifications disabled: `false` if email notifications are disabled.' field :human_time_estimate, GraphQL::Types::String, null: true, description: 'Human-readable time estimate of the issue.' @@ -162,7 +162,7 @@ module Types field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the issue.' - field :project_id, GraphQL::Types::Int, null: false, method: :project_id, + field :project_id, GraphQL::Types::Int, null: true, method: :project_id, description: 'ID of the issue project.' field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true, diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb index d7f587ff03d..491fc3d7fac 100644 --- a/app/graphql/types/issue_type_enum.rb +++ b/app/graphql/types/issue_type_enum.rb @@ -9,16 +9,16 @@ module Types value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type" end - value 'TASK', value: 'task', - description: 'Task issue type.', - alpha: { milestone: '15.2' } - value 'OBJECTIVE', value: 'objective', - description: 'Objective issue type. Available only when feature flag `okrs_mvc` is enabled.', - alpha: { milestone: '15.6' } + description: 'Objective issue type. Available only when feature flag `okrs_mvc` is enabled.', + alpha: { milestone: '15.6' } value 'KEY_RESULT', value: 'key_result', - description: 'Key Result issue type. Available only when feature flag `okrs_mvc` is enabled.', - alpha: { milestone: '15.7' } + description: 'Key Result issue type. Available only when feature flag `okrs_mvc` is enabled.', + alpha: { milestone: '15.7' } + value 'EPIC', value: 'epic', + description: 'Epic issue type. ' \ + 'Available only when feature flag `namespace_level_work_items` is enabled.', + alpha: { milestone: '16.7' } end end diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb index e7246068a05..d2d0cfd23a4 100644 --- a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb +++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb @@ -48,6 +48,9 @@ module Types value 'PREPARING', value: :preparing, description: 'Merge request diff is being created.' + value 'JIRA_ASSOCIATION', + value: :jira_association_missing, + description: 'Either the title or description must reference a Jira issue.' end end end diff --git a/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb b/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb index ac25c98941c..d5e63d9c9ca 100644 --- a/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb +++ b/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb @@ -11,7 +11,7 @@ module Types value identifier.upcase, value: identifier, - description: "Mergeability check identifier is #{identifier}." + description: check_class.description end end end diff --git a/app/graphql/types/ml/candidate_links_type.rb b/app/graphql/types/ml/candidate_links_type.rb new file mode 100644 index 00000000000..f14ab6bbc4a --- /dev/null +++ b/app/graphql/types/ml/candidate_links_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class CandidateLinksType < BaseObject + graphql_name 'MLCandidateLinks' + description 'Represents links to perform actions on the candidate' + + present_using ::Ml::CandidatePresenter + + field :show_path, GraphQL::Types::String, + null: true, description: 'Path to the details page of the candidate.', method: :path + + field :artifact_path, GraphQL::Types::String, + null: true, description: 'Path to the artifact.', method: :artifact_path + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ml/candidate_type.rb b/app/graphql/types/ml/candidate_type.rb new file mode 100644 index 00000000000..bee045c47bf --- /dev/null +++ b/app/graphql/types/ml/candidate_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class CandidateType < ::Types::BaseObject + graphql_name 'MlCandidate' + description 'Candidate for a model version in the model registry' + + connection_type_class Types::LimitedCountableConnectionType + + field :id, ::Types::GlobalIDType[::Ml::Candidate], null: false, description: 'ID of the candidate.' + + field :name, ::GraphQL::Types::String, null: false, description: 'Name of the candidate.' + + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + + field :_links, ::Types::Ml::CandidateLinksType, null: false, method: :itself, + description: 'Map of links to perform actions on the candidate.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ml/model_type.rb b/app/graphql/types/ml/model_type.rb new file mode 100644 index 00000000000..ca63918b370 --- /dev/null +++ b/app/graphql/types/ml/model_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class ModelType < ::Types::BaseObject + graphql_name 'MlModel' + description 'Machine learning model in the model registry' + + field :id, ::Types::GlobalIDType[::Ml::Model], null: false, description: 'ID of the model.' + + field :name, ::GraphQL::Types::String, null: false, description: 'Name of the model.' + + field :versions, ::Types::Ml::ModelVersionType.connection_type, null: true, + description: 'Versions of the model.' + + field :candidates, ::Types::Ml::CandidateType.connection_type, null: true, + description: 'Version candidates of the model.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ml/model_version_links_type.rb b/app/graphql/types/ml/model_version_links_type.rb new file mode 100644 index 00000000000..142f62bfad2 --- /dev/null +++ b/app/graphql/types/ml/model_version_links_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class ModelVersionLinksType < BaseObject + graphql_name 'MLModelVersionLinks' + description 'Represents links to perform actions on the model version' + + present_using ::Ml::ModelVersionPresenter + + field :show_path, GraphQL::Types::String, + null: true, description: 'Path to the details page of the model version.', method: :path + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ml/model_version_type.rb b/app/graphql/types/ml/model_version_type.rb new file mode 100644 index 00000000000..15c36a7a0d8 --- /dev/null +++ b/app/graphql/types/ml/model_version_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Ml + # rubocop: disable Graphql/AuthorizeTypes -- authorization in ModelDetailsResolver + class ModelVersionType < ::Types::BaseObject + graphql_name 'MlModelVersion' + description 'Version of a machine learning model' + + connection_type_class Types::LimitedCountableConnectionType + + field :id, ::Types::GlobalIDType[::Ml::ModelVersion], null: false, description: 'ID of the model version.' + + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + field :version, ::GraphQL::Types::String, null: false, description: 'Name of the version.' + + field :_links, ::Types::Ml::ModelVersionLinksType, null: false, method: :itself, + description: 'Map of links to perform actions on the model version.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index e1bd1f603ad..590bc0ed282 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -107,7 +107,10 @@ module Types mount_mutation Mutations::Notes::RepositionImageDiffNote mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Organizations::Create, alpha: { milestone: '16.6' } + mount_mutation Mutations::Organizations::Update, alpha: { milestone: '16.7' } mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' } + mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' } + mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' } mount_mutation Mutations::Releases::Create mount_mutation Mutations::Releases::Update mount_mutation Mutations::Releases::Delete @@ -136,10 +139,12 @@ module Types mount_mutation Mutations::DesignManagement::Update mount_mutation Mutations::ContainerExpirationPolicies::Update mount_mutation Mutations::ContainerRegistry::Protection::Rule::Create, alpha: { milestone: '16.6' } + mount_mutation Mutations::ContainerRegistry::Protection::Rule::Delete, alpha: { milestone: '16.7' } + mount_mutation Mutations::ContainerRegistry::Protection::Rule::Update, alpha: { milestone: '16.7' } mount_mutation Mutations::ContainerRepositories::Destroy mount_mutation Mutations::ContainerRepositories::DestroyTags mount_mutation Mutations::Ci::Catalog::Resources::Create, alpha: { milestone: '15.11' } - mount_mutation Mutations::Ci::Catalog::Resources::Unpublish, alpha: { milestone: '16.6' } + mount_mutation Mutations::Ci::Catalog::Resources::Destroy, alpha: { milestone: '16.6' } mount_mutation Mutations::Ci::Job::Cancel mount_mutation Mutations::Ci::Job::Play mount_mutation Mutations::Ci::Job::Retry @@ -176,13 +181,13 @@ module Types mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::Protection::Rule::Create, alpha: { milestone: '16.5' } mount_mutation Mutations::Packages::Protection::Rule::Delete, alpha: { milestone: '16.6' } + mount_mutation Mutations::Packages::Protection::Rule::Update, alpha: { milestone: '16.6' } mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo mount_mutation Mutations::WorkItems::Create, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::CreateFromTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Delete, alpha: { milestone: '15.1' } - mount_mutation Mutations::WorkItems::DeleteTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' } mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' } diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 6c6144f2357..7bf76ae7de5 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -31,7 +31,7 @@ module Types description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. ' field :nuget_duplicates_allowed, GraphQL::Types::Boolean, null: false, - description: 'Indicates whether duplicate NuGet packages are allowed for this namespace. ' + description: 'Indicates whether duplicate NuGet packages are allowed for this namespace.' field :pypi_package_requests_forwarding, GraphQL::Types::Boolean, null: true, description: 'Indicates whether PyPI package forwarding is allowed for this namespace.' @@ -58,5 +58,9 @@ module Types null: false, method: :pypi_package_requests_forwarding_locked?, description: 'Indicates whether PyPI package forwarding settings are locked by a parent namespace.' + + field :nuget_symbol_server_enabled, GraphQL::Types::Boolean, + null: false, + description: 'Indicates wheather the NuGet symbol server is enabled for this namespace.' end end diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb index e7ba8de527c..379bf9956a3 100644 --- a/app/graphql/types/organizations/organization_type.rb +++ b/app/graphql/types/organizations/organization_type.rb @@ -7,6 +7,16 @@ module Types authorize :read_organization + field :avatar_url, + type: GraphQL::Types::String, + null: true, + description: 'Avatar URL of the organization.', + alpha: { milestone: '16.7' } + field :description, + GraphQL::Types::String, + null: true, + description: 'Description of the organization.', + alpha: { milestone: '16.7' } field :groups, Types::GroupType.connection_type, null: false, @@ -33,10 +43,17 @@ module Types null: false, description: 'Path of the organization.', alpha: { milestone: '16.4' } - field :web_url, GraphQL::Types::String, + field :web_url, + GraphQL::Types::String, null: false, description: 'Web URL of the organization.', alpha: { milestone: '16.6' } + + markdown_field :description_html, null: true, alpha: { milestone: '16.7' }, &:organization_detail + + def avatar_url + object.avatar_url(only_path: false) + end end end end diff --git a/app/graphql/types/permission_types/abuse_report.rb b/app/graphql/types/permission_types/abuse_report.rb deleted file mode 100644 index abd5d545d02..00000000000 --- a/app/graphql/types/permission_types/abuse_report.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Types - module PermissionTypes - class AbuseReport < BasePermissionType - graphql_name 'AbuseReportPermissions' - - abilities :read_abuse_report, :create_note - end - end -end diff --git a/app/graphql/types/permission_types/container_repository.rb b/app/graphql/types/permission_types/container_repository.rb new file mode 100644 index 00000000000..f6a76fb4d94 --- /dev/null +++ b/app/graphql/types/permission_types/container_repository.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class ContainerRepository < BasePermissionType + graphql_name 'ContainerRepositoryPermissions' + + ability_field :destroy_container_image, + name: 'destroy_container_repository', + resolver_method: :destroy_container_image + end + end +end diff --git a/app/graphql/types/permission_types/container_repository_tag.rb b/app/graphql/types/permission_types/container_repository_tag.rb new file mode 100644 index 00000000000..e2317ccd7d7 --- /dev/null +++ b/app/graphql/types/permission_types/container_repository_tag.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class ContainerRepositoryTag < BasePermissionType + graphql_name 'ContainerRepositoryTagPermissions' + + ability_field :destroy_container_image, + name: 'destroy_container_repository_tag', + resolver_method: :destroy_container_image + end + end +end diff --git a/app/graphql/types/project_feature_access_level_enum.rb b/app/graphql/types/project_feature_access_level_enum.rb new file mode 100644 index 00000000000..a107dbedc52 --- /dev/null +++ b/app/graphql/types/project_feature_access_level_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class ProjectFeatureAccessLevelEnum < BaseEnum + graphql_name 'ProjectFeatureAccessLevel' + description 'Access level of a project feature' + + value 'DISABLED', value: ProjectFeature::DISABLED, description: 'Not enabled for anyone.' + value 'PRIVATE', value: ProjectFeature::PRIVATE, description: 'Enabled only for team members.' + value 'ENABLED', value: ProjectFeature::ENABLED, description: 'Enabled for everyone able to access the project.' + end +end diff --git a/app/graphql/types/project_feature_access_level_type.rb b/app/graphql/types/project_feature_access_level_type.rb new file mode 100644 index 00000000000..d6d9fdefa70 --- /dev/null +++ b/app/graphql/types/project_feature_access_level_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# rubocop:disable Graphql/AuthorizeTypes -- It just returns the value of an enum as an integer and a string +module Types + class ProjectFeatureAccessLevelType < Types::BaseObject + graphql_name 'ProjectFeatureAccess' + description 'Represents the access level required by the user to access a project feature' + + field :integer_value, GraphQL::Types::Int, null: true, + description: 'Integer representation of access level.', + method: :to_i + + field :string_value, Types::ProjectFeatureAccessLevelEnum, null: true, + description: 'String representation of access level.', + method: :to_i + end +end +# rubocop:enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index ec87f133843..8e84605cb05 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -151,6 +151,10 @@ module Types null: true, description: 'Number of open issues for the project.' + field :open_merge_requests_count, GraphQL::Types::Int, + null: true, + description: 'Number of open merge requests for the project.' + field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true, description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of ' \ @@ -250,6 +254,13 @@ module Types extras: [:lookahead], resolver: Resolvers::WorkItemsResolver + field :work_item_state_counts, + Types::WorkItemStateCountsType, + null: true, + alpha: { milestone: '16.7' }, + description: 'Counts of work items by state for the project.', + resolver: Resolvers::WorkItemStateCountsResolver + field :issue_status_counts, Types::IssueStatusCountsType, null: true, @@ -647,6 +658,11 @@ module Types description: 'Detailed import status of the project.', method: :import_state + field :value_streams, + description: 'Value streams available to the project.', + null: true, + resolver: Resolvers::Analytics::CycleAnalytics::ValueStreamsResolver + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end @@ -675,6 +691,19 @@ module Types end end + [:issues, :forking, :merge_requests].each do |feature| + field_name = "#{feature}_access_level" + feature_name = feature.to_s.tr("_", " ") + + field field_name, Types::ProjectFeatureAccessLevelType, + null: true, + description: "Access level required for #{feature_name} access." + + define_method field_name do + project.project_feature&.access_level(feature) + end + end + markdown_field :description_html, null: true def avatar_url @@ -689,6 +718,12 @@ module Types BatchLoader::GraphQL.wrap(object.open_issues_count) if object.feature_available?(:issues, context[:current_user]) end + def open_merge_requests_count + return unless object.feature_available?(:merge_requests, context[:current_user]) + + BatchLoader::GraphQL.wrap(object.open_merge_requests_count) + end + def forks_count BatchLoader::GraphQL.wrap(object.forks_count) end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 173e877d86c..0e39ff2c030 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -48,7 +48,7 @@ module Types required: true, description: 'Global ID of the container repository.' end - field :current_user, Types::UserType, + field :current_user, Types::CurrentUserType, null: true, description: "Get information about current user." field :design_management, Types::DesignManagementType, @@ -57,12 +57,10 @@ module Types field :echo, resolver: Resolvers::EchoResolver field :frecent_groups, [Types::GroupType], resolver: Resolvers::Users::FrecentGroupsResolver, - description: "A user's frecently visited groups. Requires the `frecent_namespaces_suggestions` feature flag to be enabled.", - alpha: { milestone: '16.6' } + description: "A user's frecently visited groups" field :frecent_projects, [Types::ProjectType], resolver: Resolvers::Users::FrecentProjectsResolver, - description: "A user's frecently visited projects. Requires the `frecent_namespaces_suggestions` feature flag to be enabled.", - alpha: { milestone: '16.6' } + description: "A user's frecently visited projects" field :gitpod_enabled, GraphQL::Types::Boolean, null: true, description: "Whether Gitpod is enabled in application settings." @@ -212,6 +210,19 @@ module Types description: 'Abuse report labels.', resolver: Resolvers::AbuseReportLabelsResolver + field :ml_model, ::Types::Ml::ModelType, + null: true, + alpha: { milestone: '16.7' }, + description: 'Find machine learning models.', + resolver: Resolvers::Ml::ModelDetailResolver + + field :work_items_by_reference, + null: true, + alpha: { milestone: '16.7' }, + description: 'Find work items by their reference.', + extras: [:lookahead], + resolver: Resolvers::WorkItemReferencesResolver + def design_management DesignManagementObject.new(nil) end diff --git a/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb index 7dd47611a2e..d4aca0a3792 100644 --- a/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb @@ -35,7 +35,7 @@ module Types description: 'URL to the file along with line number.' field :engine_name, GraphQL::Types::String, - null: false, + null: true, description: 'Code quality plugin that reported the degradation.' end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 040711b5f58..7687da35baa 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -178,7 +178,7 @@ module Types field :twitter, type: ::GraphQL::Types::String, null: true, - description: 'Twitter username of the user.' + description: 'X (formerly Twitter) username of the user.' field :discord, type: ::GraphQL::Types::String, @@ -229,5 +229,3 @@ module Types end end end - -Types::UserInterface.prepend_mod diff --git a/app/graphql/types/user_preferences_type.rb b/app/graphql/types/user_preferences_type.rb index 094c7352c96..e9ac3a28a53 100644 --- a/app/graphql/types/user_preferences_type.rb +++ b/app/graphql/types/user_preferences_type.rb @@ -14,6 +14,10 @@ module Types description: 'Determines whether the pipeline list shows ID or IID.', null: true + field :use_web_ide_extension_marketplace, GraphQL::Types::Boolean, + description: 'Whether Web IDE Extension Marketplace is enabled for the user.', + null: false + def issues_sort object.issues_sort.to_sym end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 87ca5fddf14..c5910236d51 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -14,3 +14,5 @@ module Types present_using UserPresenter end end + +Types::UserType.prepend_mod diff --git a/app/graphql/types/work_item_state_counts_type.rb b/app/graphql/types/work_item_state_counts_type.rb new file mode 100644 index 00000000000..a5fdf542464 --- /dev/null +++ b/app/graphql/types/work_item_state_counts_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes -- Parent node applies authorization + class WorkItemStateCountsType < BaseObject + graphql_name 'WorkItemStateCountsType' + description 'Represents total number of work items for the represented states' + + field :all, + GraphQL::Types::Int, + null: true, + description: 'Number of work items for the project or group.' + + field :closed, + GraphQL::Types::Int, + null: true, + description: 'Number of work items with state CLOSED for the project or group.' + + field :opened, + GraphQL::Types::Int, + null: true, + description: 'Number of work items with state OPENED for the project or group.' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 103a1c0ec9b..b42684e650b 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -67,6 +67,12 @@ module Types expose_permissions Types::PermissionTypes::WorkItem + def work_item_type + context.scoped_set!(:resource_parent, object.resource_parent) + + object.work_item_type + end + def web_url Gitlab::UrlBuilder.build(object) end diff --git a/app/graphql/types/work_items/deleted_task_input_type.rb b/app/graphql/types/work_items/deleted_task_input_type.rb deleted file mode 100644 index 92297876c89..00000000000 --- a/app/graphql/types/work_items/deleted_task_input_type.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Types - module WorkItems - class DeletedTaskInputType < BaseInputObject - graphql_name 'WorkItemDeletedTaskInput' - - argument :id, ::Types::GlobalIDType[::WorkItem], - required: true, - description: 'Global ID of the task referenced in the work item\'s description.' - argument :line_number_end, GraphQL::Types::Int, - required: true, - description: 'Last line in the Markdown source that defines the list item task.' - argument :line_number_start, GraphQL::Types::Int, - required: true, - description: 'First line in the Markdown source that defines the list item task.' - end - end -end diff --git a/app/graphql/types/work_items/type_type.rb b/app/graphql/types/work_items/type_type.rb index 4d008a21b9c..b42d73544dc 100644 --- a/app/graphql/types/work_items/type_type.rb +++ b/app/graphql/types/work_items/type_type.rb @@ -7,12 +7,20 @@ module Types authorize :read_work_item_type - field :icon_name, GraphQL::Types::String, null: true, - description: 'Icon name of the work item type.' - field :id, Types::GlobalIDType[::WorkItems::Type], null: false, - description: 'Global ID of the work item type.' - field :name, GraphQL::Types::String, null: false, - description: 'Name of the work item type.' + field :icon_name, GraphQL::Types::String, + null: true, + description: 'Icon name of the work item type.' + field :id, Types::GlobalIDType[::WorkItems::Type], + null: false, + description: 'Global ID of the work item type.' + field :name, GraphQL::Types::String, + null: false, + description: 'Name of the work item type.' + field :widget_definitions, [Types::WorkItems::WidgetDefinitionInterface], + null: true, + description: 'Available widgets for the work item type.', + method: :widgets, + alpha: { milestone: '16.7' } end end end diff --git a/app/graphql/types/work_items/widget_definition_interface.rb b/app/graphql/types/work_items/widget_definition_interface.rb new file mode 100644 index 00000000000..032e78779e7 --- /dev/null +++ b/app/graphql/types/work_items/widget_definition_interface.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module WidgetDefinitionInterface + include Types::BaseInterface + + graphql_name 'WorkItemWidgetDefinition' + + field :type, ::Types::WorkItems::WidgetTypeEnum, + null: false, + description: 'Widget type.' + + ORPHAN_TYPES = [ + ::Types::WorkItems::WidgetDefinitions::AssigneesType, + ::Types::WorkItems::WidgetDefinitions::GenericType, + ::Types::WorkItems::WidgetDefinitions::HierarchyType + ].freeze + + def self.ce_orphan_types + ORPHAN_TYPES + end + + def self.resolve_type(object, _context) + if object == ::WorkItems::Widgets::Assignees + ::Types::WorkItems::WidgetDefinitions::AssigneesType + elsif object == ::WorkItems::Widgets::Hierarchy + ::Types::WorkItems::WidgetDefinitions::HierarchyType + else + ::Types::WorkItems::WidgetDefinitions::GenericType + end + end + + orphan_types(*ce_orphan_types) + end + end +end + +Types::WorkItems::WidgetDefinitionInterface.prepend_mod diff --git a/app/graphql/types/work_items/widget_definitions/assignees_type.rb b/app/graphql/types/work_items/widget_definitions/assignees_type.rb new file mode 100644 index 00000000000..6f30148e9aa --- /dev/null +++ b/app/graphql/types/work_items/widget_definitions/assignees_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module WidgetDefinitions + # rubocop:disable Graphql/AuthorizeTypes -- Authorization too granular, parent type is authorized + class AssigneesType < BaseObject + graphql_name 'WorkItemWidgetDefinitionAssignees' + description 'Represents an assignees widget definition' + + implements Types::WorkItems::WidgetDefinitionInterface + + field :can_invite_members, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether the current user can invite members to the work item\'s parent.' + + def can_invite_members + object.can_invite_members?(current_user, resource_parent) + end + + private + + def resource_parent + context[:resource_parent] + end + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end + +Types::WorkItems::WidgetDefinitions::AssigneesType.prepend_mod diff --git a/app/graphql/types/work_items/widget_definitions/generic_type.rb b/app/graphql/types/work_items/widget_definitions/generic_type.rb new file mode 100644 index 00000000000..f3817ade654 --- /dev/null +++ b/app/graphql/types/work_items/widget_definitions/generic_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module WidgetDefinitions + # rubocop:disable Graphql/AuthorizeTypes -- Authorization too granular, parent type is authorized + class GenericType < BaseObject + graphql_name 'WorkItemWidgetDefinitionGeneric' + description 'Represents a generic widget definition' + + implements Types::WorkItems::WidgetDefinitionInterface + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widget_definitions/hierarchy_type.rb b/app/graphql/types/work_items/widget_definitions/hierarchy_type.rb new file mode 100644 index 00000000000..8bd70050c7c --- /dev/null +++ b/app/graphql/types/work_items/widget_definitions/hierarchy_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module WidgetDefinitions + # rubocop:disable Graphql/AuthorizeTypes -- authorized in work item type entity + class HierarchyType < BaseObject + graphql_name 'WorkItemWidgetDefinitionHierarchy' + description 'Represents a hierarchy widget definition' + + implements Types::WorkItems::WidgetDefinitionInterface + + field :allowed_child_types, Types::WorkItems::TypeType.connection_type, + null: true, + complexity: 5, + extras: [:parent], + description: 'Allowed child types for the work item type.' + + def allowed_child_types(parent:) + parent.allowed_child_types(cache: true) + end + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/work_items/widgets/assignees_type.rb b/app/graphql/types/work_items/widgets/assignees_type.rb index 74da3264567..ecc0bc390d3 100644 --- a/app/graphql/types/work_items/widgets/assignees_type.rb +++ b/app/graphql/types/work_items/widgets/assignees_type.rb @@ -18,11 +18,21 @@ module Types field :allows_multiple_assignees, GraphQL::Types::Boolean, null: true, method: :allows_multiple_assignees?, - description: 'Indicates whether multiple assignees are allowed.' + description: 'Indicates whether multiple assignees are allowed.', + deprecated: { + milestone: '16.7', + replacement: 'workitemWidgetDefinitionAssignees.allowsMultipleAssignees', + reason: 'Field moved to workItemType widget definition interface' + } field :can_invite_members, GraphQL::Types::Boolean, null: false, resolver_method: :can_invite_members?, - description: 'Indicates whether the current user can invite members to the work item\'s project.' + description: 'Indicates whether the current user can invite members to the work item\'s project.', + deprecated: { + milestone: '16.7', + replacement: 'workitemWidgetDefinitionAssignees.canInviteMembers', + reason: 'Field moved to workItemType widget definition interface' + } def can_invite_members? Ability.allowed?(current_user, :admin_project_member, object.work_item.project) diff --git a/app/graphql/types/work_items/widgets/labels_type.rb b/app/graphql/types/work_items/widgets/labels_type.rb index 20574b3e3bc..5e5d324341d 100644 --- a/app/graphql/types/work_items/widgets/labels_type.rb +++ b/app/graphql/types/work_items/widgets/labels_type.rb @@ -19,7 +19,12 @@ module Types field :allows_scoped_labels, GraphQL::Types::Boolean, null: true, method: :allows_scoped_labels?, - description: 'Indicates whether a scoped label is allowed.' + description: 'Indicates whether a scoped label is allowed.', + deprecated: { + milestone: '16.7', + replacement: 'WorkItemWidgetDefinitionLabels.allowsScopedLabels', + reason: 'Field moved to workItemType widget definition interface' + } end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb index cfe0b747e78..48639526c31 100644 --- a/app/helpers/active_sessions_helper.rb +++ b/app/helpers/active_sessions_helper.rb @@ -24,6 +24,6 @@ module ActiveSessionsHelper end def revoke_session_path(active_session) - profile_active_session_path(active_session.session_private_id) + user_settings_active_session_path(active_session.session_private_id) end end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 531ea08791c..07a5e711d1c 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -44,7 +44,7 @@ module AppearancesHelper end def brand_image - image_tag(brand_image_path, alt: brand_title, class: 'gl-w-10') + image_tag(brand_image_path, alt: brand_title, class: 'gl-visibility-hidden gl-h-9 js-portrait-logo-detection') end def brand_image_path diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8a0a46e6b25..49230e558a8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -315,8 +315,8 @@ module ApplicationHelper class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards) class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards) class_names << 'with-performance-bar' if performance_bar_enabled? - class_names << 'with-header' if !show_super_sidebar? || !current_user - class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar_padding + class_names << 'with-header' unless current_user + class_names << 'with-top-bar' unless @hide_top_bar_padding class_names << system_message_class class_names @@ -378,10 +378,6 @@ module ApplicationHelper external_redirect_path(url: "https://#{url[2]}/@#{url[1]}") end - def collapsed_sidebar? - cookies["sidebar_collapsed"] == "true" - end - def collapsed_super_sidebar? return false if @force_desktop_expanded_sidebar diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 0c6ab41004a..655fdf8b8ec 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -40,13 +40,10 @@ module ApplicationSettingsHelper def storage_weights # Instead of using a `Struct` we could wrap this into an object. # See https://gitlab.com/gitlab-org/gitlab/-/issues/358419 - weights = Struct.new(*Gitlab.config.repositories.storages.keys.map(&:to_sym)) + storages_weighted = @application_setting.repository_storages_with_default_weight - values = Gitlab.config.repositories.storages.keys.map do |storage| - @application_setting.repository_storages_weighted[storage] || 0 - end - - weights.new(*values) + weights = Struct.new(*storages_weighted.keys.map(&:to_sym)) + weights.new(*storages_weighted.values) end def all_protocols_enabled? @@ -500,6 +497,7 @@ module ApplicationSettingsHelper :pipeline_limit_per_project_user_sha, :invitation_flow_enforcement, :can_create_group, + :bulk_import_concurrent_pipeline_batch_limit, :bulk_import_enabled, :bulk_import_max_download_file_size, :allow_runner_registration_token, @@ -512,7 +510,8 @@ module ApplicationSettingsHelper :gitlab_shell_operation_limit, :namespace_aggregation_schedule_lease_duration_in_seconds, :ci_max_total_yaml_size_bytes, - :project_jobs_api_rate_limit + :project_jobs_api_rate_limit, + :security_txt_content ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/artifacts_helper.rb b/app/helpers/artifacts_helper.rb index 10d2714840d..3f23eacdcbb 100644 --- a/app/helpers/artifacts_helper.rb +++ b/app/helpers/artifacts_helper.rb @@ -5,7 +5,8 @@ module ArtifactsHelper { project_path: project.full_path, project_id: project.id, - can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s + can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s, + job_artifacts_count_limit: ::Ci::JobArtifacts::BulkDeleteByProjectService::JOB_ARTIFACTS_COUNT_LIMIT } end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index e447940e2af..d0a9197db0a 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -47,13 +47,13 @@ module AuthHelper provider_has_builtin_icon?(name) || provider_has_custom_icon?(name) end - def qa_selector_for_provider(provider) + def test_id_for_provider(provider) { - saml: 'saml_login_button', - openid_connect: 'oidc_login_button', - github: 'github_login_button', - gitlab: 'gitlab_oauth_login_button', - facebook: 'facebook_login_button' + saml: 'saml-login-button', + openid_connect: 'oidc-login-button', + github: 'github-login-button', + gitlab: 'gitlab-oauth-login-button', + facebook: 'facebook-login-button' }[provider.to_sym] end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index d62498aea0b..b21c8687d69 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -3,10 +3,6 @@ module AvatarsHelper DEFAULT_AVATAR_PATH = 'no_avatar.png' - def project_icon(project, options = {}) - source_icon(project, options) - end - def group_icon(group, options = {}) source_icon(group, options) end @@ -59,11 +55,13 @@ module AvatarsHelper end def author_avatar(commit_or_event, options = {}) + css_class = options[:css_class] || "gl-display-none gl-sm-display-inline-block" + user_avatar(options.merge({ user: commit_or_event.author, user_name: commit_or_event.author_name, user_email: commit_or_event.author_email, - css_class: 'd-none d-sm-inline-block' + css_class: css_class })) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 8c199aefd81..f8e43674033 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -151,6 +151,7 @@ module BlobHelper 'assets-prefix' => Gitlab::Application.config.assets.prefix, 'blob-filename' => @blob && @blob.path, 'project-id' => project.id, + 'project-path': project.full_path, 'is-markdown' => @blob && @blob.path && Gitlab::MarkupHelper.gitlab_markdown?(@blob.path), 'preview-markdown-path' => preview_markdown_path(project) } diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index 6996c7a1766..da8310995cc 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -22,9 +22,7 @@ module BreadcrumbsHelper end def breadcrumb_list_item(link) - content_tag "li" do - link + sprite_icon("chevron-lg-right", size: 8, css_class: "breadcrumbs-list-angle") - end + content_tag :li, link, class: 'gl-breadcrumb-item' end def add_to_breadcrumb_collapsed_links(link, location: :before) diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 216a8bc8fa1..37b008611e6 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -4,15 +4,16 @@ module Ci module JobsHelper def jobs_data(project, build) { - "endpoint" => project_job_path(project, build, format: :json), + "job_endpoint" => project_job_path(project, build, format: :json), + "log_endpoint" => trace_project_job_path(project, build, format: :json), + "test_report_summary_url" => test_report_summary_project_job_path(project, build, format: :json), "page_path" => project_job_path(project, build), "project_path" => project.full_path, "artifact_help_url" => help_page_path('user/gitlab_com/index.md', anchor: 'gitlab-cicd'), "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'), "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'), - "build_status" => build.status, - "build_stage" => build.stage_name, - "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') + "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'), + "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline) } end diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index 4d1bdf5fa7f..f78a4aeaa49 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -14,6 +14,7 @@ module Ci total_branches = project.repository_exists? ? project.repository.branch_count : 0 { + "ci-catalog-path" => explore_catalog_index_path, "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), @@ -34,7 +35,7 @@ module Ci "simulate-pipeline-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'simulate-a-cicd-pipeline'), "total-branches" => total_branches, "uses-external-config" => uses_external_config?(project) ? 'true' : 'false', - "validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'), + "validate-tab-illustration-path" => image_path('illustrations/empty-state/empty-devops-md.svg'), "yml-help-page-path" => help_page_path('ci/yaml/index') } end diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 9c4ceaccff1..156df0c4cc4 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -87,7 +87,8 @@ module Ci pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project), suggested_ci_templates: suggested_ci_templates.to_json, full_path: project.full_path, - visibility_pipeline_id_type: visibility_pipeline_id_type + visibility_pipeline_id_type: visibility_pipeline_id_type, + show_jenkins_ci_prompt: show_jenkins_ci_prompt(project).to_s } end @@ -104,5 +105,12 @@ module Ci yield markdown(warning.content) end end + + def show_jenkins_ci_prompt(project) + return false unless can?(current_user, :create_pipeline, project) + return false if project.repository.gitlab_ci_yml.present? + + project.repository.jenkinsfile? + end end end diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 7cc554bbeeb..f92352afaed 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -31,7 +31,7 @@ module Ci span_class = 'gl-text-orange-500' end - content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do + content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner-status-icon', qa_status: status }) do sprite_icon(icon, size: size, css_class: icon_class) end end @@ -53,16 +53,6 @@ module Ci end end - # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested. - # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-foss/issues/55920 - def runner_contacted_at(runner) - if params[:sort] == 'contacted_asc' - runner.uncached_contacted_at - else - runner.contacted_at - end - end - def admin_runners_data_attributes { # Runner install help page is external, located at diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 7c239f78088..3756584e3b3 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -3,26 +3,6 @@ module DashboardHelper include IconsHelper - def assigned_issues_dashboard_path - issues_dashboard_path(assignee_username: current_user.username) - end - - def assigned_mrs_dashboard_path - merge_requests_dashboard_path(assignee_username: current_user.username) - end - - def reviewer_mrs_dashboard_path - merge_requests_dashboard_path(reviewer_username: current_user.username) - end - - def dashboard_nav_links - @dashboard_nav_links ||= get_dashboard_nav_links - end - - def dashboard_nav_link?(link) - dashboard_nav_links.include?(link) - end - def has_start_trial? false end @@ -53,18 +33,6 @@ module DashboardHelper end end end - - private - - def get_dashboard_nav_links - links = [:projects, :groups, :snippets, :your_work, :explore] - - if can?(current_user, :read_cross_project) - links += [:activity, :milestones] - end - - links - end end DashboardHelper.prepend_mod_with('DashboardHelper') diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index b6e0b2d6b20..97ca7dd7ed0 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -22,7 +22,7 @@ module DropdownsHelper end content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" } - content_tag_options[:data] = options[:dropdown_qa_selector] ? { qa_selector: (options[:dropdown_qa_selector]).to_s } : {} + content_tag_options[:data] ||= {} content_tag_options[:data][:testid] = (options[:dropdown_testid]).to_s if options[:dropdown_testid] dropdown_output << content_tag(:div, content_tag_options) do diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 28bdd3e69b6..6b1e3075968 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -13,6 +13,8 @@ module EnvironmentsHelper { "endpoint" => folder_project_environments_path(@project, @folder, format: :json), "folder_name" => @folder, + "project_path" => project_path(@project), + "help_page_path" => help_page_path("ci/environments/index"), "can_read_environment" => can?(current_user, :read_environment, @project).to_s } end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 769af0d9ef9..ebd0c3aaf6a 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -309,14 +309,14 @@ module EventsHelper base_class = 'system-note-image' classes = current_path?('users#activity') ? "#{event.action_name.parameterize}-icon gl-rounded-full gl-bg-gray-50 gl-line-height-0" : "user-avatar" - content = current_path?('users#activity') ? icon_for_event(event.action_name, size: 14) : author_avatar(event, size: 32) + content = current_path?('users#activity') ? icon_for_event(event.action_name, size: 14) : author_avatar(event, size: 32, css_class: 'gl-display-inline-block') tag.div(class: "#{base_class} #{classes}") { content } end def inline_event_icon(event) unless current_path?('users#activity') - content_tag :span, class: "system-note-image-inline d-none d-sm-flex gl-mr-2 #{event.action_name.parameterize}-icon align-self-center" do + content_tag :span, class: "system-note-image-inline gl-display-flex gl-mr-2 #{event.action_name.parameterize}-icon align-self-center" do next design_event_icon(event.action, size: 14) if event.design? icon_for_event(event.action_name, size: 14) diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index ed24f2509e8..0b58ebc1fb2 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -25,14 +25,6 @@ module ExploreHelper request_path_with_options(options) end - def explore_nav_links - @explore_nav_links ||= get_explore_nav_links - end - - def explore_nav_link?(link) - explore_nav_links.include?(link) - end - def public_visibility_restricted? Gitlab::VisibilityLevel.public_visibility_restricted? end @@ -56,10 +48,6 @@ module ExploreHelper private - def get_explore_nav_links - [:projects, :groups, :topics, :snippets] - end - def request_path_with_options(options = {}) request.path + "?#{options.to_param}" end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 6cabdf21483..25a2cc8a5ae 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -208,21 +208,42 @@ module GroupsHelper end def access_level_roles_user_can_assign(group) - return {} unless current_user - return group.access_level_roles if current_user.can_admin_all_resources? + max_access_level = group.max_member_access_for_user(current_user) + group.access_level_roles.select do |_name, access_level| + access_level <= max_access_level + end + end + + def groups_projects_more_actions_dropdown_data(source) + model_name = source.model_name.to_s.downcase + dropdown_data = { + is_group: source.is_a?(Group).to_s, + id: source.id + } - max_access_level = group.highest_group_member(current_user)&.access_level + return dropdown_data unless current_user - return {} unless max_access_level + if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord -- we need to fetch it + dropdown_data[:leave_path] = polymorphic_path([:leave, source, :members]) + dropdown_data[:leave_confirm_message] = leave_confirmation_message(source) + elsif source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord -- we need to fetch it + requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord -- we need to fetch it + if can?(current_user, :withdraw_member_access_request, requester) + dropdown_data[:withdraw_path] = polymorphic_path([:leave, source, :members]) + dropdown_data[:withdraw_confirm_message] = remove_member_message(requester) + end + elsif source.request_access_enabled && can?(current_user, :request_access, source) + dropdown_data[:request_access_path] = polymorphic_path([:request_access, source, :members]) + end - GroupMember.access_level_roles.select { |_k, v| v <= max_access_level } + dropdown_data end private def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do - icon = group_icon(group, alt: group.name, class: "avatar-tile", width: 15, height: 15) if group.try(:avatar_url) || show_avatar + icon = render Pajamas::AvatarComponent.new(group, alt: group.name, class: "avatar-tile", size: 16) if group.try(:avatar_url) || show_avatar [icon, simple_sanitize(group.name)].join.html_safe end end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index f2d393f1f77..2ec11b8a9ed 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -52,6 +52,19 @@ module IdeHelper {} end + def new_ide_oauth_data + return {} unless ::Gitlab::WebIde::DefaultOauthApplication.feature_enabled?(current_user) + return {} unless ::Gitlab::WebIde::DefaultOauthApplication.oauth_application + + client_id = ::Gitlab::WebIde::DefaultOauthApplication.oauth_application.uid + callback_url = ::Gitlab::WebIde::DefaultOauthApplication.oauth_callback_url + + { + 'client-id' => client_id, + 'callback-url' => callback_url + } + end + def new_ide_data(project:) { 'project-path' => project&.path_with_namespace, @@ -59,7 +72,7 @@ module IdeHelper # We will replace these placeholders in the FE 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'), 'editor-font' => new_ide_fonts.to_json - }.merge(new_ide_code_suggestions_data) + }.merge(new_ide_code_suggestions_data).merge(new_ide_oauth_data) end def legacy_ide_data(project:) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index f2f20fa1b50..e02b4fa0410 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -149,16 +149,6 @@ module IssuablesHelper end end - def assigned_open_issues_count_text - count = assigned_issuables_count(:issues) - - if count > User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT - 1 - "#{count - 1}+" - else - count.to_s - end - end - def issuable_reference(issuable) @show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project) end @@ -248,7 +238,8 @@ module IssuablesHelper title: label.title, description: label.description, color: label.color, - text_color: label.text_color + text_color: label.text_color, + lock_on_merge: label.lock_on_merge } end @@ -265,7 +256,8 @@ module IssuablesHelper initial_labels: initial_labels.to_json, issuable_type: issuable.issuable_type, labels_filter_base_path: filter_base_path, - labels_manage_path: project_labels_path(project) + labels_manage_path: project_labels_path(project), + supports_lock_on_merge: issuable.supports_lock_on_merge?.to_s } end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 4419b573701..b9499d13076 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -166,10 +166,20 @@ module IssuesHelper jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: url_for(safe_params.merge(rss_url_options)), sign_in_path: new_user_session_path, - has_issue_date_filter_feature: Feature.enabled?(:issue_date_filter, namespace).to_s + has_issue_date_filter_feature: has_issue_date_filter_feature?(namespace, current_user).to_s } end + def has_issue_date_filter_feature?(namespace, current_user) + enabled_for_user = Feature.enabled?(:issue_date_filter, current_user) + return true if enabled_for_user + + enabled_for_group = Feature.enabled?(:issue_date_filter, namespace.group) if namespace.respond_to?(:group) + return true if enabled_for_group + + Feature.enabled?(:issue_date_filter, namespace) + end + def project_issues_list_data(project, current_user) common_issues_list_data(project, current_user).merge( can_bulk_update: can?(current_user, :admin_issue, project).to_s, @@ -218,6 +228,7 @@ module IssuesHelper dashboard_milestones_path: dashboard_milestones_path(format: :json), empty_state_with_filter_svg_path: image_path('illustrations/empty-state/empty-issues-md.svg'), empty_state_without_filter_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'), + has_issue_date_filter_feature: Feature.enabled?(:issue_date_filter, current_user).to_s, initial_sort: current_user&.user_preference&.issues_sort, is_public_visibility_restricted: Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s, diff --git a/app/helpers/json_helper.rb b/app/helpers/json_helper.rb deleted file mode 100644 index 2a1a6272cc9..00000000000 --- a/app/helpers/json_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module JsonHelper - # These two JSON helpers are short-form wrappers for the Gitlab::Json - # class, which should be used in place of .to_json calls or calls to - # the JSON class. - def json_generate(...) - Gitlab::Json.generate(...) - end - - def json_parse(...) - Gitlab::Json.parse(...) - end -end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 1dc4c393bf2..2f042ea6417 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -282,10 +282,6 @@ module MergeRequestsHelper _('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe } end - def single_file_file_by_file? - Feature.enabled?(:single_file_file_by_file, @project) - end - def sticky_header_data data = { iid: @merge_request.iid, diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index 88e834b537a..af81e7832c8 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -116,7 +116,7 @@ module Nav id: 'general_new_project', title: _('New project/repository'), href: new_project_path, - data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_project_link' } + data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global-new-project-link' } ) ) end @@ -127,7 +127,7 @@ module Nav id: 'general_new_group', title: _('New group'), href: new_group_path, - data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_group_link' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global-new-group-link' } ) ) end @@ -149,7 +149,7 @@ module Nav id: 'general_new_snippet', title: _('New snippet'), href: new_snippet_path, - data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_snippet_link' } + data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global-new-snippet-link' } ) ) end diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb deleted file mode 100644 index d74efac76aa..00000000000 --- a/app/helpers/nav/top_nav_helper.rb +++ /dev/null @@ -1,340 +0,0 @@ -# frozen_string_literal: true - -module Nav - module TopNavHelper - PROJECTS_VIEW = :projects - GROUPS_VIEW = :groups - NEW_VIEW = :new - SEARCH_VIEW = :search - - def top_nav_view_model(project:, group:) - builder = ::Gitlab::Nav::TopNavViewModelBuilder.new - - build_base_view_model(builder: builder, project: project, group: group) - - builder.build - end - - def top_nav_responsive_view_model(project:, group:) - builder = ::Gitlab::Nav::TopNavViewModelBuilder.new - - build_base_view_model(builder: builder, project: project, group: group) - - new_view_model = new_dropdown_view_model(project: project, group: group) - - if new_view_model && new_view_model.fetch(:menu_sections)&.any? - builder.add_view(NEW_VIEW, new_view_model) - end - - if top_nav_show_search - builder.add_view(SEARCH_VIEW, ::Gitlab::Nav::TopNavMenuItem.build(**top_nav_search_menu_item_attrs)) - end - - builder.build - end - - def top_nav_show_search - header_link?(:search) - end - - def top_nav_search_menu_item_attrs - { - id: 'search', - title: _('Search'), - icon: 'search', - href: search_context.search_url - } - end - - private - - def top_nav_localized_headers - { - explore: s_('TopNav|Explore'), - switch_to: s_('TopNav|Switch to') - }.freeze - end - - def build_base_view_model(builder:, project:, group:) - if current_user - build_view_model(builder: builder, project: project, group: group) - else - build_anonymous_view_model(builder: builder) - end - end - - def build_anonymous_view_model(builder:) - if explore_nav_link?(:projects) - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:explore], - href: explore_root_path, - active: nav == 'project' || active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]), - **projects_menu_item_attrs - ) - end - - if explore_nav_link?(:groups) - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:explore], - href: explore_groups_path, - active: nav == 'group' || active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']), - **groups_menu_item_attrs - ) - end - - if explore_nav_link?(:topics) - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:explore], - active: active_nav_link?(page: topics_explore_projects_path, path: 'projects#topic'), - href: topics_explore_projects_path, - **topics_menu_item_attrs - ) - end - - if explore_nav_link?(:snippets) - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:explore], - active: active_nav_link?(controller: :snippets), - href: explore_snippets_path, - **snippets_menu_item_attrs - ) - end - end - - def build_view_model(builder:, project:, group:) - # These come from `app/views/layouts/nav/_dashboard.html.haml` - if dashboard_nav_link?(:projects) - current_item = project ? current_project(project: project) : {} - - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:switch_to], - active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), - data: { track_label: "projects_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "projects_dropdown" }, - view: PROJECTS_VIEW, - shortcut_href: dashboard_projects_path, - **projects_menu_item_attrs - ) - builder.add_view(PROJECTS_VIEW, container_view_props(namespace: 'projects', current_item: current_item, submenu: projects_submenu)) - end - - if dashboard_nav_link?(:groups) - current_item = group ? current_group(group: group) : {} - - builder.add_primary_menu_item_with_shortcut( - header: top_nav_localized_headers[:switch_to], - active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]), - data: { track_label: "groups_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "groups_dropdown" }, - view: GROUPS_VIEW, - shortcut_href: dashboard_groups_path, - **groups_menu_item_attrs - ) - builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu)) - end - - if dashboard_nav_link?(:your_work) - builder.add_primary_menu_item( - id: 'your-work', - header: top_nav_localized_headers[:switch_to], - title: _('Your work'), - href: dashboard_projects_path, - active: active_nav_link?(controller: []), - icon: 'work', - data: { **menu_data_tracking_attrs('your-work') } - ) - end - - if dashboard_nav_link?(:explore) - builder.add_primary_menu_item( - id: 'explore', - header: top_nav_localized_headers[:switch_to], - title: _('Explore'), - href: explore_projects_path, - active: active_nav_link?(controller: ["explore/groups", "explore/snippets"], page: ["/explore/projects", "/explore", "/explore/projects/topics"], path: ["projects#topic"]), - icon: 'compass', - data: { **menu_data_tracking_attrs('explore') } - ) - end - - if dashboard_nav_link?(:milestones) - builder.add_shortcut( - id: 'milestones-shortcut', - title: _('Milestones'), - href: dashboard_milestones_path, - css_class: 'dashboard-shortcuts-milestones' - ) - end - - if dashboard_nav_link?(:snippets) - builder.add_shortcut( - id: 'snippets-shortcut', - title: _('Snippets'), - href: dashboard_snippets_path, - css_class: 'dashboard-shortcuts-snippets' - ) - end - - if dashboard_nav_link?(:activity) - builder.add_shortcut( - id: 'activity-shortcut', - title: _('Activity'), - href: activity_dashboard_path, - css_class: 'dashboard-shortcuts-activity' - ) - end - - # Using admin? is generally discouraged because it does not check for - # "admin_mode". In this case we are migrating code and check both, so - # we should be good. - # rubocop: disable Cop/UserAdmin - if current_user&.admin? - title = _('Admin') - - builder.add_secondary_menu_item( - id: 'admin', - title: title, - active: active_nav_link?(controller: 'admin/dashboard'), - icon: 'admin', - href: admin_root_path, - data: { qa_selector: 'admin_area_link', **menu_data_tracking_attrs(title) } - ) - end - - if Gitlab::CurrentSettings.admin_mode - if header_link?(:admin_mode) - builder.add_secondary_menu_item( - id: 'leave_admin_mode', - title: _('Leave admin mode'), - active: active_nav_link?(controller: 'admin/sessions'), - icon: 'lock-open', - href: destroy_admin_session_path, - data: { method: 'post', **menu_data_tracking_attrs('leave_admin_mode') } - ) - elsif current_user.admin? - title = _('Enter admin mode') - - builder.add_secondary_menu_item( - id: 'enter_admin_mode', - title: title, - active: active_nav_link?(controller: 'admin/sessions'), - icon: 'lock', - href: new_admin_session_path, - data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } - ) - end - end - # rubocop: enable Cop/UserAdmin - end - - def projects_menu_item_attrs - { - id: 'project', - title: _('Projects'), - icon: 'project', - shortcut_class: 'dashboard-shortcuts-projects' - } - end - - def groups_menu_item_attrs - { - id: 'groups', - title: _('Groups'), - icon: 'group', - shortcut_class: 'dashboard-shortcuts-groups' - } - end - - def topics_menu_item_attrs - { - id: 'topics', - title: _('Topics'), - icon: 'labels', - shortcut_class: 'dashboard-shortcuts-topics' - } - end - - def snippets_menu_item_attrs - { - id: 'snippets', - title: _('Snippets'), - icon: 'snippet', - shortcut_class: 'dashboard-shortcuts-snippets' - } - end - - def menu_data_tracking_attrs(label) - tracking_attrs( - "menu_#{label.underscore.parameterize(separator: '_')}", - 'click_dropdown', - 'navigation_top' - )[:data] || {} - end - - def container_view_props(namespace:, current_item:, submenu:) - { - namespace: namespace, - currentUserName: current_user&.username, - currentItem: current_item, - linksPrimary: submenu[:primary], - linksSecondary: submenu[:secondary] - } - end - - def current_project(project:) - return {} unless project.persisted? - - { - id: project.id, - name: project.name, - namespace: project.full_name, - webUrl: project_path(project), - avatarUrl: project.avatar_url - } - end - - def current_group(group:) - return {} unless group.persisted? - - { - id: group.id, - name: group.name, - namespace: group.full_name, - webUrl: group_path(group), - avatarUrl: group.avatar_url - } - end - - def projects_submenu - builder = ::Gitlab::Nav::TopNavMenuBuilder.new - projects_submenu_items(builder: builder) - builder.build - end - - def projects_submenu_items(builder:) - title = _('View all projects') - - builder.add_primary_menu_item( - id: 'your', - title: title, - href: dashboard_projects_path, - data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } - ) - end - - def groups_submenu - # These group links come from `app/views/layouts/nav/groups_dropdown/_show.html.haml` - builder = ::Gitlab::Nav::TopNavMenuBuilder.new - - title = _('View all groups') - - builder.add_primary_menu_item( - id: 'your', - title: title, - href: dashboard_groups_path, - data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } - ) - builder.build - end - end -end - -Nav::TopNavHelper.prepend_mod diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 0c61749701e..cb9a270253f 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -11,28 +11,11 @@ module NavHelper header_links.include?(link) end - def page_has_sidebar? - defined?(@left_sidebar) && @left_sidebar - end - - def page_has_collapsed_sidebar? - page_has_sidebar? && collapsed_sidebar? - end - - def page_has_collapsed_super_sidebar? - page_has_sidebar? && collapsed_super_sidebar? - end - def page_with_sidebar_class class_name = page_gutter_class - if show_super_sidebar? - class_name << 'page-with-super-sidebar' if page_has_sidebar? - class_name << 'page-with-super-sidebar-collapsed' if page_has_collapsed_super_sidebar? - else - class_name << 'page-with-contextual-sidebar' if page_has_sidebar? - class_name << 'page-with-icon-sidebar' if page_has_collapsed_sidebar? - end + class_name << 'page-with-super-sidebar' + class_name << 'page-with-super-sidebar-collapsed' if collapsed_super_sidebar? class_name -= ['right-sidebar-expanded'] if defined?(@right_sidebar) && !@right_sidebar @@ -57,14 +40,6 @@ module NavHelper end end - def user_dropdown_class - class_names = [] - class_names << 'header-user-dropdown-toggle' - class_names << 'impersonated-user' if session[:impersonator_id] - - class_names - end - def page_has_markdown? current_path?('projects/merge_requests#show') || current_path?('projects/merge_requests/conflicts#show') || diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 75e89a7d7bc..e67e6c22e1a 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -21,15 +21,6 @@ module NotesHelper Notes::QuickActionsService.supported?(note) end - def noteable_json(noteable) - { - id: noteable.id, - class: noteable.class.name, - resources: noteable.class.table_name, - project_id: noteable.project.id - }.to_json - end - def diff_view_data return {} unless @new_diff_note_attrs @@ -74,7 +65,7 @@ module NotesHelper content_tag( :textarea, rows: 1, - placeholder: _('Reply...'), + placeholder: _('Reply…'), 'aria-label': _('Reply to comment'), class: 'reply-placeholder-text-field js-discussion-reply-button', data: { @@ -87,10 +78,6 @@ module NotesHelper end end - def note_max_access_for_user(note) - note.project.team.max_member_access(note.author_id) - end - def note_human_max_access(note) note.project.team.human_max_access(note.author_id) end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index ddaef4652b4..b6e435986ce 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -35,42 +35,6 @@ module NotificationsHelper sprite_icon(icon) end - def notification_title(level) - # Can be anything in `NotificationSetting.level: - case level.to_sym - when :participating - s_('NotificationLevel|Participate') - when :mention - s_('NotificationLevel|On mention') - else - N_('NotificationLevel|Global') - N_('NotificationLevel|Watch') - N_('NotificationLevel|Disabled') - N_('NotificationLevel|Custom') - level = "NotificationLevel|#{level.to_s.humanize}" - s_(level) - end - end - - def notification_description(level) - case level.to_sym - when :participating - _('You will only receive notifications for threads you have participated in') - when :mention - _('You will receive notifications only for comments in which you were @mentioned') - when :watch - _('You will receive notifications for any activity') - when :disabled - _('You will not get any notifications via email') - when :global - _('Use your global notification setting') - when :custom - _('You will only receive notifications for the events you choose') - when :owner_disabled - _('Notifications have been disabled by the project or group owner') - end - end - def show_unsubscribe_title?(noteable) can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable) end diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index 61eb9b5c35f..d0dd9dc5aea 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -44,14 +44,14 @@ module Organizations def organization_user_app_data(organization) { - organization_gid: organization.to_global_id - } + organization_gid: organization.to_global_id, + paths: organizations_users_paths + }.to_json end def home_organization_setting_app_data { - # TODO: use real setting - https://gitlab.com/gitlab-org/gitlab/-/issues/428668 - initial_selection: 1 + initial_selection: current_user.home_organization_id }.to_json end @@ -65,5 +65,12 @@ module Organizations new_project_path: new_project_path } end + + # See UsersHelper#admin_users_paths for inspiration to this method + def organizations_users_paths + { + admin_user: admin_user_path(:id) + } + end end end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index fefc19d7c1a..595bb69709f 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -3,10 +3,6 @@ module PackagesHelper include ::API::Helpers::RelatedResourcesHelpers - def package_sort_path(options = {}) - "#{request.path}?#{options.to_param}" - end - def nuget_package_registry_url(project_id) expose_url(api_v4_projects_packages_nuget_index_path(id: project_id, format: '.json')) end @@ -101,11 +97,11 @@ module PackagesHelper } end - def settings_data + def settings_data(project) cleanup_settings_data.merge( - show_container_registry_settings: show_container_registry_settings(@project).to_s, - show_package_registry_settings: show_package_registry_settings(@project).to_s, - cleanup_settings_path: cleanup_image_tags_project_settings_packages_and_registries_path(@project) + show_container_registry_settings: show_container_registry_settings(project).to_s, + show_package_registry_settings: show_package_registry_settings(project).to_s, + cleanup_settings_path: cleanup_image_tags_project_settings_packages_and_registries_path(project) ) end end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 8d260d5e455..c115e4c594a 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -27,10 +27,6 @@ module ProfilesHelper params[:controller] == 'users' end - def availability_values - Types::AvailabilityEnum.enum - end - def middle_dot_divider_classes(stacking, breakpoint) ['gl-mb-3'].tap do |classes| if stacking diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index fc33e239451..b37d5f3327e 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -31,23 +31,8 @@ module Projects graphql_resource_etag: graphql_etag_pipeline_path(pipeline), pipeline_iid: pipeline.iid, pipelines_path: project_pipelines_path(project), - name: pipeline.name, - total_jobs: pipeline.total_size, yaml_errors: pipeline.yaml_errors, - failure_reason: pipeline.failure_reason, - triggered_by_path: pipeline.child? ? pipeline_path(pipeline.triggered_by_pipeline) : '', - schedule: pipeline.schedule?.to_s, - trigger: pipeline.trigger?.to_s, - child: pipeline.child?.to_s, - latest: pipeline.latest?.to_s, - merge_train_pipeline: pipeline.merge_train_pipeline?.to_s, - merged_results_pipeline: (pipeline.merged_result_pipeline? && !pipeline.merge_train_pipeline?).to_s, - invalid: pipeline.has_yaml_errors?.to_s, - failed: pipeline.failure_reason?.to_s, - auto_devops: pipeline.auto_devops_source?.to_s, - detached: pipeline.detached_merge_request_pipeline?.to_s, - stuck: pipeline.stuck?.to_s, - ref_text: pipeline.ref_text + trigger: pipeline.trigger?.to_s } end end diff --git a/app/helpers/projects/terraform_helper.rb b/app/helpers/projects/terraform_helper.rb index fb35224fad3..22cd9eb5c9b 100644 --- a/app/helpers/projects/terraform_helper.rb +++ b/app/helpers/projects/terraform_helper.rb @@ -6,7 +6,7 @@ module Projects::TerraformHelper empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'), project_path: project.full_path, terraform_admin: current_user&.can?(:admin_terraform_state, project), - access_tokens_path: profile_personal_access_tokens_path, + access_tokens_path: user_settings_personal_access_tokens_path, username: current_user&.username, terraform_api_url: "#{Settings.gitlab.url}/api/v4/projects/#{project.id}/terraform/state" } diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c3287d141f7..c2014508f4f 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -88,7 +88,7 @@ module ProjectsHelper link_to(author_html, user_path(author), class: inject_classes, data: data_attrs).html_safe else title = opts[:title].sub(":name", sanitize(author.name)) - link_to(author_html, user_path(author), class: inject_classes, title: title, data: { container: 'body', qa_selector: 'assignee_link' }).html_safe + link_to(author_html, user_path(author), class: inject_classes, title: title, data: { container: 'body' }).html_safe end end @@ -243,8 +243,8 @@ module ProjectsHelper def no_password_message push_pull_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('topics/git/terminology', anchor: 'pull-and-push') } clone_with_https_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('gitlab-basics/start-using-git', anchor: 'clone-with-https') } - set_password_link_start = '<a href="%{url}">'.html_safe % { url: edit_profile_password_path } - set_up_pat_link_start = '<a href="%{url}">'.html_safe % { url: profile_personal_access_tokens_path } + set_password_link_start = '<a href="%{url}">'.html_safe % { url: edit_user_settings_password_path } + set_up_pat_link_start = '<a href="%{url}">'.html_safe % { url: user_settings_personal_access_tokens_path } message = if current_user.require_password_creation_for_git? _('Your account is authenticated with SSO or SAML. To %{push_pull_link_start}push and pull%{link_end} over %{protocol} with Git using this account, you must %{set_password_link_start}set a password%{link_end} or %{set_up_pat_link_start}set up a Personal Access Token%{link_end} to use instead of a password. For more information, see %{clone_with_https_link_start}Clone with HTTPS%{link_end}.') @@ -687,7 +687,8 @@ module ProjectsHelper featureFlagsAccessLevel: feature.feature_flags_access_level, releasesAccessLevel: feature.releases_access_level, infrastructureAccessLevel: feature.infrastructure_access_level, - modelExperimentsAccessLevel: feature.model_experiments_access_level + modelExperimentsAccessLevel: feature.model_experiments_access_level, + modelRegistryAccessLevel: feature.model_registry_access_level } end @@ -789,7 +790,7 @@ module ProjectsHelper push_to_schema_breadcrumb(project_name, project_path(project)) link_to project_path(project) do - icon = project_icon(project, alt: project_name, class: 'avatar-tile', width: 15, height: 15) if project.avatar_url && !Rails.env.test? + icon = render Pajamas::AvatarComponent.new(project, alt: project.name, size: 16, class: 'avatar-tile') if project.avatar_url && !Rails.env.test? [icon, content_tag("span", project_name, class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe end end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index f983812ad22..9933fa8e4d9 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -8,14 +8,6 @@ module SidebarsHelper sidebar_attributes_for_object(object).fetch(:tracking_attrs, {}) end - def sidebar_qa_selector(object) - sidebar_attributes_for_object(object).fetch(:sidebar_qa_selector, nil) - end - - def scope_qa_menu_item(object) - sidebar_attributes_for_object(object).fetch(:scope_qa_menu_item, nil) - end - def scope_avatar_classes(object) %w[avatar-container rect-avatar s32].tap do |klasses| klass = sidebar_attributes_for_object(object).fetch(:scope_avatar_class, nil) @@ -72,8 +64,10 @@ module SidebarsHelper def super_sidebar_logged_in_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize super_sidebar_logged_out_context(panel: panel, panel_type: panel_type).merge({ is_logged_in: true, + is_admin: user.can_admin_all_resources?, name: user.name, username: user.username, + admin_url: admin_root_url, avatar_url: user.avatar_url, has_link_to_profile: current_user_menu?(:profile), link_to_profile: user_path(user), @@ -265,8 +259,6 @@ module SidebarsHelper def sidebar_project_attributes { tracking_attrs: sidebar_project_tracking_attrs, - sidebar_qa_selector: 'project_sidebar', - scope_qa_menu_item: 'Project scope', scope_avatar_class: 'project_avatar' } end @@ -274,8 +266,6 @@ module SidebarsHelper def sidebar_group_attributes { tracking_attrs: sidebar_group_tracking_attrs, - sidebar_qa_selector: 'group_sidebar', - scope_qa_menu_item: 'Group scope', scope_avatar_class: 'group_avatar' } end diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb index 6100093b7c2..57bc873d446 100644 --- a/app/helpers/ssh_keys_helper.rb +++ b/app/helpers/ssh_keys_helper.rb @@ -7,11 +7,11 @@ module SshKeysHelper { path: path, method: 'delete', - qa_selector: 'delete_ssh_key_button', + testid: 'delete-ssh-key-button', title: title, aria_label: title, modal_attributes: { - 'data-qa-selector': 'ssh_key_delete_modal', + 'data-testid': 'ssh-key-delete-modal', title: _('Are you sure you want to delete this SSH key?'), message: _('This action cannot be undone, and will permanently delete the %{key} SSH key') % { key: key.title }, okVariant: 'danger', @@ -29,11 +29,9 @@ module SshKeysHelper { path: path, method: 'delete', - qa_selector: 'revoke_ssh_key_button', title: title, aria_label: title, modal_attributes: { - 'data-qa-selector': 'ssh_key_revoke_modal', title: _('Are you sure you want to revoke this SSH key?'), message: _('This action cannot be undone, and will permanently delete the %{key} SSH key. All commits signed using this SSH key will be marked as unverified.') % { key: key.title }, okVariant: 'danger', diff --git a/app/helpers/stat_anchors_helper.rb b/app/helpers/stat_anchors_helper.rb index 957985d6953..31f5d73d020 100644 --- a/app/helpers/stat_anchors_helper.rb +++ b/app/helpers/stat_anchors_helper.rb @@ -11,12 +11,22 @@ module StatAnchorsHelper private + def new_button_attribute(anchor) + anchor.class_modifier || 'btn-link gl-text-blue-500!' + end + def button_attribute(anchor) anchor.class_modifier || 'btn-dashed' end def extra_classes(anchor) - if anchor.is_link + if Feature.enabled?(:project_overview_reorg) + if anchor.is_link + 'stat-link gl-px-0! gl-pb-2!' + else + "stat-link gl-px-0! gl-pb-2! #{new_button_attribute(anchor)}" + end + elsif anchor.is_link 'stat-link' else "gl-button btn #{button_attribute(anchor)}" diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 137b24102e0..90f3c1e6ae6 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -171,14 +171,6 @@ module TabHelper current_controller?(c) && current_action?(a) end - def branches_tab_class - if current_controller?(:protected_branches) || - current_controller?(:branches) || - current_page?(project_repository_path(@project)) - 'active' - end - end - private def route_matches_paths?(paths) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index d053aeb7bfe..fc4d69dcdbc 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -5,10 +5,6 @@ module TodosHelper @todos_pending_count ||= current_user.todos_pending_count end - def todos_count_format(count) - count > 99 ? '99+' : count.to_s - end - def todos_done_count @todos_done_count ||= current_user.todos_done_count end diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb index 5096d3649b7..adb9ffa39e0 100644 --- a/app/helpers/vite_helper.rb +++ b/app/helpers/vite_helper.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true module ViteHelper - private + def vite_enabled? + # vite is not production ready yet + return false if Rails.env.production? + # Enable vite if explicitly turned on in the GDK + return Gitlab::Utils.to_boolean(ViteRuby.env['VITE_ENABLED'], default: false) if ViteRuby.env.key?('VITE_ENABLED') - def vite_enabled - Feature.enabled?(:vite) && !Rails.env.test? && vite_running - end - - def vite_running - ViteRuby.instance.dev_server_running? + # Enable vite the legacy way (in case GDK hasn't been updated) + # This is going to be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/431041 + Rails.env.development? ? Feature.enabled?(:vite) : false end end diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 92874168798..e1e2d4581ac 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -16,7 +16,7 @@ module WebpackHelper end def webpack_bundle_tag(bundle) - if vite_running + if vite_enabled? vite_javascript_tag bundle else javascript_include_tag(*webpack_entrypoint_paths(bundle)) @@ -24,6 +24,8 @@ module WebpackHelper end def webpack_preload_asset_tag(asset, options = {}) + return if vite_enabled? + path = Gitlab::Webpack::Manifest.asset_paths(asset).first if options.delete(:prefetch) @@ -38,7 +40,7 @@ module WebpackHelper end def webpack_controller_bundle_tags - return if Feature.enabled?(:vite) && !Rails.env.test? + return if vite_enabled? chunks = [] diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb index f3fe609e7d1..7a20b66d439 100644 --- a/app/mailers/emails/identity_verification.rb +++ b/app/mailers/emails/identity_verification.rb @@ -5,7 +5,7 @@ module Emails def verification_instructions_email(email, token:) @token = token @expires_in_minutes = Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES - @password_link = edit_profile_password_url + @password_link = edit_user_settings_password_url @two_fa_link = help_page_url('user/profile/account/two_factor_authentication') headers = { diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 5e82a3e8dcf..c702b107b7e 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -187,3 +187,5 @@ module Emails end end end + +Emails::MergeRequests.prepend_mod_with('Emails::MergeRequests') diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 2be4cdf734a..cf46257f7d4 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -88,7 +88,7 @@ module Emails return unless user&.active? @user = user - @target_url = profile_personal_access_tokens_url + @target_url = user_settings_personal_access_tokens_url @token_name = token_name email_with_layout(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) @@ -99,7 +99,7 @@ module Emails @user = user @token_names = token_names - @target_url = profile_personal_access_tokens_url + @target_url = user_settings_personal_access_tokens_url @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) @@ -110,7 +110,7 @@ module Emails @user = user @token_names = token_names - @target_url = profile_personal_access_tokens_url + @target_url = user_settings_personal_access_tokens_url email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens have expired"))) end @@ -120,7 +120,7 @@ module Emails @user = user @token_name = token_name - @target_url = profile_personal_access_tokens_url + @target_url = user_settings_personal_access_tokens_url @source = source email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has been revoked"))) @@ -150,7 +150,7 @@ module Emails @user = user @ip = ip @time = time - @target_url = edit_profile_password_url + @target_url = edit_user_settings_password_url email_with_layout( to: @user.notification_email_or_default, diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index f67c2636fc6..64e6122f9c7 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -7,6 +7,7 @@ module Emails include ::ServiceDesk::CustomEmails::Logger EMAIL_ATTACHMENTS_SIZE_LIMIT = 10.megabytes.freeze + VERIFICATION_EMAIL_TIMEOUT = 7 included do layout 'service_desk', only: [:service_desk_thank_you_email, :service_desk_new_note_email] @@ -138,7 +139,7 @@ module Emails end def inject_service_desk_custom_email(force: false) - return mail if !service_desk_custom_email_enabled? && !force + return mail if !@service_desk_setting&.custom_email_enabled? && !force return mail unless @service_desk_setting.custom_email_credential.present? # Only set custom email reply address if it's enabled, not when we force it. @@ -146,11 +147,15 @@ module Emails log_info(project: @project) - mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_credential.delivery_options) - end + delivery_options = @service_desk_setting.custom_email_credential.delivery_options + # We force the use of custom email settings when sending out the verification email. + # If the credentials aren't correct some servers tend to take a while to answer + # which leads to some Net::ReadTimeout errors which disguises the + # real configuration issue. + # We increase the timeout for verification emails only. + delivery_options[:read_timeout] = VERIFICATION_EMAIL_TIMEOUT if force - def service_desk_custom_email_enabled? - Feature.enabled?(:service_desk_custom_email, @project) && @service_desk_setting&.custom_email_enabled? + mail.delivery_method(::Mail::SMTP, delivery_options) end def inject_service_desk_custom_email_reply_address @@ -163,7 +168,7 @@ module Emails end def service_desk_sender_email_address - return unless service_desk_custom_email_enabled? + return unless @service_desk_setting&.custom_email_enabled? @service_desk_setting.custom_email end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 6548b6d1088..2ffad4498bf 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -272,6 +272,14 @@ class NotifyPreview < ActionMailer::Preview end end + def service_desk_verification_result_email_for_incorrect_forwarding_target_error + service_desk_verification_result_email_for_error_state(error: :incorrect_forwarding_target) + end + + def service_desk_verification_result_email_for_read_timeout_error + service_desk_verification_result_email_for_error_state(error: :read_timeout) + end + def service_desk_verification_result_email_for_incorrect_token_error service_desk_verification_result_email_for_error_state(error: :incorrect_token) end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index de6b644c536..19dc0e40564 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -8,6 +8,9 @@ class AbuseReport < ApplicationRecord include Gitlab::Utils::StrongMemoize include Mentionable include Noteable + include IgnorableColumns + + ignore_column :assignee_id, remove_with: '16.9', remove_after: '2024-01-19' MAX_CHAR_LIMIT_URL = 512 MAX_FILE_SIZE = 1.megabyte @@ -17,11 +20,12 @@ class AbuseReport < ApplicationRecord belongs_to :reporter, class_name: 'User', inverse_of: :reported_abuse_reports belongs_to :user, inverse_of: :abuse_reports belongs_to :resolved_by, class_name: 'User', inverse_of: :resolved_abuse_reports - belongs_to :assignee, class_name: 'User', inverse_of: :assigned_abuse_reports has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report has_many :label_links, as: :target, inverse_of: :target has_many :labels, through: :label_links + has_many :admin_abuse_report_assignees, class_name: "Admin::AbuseReportAssignee" + has_many :assignees, class_name: "User", through: :admin_abuse_report_assignees has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report @@ -120,7 +124,7 @@ class AbuseReport < ApplicationRecord return screenshot.url unless screenshot.upload asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url - local_path = Gitlab::Routing.url_helpers.abuse_report_upload_path( + local_path = Gitlab::Routing.url_helpers.abuse_report_screenshot_path( filename: screenshot.filename, id: screenshot.upload.model_id, model: 'abuse_report', diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 9756e1b7dd3..2eb9c9bca7f 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -330,10 +330,11 @@ class ActiveSession next if entry pipeline.srem?(lookup_key, session_id) - removed << session_id end end + removed.concat(session_ids_and_entries.select { |_, v| v.nil? }.keys) + session_ids_and_entries.values.compact end end diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb index a6304f1fc35..0a4293b2bde 100644 --- a/app/models/activity_pub/releases_subscription.rb +++ b/app/models/activity_pub/releases_subscription.rb @@ -11,12 +11,12 @@ module ActivityPub validates :payload, json_schema: { filename: 'activity_pub_follow_payload' }, allow_blank: true validates :subscriber_url, presence: true, uniqueness: { case_sensitive: false, scope: :project_id }, public_url: true - validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id }, + validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id, allow_nil: true }, public_url: { allow_nil: true } validates :shared_inbox_url, public_url: { allow_nil: true } - def self.find_by_subscriber_url(subscriber_url) - find_by('LOWER(subscriber_url) = ?', subscriber_url.downcase) + def self.find_by_project_and_subscriber(project_id, subscriber_url) + find_by('project_id = ? AND LOWER(subscriber_url) = ?', project_id, subscriber_url.downcase) end end end diff --git a/app/models/admin/abuse_report_assignee.rb b/app/models/admin/abuse_report_assignee.rb new file mode 100644 index 00000000000..d8f6f8ded00 --- /dev/null +++ b/app/models/admin/abuse_report_assignee.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportAssignee < ApplicationRecord + self.table_name = 'abuse_report_assignees' + + belongs_to :abuse_report, touch: true + belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :admin_abuse_report_assignees + + validates :assignee, uniqueness: { scope: :abuse_report_id } + end +end diff --git a/app/models/admin/abuse_report_label.rb b/app/models/admin/abuse_report_label.rb index a2ccc8b5513..6f951b02933 100644 --- a/app/models/admin/abuse_report_label.rb +++ b/app/models/admin/abuse_report_label.rb @@ -2,5 +2,6 @@ module Admin class AbuseReportLabel < Label + self.allow_legacy_sti_class = true end end diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb index d884932072b..4d1d764755e 100644 --- a/app/models/analytics/cycle_analytics/value_stream.rb +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -42,6 +42,13 @@ module Analytics namespace.project end + def to_global_id + return super if persisted? + + # Returns default name as id for built in value stream at FOSS level + name + end + private def max_value_streams_count diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 15e44296635..45983c08a3e 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ApplicationRecord < ActiveRecord::Base + include DisablesSti include DatabaseReflection include Transactions include LegacyBulkInsert diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 8d4f50de75e..cb533a5e99d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -10,28 +10,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22' - ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18' - ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18' ignore_column :web_ide_clientside_preview_enabled, remove_with: '15.11', remove_after: '2023-04-22' ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22' - ignore_column :database_apdex_settings, remove_with: '16.4', remove_after: '2023-08-22' - - ignore_column %i[ - relay_state_domain_allowlist - in_product_marketing_emails_enabled - ], remove_with: '16.6', remove_after: '2023-10-22' - - ignore_columns %i[ - encrypted_product_analytics_clickhouse_connection_string - encrypted_product_analytics_clickhouse_connection_string_iv - encrypted_jitsu_administrator_password - encrypted_jitsu_administrator_password_iv - jitsu_host - jitsu_project_xid - jitsu_administrator_email - ], remove_with: '16.5', remove_after: '2023-09-22' ignore_columns %i[encrypted_ai_access_token encrypted_ai_access_token_iv], remove_with: '16.10', remove_after: '2024-03-22' - ignore_columns %i[repository_storages], remove_with: '16.8', remove_after: '2023-12-21' INSTANCE_REVIEW_MIN_USERS = 50 @@ -61,6 +42,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord add_authentication_token_field :error_tracking_access_token, encrypted: :required belongs_to :push_rule + belongs_to :web_ide_oauth_application, class_name: 'Doorkeeper::Application' alias_attribute :housekeeping_optimize_repository_period, :housekeeping_incremental_repack_period @@ -487,7 +469,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :invitation_flow_enforcement, :can_create_group, :allow_project_creation_for_guest_and_below, :user_defaults_to_private_profile, + validates :invitation_flow_enforcement, + :can_create_group, + :can_create_organization, + :allow_project_creation_for_guest_and_below, + :user_defaults_to_private_profile, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -528,7 +514,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord exclusion: { in: :restricted_visibility_levels, message: "cannot be set to a restricted visibility level" }, if: :should_prevent_visibility_restriction? - validates_each :import_sources do |record, attr, value| + validates_each :import_sources, on: :update do |record, attr, value| value&.each do |source| unless Gitlab::ImportSources.options.value?(source) record.errors.add(attr, _("'%{source}' is not a import source") % { source: source }) @@ -763,6 +749,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :package_registry_allow_anyone_to_pull_option, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :security_txt_content, + length: { maximum: 2_048, message: N_('is too long (maximum is %{count} characters)') }, + allow_blank: true + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -800,6 +790,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord attr_encrypted :database_grafana_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :arkose_labs_data_exchange_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) @@ -824,6 +815,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :bulk_import_concurrent_pipeline_batch_limit, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + validates :allow_runner_registration_token, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -1009,10 +1004,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord end def should_prevent_visibility_restriction? - Feature.enabled?(:prevent_visibility_restriction) && - (default_project_visibility_changed? || - default_group_visibility_changed? || - restricted_visibility_levels_changed?) + default_project_visibility_changed? || + default_group_visibility_changed? || + restricted_visibility_levels_changed? end end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 00b093c8ac3..851b65055d0 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -110,6 +110,7 @@ module ApplicationSettingImplementation housekeeping_gc_period: 200, housekeeping_incremental_repack_period: 10, import_sources: Settings.gitlab['import_sources'], + instance_level_ai_beta_features_enabled: false, instance_level_code_suggestions_enabled: false, invisible_captcha_enabled: false, issues_create_limit: 300, @@ -263,6 +264,7 @@ module ApplicationSettingImplementation users_get_by_id_limit: 300, users_get_by_id_limit_allowlist: [], can_create_group: true, + can_create_organization: true, bulk_import_enabled: false, bulk_import_max_download_file_size: 5120, allow_runner_registration_token: true, @@ -272,7 +274,8 @@ module ApplicationSettingImplementation ci_max_includes: 150, allow_account_deletion: true, gitlab_shell_operation_limit: 600, - project_jobs_api_rate_limit: 600 + project_jobs_api_rate_limit: 600, + security_txt_content: nil }.tap do |hsh| hsh.merge!(non_production_defaults) unless Rails.env.production? end @@ -568,6 +571,16 @@ module ApplicationSettingImplementation end end + def repository_storages_with_default_weight + # config file config/gitlab.yml becomes SSOT for this API + # see https://gitlab.com/gitlab-org/gitlab/-/issues/426091#note_1675160909 + storages_map = Gitlab.config.repositories.storages.keys.map do |storage| + [storage, repository_storages_weighted[storage] || 0] + end + + Hash[storages_map] + end + private def set_max_key_restriction!(key_type) diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 163e741d990..a990837826d 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -45,6 +45,10 @@ class AuditEvent < ApplicationRecord # https://gitlab.com/groups/gitlab-org/-/epics/2765 after_validation :parallel_persist + def self.supported_keyset_orderings + { id: [:desc] } + end + def self.order_by(method) case method.to_s when 'created_asc' diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index e9fe49f980d..e3a5922efd1 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -21,6 +21,8 @@ class AuthenticationEvent < MainClusterwide::ApplicationRecord scope :for_provider, ->(provider) { where(provider: provider) } scope :ldap, -> { where('provider LIKE ?', 'ldap%') } + scope :for_user, ->(user) { where(user: user) } + scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } def self.providers STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s) diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index e445d08a096..9c1005e19c7 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -7,9 +7,6 @@ class AwardEmoji < ApplicationRecord include Participable include GhostUser include Importable - include IgnorableColumns - - ignore_column :awardable_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user @@ -69,7 +66,8 @@ class AwardEmoji < ApplicationRecord def url return if TanukiEmoji.find_by_alpha_code(name) - CustomEmoji.for_resource(resource_parent).by_name(name).select(:url).first&.url + Groups::CustomEmojiFinder.new(resource_parent, { include_ancestor_groups: true }).execute + .by_name(name)&.select(:url)&.first&.url end def expire_cache diff --git a/app/models/badges/group_badge.rb b/app/models/badges/group_badge.rb index f74c9f89e9f..2ef8a3bd821 100644 --- a/app/models/badges/group_badge.rb +++ b/app/models/badges/group_badge.rb @@ -3,6 +3,8 @@ class GroupBadge < Badge include EachBatch + self.allow_legacy_sti_class = true + belongs_to :group validates :group, presence: true diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb index 8c51ebafb5e..6a8a70dfe02 100644 --- a/app/models/badges/project_badge.rb +++ b/app/models/badges/project_badge.rb @@ -3,6 +3,8 @@ class ProjectBadge < Badge include EachBatch + self.allow_legacy_sti_class = true + belongs_to :project validates :project, presence: true diff --git a/app/models/batched_git_ref_updates/deletion.rb b/app/models/batched_git_ref_updates/deletion.rb index 61bba8aeba9..fdab19e6f78 100644 --- a/app/models/batched_git_ref_updates/deletion.rb +++ b/app/models/batched_git_ref_updates/deletion.rb @@ -15,7 +15,7 @@ module BatchedGitRefUpdates # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving # incorrect partition_id. - ignore_column :partition_id, remove_with: '3000.0', remove_after: '3000-01-01' + ignore_column :partition_id, remove_never: true belongs_to :project, inverse_of: :to_be_deleted_git_refs diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb index 9cee536d15b..9f86b93ebc9 100644 --- a/app/models/blob_viewer/gitlab_ci_yml.rb +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -7,9 +7,14 @@ module BlobViewer self.partial_name = 'gitlab_ci_yml' self.loading_partial_name = 'gitlab_ci_yml_loading' - self.file_types = %i[gitlab_ci] self.binary = false + # rubocop:disable Lint/UnusedMethodArgument -- The keyword argument is required by the parent class but not here. + def self.can_render?(blob, verify_binary: true) + blob.path == blob.project.ci_config_path_or_default + end + # rubocop:enable Lint/UnusedMethodArgument + def validation_message(opts) return @validation_message if defined?(@validation_message) diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index a7ace7429d7..3381ff881f1 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -16,7 +16,8 @@ class BulkImport < ApplicationRecord enum source_type: { gitlab: 0 } - scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) } + scope :stale, -> { where('updated_at < ?', 24.hours.ago).where(status: [0, 1]) } + scope :order_by_updated_at_and_id, ->(direction) { order(updated_at: direction, id: :asc) } scope :order_by_created_at, ->(direction) { order(created_at: direction) } state_machine :status, initial: :created do diff --git a/app/models/bulk_imports/batch_tracker.rb b/app/models/bulk_imports/batch_tracker.rb index eb7fe9f9913..09f220b96b0 100644 --- a/app/models/bulk_imports/batch_tracker.rb +++ b/app/models/bulk_imports/batch_tracker.rb @@ -8,6 +8,18 @@ module BulkImports validates :batch_number, presence: true, uniqueness: { scope: :tracker_id } + IN_PROGRESS_STATES = %i[created started].freeze + + scope :by_last_updated, -> { order(updated_at: :desc) } + scope :in_progress, -> { with_status(IN_PROGRESS_STATES) } + + # rubocop: disable Database/AvoidUsingPluckWithoutLimit -- We should use this method only when scoped to a tracker. + # Batches are self-limiting per tracker based on the amount of data being imported. + def self.pluck_batch_numbers + pluck(:batch_number) + end + # rubocop: enable Database/AvoidUsingPluckWithoutLimit + state_machine :status, initial: :created do state :created, value: 0 state :started, value: 1 diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index a075c2f7e4f..894e28dd88a 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -54,9 +54,11 @@ class BulkImports::Entity < ApplicationRecord enum source_type: { group_entity: 0, project_entity: 1 } scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) } - scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) } + scope :stale, -> { where('updated_at < ?', 24.hours.ago).where(status: [0, 1]) } scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id) } scope :order_by_created_at, ->(direction) { order(created_at: direction) } + scope :order_by_updated_at_and_id, ->(direction) { order(updated_at: direction, id: :asc) } + scope :with_trackers, -> { includes(:trackers) } alias_attribute :destination_slug, :destination_name @@ -198,6 +200,7 @@ class BulkImports::Entity < ApplicationRecord return unless failures.any? update!(has_failures: true) + bulk_import.update!(has_failures: true) end def source_version diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb index 3d820e65d5b..9e3815e7569 100644 --- a/app/models/bulk_imports/export_status.rb +++ b/app/models/bulk_imports/export_status.rb @@ -4,6 +4,8 @@ module BulkImports class ExportStatus include Gitlab::Utils::StrongMemoize + CACHE_KEY = 'bulk_imports/export_status/%{entity_id}/%{relation}' + def initialize(pipeline_tracker, relation) @pipeline_tracker = pipeline_tracker @relation = relation @@ -50,11 +52,12 @@ module BulkImports def status strong_memoize(:status) do - status = fetch_status - - next status if status.is_a?(Hash) || status.nil? + # As an optimization, once an export status has finished or failed it will + # be cached, so we do not fetch from the remote source again. + status = status_from_cache + next status if status - status.find { |item| item['relation'] == relation } + status_from_remote rescue BulkImports::NetworkError => e raise BulkImports::RetryPipelineError.new(e.message, 2.seconds) if e.retriable?(pipeline_tracker) @@ -64,8 +67,38 @@ module BulkImports end end - def fetch_status - client.get(status_endpoint, relation: relation).parsed_response + def status_from_cache + status = Gitlab::Cache::Import::Caching.read(cache_key) + + Gitlab::Json.parse(status) if status + end + + def status_from_remote + raw_status = client.get(status_endpoint, relation: relation).parsed_response + + parse_status_from_remote(raw_status).tap do |status| + cache_status(status) if cache_status?(status) + end + end + + def parse_status_from_remote(status) + # Non-batched status + return status if status.is_a?(Hash) || status.nil? + + # Batched status + status.find { |item| item['relation'] == relation } + end + + def cache_status?(status) + status.present? && status['status'].in?([Export::FINISHED, Export::FAILED]) + end + + def cache_status(status) + Gitlab::Cache::Import::Caching.write(cache_key, status.to_json) + end + + def cache_key + Kernel.format(CACHE_KEY, entity_id: entity.id, relation: relation) end def status_endpoint diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb index 00f8e8f1304..0560933ed93 100644 --- a/app/models/bulk_imports/export_upload.rb +++ b/app/models/bulk_imports/export_upload.rb @@ -11,6 +11,14 @@ module BulkImports mount_uploader :export_file, ExportUploader + # This causes CarrierWave v1 and v3 (but not v2) to upload the file to + # object storage *after* the database entry has been committed to the + # database. This avoids idling in a transaction. Similar to `ImportExportUpload`. + if Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_STORE_EXPORT_FILE_AFTER_COMMIT', true)) + skip_callback :save, :after, :store_export_file! + set_callback :commit, :after, :store_export_file! + end + def retrieve_upload(_identifier, paths) Upload.find_by(model: self, path: paths) end diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index d9efd489af5..b5092591019 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -21,10 +21,9 @@ class BulkImports::Tracker < ApplicationRecord validates :stage, presence: true - delegate :file_extraction_pipeline?, to: :pipeline_class + delegate :file_extraction_pipeline?, :abort_on_failure?, to: :pipeline_class DEFAULT_PAGE_SIZE = 500 - STALE_AFTER = 4.hours scope :next_pipeline_trackers_for, -> (entity_id) { entity_scope = where(bulk_import_entity_id: entity_id) @@ -88,8 +87,4 @@ class BulkImports::Tracker < ApplicationRecord transition [:created, :started] => :timeout end end - - def stale? - created_at < STALE_AFTER.ago - end end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index cf6401dc1da..8db80cd05dc 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -16,6 +16,8 @@ module Ci pipeline_variables: false }.freeze + self.allow_legacy_sti_class = true + belongs_to :project belongs_to :trigger_request @@ -168,6 +170,18 @@ module Ci end # rubocop: enable CodeReuse/ServiceClass + def job_artifacts + Ci::JobArtifact.none + end + + def artifacts_expire_at; end + + def runner; end + + def tag_list + ActsAsTaggableOn::TagList.new + end + def artifacts? false end @@ -220,8 +234,19 @@ module Ci def variables strong_memoize(:variables) do + bridge_variables = + if ::Feature.disabled?(:exclude_protected_variables_from_multi_project_pipeline_triggers, project) || + (expose_protected_project_variables? && expose_protected_group_variables?) + scoped_variables + else + unprotected_scoped_variables( + expose_project_variables: expose_protected_project_variables?, + expose_group_variables: expose_protected_group_variables? + ) + end + Gitlab::Ci::Variables::Collection.new - .concat(scoped_variables) + .concat(bridge_variables) .concat(pipeline.persisted_variables) end end @@ -260,6 +285,20 @@ module Ci private + def expose_protected_group_variables? + return true if downstream_project.nil? + return true if project.group.present? && project.group == downstream_project.group + + false + end + + def expose_protected_project_variables? + return true if downstream_project.nil? + return true if project.id == downstream_project.id + + false + end + def cross_project_params { project: downstream_project, diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 0bb93a68470..e56f3d2536c 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,11 +15,18 @@ module Ci extend ::Gitlab::Utils::Override + self.allow_legacy_sti_class = true + belongs_to :project, inverse_of: :builds belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' - belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, inverse_of: :builds + belongs_to :pipeline, + ->(build) { in_partition(build) }, + class_name: 'Ci::Pipeline', + foreign_key: :commit_id, + partition_foreign_key: :partition_id, + inverse_of: :builds RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -38,7 +45,12 @@ module Ci has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build - has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build + has_many :trace_chunks, + ->(build) { in_partition(build) }, + class_name: 'Ci::BuildTraceChunk', + foreign_key: :build_id, + inverse_of: :build, + partition_foreign_key: :partition_id has_many :report_results, class_name: 'Ci::BuildReportResult', foreign_key: :build_id, inverse_of: :build has_one :namespace, through: :project @@ -48,7 +60,12 @@ module Ci # Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685 has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job - has_many :job_annotations, class_name: 'Ci::JobAnnotation', foreign_key: :job_id, inverse_of: :job + has_many :job_annotations, + ->(build) { in_partition(build) }, + class_name: 'Ci::JobAnnotation', + foreign_key: :job_id, + partition_foreign_key: :partition_id, + inverse_of: :job has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :build has_many :pages_deployments, foreign_key: :ci_build_id, inverse_of: :ci_build @@ -57,7 +74,12 @@ module Ci has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', foreign_key: :job_id, inverse_of: :job end - has_one :runner_manager_build, class_name: 'Ci::RunnerManagerBuild', foreign_key: :build_id, inverse_of: :build, + has_one :runner_manager_build, + ->(build) { in_partition(build) }, + class_name: 'Ci::RunnerManagerBuild', + foreign_key: :build_id, + inverse_of: :build, + partition_foreign_key: :partition_id, autosave: true has_one :runner_manager, foreign_key: :runner_machine_id, through: :runner_manager_build, class_name: 'Ci::RunnerManager' @@ -89,11 +111,6 @@ module Ci validates :coverage, numericality: true, allow_blank: true validates :ref, presence: true - scope :not_interruptible, -> do - joins(:metadata) - .where.not(Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) }) - end - scope :unstarted, -> { where(runner_id: nil) } scope :with_any_artifacts, -> do @@ -179,6 +196,10 @@ module Ci scope :without_coverage, -> { where(coverage: nil) } scope :with_coverage_regex, -> { where.not(coverage_regex: nil) } + scope :in_merge_request, ->(merge_request) do + joins(:pipeline).where(Ci::Pipeline.arel_table[:merge_request_id].eq(merge_request)) + end + acts_as_taggable add_authentication_token_field :token, @@ -210,7 +231,11 @@ module Ci yaml_variables when environment coverage_regex description tag_list protected needs_attributes job_variables_attributes resource_group scheduling_type - ci_stage partition_id id_tokens].freeze + ci_stage partition_id id_tokens interruptible].freeze + end + + def supported_keyset_orderings + { id: [:desc] } end end @@ -358,6 +383,10 @@ module Ci end end + def self.ids_in_merge_request(merge_request_ids) + in_merge_request(merge_request_ids).pluck(:id) + end + def build_matcher strong_memoize(:build_matcher) do Gitlab::Ci::Matching::BuildMatcher.new({ @@ -700,13 +729,17 @@ module Ci end def artifacts_public? - return true unless Feature.enabled?(:non_public_artifacts, type: :development) + return true if job_artifacts_archive.nil? # To backward compatibility return true if no artifacts found + + job_artifacts_archive.public_access? + end + def artifact_is_public_in_config? artifacts_public = options.dig(:artifacts, :public) return true if artifacts_public.nil? # Default artifacts:public to true - options.dig(:artifacts, :public) + artifacts_public end def artifacts_metadata_entry(path, **options) @@ -918,7 +951,7 @@ module Ci job_artifacts.all_reports end - # Consider this object to have a structural integrity problems + # Consider this object to have an unknown job problem def doom! transaction do now = Time.current diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index c4a04d42a1e..e1521e2f5fa 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -36,7 +36,7 @@ module Ci next [] if no_local_dependencies_specified? next [] unless processable.pipeline_id # we don't have any dependency when creating the pipeline - deps = model_class.where(pipeline_id: processable.pipeline_id).latest + deps = model_class.where(pipeline_id: processable.pipeline_id, partition_id: processable.partition_id).latest deps = find_dependencies(processable, deps) from_dependencies(deps).to_a diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 555565ff621..1fe6af8c595 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -14,9 +14,14 @@ module Ci self.table_name = 'p_ci_builds_metadata' self.primary_key = 'id' + query_constraints :id, :partition_id partitionable scope: :build, partitioned: true - belongs_to :build, class_name: 'CommitStatus' + belongs_to :build, # rubocop: disable Rails/InverseOf -- this relation is not present on CommitStatus + ->(metadata) { in_partition(metadata) }, + partition_foreign_key: :partition_id, + class_name: 'CommitStatus' + belongs_to :project before_create :set_build_project @@ -31,7 +36,11 @@ module Ci chronic_duration_attr_reader :timeout_human_readable, :timeout - scope :scoped_build, -> { where("#{quoted_table_name}.build_id = #{Ci::Build.quoted_table_name}.id") } + scope :scoped_build, -> do + where(arel_table[:build_id].eq(Ci::Build.arel_table[:id])) + .where(arel_table[:partition_id].eq(Ci::Build.arel_table[:partition_id])) + end + scope :with_interruptible, -> { where(interruptible: true) } scope :with_exposed_artifacts, -> { where(has_exposed_artifacts: true) } @@ -42,6 +51,10 @@ module Ci job_timeout_source: 4 } + def self.use_partition_id_filter? + Ci::Pipeline.use_partition_id_filter? + end + def update_timeout_state timeout = timeout_with_highest_precedence diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 1831b7868f9..54a54c42fd1 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -4,14 +4,16 @@ module Ci class BuildNeed < Ci::ApplicationRecord include Ci::Partitionable include IgnorableColumns - include SafelyChangeColumnDefault include BulkInsertSafe MAX_JOB_NAME_LENGTH = 255 - columns_changing_default :partition_id - - belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs + belongs_to :build, + ->(need) { in_partition(need) }, + class_name: 'Ci::Processable', + foreign_key: :build_id, + partition_foreign_key: :partition_id, + inverse_of: :needs partitionable scope: :build @@ -19,7 +21,10 @@ module Ci validates :name, presence: true, length: { maximum: MAX_JOB_NAME_LENGTH } validates :optional, inclusion: { in: [true, false] } - scope :scoped_build, -> { where("#{Ci::Build.quoted_table_name}.id = #{quoted_table_name}.build_id") } + scope :scoped_build, -> { + where(arel_table[:build_id].eq(Ci::Build.arel_table[:id])) + .where(arel_table[:partition_id].eq(Ci::Build.arel_table[:partition_id])) + } scope :artifacts, -> { where(artifacts: true) } end end diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 0b88f745d78..b02e96ee140 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -2,11 +2,13 @@ class Ci::BuildPendingState < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - columns_changing_default :partition_id - - belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id, inverse_of: :pending_state + belongs_to :build, + ->(pending_state) { in_partition(pending_state) }, + class_name: 'Ci::Build', + foreign_key: :build_id, + partition_foreign_key: :partition_id, + inverse_of: :pending_state partitionable scope: :build diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index 90b621b8da1..bf58ebfd568 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -3,13 +3,14 @@ module Ci class BuildReportResult < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id self.primary_key = :build_id - belongs_to :build, class_name: "Ci::Build", inverse_of: :report_results + belongs_to :build, + ->(report_result) { in_partition(report_result) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id, + inverse_of: :report_results belongs_to :project, class_name: "Project", inverse_of: :build_report_results partitionable scope: :build diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index e197217bb70..c102b95a6a4 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -5,9 +5,6 @@ module Ci # Data will be removed after transitioning from running to any state. class BuildRunnerSession < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' @@ -15,7 +12,11 @@ module Ci self.table_name = 'ci_builds_runner_session' - belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session + belongs_to :build, + ->(runner_session) { in_partition(runner_session) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id, + inverse_of: :runner_session partitionable scope: :build diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 0a0f401c9d5..12587ec13fb 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -8,11 +8,13 @@ module Ci include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::OptimisticLocking - include SafelyChangeColumnDefault - columns_changing_default :partition_id - - belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :trace_chunks + belongs_to :build, + ->(trace_chunks) { in_partition(trace_chunks) }, + class_name: 'Ci::Build', + foreign_key: :build_id, + partition_foreign_key: :partition_id, + inverse_of: :trace_chunks partitionable scope: :build diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 525cb08f2ca..ac0d7ce4e76 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -3,15 +3,16 @@ module Ci class BuildTraceMetadata < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id MAX_ATTEMPTS = 5 self.table_name = 'ci_build_trace_metadata' self.primary_key = :build_id - belongs_to :build, class_name: 'Ci::Build' + belongs_to :build, + ->(trace_metadata) { in_partition(trace_metadata) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id, + inverse_of: :trace_metadata belongs_to :trace_artifact, class_name: 'Ci::JobArtifact' partitionable scope: :build diff --git a/app/models/ci/catalog/components_project.rb b/app/models/ci/catalog/components_project.rb index 02593d41bc2..794cb70c126 100644 --- a/app/models/ci/catalog/components_project.rb +++ b/app/models/ci/catalog/components_project.rb @@ -45,6 +45,8 @@ module Ci end def fetch_component(component_name) + return ComponentData.new unless component_name.index('/').nil? + path = simple_template_path(component_name) content = fetch_content(path) @@ -53,11 +55,6 @@ module Ci content = fetch_content(path) end - if content.nil? - path = legacy_template_path(component_name) - content = fetch_content(path) - end - ComponentData.new(content: content, path: path) end @@ -71,9 +68,6 @@ module Ci # A simple template consists of a single file def simple_template_path(component_name) - # TODO: Extract this line and move to fetch_content once we remove legacy fetching - return unless component_name.index('/').nil? - File.join(TEMPLATES_DIR, "#{component_name}.yml") end @@ -81,15 +75,8 @@ module Ci # Given a path like "my-org/sub-group/the-project/templates/component" # returns the entry point path: "templates/component/template.yml". def complex_template_path(component_name) - # TODO: Extract this line and move to fetch_content once we remove legacy fetching - return unless component_name.index('/').nil? - File.join(TEMPLATES_DIR, component_name, TEMPLATE_FILE) end - - def legacy_template_path(component_name) - File.join(component_name, TEMPLATE_FILE).delete_prefix('/') - end end end end diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index 51bd85016a5..2c96dda988e 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -2,19 +2,17 @@ module Ci module Catalog + # This class is the SSoT to displaying the list of resources in the CI/CD Catalog. class Listing - # This class is the SSoT to displaying the list of resources in the CI/CD Catalog. - # This model is not directly backed by a table and joins catalog resources - # with projects to return relevant data. - MIN_SEARCH_LENGTH = 3 def initialize(current_user) @current_user = current_user end - def resources(namespace: nil, sort: nil, search: nil) - relation = all_resources + def resources(namespace: nil, sort: nil, search: nil, scope: :all) + relation = Ci::Catalog::Resource.published.includes(:project) + relation = by_scope(relation, scope) relation = by_namespace(relation, namespace) relation = by_search(relation, search) @@ -29,20 +27,25 @@ module Ci end end - private + def find_resource(id: nil, full_path: nil) + resource = id ? Ci::Catalog::Resource.find_by_id(id) : Project.find_by_full_path(full_path)&.catalog_resource - attr_reader :current_user + return unless resource.present? + return unless resource.published? + return unless Ability.allowed?(current_user, :read_code, resource.project) - def all_resources - Ci::Catalog::Resource.joins(:project).includes(:project) - .merge(Project.public_or_visible_to_user(current_user)) + resource end + private + + attr_reader :current_user + def by_namespace(relation, namespace) return relation unless namespace raise ArgumentError, 'Namespace is not a root namespace' unless namespace.root? - relation.merge(Project.in_namespace(namespace.self_and_descendant_ids)) + relation.joins(:project).merge(Project.in_namespace(namespace.self_and_descendant_ids)) end def by_search(relation, search) @@ -51,6 +54,14 @@ module Ci relation.search(search) end + + def by_scope(relation, scope) + if scope == :namespaces && Feature.enabled?(:ci_guard_for_catalog_resource_scope, current_user) + relation.visible_to_user(current_user) + else + relation.public_or_visible_to_user(current_user) + end + end end end end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index f947c5158cf..d1b3a3a4d8a 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -8,7 +8,8 @@ module Ci # dependency on the Project model and its need to join with that table # in order to generate the CI/CD catalog. class Resource < ::ApplicationRecord - include Gitlab::SQL::Pattern + include PgFullTextSearchable + include Gitlab::VisibilityLevel self.table_name = 'catalog_resources' @@ -17,9 +18,14 @@ module Ci inverse_of: :catalog_resource has_many :versions, class_name: 'Ci::Catalog::Resources::Version', foreign_key: :catalog_resource_id, inverse_of: :catalog_resource + has_many :sync_events, class_name: 'Ci::Catalog::Resources::SyncEvent', foreign_key: :catalog_resource_id, + inverse_of: :catalog_resource scope :for_projects, ->(project_ids) { where(project_id: project_ids) } - scope :search, ->(query) { fuzzy_search(query, [:name, :description], use_minimum_char_limit: false) } + + # The `search_vector` column contains a tsvector that has a greater weight on `name` than `description`. + # The vector is automatically generated by the database when `name` or `description` is updated. + scope :search, ->(query) { pg_full_text_search_in_model(query) } scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } @@ -28,14 +34,38 @@ module Ci scope :order_by_latest_released_at_desc, -> { reorder(arel_table[:latest_released_at].desc.nulls_last) } scope :order_by_latest_released_at_asc, -> { reorder(arel_table[:latest_released_at].asc.nulls_last) } - delegate :avatar_path, :star_count, :forks_count, to: :project + delegate :avatar_path, :star_count, :full_path, to: :project enum state: { draft: 0, published: 1 } before_create :sync_with_project - def unpublish! - update!(state: :draft) + class << self + def public_or_visible_to_user(user) + return public_to_user unless user + + where( + 'EXISTS (?) OR catalog_resources.visibility_level IN (?)', + user.authorizations_for_projects(related_project_column: 'catalog_resources.project_id'), + Gitlab::VisibilityLevel.levels_for_user(user) + ) + end + + def visible_to_user(user) + return none unless user + + where_exists(user.authorizations_for_projects(related_project_column: 'catalog_resources.project_id')) + end + + # Used by Ci::ProcessSyncEventsService + def sync!(event) + # There may be orphaned records since this table does not enforce FKs + event.catalog_resource&.sync_with_project! + end + end + + def to_param + full_path end def publish! @@ -47,12 +77,21 @@ module Ci save! end + # Triggered in Ci::Catalog::Resources::Version and Release model callbacks + def update_latest_released_at! + update!(latest_released_at: versions.latest&.released_at) + end + + # Required for Gitlab::VisibilityLevel module + def visibility_level_field + :visibility_level + end + private - # These columns are denormalized from the `projects` table. We first sync these - # columns when the catalog resource record is created. Then any updates to the - # `projects` columns will be synced to the `catalog_resources` table by a worker - # (to be implemented in https://gitlab.com/gitlab-org/gitlab/-/issues/429376.) + # These denormalized columns are first synced when a new catalog resource is created. + # A PG trigger adds a SyncEvent when the associated project updates any of these columns. + # A worker processes the SyncEvents with Ci::ProcessSyncEventsService. def sync_with_project self.name = project.name self.description = project.description diff --git a/app/models/ci/catalog/resources/sync_event.rb b/app/models/ci/catalog/resources/sync_event.rb new file mode 100644 index 00000000000..8783d023023 --- /dev/null +++ b/app/models/ci/catalog/resources/sync_event.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + # This table is used as a queue of catalog resources that need to be synchronized with `projects`. + # A PG trigger adds a SyncEvent when the associated `projects` record of a catalog resource + # updates any of the relevant columns referenced in `Ci::Catalog::Resource#sync_with_project` + # (DB function name: `insert_catalog_resource_sync_event`). + class SyncEvent < ::ApplicationRecord + include PartitionedTable + include IgnorableColumns + + PARTITION_DURATION = 1.day + + self.table_name = 'p_catalog_resource_sync_events' + self.primary_key = :id + self.sequence_name = :p_catalog_resource_sync_events_id_seq + + ignore_column :partition_id, remove_never: true + + belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :sync_events + belongs_to :project, inverse_of: :catalog_resource_sync_events + + scope :for_partition, ->(partition) { where(partition_id: partition) } + scope :select_with_partition, + -> { select(:id, :catalog_resource_id, arel_table[:partition_id].as('partition')) } + + scope :unprocessed_events, -> { select_with_partition.status_pending } + scope :preload_synced_relation, -> { preload(catalog_resource: :project) } + + enum status: { pending: 1, processed: 2 }, _prefix: :status + + partitioned_by :partition_id, strategy: :sliding_list, + next_partition_if: ->(active_partition) do + oldest_record_in_partition = Ci::Catalog::Resources::SyncEvent + .select(:id, :created_at) + .for_partition(active_partition.value) + .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 + !Ci::Catalog::Resources::SyncEvent + .for_partition(partition.value) + .status_pending + .exists? + end + + class << self + def mark_records_processed(records) + update_by_partition(records) do |partitioned_scope| + partitioned_scope.update_all(status: :processed) + end + end + + def enqueue_worker + ::Ci::Catalog::Resources::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker -- Worker is scheduled in model callback functions + end + + def upper_bound_count + select('COALESCE(MAX(id) - MIN(id) + 1, 0) AS upper_bound_count') + .status_pending.to_a.first.upper_bound_count + end + + private + + # You must use .select_with_partition before calling this method + # as it requires the partition to be explicitly selected. + def update_by_partition(records) + records.group_by(&:partition).each do |partition, records_within_partition| + partitioned_scope = status_pending + .for_partition(partition) + .where(id: records_within_partition.map(&:id)) + + yield(partitioned_scope) + end + end + end + end + end + end +end diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index bd0ebc77a6d..4273c4515bc 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -26,7 +26,10 @@ module Ci scope :order_by_released_at_asc, -> { joins(:release).keyset_order_by_released_at_asc } scope :order_by_released_at_desc, -> { joins(:release).keyset_order_by_released_at_desc } - delegate :name, :description, :tag, :sha, :released_at, :author_id, to: :release + delegate :sha, :released_at, :author_id, to: :release + + after_destroy :update_catalog_resource + after_save :update_catalog_resource class << self # In the future, we should support semantic versioning. @@ -110,6 +113,20 @@ module Ci end end end + + def name + release.tag + end + + def commit + project.commit_by(oid: sha) + end + + private + + def update_catalog_resource + catalog_resource.update_latest_released_at! + end end end end diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 3e572dbe18f..179befb8469 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -47,5 +47,9 @@ module Ci end end end + + def audit_details + key + end end end diff --git a/app/models/ci/job_annotation.rb b/app/models/ci/job_annotation.rb index a6ce4196cc1..d56830433b1 100644 --- a/app/models/ci/job_annotation.rb +++ b/app/models/ci/job_annotation.rb @@ -8,7 +8,11 @@ module Ci self.table_name = :p_ci_job_annotations self.primary_key = :id - belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_annotations + belongs_to :job, + ->(job_annotation) { in_partition(job_annotation) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id, + inverse_of: :job_annotations partitionable scope: :job, partitioned: true diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index fe4437a4ad6..369737d78c8 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -13,9 +13,6 @@ module Ci include FileStoreMounter include EachBatch include Gitlab::Utils::StrongMemoize - include SafelyChangeColumnDefault - - columns_changing_default :partition_id enum accessibility: { public: 0, private: 1 }, _suffix: true @@ -61,7 +58,8 @@ module Ci coverage_fuzzing: 'gl-coverage-fuzzing.json', api_fuzzing: 'gl-api-fuzzing-report.json', cyclonedx: 'gl-sbom.cdx.json', - annotations: 'gl-annotations.json' + annotations: 'gl-annotations.json', + repository_xray: 'gl-repository-xray.json' }.freeze INTERNAL_TYPES = { @@ -81,6 +79,7 @@ module Ci lsif: :zip, cyclonedx: :gzip, annotations: :gzip, + repository_xray: :gzip, # Security reports and license scanning reports are raw artifacts # because they used to be fetched by the frontend, but this is not the case anymore. @@ -224,7 +223,8 @@ module Ci cluster_image_scanning: 27, ## EE-specific cyclonedx: 28, ## EE-specific requirements_v2: 29, ## EE-specific - annotations: 30 + annotations: 30, + repository_xray: 31 ## EE-specifric } # `file_location` indicates where actual files are stored. @@ -365,8 +365,6 @@ module Ci end def public_access? - return true unless Feature.enabled?(:non_public_artifacts, type: :development) - public_accessibility? end diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 17809ba20d3..a29d4fee299 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -54,10 +54,7 @@ module Ci # if the setting is disabled any project is considered to be in scope. return true unless current_project.ci_outbound_job_token_scope_enabled? - if !accessed_project.private? && - Feature.enabled?(:restrict_ci_job_token_for_public_and_internal_projects, accessed_project) - return true - end + return true unless accessed_project.private? outbound_allowlist.includes?(accessed_project) end diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb index 21c9842399e..573999995bc 100644 --- a/app/models/ci/job_variable.rb +++ b/app/models/ci/job_variable.rb @@ -5,11 +5,8 @@ module Ci include Ci::Partitionable include Ci::NewHasVariable include Ci::RawVariable - include SafelyChangeColumnDefault include BulkInsertSafe - columns_changing_default :partition_id - belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id, inverse_of: :job_variables partitionable scope: :job diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index dc9a8b7a1bf..1e628381233 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -4,12 +4,13 @@ module Ci class PendingBuild < Ci::ApplicationRecord include EachBatch include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id belongs_to :project - belongs_to :build, class_name: 'Ci::Build' + + belongs_to :build, # rubocop: disable Rails/InverseOf -- this relation is not present on build + ->(pending_build) { in_partition(pending_build) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id belongs_to :namespace, inverse_of: :pending_builds, class_name: 'Namespace' partitionable scope: :build diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index cf3efc5998f..9d5b2e5a0b1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -17,9 +17,6 @@ module Ci include UpdatedAtFilterable include EachBatch include FastDestroyAll::Helpers - include SafelyChangeColumnDefault - - columns_changing_default :partition_id include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' @@ -35,12 +32,14 @@ module Ci CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze UNLOCKABLE_STATUSES = (Ci::Pipeline.completed_statuses + [:manual]).freeze + INITIAL_PARTITION_VALUE = 100 + NEXT_PARTITION_VALUE = 101 paginates_per 15 sha_attribute :source_sha sha_attribute :target_sha - partitionable scope: ->(_) { Ci::Pipeline.current_partition_value } + partitionable scope: ->(pipeline) { Ci::Pipeline.current_partition_value(pipeline.project) } # Ci::CreatePipelineService returns Ci::Pipeline so this is the only place # where we can pass additional information from the service. This accessor # is used for storing the processed metadata for linting purposes. @@ -78,31 +77,31 @@ module Ci # Ci:Job models. With that epic, we aim to replace `statuses` with `jobs`. # # DEPRECATED: - has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :processables, class_name: 'Ci::Processable', 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, - 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 - has_many :generic_commit_statuses, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus' + has_many :statuses, ->(pipeline) { in_partition(pipeline) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :processables, ->(pipeline) { in_partition(pipeline) }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :latest_statuses_ordered_by_stage, -> (pipeline) { latest.in_partition(pipeline).order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :latest_statuses, ->(pipeline) { latest.in_partition(pipeline) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :statuses_order_id_desc, ->(pipeline) { in_partition(pipeline).order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id, + inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :bridges, ->(pipeline) { in_partition(pipeline) }, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :builds, ->(pipeline) { in_partition(pipeline) }, foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :generic_commit_statuses, ->(pipeline) { in_partition(pipeline) }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus', partition_foreign_key: :partition_id # # NEW: - has_many :all_jobs, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :current_jobs, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :all_processable_jobs, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline - has_many :current_processable_jobs, -> { latest }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline + has_many :all_jobs, ->(pipeline) { in_partition(pipeline) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :current_jobs, ->(pipeline) { latest.in_partition(pipeline) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :all_processable_jobs, ->(pipeline) { in_partition(pipeline) }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id + has_many :current_processable_jobs, ->(pipeline) { latest.in_partition(pipeline) }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline, partition_foreign_key: :partition_id has_many :job_artifacts, through: :builds has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' - has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' + has_many :latest_builds, ->(pipeline) { in_partition(pipeline).latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' has_many :downloadable_artifacts, -> do not_expired.or(where_exists(Ci::Pipeline.artifacts_locked.where("#{Ci::Pipeline.quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id"))).downloadable.with_job end, through: :latest_builds, source: :job_artifacts - has_many :latest_successful_jobs, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Processable' + has_many :latest_successful_jobs, ->(pipeline) { in_partition(pipeline).latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Processable' has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline @@ -111,14 +110,14 @@ module Ci has_many :merge_requests_as_head_pipeline, foreign_key: :head_pipeline_id, class_name: 'MergeRequest', inverse_of: :head_pipeline - 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', + has_many :pending_builds, ->(pipeline) { in_partition(pipeline).pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline + has_many :failed_builds, ->(pipeline) { in_partition(pipeline).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 - has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus', + has_many :retryable_builds, ->(pipeline) { in_partition(pipeline).latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline + has_many :cancelable_statuses, ->(pipeline) { in_partition(pipeline).cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus', inverse_of: :pipeline - has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Processable', inverse_of: :pipeline - has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline + has_many :manual_actions, ->(pipeline) { in_partition(pipeline).latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Processable', inverse_of: :pipeline + has_many :scheduled_actions, ->(pipeline) { in_partition(pipeline).latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: :auto_canceled_by_id, inverse_of: :auto_canceled_by @@ -152,7 +151,7 @@ module Ci accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :full_path, to: :project, prefix: true - delegate :name, to: :pipeline_metadata, allow_nil: true + delegate :name, :auto_cancel_on_job_failure, to: :pipeline_metadata, allow_nil: true validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } @@ -274,26 +273,6 @@ module Ci end after_transition any => UNLOCKABLE_STATUSES do |pipeline| - # This is a temporary flag that we added just in case we need to totally - # stop unlocking pipelines due to unexpected issues during rollout. - next if Feature.enabled?(:ci_stop_unlock_pipelines, pipeline.project) - - next unless Feature.enabled?(:ci_unlock_non_successful_pipelines, pipeline.project) - - pipeline.run_after_commit do - Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(pipeline.ci_ref_id) - end - end - - # TODO: Remove this block once we've completed roll-out of ci_unlock_non_successful_pipelines - # https://gitlab.com/gitlab-org/gitlab/-/issues/428408 - after_transition any => :success do |pipeline| - # This is a temporary flag that we added just in case we need to totally - # stop unlocking pipelines due to unexpected issues during rollout. - next if Feature.enabled?(:ci_stop_unlock_pipelines, pipeline.project) - - next unless Feature.disabled?(:ci_unlock_non_successful_pipelines, pipeline.project) - pipeline.run_after_commit do Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(pipeline.ci_ref_id) end @@ -413,7 +392,6 @@ module Ci pipeline.run_after_commit do next if pipeline.child? - next unless Feature.enabled?(:widget_pipeline_pass_subscription_update, project) || project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) pipeline.all_merge_requests.opened.each do |merge_request| GraphqlTriggers.merge_request_merge_status_updated(merge_request) @@ -458,20 +436,12 @@ module Ci end scope :with_reports, -> (reports_scope) do - where('EXISTS (?)', - ::Ci::Build - .latest - .with_artifacts(reports_scope) - .where("#{quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id") - .select(1) - ) + where_exists(Ci::Build.latest.scoped_pipeline.with_artifacts(reports_scope)) end scope :with_only_interruptible_builds, -> do - where('NOT EXISTS (?)', - Ci::Build.where("#{Ci::Build.quoted_table_name}.commit_id = #{quoted_table_name}.id") - .with_status(STARTED_STATUSES) - .not_interruptible + where_not_exists( + Ci::Build.scoped_pipeline.with_status(STARTED_STATUSES).not_interruptible ) end @@ -595,14 +565,26 @@ module Ci @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines') end - def self.current_partition_value - 100 + def self.current_partition_value(project = nil) + Gitlab::SafeRequestStore.fetch(:ci_current_partition_value) do + if Feature.enabled?(:ci_current_partition_value_101, project) + NEXT_PARTITION_VALUE + else + INITIAL_PARTITION_VALUE + end + end end def self.object_hierarchy(relation, options = {}) ::Gitlab::Ci::PipelineObjectHierarchy.new(relation, options: options) end + def self.use_partition_id_filter? + ::Gitlab::SafeRequestStore.fetch(:ci_builds_partition_id_query_filter) do + ::Feature.enabled?(:ci_builds_partition_id_query_filter) + end + end + def uses_needs? processables.where(scheduling_type: :dag).any? end @@ -842,6 +824,13 @@ module Ci add_message(:warning, content) end + # Like #drop!, but does not persist the pipeline nor trigger any state + # machine callbacks. + def set_failed(drop_reason) + self.failure_reason = drop_reason.to_s + self.status = 'failed' + end + # We can't use `messages.error` scope here because messages should also be # read when the pipeline is not persisted. Using the scope will return no # results as it would query persisted data. @@ -992,15 +981,15 @@ module Ci end def builds_in_self_and_project_descendants - Ci::Build.latest.where(pipeline: self_and_project_descendants) + Ci::Build.in_partition(self).latest.where(pipeline: self_and_project_descendants) end def bridges_in_self_and_project_descendants - Ci::Bridge.latest.where(pipeline: self_and_project_descendants) + Ci::Bridge.in_partition(self).latest.where(pipeline: self_and_project_descendants) end def jobs_in_self_and_project_descendants - Ci::Processable.latest.where(pipeline: self_and_project_descendants) + Ci::Processable.in_partition(self).latest.where(pipeline: self_and_project_descendants) end def environments_in_self_and_project_descendants(deployment_status: nil) @@ -1091,6 +1080,10 @@ module Ci persisted? && failure_reason.blank? end + def filtered_as_empty? + filtered_by_rules? || filtered_by_workflow_rules? + end + def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory .new(self.present, current_user) diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index 37916c0b302..ba20c993e36 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -3,9 +3,6 @@ module Ci class PipelineChatData < Ci::ApplicationRecord include Ci::NamespacedModelName - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' self.table_name = 'ci_pipeline_chat_data' diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb index c997ec5cd62..5668da915e6 100644 --- a/app/models/ci/pipeline_message.rb +++ b/app/models/ci/pipeline_message.rb @@ -2,10 +2,6 @@ module Ci class PipelineMessage < Ci::ApplicationRecord - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-09-22' - MAX_CONTENT_LENGTH = 10_000 belongs_to :pipeline diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb index 2bd206c5ca5..37fa3e32ad8 100644 --- a/app/models/ci/pipeline_metadata.rb +++ b/app/models/ci/pipeline_metadata.rb @@ -4,11 +4,22 @@ module Ci class PipelineMetadata < Ci::ApplicationRecord self.primary_key = :pipeline_id + enum auto_cancel_on_new_commit: { + conservative: 0, + interruptible: 1, + disabled: 2 + }, _prefix: true + + enum auto_cancel_on_job_failure: { + none: 0, + all: 1 + }, _prefix: true + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_metadata belongs_to :project, class_name: "Project", inverse_of: :pipeline_metadata validates :pipeline, presence: true validates :project, presence: true - validates :name, presence: true, length: { minimum: 1, maximum: 255 } + validates :name, length: { minimum: 1, maximum: 255 }, allow_nil: true end end diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index a422aaa7daa..b1831e365b1 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -6,13 +6,13 @@ module Ci include Ci::HasVariable include Ci::RawVariable include IgnorableColumns - include SafelyChangeColumnDefault - columns_changing_default :partition_id ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' belongs_to :pipeline + self.primary_key = :id + partitionable scope: :pipeline alias_attribute :secret_value, :value diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 7ad1a727a0e..414d36da7c3 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -9,6 +9,8 @@ module Ci include Ci::Metadatable extend ::Gitlab::Utils::Override + self.allow_legacy_sti_class = true + has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable has_one :sourced_pipeline, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :source_job @@ -31,6 +33,12 @@ module Ci where('NOT EXISTS (?)', needs) end + scope :not_interruptible, -> do + joins(:metadata).where.not( + Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) } + ) + end + state_machine :status do event :enqueue do transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :with_resource_group? diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb index cfdc47de531..e70ba3c97c3 100644 --- a/app/models/ci/running_build.rb +++ b/app/models/ci/running_build.rb @@ -10,14 +10,14 @@ module Ci # of the running builds there is worth the additional pressure. class RunningBuild < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id partitionable scope: :build belongs_to :project - belongs_to :build, class_name: 'Ci::Build' + belongs_to :build, # rubocop: disable Rails/InverseOf -- this relation is not present on build + ->(running_build) { in_partition(running_build) }, + class_name: 'Ci::Build', + partition_foreign_key: :partition_id belongs_to :runner, class_name: 'Ci::Runner' enum runner_type: ::Ci::Runner.runner_types diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 475d57ee4c8..51c8fb787c7 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -5,20 +5,12 @@ module Ci class Pipeline < Ci::ApplicationRecord include Ci::Partitionable include Ci::NamespacedModelName - include SafelyChangeColumnDefault - include IgnorableColumns - - ignore_columns [ - :pipeline_id_convert_to_bigint, :source_pipeline_id_convert_to_bigint - ], remove_with: '16.6', remove_after: '2023-10-22' - - columns_changing_default :partition_id, :source_partition_id self.table_name = "ci_sources_pipelines" belongs_to :project, class_name: "::Project" belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :source_pipeline - belongs_to :build, class_name: "Ci::Build", foreign_key: :source_job_id, inverse_of: :sourced_pipelines + belongs_to :build, class_name: 'Ci::Build', foreign_key: :source_job_id, inverse_of: :sourced_pipelines belongs_to :source_project, class_name: "::Project", foreign_key: :source_project_id belongs_to :source_job, class_name: "CommitStatus", foreign_key: :source_job_id diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 3d2df9a45ef..becb8f204bf 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -7,11 +7,8 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable - include SafelyChangeColumnDefault include IgnorableColumns - columns_changing_default :partition_id - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' partitionable scope: :pipeline @@ -21,19 +18,45 @@ module Ci belongs_to :project belongs_to :pipeline - has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id, inverse_of: :ci_stage - has_many :latest_statuses, -> { ordered.latest }, + has_many :statuses, + ->(stage) { in_partition(stage) }, + class_name: 'CommitStatus', + foreign_key: :stage_id, + partition_foreign_key: :partition_id, + inverse_of: :ci_stage + has_many :latest_statuses, + ->(stage) { in_partition(stage).ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id, + partition_foreign_key: :partition_id, inverse_of: :ci_stage - has_many :retried_statuses, -> { ordered.retried }, + has_many :retried_statuses, + ->(stage) { in_partition(stage).ordered.retried }, class_name: 'CommitStatus', foreign_key: :stage_id, + partition_foreign_key: :partition_id, + inverse_of: :ci_stage + has_many :processables, + ->(stage) { in_partition(stage) }, + class_name: 'Ci::Processable', + foreign_key: :stage_id, + partition_foreign_key: :partition_id, + inverse_of: :ci_stage + has_many :builds, + ->(stage) { in_partition(stage) }, + foreign_key: :stage_id, + partition_foreign_key: :partition_id, + inverse_of: :ci_stage + has_many :bridges, + ->(stage) { in_partition(stage) }, + foreign_key: :stage_id, + partition_foreign_key: :partition_id, + inverse_of: :ci_stage + has_many :generic_commit_statuses, + ->(stage) { in_partition(stage) }, + foreign_key: :stage_id, + partition_foreign_key: :partition_id, inverse_of: :ci_stage - has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id, inverse_of: :ci_stage - has_many :builds, foreign_key: :stage_id, inverse_of: :ci_stage - has_many :bridges, foreign_key: :stage_id, inverse_of: :ci_stage - has_many :generic_commit_statuses, foreign_key: :stage_id, inverse_of: :ci_stage scope :ordered, -> { order(position: :asc) } scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } @@ -107,6 +130,10 @@ module Ci end end + def self.use_partition_id_filter? + Ci::Pipeline.use_partition_id_filter? + end + def set_status(new_status) retry_optimistic_lock(self, name: 'ci_stage_set_status') do case new_status diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 58da1b4bd7e..228ab7d1624 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,12 +4,9 @@ module Ci class Trigger < Ci::ApplicationRecord include Presentable include Limitable - include IgnorableColumns TRIGGER_TOKEN_PREFIX = 'glptt-' - ignore_column :ref, remove_with: '16.1', remove_after: '2023-05-22' - self.limit_name = 'pipeline_triggers' self.limit_scope = :project diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index 37893f6cdae..97e07463921 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -3,16 +3,17 @@ module Ci class UnitTestFailure < Ci::ApplicationRecord include Ci::Partitionable - include SafelyChangeColumnDefault - - columns_changing_default :partition_id REPORT_WINDOW = 14.days validates :unit_test, :build, :failed_at, presence: true belongs_to :unit_test, class_name: "Ci::UnitTest", foreign_key: :unit_test_id - belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + belongs_to :build, + ->(unit_test_failure) { in_partition(unit_test_failure) }, + class_name: 'Ci::Build', + foreign_key: :build_id, + partition_foreign_key: :partition_id partitionable scope: :build diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 9f77bd8ebe2..f1aeb7e528f 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -25,11 +25,12 @@ class CommitStatus < Ci::ApplicationRecord self.sequence_name = :ci_builds_id_seq self.primary_key = :id + query_constraints :id, :partition_id partitionable scope: :pipeline, partitioned: true belongs_to :user belongs_to :project - belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, inverse_of: :statuses + belongs_to :pipeline, ->(build) { in_partition(build) }, class_name: 'Ci::Pipeline', foreign_key: :commit_id, inverse_of: :statuses, partition_foreign_key: :partition_id belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline', inverse_of: :auto_canceled_jobs belongs_to :ci_stage, class_name: 'Ci::Stage', foreign_key: :stage_id @@ -85,7 +86,7 @@ class CommitStatus < Ci::ApplicationRecord scope :for_project_paths, -> (paths) do # 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) + project_ids = Project.where_full_path_in(Array(paths), use_includes: false).pluck(:id) for_project(project_ids) end @@ -98,6 +99,11 @@ class CommitStatus < Ci::ApplicationRecord preload(project: :namespace) end + scope :scoped_pipeline, -> do + where(arel_table[:commit_id].eq(Ci::Pipeline.arel_table[:id])) + .where(arel_table[:partition_id].eq(Ci::Pipeline.arel_table[:partition_id])) + end + scope :match_id_and_lock_version, -> (items) do # it expects that items are an array of attributes to match # each hash needs to have `id` and `lock_version` @@ -233,6 +239,10 @@ class CommitStatus < Ci::ApplicationRecord false end + def self.use_partition_id_filter? + Ci::Pipeline.use_partition_id_filter? + end + def locking_enabled? will_save_change_to_status? end diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb index fa7f065b6b4..680d20b61cf 100644 --- a/app/models/commit_user_mention.rb +++ b/app/models/commit_user_mention.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true class CommitUserMention < UserMention - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - belongs_to :note end diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb index dfcc905b3c3..618ea7d979c 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -111,7 +111,8 @@ module Analytics :author_id, :state_id, :start_event_timestamp, - :end_event_timestamp + :end_event_timestamp, + :duration_in_milliseconds ] end @@ -125,7 +126,8 @@ module Analytics :author_id, :state_id, :start_event_timestamp, - :end_event_timestamp + :end_event_timestamp, + :duration_in_milliseconds ] end diff --git a/app/models/concerns/analytics/cycle_analytics/stageable.rb b/app/models/concerns/analytics/cycle_analytics/stageable.rb index d1dd46883e3..5cdfa32b796 100644 --- a/app/models/concerns/analytics/cycle_analytics/stageable.rb +++ b/app/models/concerns/analytics/cycle_analytics/stageable.rb @@ -31,6 +31,7 @@ module Analytics scope :with_preloaded_labels, -> { includes(:start_event_label, :end_event_label) } scope :for_list, -> { with_preloaded_labels.ordered } scope :by_value_stream, ->(value_stream) { where(value_stream_id: value_stream.id) } + scope :by_value_streams_ids, ->(value_stream_ids) { where(value_stream_id: value_stream_ids) } before_save :ensure_stage_event_hash_id after_commit :cleanup_old_stage_event_hash diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index e342939b3d6..14c750072c2 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -7,7 +7,10 @@ module Avatarable PROJECT_AVATAR_SIZES = [15, 40, 48, 64, 88].freeze GROUP_AVATAR_SIZES = [15, 37, 38, 39, 40, 64, 96].freeze - ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze + COMBINED_AVATAR_SIZES = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze + COMBINED_AVATAR_SIZES_RETINA = COMBINED_AVATAR_SIZES.map { |size| size * 2 } + + ALLOWED_IMAGE_SCALER_WIDTHS = (COMBINED_AVATAR_SIZES | COMBINED_AVATAR_SIZES_RETINA).uniq.freeze # This value must not be bigger than then: https://gitlab.com/gitlab-org/gitlab/-/blob/master/workhorse/config.toml.example#L20 # diff --git a/app/models/concerns/cached_commit.rb b/app/models/concerns/cached_commit.rb index 8a53fec0612..74120f49d01 100644 --- a/app/models/concerns/cached_commit.rb +++ b/app/models/concerns/cached_commit.rb @@ -19,4 +19,8 @@ module CachedCommit def referenced_by [] end + + def extended_trailers + {} + end end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 88b7bb89b89..dcbee529637 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -17,6 +17,25 @@ module Ci end end + def unprotected_scoped_variables( + expose_project_variables:, + expose_group_variables:, + environment: expanded_environment_name, + dependencies: true) + + track_duration do + pipeline + .variables_builder + .unprotected_scoped_variables( + self, + expose_project_variables: expose_project_variables, + expose_group_variables: expose_group_variables, + environment: environment, + dependencies: dependencies + ) + end + end + def track_duration start_time = ::Gitlab::Metrics::System.monotonic_time result = yield diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index b785e39523d..3f48df08016 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -10,8 +10,10 @@ module Ci included do has_one :metadata, + ->(build) { where(partition_id: build.partition_id) }, class_name: 'Ci::BuildMetadata', foreign_key: :build_id, + partition_foreign_key: :partition_id, inverse_of: :build, autosave: true @@ -26,9 +28,7 @@ module Ci before_validation :ensure_metadata, on: :create scope :with_project_and_metadata, -> do - if Feature.enabled?(:non_public_artifacts, type: :development) - joins(:metadata).includes(:metadata).preload(:project) - end + joins(:metadata).includes(:metadata).preload(:project) end end diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index c4b1281fa72..03cce1edf74 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -19,48 +19,16 @@ module Ci extend ActiveSupport::Concern include ::Gitlab::Utils::StrongMemoize - module Testing - InclusionError = Class.new(StandardError) - - PARTITIONABLE_MODELS = %w[ - CommitStatus - Ci::BuildMetadata - Ci::BuildNeed - Ci::BuildReportResult - Ci::BuildRunnerSession - Ci::BuildTraceChunk - Ci::BuildTraceMetadata - Ci::BuildPendingState - Ci::JobAnnotation - Ci::JobArtifact - Ci::JobVariable - Ci::Pipeline - Ci::PendingBuild - Ci::RunningBuild - Ci::RunnerManagerBuild - Ci::PipelineVariable - Ci::Sources::Pipeline - Ci::Stage - Ci::UnitTestFailure - ].freeze - - def self.check_inclusion(klass) - return if PARTITIONABLE_MODELS.include?(klass.name) - - raise Partitionable::Testing::InclusionError, - "#{klass} must be included in PARTITIONABLE_MODELS" - - rescue InclusionError => e - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) - end - end - included do Partitionable::Testing.check_inclusion(self) before_validation :set_partition_id, on: :create validates :partition_id, presence: true + scope :in_partition, ->(id) do + where(partition_id: (id.respond_to?(:partition_id) ? id.partition_id : id)) + end + def set_partition_id return if partition_id_changed? && partition_id.present? return unless partition_scope_value @@ -106,7 +74,9 @@ module Ci partitioned_by :partition_id, strategy: :ci_sliding_list, - next_partition_if: proc { false }, + next_partition_if: ->(latest_partition) do + latest_partition.blank? || Ci::Pipeline::NEXT_PARTITION_VALUE > latest_partition.value + end, detach_partition_if: proc { false }, # Most of the db tasks are run in a weekly basis, e.g. execute_batched_migrations. # Therefore, let's start with 1.week and see how it'd go. diff --git a/app/models/concerns/ci/partitionable/testing.rb b/app/models/concerns/ci/partitionable/testing.rb new file mode 100644 index 00000000000..b961d72db94 --- /dev/null +++ b/app/models/concerns/ci/partitionable/testing.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Ci + module Partitionable + module Testing + InclusionError = Class.new(StandardError) + + PARTITIONABLE_MODELS = %w[ + CommitStatus + Ci::BuildMetadata + Ci::BuildNeed + Ci::BuildReportResult + Ci::BuildRunnerSession + Ci::BuildTraceChunk + Ci::BuildTraceMetadata + Ci::BuildPendingState + Ci::JobAnnotation + Ci::JobArtifact + Ci::JobVariable + Ci::Pipeline + Ci::PendingBuild + Ci::RunningBuild + Ci::RunnerManagerBuild + Ci::PipelineVariable + Ci::Sources::Pipeline + Ci::Stage + Ci::UnitTestFailure + ].freeze + + def self.check_inclusion(klass) + return if partitionable_models.include?(klass.name) + + raise Partitionable::Testing::InclusionError, + "#{klass} must be included in PARTITIONABLE_MODELS" + + rescue InclusionError => e + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) + end + + def self.partitionable_models + PARTITIONABLE_MODELS + end + end + end +end + +Ci::Partitionable::Testing.prepend_mod diff --git a/app/models/concerns/disables_sti.rb b/app/models/concerns/disables_sti.rb new file mode 100644 index 00000000000..ca6be5c43a8 --- /dev/null +++ b/app/models/concerns/disables_sti.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Allow legacy usage of STI in models. +# +# The use of STI is disallowed otherwise and checked via +# `spec/support/shared_examples/models/disable_sti_shared_examples.rb`. +# +# See https://docs.gitlab.com/ee/development/database/single_table_inheritance.html +module DisablesSti + extend ActiveSupport::Concern + + included do + class_attribute :allow_legacy_sti_class + end +end diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index f5ffeb8c425..7dd9dece5e0 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -15,10 +15,20 @@ module Enums job_activity_limit_exceeded: 22, deployments_limit_exceeded: 23, # 24 was previously used by the deprecated `user_blocked` - project_deleted: 25 + project_deleted: 25, + filtered_by_rules: 26, + filtered_by_workflow_rules: 27 } end + def self.persistable_failure_reasons + failure_reasons.except(:filtered_by_rules, :filtered_by_workflow_rules) + end + + def self.persistable_failure_reason?(reason) + persistable_failure_reasons.include?(reason) + end + # Returns the `Hash` to use for creating the `sources` enum for # `Ci::Pipeline`. def self.sources diff --git a/app/models/concerns/enums/package_metadata.rb b/app/models/concerns/enums/package_metadata.rb index 352eb41829b..385807cd7ed 100644 --- a/app/models/concerns/enums/package_metadata.rb +++ b/app/models/concerns/enums/package_metadata.rb @@ -2,25 +2,9 @@ module Enums class PackageMetadata - PURL_TYPES = { - composer: 1, - conan: 2, - gem: 3, - golang: 4, - maven: 5, - npm: 6, - nuget: 7, - pypi: 8, - apk: 9, - rpm: 10, - deb: 11, - 'cbl-mariner': 12, - wolfi: 13 - }.with_indifferent_access.freeze - ADVISORY_SOURCES = { glad: 1, # gitlab advisory db - trivy: 2 + 'trivy-db': 2 }.with_indifferent_access.freeze DATA_TYPES = { @@ -33,14 +17,6 @@ module Enums v2: 2 }.with_indifferent_access.freeze - def self.purl_types - PURL_TYPES - end - - def self.purl_types_numerical - purl_types.invert - end - def self.advisory_sources ADVISORY_SOURCES end diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb index af8e37b4248..4e54e48e667 100644 --- a/app/models/concerns/enums/sbom.rb +++ b/app/models/concerns/enums/sbom.rb @@ -22,14 +22,45 @@ module Enums wolfi: 13 }.with_indifferent_access.freeze + DEPENDENCY_SCANNING_PURL_TYPES = %w[ + composer + conan + gem + golang + maven + npm + nuget + pypi + ].freeze + + CONTAINER_SCANNING_PURL_TYPES = %w[ + apk + rpm + deb + cbl-mariner + wolfi + ].freeze + def self.component_types COMPONENT_TYPES end + def self.dependency_scanning_purl_type?(purl_type) + DEPENDENCY_SCANNING_PURL_TYPES.include?(purl_type) + end + + def self.container_scanning_purl_type?(purl_type) + CONTAINER_SCANNING_PURL_TYPES.include?(purl_type) + end + def self.purl_types # return 0 by default if the purl_type is not found, to prevent # consumers from producing invalid SQL caused by null entries @_purl_types ||= PURL_TYPES.dup.tap { |h| h.default = 0 } end + + def self.purl_types_numerical + purl_types.invert + end end end diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index dbf05dbc428..f7d35c77648 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -46,6 +46,30 @@ module Enums dismissed: 2 }.with_indifferent_access.freeze + OWASP_TOP_10 = { + "A1:2017-Injection" => 1, + "A2:2017-Broken Authentication" => 2, + "A3:2017-Sensitive Data Exposure" => 3, + "A4:2017-XML External Entities (XXE)" => 4, + "A5:2017-Broken Access Control" => 5, + "A6:2017-Security Misconfiguration" => 6, + "A7:2017-Cross-Site Scripting (XSS)" => 7, + "A8:2017-Insecure Deserialization" => 8, + "A9:2017-Using Components with Known Vulnerabilities" => 9, + "A10:2017-Insufficient Logging & Monitoring" => 10, + + "A1:2021-Broken Access Control" => 11, + "A2:2021-Cryptographic Failures" => 12, + "A3:2021-Injection" => 13, + "A4:2021-Insecure Design" => 14, + "A5:2021-Security Misconfiguration" => 15, + "A6:2021-Vulnerable and Outdated Components" => 16, + "A7:2021-Identification and Authentication Failures" => 17, + "A8:2021-Software and Data Integrity Failures" => 18, + "A9:2021-Security Logging and Monitoring Failures" => 19, + "A10:2021-Server-Side Request Forgery" => 20 + }.with_indifferent_access.freeze + def self.confidence_levels CONFIDENCE_LEVELS end @@ -73,6 +97,10 @@ module Enums def self.vulnerability_states VULNERABILITY_STATES end + + def self.owasp_top_10 + OWASP_TOP_10 + end end end diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb index 249d0b99494..fb114eed400 100644 --- a/app/models/concerns/ignorable_columns.rb +++ b/app/models/concerns/ignorable_columns.rb @@ -3,13 +3,11 @@ module IgnorableColumns extend ActiveSupport::Concern - ColumnIgnore = Struct.new(:remove_after, :remove_with) do + ColumnIgnore = Struct.new(:remove_after, :remove_with, :remove_never) do def safe_to_remove? - Date.today > remove_after - end + return false if remove_never - def to_s - "(#{remove_after}, #{remove_with})" + Date.today > remove_after end end @@ -17,14 +15,17 @@ module IgnorableColumns # Ignore database columns in a model # # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release) - def ignore_columns(*columns, remove_after:, remove_with:) - raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless Gitlab::Regex.utc_date_regex.match?(remove_after) - raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + def ignore_columns(*columns, remove_after: nil, remove_with: nil, remove_never: false) + unless remove_never + raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after && Gitlab::Regex.utc_date_regex.match?(remove_after) + raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + end self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns columns.flatten.each do |column| - self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(Date.parse(remove_after), remove_with) + remove_after_date = remove_after ? Date.parse(remove_after) : nil + self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(remove_after_date, remove_with, remove_never) end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 971089edc45..77e2a091139 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -49,8 +49,6 @@ module Noteable end def supports_resolvable_notes? - return false if is_a?(Issue) && Feature.disabled?(:resolvable_issue_threads, project) - self.class.resolvable_types.include?(base_class_name) end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb index b31a52f98ca..d32c4c9baf1 100644 --- a/app/models/concerns/pg_full_text_searchable.rb +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -17,6 +17,9 @@ # This also adds a `pg_full_text_search` scope so you can do: # # Model.pg_full_text_search("some search term") +# +# For situations where the `search_vector` column exists within the model table and not +# in a `search_data` association, you may instead use `pg_full_text_search_in_model`. module PgFullTextSearchable extend ActiveSupport::Concern @@ -108,20 +111,27 @@ module PgFullTextSearchable def pg_full_text_search(query, matched_columns: []) search_data_table = reflect_on_association(:search_data).klass.arel_table - joins(:search_data).where( - Arel::Nodes::InfixOperation.new( - '@@', - search_data_table[:search_vector], - Arel::Nodes::NamedFunction.new( - 'to_tsquery', - [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)] - ) - ) - ) + joins(:search_data) + .where(pg_full_text_search_query(query, search_data_table, matched_columns: matched_columns)) + end + + def pg_full_text_search_in_model(query, matched_columns: []) + where(pg_full_text_search_query(query, arel_table, matched_columns: matched_columns)) end private + def pg_full_text_search_query(query, search_table, matched_columns: []) + Arel::Nodes::InfixOperation.new( + '@@', + search_table[:search_vector], + Arel::Nodes::NamedFunction.new( + 'to_tsquery', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)] + ) + ) + end + def build_tsquery(query, matched_columns) # URLs get broken up into separate words when : is removed below, so we just remove the whole scheme. query = remove_url_scheme(query) diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index c70100c03c8..1d687b29b02 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -118,6 +118,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:model_experiments_access_level, value) end + def model_registry_access_level=(value) + write_feature_attribute_string(:model_registry_access_level, value) + end + # TODO: Remove this method after we drop support for project create/edit APIs to set the # container_registry_enabled attribute. They can instead set the container_registry_access_level # attribute. diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 4c16ba18823..242194be440 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -94,16 +94,31 @@ module Routable "(LOWER(routes.path) = LOWER(#{connection.quote(path)}))" end - route = + if Feature.enabled?(:optimize_where_full_path_in, Feature.current_request) + route_scope = all + source_type_condition = { source_type: route_scope.klass.base_class } + + routes_matching_condition = Route.where(source_type_condition).where(wheres.join(' OR ')) + + result = route_scope.where(id: routes_matching_condition.pluck(:source_id)) + if use_includes - includes(:route).references(:routes) + result.preload(:route) else - joins(:route) + result end - - route - .where(wheres.join(' OR ')) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + else + route = + if use_includes + includes(:route).references(:routes) + else + joins(:route) + end + + route + .where(wheres.join(' OR ')) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + end end end diff --git a/app/models/concerns/transitionable.rb b/app/models/concerns/transitionable.rb index 70e1fc8b78a..b64b09336bb 100644 --- a/app/models/concerns/transitionable.rb +++ b/app/models/concerns/transitionable.rb @@ -6,9 +6,7 @@ module Transitionable attr_accessor :transitioning def transitioning? - return false unless transitioning && Feature.enabled?(:skip_validations_during_transitions, project) - - true + transitioning end def enable_transitioning diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index 94d091e8459..337c7f0571a 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -3,12 +3,10 @@ module VulnerabilityFindingHelpers extend ActiveSupport::Concern - # Manually resolvable report types cannot be considered fixed once removed from the - # target branch due to requiring active triage, such as rotation of an exposed token. - REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION = %w[secret_detection].freeze - def requires_manual_resolution? - REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type) + return false unless defined?(::Vulnerability::REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION) + + ::Vulnerability::REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type) end def matches_signatures(other_signatures, other_uuid) diff --git a/app/models/container_registry/protection/rule.rb b/app/models/container_registry/protection/rule.rb index a91f3633d75..a7324b3b3b8 100644 --- a/app/models/container_registry/protection/rule.rb +++ b/app/models/container_registry/protection/rule.rb @@ -3,6 +3,10 @@ module ContainerRegistry module Protection class Rule < ApplicationRecord + include IgnorableColumns + + ignore_column :container_path_pattern, remove_with: '16.8', remove_after: '2023-12-22' + enum delete_protected_up_to_access_level: Gitlab::Access.sym_options_with_owner.slice(:maintainer, :owner, :developer), _prefix: :delete_protected_up_to @@ -12,7 +16,7 @@ module ContainerRegistry belongs_to :project, inverse_of: :container_registry_protection_rules - validates :container_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 } + validates :repository_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 } validates :delete_protected_up_to_access_level, presence: true validates :push_protected_up_to_access_level, presence: true end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 15ed517dc12..6bcfd23e69c 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -656,7 +656,7 @@ class ContainerRepository < ApplicationRecord tag.updated_at = raw_tag['updated_at'] tag.total_size = raw_tag['size_bytes'] tag.manifest_digest = raw_tag['digest'] - tag.revision = raw_tag['config_digest'].to_s.split(':')[1] + tag.revision = raw_tag['config_digest'].to_s.split(':')[1] || '' tag end end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index c704795130b..a3318cd0bd8 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -25,20 +25,33 @@ class CustomEmoji < ApplicationRecord format: { with: /\A#{NAME_REGEXP}\z/o } scope :by_name, -> (names) { where(name: names) } + scope :for_namespaces, -> (namespace_ids) do + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'name', + order_expression: CustomEmoji.arel_table[:name].asc, + nullable: :not_nullable, + distinct: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'current_namespace', + order_expression: Arel.sql("CASE WHEN namespace_id = #{namespace_ids.first} THEN 0 ELSE 1 END").asc, + nullable: :not_nullable, + add_to_projections: true + ) + ]) + where(namespace_id: namespace_ids) + .select("DISTINCT ON (name) *") + .order(order) + end alias_attribute :url, :file # this might need a change in https://gitlab.com/gitlab-org/gitlab/-/issues/230467 - # Find custom emoji for the given resource. - # A resource can be either a Project or a Group, or anything responding to #root_ancestor. - # Usually it's the return value of #resource_parent on any model. scope :for_resource, -> (resource) do - return none if resource.nil? - - namespace = resource.root_ancestor - - return none if namespace.nil? || Feature.disabled?(:custom_emoji, namespace) + return none if resource.nil? || Feature.disabled?(:custom_emoji, resource) + return none unless resource.is_a?(Group) - namespace.custom_emoji + resource.custom_emoji end private diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index de777b8ae53..5923497a1a3 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -6,6 +6,8 @@ class DeployKey < Key include PolicyActor include Presentable + self.allow_legacy_sti_class = true + has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :deploy_keys_projects @@ -23,6 +25,7 @@ class DeployKey < Key scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) } scope :including_projects_with_write_access, -> { includes(:projects_with_write_access) } scope :including_projects_with_readonly_access, -> { includes(:projects_with_readonly_access) } + scope :not_in, ->(keys) { where.not(id: keys.select(:id)) } accepts_nested_attributes_for :deploy_keys_projects, reject_if: :reject_deploy_keys_projects? diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 920321a1699..60ecdf6c367 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -6,12 +6,12 @@ class DeployToken < ApplicationRecord include PolicyActor include Gitlab::Utils::StrongMemoize - add_authentication_token_field :token, encrypted: :required - AVAILABLE_SCOPES = %i[read_repository read_registry write_registry read_package_registry write_package_registry].freeze GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token' - REQUIRED_DEPENDENCY_PROXY_SCOPES = %i[read_registry write_registry].freeze + DEPLOY_TOKEN_PREFIX = 'gldt-' + + add_authentication_token_field :token, encrypted: :required, format_with_prefix: :prefix_for_deploy_token attribute :expires_at, default: -> { Forever.date } @@ -57,7 +57,7 @@ class DeployToken < ApplicationRecord def valid_for_dependency_proxy? group_type? && active? && - REQUIRED_DEPENDENCY_PROXY_SCOPES.all? { |scope| scope.in?(scopes) } + (Gitlab::Auth::REGISTRY_SCOPES & scopes).size == Gitlab::Auth::REGISTRY_SCOPES.size end def revoke! @@ -141,6 +141,10 @@ class DeployToken < ApplicationRecord write_attribute(:expires_at, value.presence || Forever.date) end + def prefix_for_deploy_token + DEPLOY_TOKEN_PREFIX + end + private def expired? diff --git a/app/models/deployment.rb b/app/models/deployment.rb index f0093445ba8..36f4a0ef426 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -395,9 +395,9 @@ class Deployment < ApplicationRecord def update_status(status) update_status!(status) rescue StandardError => e - Gitlab::ErrorTracking.track_exception( - StatusUpdateError.new(e.message), deployment_id: self.id) - + error = StatusUpdateError.new(e.message) + error.set_backtrace(caller) + Gitlab::ErrorTracking.track_exception(error, deployment_id: self.id) false end @@ -410,9 +410,9 @@ class Deployment < ApplicationRecord update_status!(job_status) rescue StandardError => e - Gitlab::ErrorTracking.track_exception( - StatusSyncError.new(e.message), deployment_id: self.id, job_id: job.id) - + error = StatusSyncError.new(e.message) + error.set_backtrace(caller) + Gitlab::ErrorTracking.track_exception(error, deployment_id: self.id, job_id: job.id) false end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index 05cca9f931f..71c16621c9e 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -31,4 +31,4 @@ class DescriptionVersion < ApplicationRecord end end -DescriptionVersion.prepend_mod_with('DescriptionVersion') +DescriptionVersion.prepend_mod diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index 505935bb230..d140c90781d 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -82,9 +82,9 @@ module DesignManagement # As a query, we ascertain this by finding the last event prior to # (or equal to) the cut-off, and seeing whether that version was a deletion. scope :visible_at_version, -> (version) do - deletion = ::DesignManagement::Action.events[:deletion] + deletion = DesignManagement::Action.events[:deletion] designs = arel_table - actions = ::DesignManagement::Action + actions = DesignManagement::Action .most_recent.up_to_version(version) .arel.as('most_recent_actions') @@ -253,7 +253,7 @@ module DesignManagement def user_notes_count_service strong_memoize(:user_notes_count_service) do - ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass + DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass end end end diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb index 2f045358914..eaa081a33cd 100644 --- a/app/models/design_management/design_at_version.rb +++ b/app/models/design_management/design_at_version.rb @@ -53,11 +53,11 @@ module DesignManagement design_ids = pairs.map(&:first).uniq version_ids = pairs.map(&:second).uniq - designs = ::DesignManagement::Design + designs = DesignManagement::Design .where(id: design_ids) .index_by(&:id) - versions = ::DesignManagement::Version + versions = DesignManagement::Version .where(id: version_ids) .index_by(&:id) @@ -93,7 +93,7 @@ module DesignManagement def action strong_memoize(:most_recent_action) do - ::DesignManagement::Action + DesignManagement::Action .most_recent.up_to_version(version) .find_by(design: design) end diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb index 7410944e174..eb9ff9fb32e 100644 --- a/app/models/design_management/repository.rb +++ b/app/models/design_management/repository.rb @@ -11,7 +11,7 @@ module DesignManagement delegate :lfs_enabled?, :storage, :repository_storage, :run_after_commit, to: :project def repository - ::DesignManagement::GitRepository.new( + DesignManagement::GitRepository.new( full_path, self, shard: repository_storage, diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb index dd6812f0eac..674fdf29216 100644 --- a/app/models/design_management/version.rb +++ b/app/models/design_management/version.rb @@ -52,7 +52,7 @@ module DesignManagement delegate :project, to: :issue scope :for_designs, -> (designs) do - where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct + where(id: DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct end scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection scope :ordered, -> { order(id: :desc) } @@ -88,7 +88,7 @@ module DesignManagement rows = design_actions.map { |action| action.row_attrs(version) } - ApplicationRecord.legacy_bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert + ApplicationRecord.legacy_bulk_insert(DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert version.designs.reset version.validate! design_actions.each(&:performed) diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb index ba1ef1b5712..baf4db29a0f 100644 --- a/app/models/design_user_mention.rb +++ b/app/models/design_user_mention.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class DesignUserMention < UserMention - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - belongs_to :design, class_name: 'DesignManagement::Design' belongs_to :note end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index d680d0e334f..1de302761bd 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -8,6 +8,8 @@ class DiffNote < Note include DiffPositionableNote include Gitlab::Utils::StrongMemoize + self.allow_legacy_sti_class = true + def self.noteable_types %w[MergeRequest Commit DesignManagement::Design] end @@ -51,7 +53,7 @@ class DiffNote < Note end creation_params = diff_file.diff.to_hash - .except(:too_large) + .except(:too_large, :generated) .merge(diff: diff_file.diff_hunk(diff_line)) create_note_diff_file(creation_params) diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 31118791075..4fd3ee8425c 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -72,6 +72,10 @@ module DiffViewer !@initially_binary && diff_file.binary_in_repo? end + def generated? + diff_file.generated? + end + # This method is used on the server side to check whether we can attempt to # render the diff_file at all. The human-readable error message can be # retrieved by #render_error_message. diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index fca6c664196..28489be9dfa 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -9,7 +9,10 @@ module DiffViewer self.extensions = UploaderHelper::SAFE_IMAGE_EXT self.binary = true self.switcher_icon = 'doc-image' - self.switcher_title = _('image diff') + + def self.switcher_title + _('image diff') + end def self.can_render?(diff_file, verify_binary: true) # When both blobs are missing, we often still have a textual diff that can diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb index 0d94d8f773b..a37531f66d4 100644 --- a/app/models/diff_viewer/rich.rb +++ b/app/models/diff_viewer/rich.rb @@ -7,7 +7,10 @@ module DiffViewer included do self.type = :rich self.switcher_icon = 'doc-text' - self.switcher_title = _('rendered diff') + + def self.switcher_title + _('rendered diff') + end end end end diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb index 929d8ad5a7e..5c53b671106 100644 --- a/app/models/diff_viewer/simple.rb +++ b/app/models/diff_viewer/simple.rb @@ -7,7 +7,10 @@ module DiffViewer included do self.type = :simple self.switcher_icon = 'code' - self.switcher_title = _('source diff') + + def self.switcher_title + _('source diff') + end end end end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb index fa830179022..7e17a9c759e 100644 --- a/app/models/discussion_note.rb +++ b/app/models/discussion_note.rb @@ -7,6 +7,8 @@ class DiscussionNote < Note # This prepend must stay here because the `validates` below depends on it. prepend_mod_with('DiscussionNote') # rubocop: disable Cop/InjectEnterpriseEditionModule + self.allow_legacy_sti_class = true + # Names of all implementers of `Noteable` that support discussions. def self.noteable_types %w[MergeRequest Issue Commit Snippet AbuseReport] diff --git a/app/models/event.rb b/app/models/event.rb index 7de7ad8ccd6..7a09e9d2da4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -9,11 +9,8 @@ class Event < ApplicationRecord include Gitlab::Utils::StrongMemoize include UsageStatistics include ShaAttribute - include IgnorableColumns include EachBatch - ignore_column :target_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - ACTIONS = HashWithIndifferentAccess.new( created: 1, updated: 2, @@ -82,23 +79,38 @@ class Event < ApplicationRecord # Callbacks after_create :reset_project_activity after_create :set_last_repository_updated_at, if: :push_action? - after_create ->(event) { UserInteractedProject.track(event) } # Scopes scope :recent, -> { reorder(id: :desc) } scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') } scope :for_design, -> { where(target_type: 'DesignManagement::Design') } scope :for_issue, -> { where(target_type: ISSUE_TYPES) } + scope :for_merge_request, -> { where(target_type: 'MergeRequest') } scope :for_fingerprint, ->(fingerprint) do fingerprint.present? ? where(fingerprint: fingerprint) : none end scope :for_action, ->(action) { where(action: action) } + scope :created_between, ->(start_time, end_time) { where(created_at: start_time..end_time) } + scope :count_by_dates, ->(date_interval) { group("DATE(created_at + #{date_interval})").count } + + scope :contributions, -> do + contribution_actions = [actions[:pushed], actions[:commented]] + + contributable_target_types = %w[MergeRequest Issue WorkItem] + target_contribution_actions = [actions[:created], actions[:closed], actions[:merged], actions[:approved]] + + where( + 'action IN (?) OR (target_type IN (?) AND action IN (?))', + contribution_actions, + contributable_target_types, target_contribution_actions + ) + end scope :with_associations, -> do # We're using preload for "push_event_payload" as otherwise the association # is not always available (depending on the query being built). - includes(:author, :project, project: [:project_feature, :import_data, :namespace]) - .preload(:target, :push_event_payload) + includes(:project, project: [:project_feature, :import_data, :namespace]) + .preload(:author, :target, :push_event_payload) end scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) } @@ -133,15 +145,6 @@ class Event < ApplicationRecord end end - # Update Gitlab::ContributionsCalendar#activity_dates if this changes - def contributions - where( - 'action IN (?) OR (target_type IN (?) AND action IN (?))', - [actions[:pushed], actions[:commented]], - %w[MergeRequest Issue WorkItem], [actions[:created], actions[:closed], actions[:merged]] - ) - end - def limit_recent(limit = 20, offset = nil) recent.limit(limit).offset(offset) end diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb index f795585dfc5..356acfa063d 100644 --- a/app/models/generic_commit_status.rb +++ b/app/models/generic_commit_status.rb @@ -3,6 +3,8 @@ class GenericCommitStatus < CommitStatus EXTERNAL_STAGE_IDX = 1_000_000 + self.allow_legacy_sti_class = true + validates :target_url, addressable_url: true, length: { maximum: 255 }, allow_nil: true validate :name_uniqueness_across_types, unless: :importing? diff --git a/app/models/group.rb b/app/models/group.rb index 70831573dfc..ac843f392fd 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -23,24 +23,29 @@ class Group < Namespace extend ::Gitlab::Utils::Override + self.allow_legacy_sti_class = true + README_PROJECT_PATH = 'gitlab-profile' def self.sti_name 'Group' end - has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent - has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent - has_many :namespace_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) }, + def self.supported_keyset_orderings + { name: [:asc] } + end + + has_many :all_group_members, -> { non_request }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent + has_many :all_owner_members, -> { non_request.all_owners }, as: :source, class_name: 'GroupMember' + has_many :group_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent + has_many :namespace_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) }, foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' alias_method :members, :group_members - has_many :users, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") }, + has_many :users, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/432604") }, through: :group_members - has_many :owners, -> { - where(members: { access_level: Gitlab::Access::OWNER }) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405") - }, through: :all_group_members, source: :user + has_many :owners, -> { allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/432604") }, + through: :all_owner_members, source: :user has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :namespace_requesters, -> { where.not(requested_at: nil).unscope(where: %i[source_id source_type]) }, @@ -248,12 +253,20 @@ class Group < Namespace end end + scope :order_path_asc, -> { reorder(self.arel_table['path'].asc) } + scope :order_path_desc, -> { reorder(self.arel_table['path'].desc) } + class << self def sort_by_attribute(method) - if method == 'storage_size_desc' + case method.to_s + when 'storage_size_desc' # storage_size is a virtual column so we need to # pass a string to avoid AR adding the table name reorder('storage_size DESC, namespaces.id DESC') + when 'path_asc' + order_path_asc + when 'path_desc' + order_path_desc else order_by(method) end @@ -400,7 +413,7 @@ class Group < Namespace end def visibility_level_allowed_by_projects?(level = self.visibility_level) - !projects.without_deleted.where('visibility_level > ?', level).exists? + !projects.not_aimed_for_deletion.where('visibility_level > ?', level).exists? end def visibility_level_allowed_by_sub_groups?(level = self.visibility_level) @@ -434,15 +447,8 @@ class Group < Namespace ) end - def add_member(user, access_level, current_user: nil, expires_at: nil, ldap: false) - Members::Groups::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass - self, - user, - access_level, - current_user: current_user, - expires_at: expires_at, - ldap: ldap - ) + def add_member(user, access_level, ...) + Members::Groups::CreatorService.add_member(self, user, access_level, ...) # rubocop:disable CodeReuse/ServiceClass end def add_guest(user, current_user = nil) @@ -791,10 +797,6 @@ class Group < Namespace end strong_memoize_attr :has_project_with_service_desk_enabled? - def activity_path - Gitlab::Routing.url_helpers.activity_group_path(self) - end - # rubocop: disable CodeReuse/ServiceClass def open_issues_count(current_user = nil) Groups::OpenIssuesCountService.new(self, current_user).count diff --git a/app/models/group_label.rb b/app/models/group_label.rb index 46e56166951..efad937b390 100644 --- a/app/models/group_label.rb +++ b/app/models/group_label.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GroupLabel < Label + self.allow_legacy_sti_class = true + belongs_to :group belongs_to :parent_container, foreign_key: :group_id, class_name: 'Group' diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 05c5ad22218..9b700ff17b2 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -6,6 +6,8 @@ class ProjectHook < WebHook include Limitable extend ::Gitlab::Utils::Override + self.allow_legacy_sti_class = true + self.limit_scope = :project triggerable_hooks [ diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 453b986ca4d..b1f1ab79b6a 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -5,6 +5,8 @@ class ServiceHook < WebHook extend ::Gitlab::Utils::Override + self.allow_legacy_sti_class = true + belongs_to :integration validates :integration, presence: true diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 3c7f0ef9ffc..57409faee5a 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -3,6 +3,8 @@ class SystemHook < WebHook include TriggerableHooks + self.allow_legacy_sti_class = true + triggerable_hooks [ :repository_update_hooks, :push_hooks, diff --git a/app/models/integration.rb b/app/models/integration.rb index 7c14c1b1716..618f9f986e8 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -15,6 +15,7 @@ class Integration < ApplicationRecord UnknownType = Class.new(StandardError) + self.allow_legacy_sti_class = true self.inheritance_column = :type_new INTEGRATION_NAMES = %w[ @@ -25,10 +26,9 @@ class Integration < ApplicationRecord 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[ - apple_app_store gitlab_slack_application google_play jenkins shimo + apple_app_store gitlab_slack_application google_play jenkins ].freeze # Fake integrations to help with local development. @@ -526,6 +526,17 @@ class Integration < ApplicationRecord fields.reject { _1[:type] == :password || _1[:name] == 'webhook' || (_1.key?(:if) && _1[:if] != true) }.pluck(:name) end + def self.api_fields + fields.map do |field| + { + required: field.required?, + name: field.name.to_sym, + type: field.api_type, + desc: field.description + } + end + end + def form_fields fields.reject { _1[:api_only] == true || (_1.key?(:if) && _1[:if] != true) } end diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index f8fddf8a457..a248a1aa561 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -21,21 +21,31 @@ module Integrations field :app_store_issuer_id, section: SECTION_TYPE_CONNECTION, required: true, - title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') } + title: -> { s_('AppleAppStore|Apple App Store Connect issuer ID') }, + description: -> { s_('AppleAppStore|Apple App Store Connect issuer ID.') } field :app_store_key_id, section: SECTION_TYPE_CONNECTION, required: true, - title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') } + title: -> { s_('AppleAppStore|Apple App Store Connect key ID') }, + description: -> { s_('AppleAppStore|Apple App Store Connect key ID.') } - field :app_store_private_key_file_name, section: SECTION_TYPE_CONNECTION - field :app_store_private_key, api_only: true + field :app_store_private_key_file_name, + description: -> { s_('Apple App Store Connect private key file name.') }, + section: SECTION_TYPE_CONNECTION, + required: true + + field :app_store_private_key, + description: -> { s_('Apple App Store Connect private key.') }, + required: true, + api_only: true field :app_store_protected_refs, type: :checkbox, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('AppleAppStore|Protected branches and tags only') }, - checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') } + description: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') }, + checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') } def self.title 'Apple App Store Connect' @@ -55,10 +65,10 @@ module Integrations # rubocop:disable Layout/LineLength texts = [ - s_("Use the Apple App Store Connect integration to easily connect to the Apple App Store with Fastlane in CI/CD pipelines."), - s_("After the Apple App Store Connect integration is activated, the following protected variables will be created for CI/CD use."), + s_("Use this integration to connect to the Apple App Store with fastlane in CI/CD pipelines."), + s_("After you enable the integration, the following protected variables are created for CI/CD use:"), variable_list.join('<br>'), - s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/apple_app_store'))).html_safe + s_(format("For more information, see the <a href='%{url}' target='_blank'>documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/apple_app_store'))).html_safe ] # rubocop:enable Layout/LineLength diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 39407acd6c9..07e8b5904cb 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -14,11 +14,13 @@ module Integrations non_empty_password_title: -> { s_('ProjectService|Enter new API key') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') }, placeholder: '0/68a9e79b868c6789e79a124c30b0', # Example Personal Access Token from Asana docs + description: -> { s_('User API token. The user must have access to the task. All comments are attributed to this user.') }, required: true field :restrict_to_branch, title: -> { s_('Integrations|Restrict to branch (optional)') }, - help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') } + help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') }, + description: -> { s_('Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') } def self.title 'Asana' diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index bbdd0e183f2..c441ead50de 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -6,12 +6,14 @@ module Integrations field :token, type: :password, + description: -> { s_('The authentication token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '', required: true field :subdomain, + description: -> { s_('The subdomain setting.') }, exposes_secrets: true, placeholder: '' diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb index 9dc90629344..324848daf4f 100644 --- a/app/models/integrations/field.rb +++ b/app/models/integrations/field.rb @@ -6,7 +6,7 @@ module Integrations ATTRIBUTES = %i[ section type placeholder choices value checkbox_label - title help if + title help if description non_empty_password_help non_empty_password_title ].concat(BOOLEAN_ATTRIBUTES).freeze @@ -60,6 +60,10 @@ module Integrations define_method("#{type}?") { self[:type] == type } end + def api_type + checkbox? ? ::API::Integrations::Boolean : String + end + private attr_reader :attributes diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index a1ce0877957..f441ef25015 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -89,16 +89,18 @@ module Integrations def execute(data) return unless supported_events.include?(data[:object_kind]) + serialized_data = data.deep_stringify_keys + Integrations::IrkerWorker.perform_async( project_id, channels, - colorize_messages, data, settings + colorize_messages, serialized_data, settings ) end def settings { - server_host: server_host.presence || 'localhost', - server_port: server_port.presence || 6659 + 'server_host' => server_host.presence || 'localhost', + 'server_port' => server_port.presence || 6659 } end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index bf49dbca294..e8f283054a4 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -34,6 +34,8 @@ module Integrations SNOWPLOW_EVENT_CATEGORY = name + RE2_SYNTAX_DOC_URL = 'https://github.com/google/re2/wiki/Syntax' + validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: ->(object) { @@ -110,7 +112,15 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, required: false, title: -> { s_('JiraService|Jira issue regex') }, - help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') } + help: -> do + format(ERB::Util.html_escape( + s_("JiraService|Use regular expression to match Jira issue keys. The regular expression must follow the " \ + "%{link_start}RE2 syntax%{link_end}. If empty, the default behavior is used.")), + link_start: format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, + url: RE2_SYNTAX_DOC_URL), + link_end: '</a>'.html_safe + ) + end field :jira_issue_prefix, section: SECTION_TYPE_CONFIGURATION, diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb deleted file mode 100644 index 1d004356469..00000000000 --- a/app/models/integrations/shimo.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Integrations - class Shimo < BaseThirdPartyWiki - validates :external_wiki_url, presence: true, public_url: true, if: :activated? - - field :external_wiki_url, - title: -> { s_('Shimo|Shimo Workspace URL') }, - required: true - - def avatar_url - ActionController::Base.helpers.image_path('logos/shimo.svg') - end - - def render? - valid? && activated? - end - - def self.title - s_('Shimo|Shimo') - end - - def self.description - s_('Shimo|Link to a Shimo Workspace from the sidebar.') - end - - def self.to_param - 'shimo' - end - - # support for `test` method - def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) - response.body if response.code == 200 - rescue StandardError - nil - end - end -end diff --git a/app/models/issue.rb b/app/models/issue.rb index b207785021d..1c9a8d65e3d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -65,24 +65,7 @@ class Issue < ApplicationRecord belongs_to :moved_to, class_name: 'Issue', inverse_of: :moved_from has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id, inverse_of: :moved_to - has_internal_id :iid, scope: :namespace, track_if: -> { !importing? }, init: ->(issue, scope) do - # we need this init for the case where the IID allocation in internal_ids#last_value - # is higher than the actual issues.max(iid) value for a given project. For instance - # in case of an import where a batch of IIDs may be prealocated - # - # TODO: remove this once the UpdateIssuesInternalIdScope migration completes - if issue - [ - InternalId.where(project: issue.project, usage: :issues).pick(:last_value).to_i, - issue.namespace&.issues&.maximum(:iid).to_i - ].max - else - [ - InternalId.where(**scope, usage: :issues).pick(:last_value).to_i, - where(**scope).maximum(:iid).to_i - ].max - end - end + has_internal_id :iid, scope: :namespace, track_if: -> { !importing? } has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -132,16 +115,16 @@ class Issue < ApplicationRecord validate :due_date_after_start_date validate :parent_link_confidentiality - alias_method :issuing_parent, :project - alias_attribute :issuing_parent_id, :project_id - alias_attribute :external_author, :service_desk_reply_to pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }] + scope :in_namespaces, ->(namespaces) { where(namespace: namespaces) } scope :in_projects, ->(project_ids) { where(project_id: project_ids) } scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) } + scope :non_archived, -> { left_joins(:project).where(project_id: nil).or(where(projects: { archived: false })) } + scope :with_due_date, -> { where.not(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } @@ -639,7 +622,7 @@ class Issue < ApplicationRecord end def design_collection - @design_collection ||= ::DesignManagement::DesignCollection.new(self) + @design_collection ||= DesignManagement::DesignCollection.new(self) end def from_service_desk? @@ -732,6 +715,11 @@ class Issue < ApplicationRecord def resource_parent project || namespace end + alias_method :issuing_parent, :resource_parent + + def issuing_parent_id + project_id.presence || namespace_id + end # Persisted records will always have a work_item_type. This method is useful # in places where we use a non persisted issue to perform feature checks @@ -753,6 +741,11 @@ class Issue < ApplicationRecord Gitlab::HookData::IssueBuilder.new(self).build end + override :gfm_reference + def gfm_reference(from = nil) + "#{work_item_type_with_default.name.underscore} #{to_reference(from)}" + end + private def project_level_readable_by?(user) diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb index 6c3bedfccca..3eadd580f7f 100644 --- a/app/models/issue_user_mention.rb +++ b/app/models/issue_user_mention.rb @@ -3,7 +3,4 @@ class IssueUserMention < UserMention belongs_to :issue belongs_to :note - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' end diff --git a/app/models/key.rb b/app/models/key.rb index fdc54c9f56e..4ff5f7a8e6a 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -21,6 +21,7 @@ class Key < ApplicationRecord validates :key, presence: true, + ssh_key: true, length: { maximum: 5000 }, format: { with: /\A(#{Gitlab::SSHPublicKey.supported_algorithms.join('|')})/ } @@ -28,7 +29,6 @@ class Key < ApplicationRecord uniqueness: true, presence: { message: 'cannot be generated' } - validate :key_meets_restrictions validate :expiration, on: :create validate :banned_key, if: :key_changed? @@ -154,16 +154,6 @@ class Key < ApplicationRecord self.fingerprint_sha256 = public_key.fingerprint_sha256.gsub("SHA256:", "") end - def key_meets_restrictions - restriction = Gitlab::CurrentSettings.key_restriction_for(public_key.type) - - if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE - errors.add(:key, forbidden_key_type_message) - elsif public_key.bits < restriction - errors.add(:key, "must be at least #{restriction} bits") - end - end - def banned_key return unless public_key.banned? @@ -179,12 +169,6 @@ class Key < ApplicationRecord ) end - def forbidden_key_type_message - allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase) - - "type is forbidden. Must be #{Gitlab::Sentence.to_exclusive_sentence(allowed_types)}" - end - def expiration errors.add(:key, message: 'has expired') if expired? end diff --git a/app/models/label_note.rb b/app/models/label_note.rb index eda650f2fa2..050797fb12c 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class LabelNote < SyntheticNote + self.allow_legacy_sti_class = true + attr_accessor :resource_parent attr_reader :events @@ -44,27 +46,19 @@ class LabelNote < SyntheticNote end def note_text(html: false) - added = labels_str(label_refs_by_action('add', html).uniq, prefix: 'added', suffix: added_suffix) - removed = labels_str(label_refs_by_action('remove', html).uniq, prefix: removed_prefix) + added = labels_str(label_refs_by_action('add', html).uniq, prefix: 'added') + removed = labels_str(label_refs_by_action('remove', html).uniq, prefix: 'removed') [added, removed].compact.join(' and ') end - def removed_prefix - 'removed' - end - - def added_suffix - '' - end - # returns string containing added/removed labels including # count of deleted labels: # # added ~1 ~2 + 1 deleted label # added 3 deleted labels # added ~1 ~2 labels - def labels_str(label_refs, prefix: '', suffix: '') + def labels_str(label_refs, prefix: '') existing_refs = label_refs.select { |ref| ref.present? }.sort refs_str = existing_refs.empty? ? nil : existing_refs.join(' ') @@ -74,7 +68,7 @@ class LabelNote < SyntheticNote return unless refs_str || deleted_str label_list_str = [refs_str, deleted_str].compact.join(' + ') - suffix += ' label'.pluralize(deleted > 0 ? deleted : existing_refs.count) + suffix = ' label'.pluralize(deleted > 0 ? deleted : existing_refs.count) "#{prefix} #{label_list_str} #{suffix.squish}" end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index ede20578850..336264317ac 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -9,6 +9,8 @@ class LegacyDiffNote < Note include NoteOnDiff + self.allow_legacy_sti_class = true + serialize :st_diff # rubocop:disable Cop/ActiveRecordSerialize validates :line_code, presence: true, line_code: true diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 6af80686ec2..beafd9b7d4b 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -6,9 +6,13 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel PARTITION_DURATION = 1.day include PartitionedTable + include IgnorableColumns self.primary_key = :id - self.ignored_columns = %i[partition] + + # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving + # incorrect partition. + ignore_column :partition, remove_never: true partitioned_by :partition, strategy: :sliding_list, next_partition_if: -> (active_partition) do diff --git a/app/models/member.rb b/app/models/member.rb index 9690e16fd7d..25dae518406 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -28,7 +28,6 @@ class Member < ApplicationRecord belongs_to :user belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :member_namespace, inverse_of: :namespace_members, foreign_key: 'member_namespace_id', class_name: 'Namespace' - belongs_to :member_role delegate :name, :username, :email, :last_activity_on, to: :user, prefix: true @@ -57,9 +56,6 @@ class Member < ApplicationRecord }, if: :project_bot? validate :access_level_inclusion - validate :validate_member_role_access_level - validate :validate_access_level_locked_for_member_role, on: :update - validate :validate_member_role_belongs_to_same_root_namespace scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -175,6 +171,8 @@ class Member < ApplicationRecord scope :by_access_level, -> (access_level) { active.where(access_level: access_level) } scope :all_by_access_level, -> (access_level) { where(access_level: access_level) } + scope :preload_user, -> { preload(:user) } + scope :preload_user_and_notification_settings, -> do preload(user: :notification_settings) .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") @@ -357,7 +355,7 @@ class Member < ApplicationRecord end def access_for_user_ids(user_ids) - where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h + with_user(user_ids).has_access.pluck(:user_id, :access_level).to_h end def find_by_invite_token(raw_invite_token) @@ -514,32 +512,6 @@ class Member < ApplicationRecord errors.add(:access_level, "is not included in the list") end - def validate_member_role_access_level - return unless member_role_id - - if access_level != member_role.base_access_level - errors.add(:member_role_id, _("role's base access level does not match the access level of the membership")) - end - end - - def validate_access_level_locked_for_member_role - return unless member_role_id - return if member_role_changed? # it is ok to change the access level when changing member role - - if access_level_changed? - errors.add(:access_level, _("cannot be changed since member is associated with a custom role")) - end - end - - def validate_member_role_belongs_to_same_root_namespace - return unless member_role_id - - return if member_namespace.id == member_role.namespace_id - return if member_namespace.root_ancestor.id == member_role.namespace_id - - errors.add(:member_namespace, _("must be in same hierarchy as custom role's namespace")) - end - def send_invite # override in subclass end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index b5a590d646e..e3ead1b04d0 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -4,6 +4,8 @@ class GroupMember < Member include FromUnion include CreatedAtFilterable + self.allow_legacy_sti_class = true + SOURCE_TYPE = 'Namespace' SOURCE_TYPE_FORMAT = /\ANamespace\z/ @@ -93,9 +95,7 @@ class GroupMember < Member end def post_create_hook - if send_welcome_email? - run_after_commit_or_now { notification_service.new_group_member(self) } - end + run_after_commit_or_now { notification_service.new_group_member(self) } super end @@ -121,10 +121,6 @@ class GroupMember < Member super end - - def send_welcome_email? - true - end end GroupMember.prepend_mod_with('GroupMember') diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 5e5f9ab7385..f52fef9e247 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -4,6 +4,8 @@ class ProjectMember < Member SOURCE_TYPE = 'Project' SOURCE_TYPE_FORMAT = /\AProject\z/ + self.allow_legacy_sti_class = true + belongs_to :project, foreign_key: 'source_id' delegate :namespace_id, to: :project @@ -49,8 +51,15 @@ class ProjectMember < Member end def permissible_access_level_roles_for_project_access_token(current_user, project) - permissible_access_level_roles(current_user, project).filter do |_, value| - value <= project.project_authorizations.find_by(user: current_user).access_level + if Ability.allowed?(current_user, :manage_owners, project) + Gitlab::Access.options_with_owner + else + max_access_level = project.team.max_member_access(current_user.id) + return {} unless max_access_level.present? + + ProjectMember.access_level_roles.filter do |_, value| + value <= max_access_level + end end end diff --git a/app/models/members/project_namespace_member.rb b/app/models/members/project_namespace_member.rb index 0e0c52ee3ca..f74a085669c 100644 --- a/app/models/members/project_namespace_member.rb +++ b/app/models/members/project_namespace_member.rb @@ -4,4 +4,5 @@ # This file is a part of the Consolidate Group and Project member management epic, # and will be developed further as we progress through that epic. class ProjectNamespaceMember < ProjectMember # rubocop:disable Gitlab/NamespacedClass + self.allow_legacy_sti_class = true end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 524a9b8074b..bf21eca8857 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -36,6 +36,7 @@ class MergeRequest < ApplicationRecord self.reactive_cache_work_type = :no_dependency SORTING_PREFERENCE_FIELD = :merge_requests_sort + CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH = 2700 belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" @@ -1572,6 +1573,10 @@ class MergeRequest < ApplicationRecord project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) end + def allow_merge_without_pipeline? + project.allow_merge_without_pipeline?(inherit_group_setting: true) + end + def only_allow_merge_if_all_discussions_are_resolved? project.only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: true) end @@ -1579,6 +1584,7 @@ class MergeRequest < ApplicationRecord def mergeable_ci_state? return true unless only_allow_merge_if_pipeline_succeeds? return false unless actual_head_pipeline + return true if project.allow_merge_on_skipped_pipeline?(inherit_group_setting: true) && actual_head_pipeline.skipped? actual_head_pipeline.success? @@ -1706,6 +1712,8 @@ class MergeRequest < ApplicationRecord actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)) end + # rubocop: disable Metrics/AbcSize + # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) @@ -1717,6 +1725,15 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s) variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) + + if ::Feature.enabled?(:truncate_ci_merge_request_description) + mr_description, mr_description_truncated = truncate_mr_description + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) + else + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: description) + end + variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? @@ -1724,6 +1741,8 @@ class MergeRequest < ApplicationRecord variables.concat(source_project_variables) end end + # rubocop: enable Metrics/AbcSize + # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def compare_test_reports unless has_test_reports? @@ -2167,15 +2186,7 @@ class MergeRequest < ApplicationRecord end def current_patch_id_sha - return merge_request_diff.patch_id_sha if merge_request_diff.patch_id_sha.present? - - base_sha = diff_refs&.base_sha - head_sha = diff_refs&.head_sha - - return unless base_sha && head_sha - return if base_sha == head_sha - - project.repository.get_patch_id(base_sha, head_sha) + merge_request_diff.get_patch_id_sha end private @@ -2253,6 +2264,14 @@ class MergeRequest < ApplicationRecord !!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) end end + + def truncate_mr_description + if description && description.length > CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH + [description.truncate(CI_MERGE_REQUEST_DESCRIPTION_MAX_LENGTH), 'true'] + else + [description, 'false'] + end + end end MergeRequest.prepend_mod_with('MergeRequest') diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index 281e11c7c13..921ad7e1f0a 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -26,6 +26,13 @@ class MergeRequestContextCommit < ApplicationRecord # create MergeRequestContextCommit by given commit sha and it's diff file record def self.bulk_insert(rows, **args) + # Remove the new extended_trailers attribute as this shouldn't be + # inserted into the database. This will be removed once the old + # format of the trailers attribute is deprecated. + rows = rows.map do |row| + row.except(:extended_trailers).to_hash + end + ApplicationRecord.legacy_bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 900f4bcfeb2..0b183131a47 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -190,6 +190,8 @@ class MergeRequestDiff < ApplicationRecord mount_uploader :external_diff, ExternalDiffUploader + before_save :ensure_project_id + # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? @@ -235,6 +237,17 @@ class MergeRequestDiff < ApplicationRecord ) end + def get_patch_id_sha + return patch_id_sha if patch_id_sha.present? + + set_patch_id_sha + + return unless patch_id_sha.present? + + save + patch_id_sha + end + def set_as_latest_diff # Don't set merge_head diff as latest so it won't get considered as the # MergeRequest#merge_request_diff. @@ -792,7 +805,10 @@ class MergeRequestDiff < ApplicationRecord if compare.commits.empty? new_attributes[:state] = :empty else - diff_collection = compare.diffs(Commit.max_diff_options) + options = Commit.max_diff_options + options[:generated_files] = compare.generated_files if Feature.enabled?(:collapse_generated_diff_files, project) + + diff_collection = compare.diffs(options) new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? @@ -827,6 +843,10 @@ class MergeRequestDiff < ApplicationRecord ) end + def ensure_project_id + self.project_id ||= merge_request.target_project_id + end + def repository project.repository end diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 790520c4123..d0d9f173346 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -53,8 +53,13 @@ class MergeRequestDiffCommit < ApplicationRecord # These fields are only used to determine the author/committer IDs, we # don't store them in the DB. + # + # Trailers are stored in the DB here in order to allow changelog parsing. + # Rather than add an additional column for :extended_trailers, we're instead + # ignoring it for now until we deprecate the :trailers field and replace it with + # the new functionality. commit_hash = commit_hash - .except(:author_name, :author_email, :committer_name, :committer_email) + .except(:author_name, :author_email, :committer_name, :committer_email, :extended_trailers) commit_hash.merge( commit_author_id: author.id, diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb index 548a91162cd..222d9c1aa8c 100644 --- a/app/models/merge_request_user_mention.rb +++ b/app/models/merge_request_user_mention.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class MergeRequestUserMention < UserMention - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - belongs_to :merge_request belongs_to :note end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d5b9a4dc30f..c23921f28bd 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -56,6 +56,7 @@ class Milestone < ApplicationRecord scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) } scope :preload_for_indexing, -> { includes(project: [:project_feature]) } scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } + scope :with_ids_or_title, ->(ids:, title:) { id_in(ids).or(with_title(title)) } validates :group, presence: true, unless: :project validates :project, presence: true, unless: :group diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb index 14808158fd0..2bbd2d9a383 100644 --- a/app/models/milestone_note.rb +++ b/app/models/milestone_note.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MilestoneNote < SyntheticNote + self.allow_legacy_sti_class = true + attr_accessor :milestone def self.from_event(event, resource: nil, resource_parent: nil) diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index 70eaab8c0ab..7335554d96b 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -3,10 +3,8 @@ module Ml class Candidate < ApplicationRecord include Sortable + include Presentable include AtomicInternalId - include IgnorableColumns - - ignore_column :iid, remove_with: '16.0', remove_after: '2023-05-01' enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 } @@ -33,6 +31,7 @@ module Ml scope :including_relationships, -> { includes(:latest_metrics, :params, :user, :package, :project, :ci_build) } scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection + scope :without_model_version, -> { where(model_version: nil) } scope :order_by_metric, ->(metric, direction) do subquery = Ml::CandidateMetric.latest.where(name: metric) diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index b6f7e9a0639..1d5625ec8de 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -18,6 +18,7 @@ module Ml belongs_to :project belongs_to :user has_many :versions, class_name: 'Ml::ModelVersion' + has_many :candidates, -> { without_model_version }, class_name: 'Ml::Candidate', through: :default_experiment has_many :metadata, class_name: 'Ml::ModelMetadata' has_one :latest_version, -> { latest_by_model }, class_name: 'Ml::ModelVersion', inverse_of: :model @@ -31,6 +32,10 @@ module Ml scope :by_name, ->(name) { where("ml_models.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection scope :by_project, ->(project) { where(project_id: project.id) } + def all_packages + Packages::MlModel::Package.where(project: project, id: versions.select(:package_id)) + end + def valid_default_experiment? return unless default_experiment diff --git a/app/models/namespace.rb b/app/models/namespace.rb index cd54ac1b24a..c665c2278a5 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -81,6 +81,7 @@ class Namespace < ApplicationRecord has_one :cluster_enabled_grant, inverse_of: :namespace, class_name: 'Clusters::ClusterEnabledGrant' has_many :work_items, inverse_of: :namespace + has_many :work_items_dates_source, inverse_of: :namespace, class_name: 'WorkItems::DatesSource' has_many :issues, inverse_of: :namespace has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory' @@ -95,7 +96,7 @@ class Namespace < ApplicationRecord length: { maximum: 255 } validates :name, uniqueness: { scope: [:type, :parent_id] }, if: -> { parent_id.present? } - validates :description, length: { maximum: 255 } + validates :description, length: { maximum: 500 } validates :path, presence: true, diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index a249bb144f9..a5a393ad8a2 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -23,6 +23,7 @@ class Namespace::PackageSetting < ApplicationRecord validates :generic_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } validates :nuget_duplicates_allowed, inclusion: { in: [true, false] } validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + validates :nuget_symbol_server_enabled, inclusion: { in: [true, false] } class << self def duplicates_allowed?(package) diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 13d2c5a62e2..0263942116d 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -6,6 +6,7 @@ class NamespaceSetting < ApplicationRecord include ChronicDurationAttribute cascading_attr :delayed_project_removal + cascading_attr :toggle_security_policy_custom_ci belongs_to :namespace, inverse_of: :namespace_settings diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb index 288c5c0d2d4..573944d8f5c 100644 --- a/app/models/namespaces/project_namespace.rb +++ b/app/models/namespaces/project_namespace.rb @@ -2,6 +2,8 @@ module Namespaces class ProjectNamespace < Namespace + self.allow_legacy_sti_class = true + # These aliases are added to make it easier to sync parent/parent_id attribute with # project.namespace/project.namespace_id attribute. # diff --git a/app/models/namespaces/sync_event.rb b/app/models/namespaces/sync_event.rb index fbe047f2c5a..c06d86cb297 100644 --- a/app/models/namespaces/sync_event.rb +++ b/app/models/namespaces/sync_event.rb @@ -7,9 +7,14 @@ class Namespaces::SyncEvent < ApplicationRecord belongs_to :namespace + scope :unprocessed_events, -> { all } scope :preload_synced_relation, -> { preload(:namespace) } scope :order_by_id_asc, -> { order(id: :asc) } + def self.mark_records_processed(records) + id_in(records).delete_all + end + def self.enqueue_worker ::Namespaces::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker end diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb index 408acb6dcce..fde6c4291ee 100644 --- a/app/models/namespaces/user_namespace.rb +++ b/app/models/namespaces/user_namespace.rb @@ -22,6 +22,8 @@ module Namespaces #################################################################### class UserNamespace < Namespace + self.allow_legacy_sti_class = true + def self.sti_name 'User' end diff --git a/app/models/note.rb b/app/models/note.rb index 6f4a56dd3cc..953632ba910 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -23,11 +23,8 @@ class Note < ApplicationRecord include FromUnion include Sortable include EachBatch - include IgnorableColumns include Spammable - ignore_column :id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/ cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index 624a722e369..ba96fd9f3d5 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -2,9 +2,6 @@ class NoteDiffFile < ApplicationRecord include DiffFile - include IgnorableColumns - - ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' scope :referencing_sha, -> (oids, project_id:) do joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids }) diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 20d5a5ae1a1..7ef2aa194b9 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -119,7 +119,7 @@ class NotificationRecipient end def excluded_watcher_action? - return false unless @type == :watch + return false unless notification_level == :watch return false unless @custom_action NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index eb4fa9ac474..6c165b10a6a 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -70,7 +70,8 @@ class NotificationSetting < ApplicationRecord EXCLUDED_WATCHER_EVENTS = [ :push_to_merge_request, :issue_due, - :success_pipeline + :success_pipeline, + :approver ].freeze def self.find_or_create_for(source) diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb index ecc78418256..83030732c6a 100644 --- a/app/models/onboarding/progress.rb +++ b/app/models/onboarding/progress.rb @@ -31,7 +31,8 @@ module Onboarding :secure_coverage_fuzzing_run, :secure_api_fuzzing_run, :secure_cluster_image_scanning_run, - :license_scanning_run + :license_scanning_run, + :promote_ultimate_features ].freeze scope :incomplete_actions, ->(actions) do @@ -103,10 +104,6 @@ module Onboarding end end - def number_of_completed_actions - attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size - end - private def namespace_is_root_namespace diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 157b851e009..764378a5d19 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -13,6 +13,7 @@ module Organizations has_many :projects has_one :settings, class_name: "OrganizationSetting" + has_one :organization_detail, inverse_of: :organization, autosave: true has_many :organization_users, inverse_of: :organization has_many :users, through: :organization_users, inverse_of: :organizations @@ -23,13 +24,22 @@ module Organizations validates :path, presence: true, + uniqueness: { case_sensitive: false }, 'organizations/path': true, length: { minimum: 2, maximum: 255 } + delegate :description, :avatar, :avatar_url, to: :organization_detail + + accepts_nested_attributes_for :organization_detail + def self.default_organization find_by(id: DEFAULT_ORGANIZATION_ID) end + def organization_detail + super.presence || build_organization_detail + end + def default? id == DEFAULT_ORGANIZATION_ID end diff --git a/app/models/organizations/organization_detail.rb b/app/models/organizations/organization_detail.rb new file mode 100644 index 00000000000..b69ec5eae76 --- /dev/null +++ b/app/models/organizations/organization_detail.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationDetail < ApplicationRecord + include CacheMarkdownField + include Avatarable + include WithUploads + + cache_markdown_field :description + + belongs_to :organization, inverse_of: :organization_detail + + validates :organization, presence: true + validates :description, length: { maximum: 1024 } + end +end diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb index 61e2194006b..6b200302aae 100644 --- a/app/models/packages/build_info.rb +++ b/app/models/packages/build_info.rb @@ -9,4 +9,8 @@ class Packages::BuildInfo < ApplicationRecord scope :order_by_pipeline_id, -> (direction) { order(pipeline_id: direction) } scope :with_pipeline_id_less_than, -> (pipeline_id) { where("#{table_name}.pipeline_id < ?", pipeline_id) } scope :with_pipeline_id_greater_than, -> (pipeline_id) { where("#{table_name}.pipeline_id > ?", pipeline_id) } + + def self.supported_keyset_orderings + { id: [:desc] } + end end diff --git a/app/models/packages/ml_model/package.rb b/app/models/packages/ml_model/package.rb index de2b5f8f2a8..a327a30ce26 100644 --- a/app/models/packages/ml_model/package.rb +++ b/app/models/packages/ml_model/package.rb @@ -3,6 +3,8 @@ module Packages module MlModel class Package < Packages::Package + self.allow_legacy_sti_class = true + has_one :model_version, class_name: "Ml::ModelVersion", inverse_of: :package validates :name, diff --git a/app/models/packages/nuget/symbol.rb b/app/models/packages/nuget/symbol.rb index 3315f11b974..f15b045f303 100644 --- a/app/models/packages/nuget/symbol.rb +++ b/app/models/packages/nuget/symbol.rb @@ -5,6 +5,10 @@ module Packages class Symbol < ApplicationRecord include FileStoreMounter include ShaAttribute + include Packages::Destructible + + # Used in destroying stale symbols in worker + enum :status, default: 0, processing: 1, error: 3 belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_symbols @@ -20,6 +24,12 @@ module Packages before_validation :set_object_storage_key, on: :create + scope :stale, -> { where(package_id: nil) } + scope :pending_destruction, -> { stale.default } + scope :with_file_name, ->(file_name) { where(file: file_name) } + scope :with_signature, ->(signature) { where(signature: signature) } + scope :with_file_sha256, ->(checksums) { where(file_sha256: checksums) } + private def set_object_storage_key diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 02e3908b3bf..3ca7337dd53 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -364,7 +364,13 @@ class Packages::Package < ApplicationRecord def sync_maven_metadata(user) return unless maven? && version? && user - ::Packages::Maven::Metadata::SyncWorker.perform_async(user.id, project.id, name) + ::Packages::Maven::Metadata::SyncWorker.perform_async(user.id, project_id, name) + end + + def sync_npm_metadata_cache + return unless npm? + + ::Packages::Npm::CreateMetadataCacheWorker.perform_async(project_id, name) end def create_build_infos!(build) diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb index 0df64bfba54..95cf312c174 100644 --- a/app/models/packages/tag.rb +++ b/app/models/packages/tag.rb @@ -19,6 +19,17 @@ class Packages::Tag < ApplicationRecord .limit(FOR_PACKAGES_TAGS_LIMIT) end + def self.for_package_ids_with_distinct_names(package_ids) + inner_query = select('DISTINCT ON (name) *').order(:name).for_package_ids(package_ids) + + cte = Gitlab::SQL::CTE.new(:distinct_names_cte, inner_query) + cte_alias = cte.table.alias(table_name) + + with(cte.to_arel) + .from(cte_alias) + .order(updated_at: :desc) + end + def ensure_project_id self.project_id ||= package.project_id end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index e5e23c3bb84..d79e4a5c12e 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -4,10 +4,11 @@ module Pages class LookupPath include Gitlab::Utils::StrongMemoize - def initialize(project, trim_prefix: nil, domain: nil) - @project = project + def initialize(deployment:, domain: nil, trim_prefix: nil) + @deployment = deployment + @project = deployment.project @domain = domain - @trim_prefix = trim_prefix || project.full_path + @trim_prefix = trim_prefix || @project.full_path end def project_id @@ -45,11 +46,7 @@ module Pages strong_memoize_attr :source def prefix - if url_builder.namespace_pages? - '/' - else - "#{project.full_path.delete_prefix(trim_prefix)}/" - end + ensure_leading_and_trailing_slash(prefix_value) end strong_memoize_attr :prefix @@ -73,23 +70,24 @@ module Pages private - attr_reader :project, :trim_prefix, :domain - - # project.active_pages_deployments is already loaded from the database, - # so selecting from the array to avoid N+1 - # this will change with when serving multiple versions on - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133261 - def deployment - project - .active_pages_deployments - .to_a - .find { |deployment| deployment.path_prefix.blank? } - end - strong_memoize_attr :deployment + attr_reader :project, :deployment, :trim_prefix, :domain def url_builder Gitlab::Pages::UrlBuilder.new(project) end strong_memoize_attr :url_builder + + def prefix_value + return deployment.path_prefix if url_builder.namespace_pages? + + [project.full_path.delete_prefix(trim_prefix), deployment.path_prefix].compact.join('/') + end + + def ensure_leading_and_trailing_slash(value) + value + .to_s + .then { |s| s.start_with?("/") ? s : "/#{s}" } + .then { |s| s.end_with?("/") ? s : "#{s}/" } + end end end diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 0a64e91bf60..94ad1491889 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -17,11 +17,7 @@ module Pages end def lookup_paths - projects - .map { |project| lookup_paths_for(project) } - .select(&:source) # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/328715 - .sort_by(&:prefix) - .reverse + projects.flat_map { |project| lookup_paths_for(project) } end private @@ -29,7 +25,26 @@ module Pages attr_reader :projects, :trim_prefix, :domain def lookup_paths_for(project) - Pages::LookupPath.new(project, trim_prefix: trim_prefix, domain: domain) + deployments_for(project).map do |deployment| + Pages::LookupPath.new( + deployment: deployment, + trim_prefix: trim_prefix, + domain: domain) + end + end + + def deployments_for(project) + if ::Gitlab::Pages.multiple_versions_enabled_for?(project) + project.active_pages_deployments + else + # project.active_pages_deployments is already loaded from the database, + # so finding from the array to avoid N+1 + project + .active_pages_deployments + .to_a + .find { |deployment| deployment.path_prefix.blank? } + .then { |deployment| [deployment] } + end end end end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index 0d87a8f6cf6..e8b186234af 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -20,6 +20,7 @@ class PagesDeployment < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) } scope :project_id_in, ->(ids) { where(project_id: ids) } + scope :ci_build_id_in, ->(ids) { where(ci_build_id: ids) } scope :with_path_prefix, ->(prefix) { where("COALESCE(path_prefix, '') = ?", prefix.to_s) } # We have to mark the PagesDeployment upload as ready to ensure we only @@ -28,6 +29,7 @@ class PagesDeployment < ApplicationRecord scope :active, -> { upload_ready.where(deleted_at: nil).order(created_at: :desc) } scope :deactivated, -> { where('deleted_at < ?', Time.now.utc) } + scope :versioned, -> { where.not(path_prefix: [nil, '']) } validates :file, presence: true validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES } @@ -62,6 +64,10 @@ class PagesDeployment < ApplicationRecord .update_all(updated_at: now, deleted_at: time || now) end + def self.deactivate + update(deleted_at: Time.now.utc) + end + private def set_size diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 0915278fb65..299f1f7a630 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PersonalSnippet < Snippet + self.allow_legacy_sti_class = true + include WithUploads def parent_user diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index 478fc1c418a..501b4c64833 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -6,7 +6,6 @@ class PlanLimits < ApplicationRecord dashboard_limit_enabled_at].freeze ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22' - ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22' attribute :limits_history, :ind_jsonb, default: -> { {} } validates :limits_history, json_schema: { filename: 'plan_limits_history' } diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb deleted file mode 100644 index 52baa3be6c4..00000000000 --- a/app/models/product_analytics_event.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class ProductAnalyticsEvent < ApplicationRecord - self.table_name = 'product_analytics_events_experimental' - - # Ignore that the partition key :project_id is part of the formal primary key - self.primary_key = :id - - belongs_to :project - - validates :event_id, :project_id, :v_collector, :v_etl, presence: true - - # There is no default Rails timestamps in the table. - # collector_tstamp is a timestamp when a collector recorded an event. - scope :order_by_time, -> { order(collector_tstamp: :desc) } - - # If we decide to change this scope to use date_trunc('day', collector_tstamp), - # we should remember that a btree index on collector_tstamp will be no longer effective. - scope :timerange, ->(duration, today = Time.zone.today) { - where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1) - } - - def self.count_by_graph(graph, days) - group(graph).timerange(days).count - end - - def self.count_collector_tstamp_by_day(days) - group("DATE_TRUNC('day', collector_tstamp)") - .reorder('date_trunc_day_collector_tstamp') - .timerange(days) - .count - end - - def as_json_wo_empty - as_json.compact - end -end diff --git a/app/models/project.rb b/app/models/project.rb index 0d103094aec..7b996457c0d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -142,9 +142,8 @@ class Project < ApplicationRecord after_create :set_timestamps_for_create after_create :check_repository_absence! - # TODO: Remove this callback after background syncing is implemented. See https://gitlab.com/gitlab-org/gitlab/-/issues/429376. - after_update :update_catalog_resource, - if: -> { (saved_change_to_name? || saved_change_to_description? || saved_change_to_visibility_level?) && catalog_resource } + after_update :enqueue_catalog_resource_sync_event_worker, + if: -> { catalog_resource && (saved_change_to_name? || saved_change_to_description? || saved_change_to_visibility_level?) } before_destroy :remove_private_deploy_keys after_destroy :remove_exports @@ -187,6 +186,7 @@ class Project < ApplicationRecord has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project has_many :ci_components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :project has_many :catalog_resource_versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :project + has_many :catalog_resource_sync_events, class_name: 'Ci::Catalog::Resources::SyncEvent', inverse_of: :project has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event' has_many :boards @@ -232,7 +232,6 @@ class Project < ApplicationRecord has_one :pumble_integration, class_name: 'Integrations::Pumble' has_one :pushover_integration, class_name: 'Integrations::Pushover' has_one :redmine_integration, class_name: 'Integrations::Redmine' - has_one :shimo_integration, class_name: 'Integrations::Shimo' has_one :slack_integration, class_name: 'Integrations::Slack' has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands' has_one :squash_tm_integration, class_name: 'Integrations::SquashTm' @@ -469,10 +468,6 @@ class Project < ApplicationRecord # rubocop:enable Cop/ActiveRecordDependent has_many :active_pages_deployments, -> { active }, class_name: 'PagesDeployment', inverse_of: :project - # Can be too many records. We need to implement delete_all in batches. - # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/228637 - has_many :product_analytics_events, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag' has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' @@ -504,9 +499,9 @@ class Project < ApplicationRecord accepts_nested_attributes_for :prometheus_integration, update_only: true accepts_nested_attributes_for :alerting_setting, update_only: true - delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, to: :project_feature, allow_nil: true + delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, :model_registry_access_level, to: :project_feature, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true - delegate :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage + delegate :jira_dvcs_server_last_sync_at, to: :feature_usage delegate :last_pipeline, to: :commit, allow_nil: true with_options to: :team do @@ -539,8 +534,8 @@ class Project < ApplicationRecord with_options to: :project_setting do delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline= + delegate :allow_merge_without_pipeline, :allow_merge_without_pipeline?, :allow_merge_without_pipeline= delegate :has_confluence? - delegate :has_shimo? delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email? delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled? delegate :emails_enabled, :emails_enabled=, :emails_enabled? @@ -556,6 +551,7 @@ class Project < ApplicationRecord delegate :show_default_award_emojis, :show_default_award_emojis= delegate :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads= delegate :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters= + delegate :code_suggestions, :code_suggestions= end end @@ -676,7 +672,6 @@ class Project < ApplicationRecord scope :non_archived, -> { where(archived: false) } scope :with_push, -> { joins(:events).merge(Event.pushed_action) } scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } - scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) } scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) } scope :inc_routes, -> { includes(:route, namespace: :route) } scope :with_statistics, -> { includes(:statistics) } @@ -780,6 +775,8 @@ class Project < ApplicationRecord .order(id: :desc) end + scope :in_organization, -> (organization) { where(organization: organization) } + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, @@ -858,6 +855,7 @@ class Project < ApplicationRecord cascading_with_parent_namespace :only_allow_merge_if_pipeline_succeeds cascading_with_parent_namespace :allow_merge_on_skipped_pipeline cascading_with_parent_namespace :only_allow_merge_if_all_discussions_are_resolved + cascading_with_parent_namespace :allow_merge_without_pipeline def self.with_feature_available_for_user(feature, user) with_project_feature.merge(ProjectFeature.with_feature_available_for_user(feature, user)) @@ -1731,7 +1729,7 @@ class Project < ApplicationRecord def disabled_integrations return [] if Rails.env.development? - names = %w[shimo zentao] + names = %w[zentao] # The Slack Slash Commands integration is only available for customers who cannot use the GitLab for Slack app. # The GitLab for Slack app integration is only available when enabled through settings. @@ -1940,14 +1938,14 @@ class Project < ApplicationRecord repository = project_repository || build_project_repository repository.update!(shard_name: repository_storage, disk_path: disk_path) - cleanup if replicate_object_pool_on_move_ff_enabled? + cleanup end - def create_repository(force: false, default_branch: nil) + def create_repository(force: false, default_branch: nil, object_format: nil) # Forked import is handled asynchronously return if forked? && !force - repository.create_repository(default_branch) + repository.create_repository(default_branch, object_format: object_format) repository.after_create true @@ -2763,7 +2761,6 @@ class Project < ApplicationRecord # After repository is moved from shard to shard, disconnect it from the previous object pool and connect to the new pool def swap_pool_repository! - return unless replicate_object_pool_on_move_ff_enabled? return unless repository_exists? old_pool_repository = pool_repository @@ -2778,7 +2775,7 @@ class Project < ApplicationRecord def link_pool_repository return unless pool_repository - return if (pool_repository.shard_name != repository.shard) && replicate_object_pool_on_move_ff_enabled? + return if pool_repository.shard_name != repository.shard pool_repository.link_repository(repository) end @@ -2910,7 +2907,6 @@ class Project < ApplicationRecord end def service_desk_custom_address - return unless Feature.enabled?(:service_desk_custom_email, self) return unless service_desk_setting&.custom_email_enabled? service_desk_setting.custom_email @@ -2995,10 +2991,6 @@ class Project < ApplicationRecord Projects::GitGarbageCollectWorker end - def activity_path - Gitlab::Routing.url_helpers.activity_project_path(self) - end - def ci_forward_deployment_enabled? return false unless ci_cd_settings @@ -3208,6 +3200,11 @@ class Project < ApplicationRecord end strong_memoize_attr :instance_runner_running_jobs_count + def code_suggestions_enabled? + code_suggestions && (group.nil? || group.code_suggestions) + end + strong_memoize_attr :code_suggestions_enabled? + private # overridden in EE @@ -3471,18 +3468,17 @@ class Project < ApplicationRecord RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX end - def replicate_object_pool_on_move_ff_enabled? - Feature.enabled?(:replicate_object_pool_on_move, self) - end - def pool_repository_shard_matches_repository?(pool) pool_repository_shard = pool.shard.name pool_repository_shard == repository_storage end - def update_catalog_resource - catalog_resource.sync_with_project! + # Catalog resource SyncEvents are created by PG triggers + def enqueue_catalog_resource_sync_event_worker + run_after_commit do + ::Ci::Catalog::Resources::SyncEvent.enqueue_worker + end end end diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 4d0c6029235..194f37fcb89 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -2,6 +2,7 @@ class ProjectAuthorization < ApplicationRecord extend SuppressCompositePrimaryKeyWarning + include EachBatch include FromUnion belongs_to :user @@ -13,6 +14,9 @@ class ProjectAuthorization < ApplicationRecord scope :for_project, ->(projects) { where(project: projects) } scope :non_guests, -> { where('access_level > ?', ::Gitlab::Access::GUEST) } + scope :owners, -> { where(access_level: ::Gitlab::Access::OWNER) } + + scope :preload_user, -> { preload(:user) } # TODO: To be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/418205 before_create :assign_is_unique diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb index 1f0cec1a50c..ac52bdfdb07 100644 --- a/app/models/project_authorizations/changes.rb +++ b/app/models/project_authorizations/changes.rb @@ -11,14 +11,16 @@ module ProjectAuthorizations # changes.remove_projects_for_user(user, project_ids) # end.apply! class Changes - attr_reader :projects_to_remove, :users_to_remove, :authorizations_to_add + attr_reader :projects_to_remove, :users_to_remove_in_project, :authorizations_to_add BATCH_SIZE = 1000 + EVENT_USER_BATCH_SIZE = 100 SLEEP_DELAY = 0.1 def initialize @authorizations_to_add = [] @affected_project_ids = Set.new + @removed_user_ids = Set.new yield self end @@ -27,7 +29,7 @@ module ProjectAuthorizations end def remove_users_in_project(project, user_ids) - @users_to_remove = { user_ids: user_ids, scope: project } + @users_to_remove_in_project = { user_ids: user_ids, scope: project } end def remove_projects_for_user(user, project_ids) @@ -66,6 +68,7 @@ module ProjectAuthorizations ids_to_remove: project_ids, column_name_of_ids_to_remove: :project_id) @affected_project_ids += project_ids + @removed_user_ids.add(user.id) end def delete_authorizations_for_project @@ -73,6 +76,7 @@ module ProjectAuthorizations ids_to_remove: user_ids, column_name_of_ids_to_remove: :user_id) @affected_project_ids << project.id + @removed_user_ids += user_ids end def delete_all_in_batches(resource:, ids_to_remove:, column_name_of_ids_to_remove:) @@ -127,11 +131,11 @@ module ProjectAuthorizations end def project - users_to_remove&.[](:scope) + users_to_remove_in_project&.[](:scope) end def user_ids - users_to_remove&.[](:user_ids) + users_to_remove_in_project&.[](:user_ids) end def publish_events @@ -140,6 +144,18 @@ module ProjectAuthorizations ::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id }) ) end + return if ::Feature.disabled?(:user_approval_rules_removal) || @removed_user_ids.blank? + + @affected_project_ids.each do |project_id| + @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).each do |user_ids_batch| + ::Gitlab::EventStore.publish( + ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: { + project_id: project_id, + user_ids: user_ids_batch + }) + ) + end + end end end end diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb index d26ce5465cd..4b7ec6d37ee 100644 --- a/app/models/project_export_job.rb +++ b/app/models/project_export_job.rb @@ -40,7 +40,7 @@ class ProjectExportJob < ApplicationRecord class << self def prune_expired_jobs - prunable.each_batch do |relation| # rubocop:disable Style/SymbolProc + prunable.each_batch do |relation| relation.delete_all end end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 36f1e09b2ba..fa19ffe86da 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -27,9 +27,10 @@ class ProjectFeature < ApplicationRecord releases infrastructure model_experiments + model_registry ].freeze - EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze + EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages, :metrics_dashboard]).freeze set_available_features(FEATURES) @@ -81,6 +82,7 @@ class ProjectFeature < ApplicationRecord attribute :feature_flags_access_level, default: ENABLED attribute :environments_access_level, default: ENABLED attribute :model_experiments_access_level, default: ENABLED + attribute :model_registry_access_level, default: ENABLED attribute :package_registry_access_level, default: -> do if ::Gitlab.config.packages.enabled diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index 5e47ec6310d..1f371156873 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ProjectFeatureUsage < ApplicationRecord + include IgnorableColumns + ignore_column :jira_dvcs_cloud_last_sync_at, remove_with: '16.9', remove_after: '2024-01-21' + self.primary_key = :project_id JIRA_DVCS_CLOUD_FIELD = 'jira_dvcs_cloud_last_sync_at' diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 69d8c0db55b..de323d98584 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -11,8 +11,7 @@ class ProjectGroupLink < ApplicationRecord validates :project_id, presence: true validates :group, presence: true validates :group_id, uniqueness: { scope: [:project_id], message: N_("already shared with this group") } - validates :group_access, presence: true - validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true validate :different_group scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) } @@ -22,20 +21,16 @@ class ProjectGroupLink < ApplicationRecord alias_method :shared_with_group, :group alias_method :shared_from, :project - def self.access_options - Gitlab::Access.options - end - - def self.default_access - Gitlab::Access::DEVELOPER - end - def self.search(query) joins(:group).merge(Group.search(query)) end def human_access - self.class.access_options.key(self.group_access) + Gitlab::Access.human_access(self.group_access) + end + + def owner_access? + group_access.to_i == Gitlab::Access::OWNER end private diff --git a/app/models/project_label.rb b/app/models/project_label.rb index 05d7b7429ff..2867006bd2e 100644 --- a/app/models/project_label.rb +++ b/app/models/project_label.rb @@ -3,6 +3,8 @@ class ProjectLabel < Label MAX_NUMBER_OF_PRIORITIES = 1 + self.allow_legacy_sti_class = true + belongs_to :project belongs_to :parent_container, foreign_key: :project_id, class_name: 'Project' diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb index a9cef16f3ac..4119a00b84b 100644 --- a/app/models/project_repository.rb +++ b/app/models/project_repository.rb @@ -4,6 +4,10 @@ class ProjectRepository < ApplicationRecord include EachBatch include Shardable + enum object_format: { sha1: 0, sha256: 1 } + + validates :object_format, presence: true + belongs_to :project, inverse_of: :project_repository class << self diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index d16fe996672..e3ffe2347d8 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -11,18 +11,6 @@ class ProjectSetting < ApplicationRecord scope :for_projects, ->(projects) { where(project_id: projects) } - ignore_columns %i[ - encrypted_product_analytics_clickhouse_connection_string - encrypted_product_analytics_clickhouse_connection_string_iv - encrypted_jitsu_administrator_password - encrypted_jitsu_administrator_password_iv - jitsu_host - jitsu_project_xid - jitsu_administrator_email - ], remove_with: '16.5', remove_after: '2023-09-22' - - ignore_column :jitsu_key, remove_with: '16.7', remove_after: '2023-11-17' - attr_encrypted :cube_api_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, @@ -51,6 +39,7 @@ class ProjectSetting < ApplicationRecord validates :issue_branch_template, length: { maximum: Issue::MAX_BRANCH_TEMPLATE } validates :target_platforms, inclusion: { in: ALLOWED_TARGET_PLATFORMS } validates :suggested_reviewers_enabled, inclusion: { in: [true, false] } + validates :code_suggestions, allow_nil: false, inclusion: { in: [true, false] } validates :pages_unique_domain, uniqueness: { if: -> { pages_unique_domain.present? } }, diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index 7a80ad33d68..0f3c22bbb92 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectSnippet < Snippet + self.allow_legacy_sti_class = true + belongs_to :project validates :project, presence: true diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 586294f0dd0..5078642ea3a 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -78,7 +78,7 @@ class ProjectTeam # so we filter out only members of project or project's group def members_in_project_and_ancestors members.where(id: member_user_ids) - .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422405') + .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/432606') end def members_with_access_levels(access_levels = []) diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb index e2c6d1853a9..ae815bf366d 100644 --- a/app/models/projects/repository_storage_move.rb +++ b/app/models/projects/repository_storage_move.rb @@ -12,6 +12,7 @@ module Projects belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id alias_attribute :project, :container + alias_attribute :container_id, :project_id scope :with_projects, -> { includes(container: :route) } override :schedule_repository_storage_update_worker diff --git a/app/models/projects/sync_event.rb b/app/models/projects/sync_event.rb index 7af863c0cf0..f1688bcd19d 100644 --- a/app/models/projects/sync_event.rb +++ b/app/models/projects/sync_event.rb @@ -7,9 +7,14 @@ class Projects::SyncEvent < ApplicationRecord belongs_to :project + scope :unprocessed_events, -> { all } scope :preload_synced_relation, -> { preload(:project) } scope :order_by_id_asc, -> { order(id: :asc) } + def self.mark_records_processed(records) + id_in(records).delete_all + end + def self.enqueue_worker ::Projects::ProcessSyncEventsWorker.perform_async # rubocop:disable CodeReuse/Worker end diff --git a/app/models/push_event.rb b/app/models/push_event.rb index 0f626cb618e..4e18d4840e6 100644 --- a/app/models/push_event.rb +++ b/app/models/push_event.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PushEvent < Event + self.allow_legacy_sti_class = true + # This validation exists so we can't accidentally use PushEvent with a # different "action" value. validate :validate_push_action diff --git a/app/models/release.rb b/app/models/release.rb index 6830f6e8480..1cd623e1254 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -25,6 +25,9 @@ class Release < ApplicationRecord accepts_nested_attributes_for :links, allow_destroy: true before_create :set_released_at + # TODO: Remove this callback after catalog_resource.released_at is denormalized. See https://gitlab.com/gitlab-org/gitlab/-/issues/430117. + after_update :update_catalog_resource, if: -> { project.catalog_resource && saved_change_to_released_at? } + after_destroy :update_catalog_resource, if: -> { project.catalog_resource } validates :project, :tag, presence: true validates :author_id, presence: true, on: :create @@ -35,6 +38,10 @@ class Release < ApplicationRecord validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] } + # All releases should have tags, but because of existing invalid data, we need a work around so that presenters don't + # fail to generate URLs on release related pages + scope :tagged, -> { where.not(tag: [nil, '']) } + scope :sorted, -> { order(released_at: :desc) } scope :preloaded, -> { includes( @@ -47,6 +54,8 @@ class Release < ApplicationRecord scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } + scope :for_projects, ->(projects) { where(project_id: projects) } + scope :by_tag, ->(tag) { where(tag: tag) } # Sorting scope :order_created, -> { reorder(created_at: :asc) } @@ -168,6 +177,10 @@ class Release < ApplicationRecord order_created_desc end end + + def update_catalog_resource + project.catalog_resource.update_latest_released_at! + end end Release.prepend_mod_with('Release') diff --git a/app/models/repository.rb b/app/models/repository.rb index e639a389e0a..5ab35ed1ef9 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -28,6 +28,9 @@ class Repository #{REF_PIPELINES} ].freeze + FORMAT_SHA1 = 'sha1' + FORMAT_SHA256 = 'sha256' + include Gitlab::RepositoryCacheAdapter attr_accessor :full_path, :shard, :disk_path, :container, :repo_type @@ -676,6 +679,10 @@ class Repository end cache_method :gitlab_ci_yml + def jenkinsfile? + file_on_head(:jenkinsfile).present? + end + def xcode_project? file_on_head(:xcode_config, :tree).present? end @@ -1271,6 +1278,17 @@ class Repository .map(&:to_h) end + def object_format + return unless exists? + + case raw.object_format + when :OBJECT_FORMAT_SHA1 + FORMAT_SHA1 + when :OBJECT_FORMAT_SHA256 + FORMAT_SHA256 + end + end + private def ancestor_cache_key(ancestor_id, descendant_id) diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 30c53b978f8..f3a0479d3b7 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class SentNotification < ApplicationRecord - include IgnorableColumns - - ignore_column %i[id_convert_to_bigint], remove_with: '16.5', remove_after: '2023-09-22' - belongs_to :project belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :recipient, class_name: "User" diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb index 82bda673491..7ae44ac6aa1 100644 --- a/app/models/service_desk/custom_email_credential.rb +++ b/app/models/service_desk/custom_email_credential.rb @@ -66,7 +66,7 @@ module ServiceDesk ascii_only: true, enforce_sanitization: true, allow_localhost: false, - allow_local_network: false + allow_local_network: !::Gitlab.com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network ) rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e errors.add(:smtp_address, e) diff --git a/app/models/service_desk/custom_email_verification.rb b/app/models/service_desk/custom_email_verification.rb index 5099cf4c5bb..a03c984c3a6 100644 --- a/app/models/service_desk/custom_email_verification.rb +++ b/app/models/service_desk/custom_email_verification.rb @@ -10,7 +10,9 @@ module ServiceDesk incorrect_from: 1, mail_not_received_within_timeframe: 2, invalid_credentials: 3, - smtp_host_issue: 4 + smtp_host_issue: 4, + read_timeout: 5, + incorrect_forwarding_target: 6 } attr_encrypted :token, diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index 6560b25b39c..095eb0b67f3 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -2,18 +2,9 @@ class ServiceDeskSetting < ApplicationRecord include Gitlab::Utils::StrongMemoize - include IgnorableColumns CUSTOM_EMAIL_VERIFICATION_SUBADDRESS = '+verify' - ignore_columns %i[ - custom_email_smtp_address - custom_email_smtp_port - custom_email_smtp_username - encrypted_custom_email_smtp_password - encrypted_custom_email_smtp_password_iv - ], remove_with: '16.1', remove_after: '2023-05-22' - attribute :custom_email_enabled, default: false belongs_to :project diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb index 2b6845495bc..87ce77a5787 100644 --- a/app/models/snippet_user_mention.rb +++ b/app/models/snippet_user_mention.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class SnippetUserMention < UserMention - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - belongs_to :snippet belongs_to :note end diff --git a/app/models/snippets/repository_storage_move.rb b/app/models/snippets/repository_storage_move.rb index 3d6e1b0ccea..9db25ef4fc5 100644 --- a/app/models/snippets/repository_storage_move.rb +++ b/app/models/snippets/repository_storage_move.rb @@ -12,6 +12,7 @@ module Snippets belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id alias_attribute :snippet, :container + alias_attribute :container_id, :snippet_id override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker diff --git a/app/models/state_note.rb b/app/models/state_note.rb index 93c025a9bf0..8b1474b3c7a 100644 --- a/app/models/state_note.rb +++ b/app/models/state_note.rb @@ -3,6 +3,8 @@ class StateNote < SyntheticNote include Gitlab::Utils::StrongMemoize + self.allow_legacy_sti_class = true + def self.from_event(event, resource: nil, resource_parent: nil) attrs = note_attributes(action_by(event), event, resource, resource_parent) diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index c4178d3c5f1..ca2ad8bf88c 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -3,9 +3,6 @@ class Suggestion < ApplicationRecord include Importable include Suggestible - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :note, inverse_of: :suggestions validates :note, presence: true, unless: :importing? diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb index f88fa052665..e71f9838d9b 100644 --- a/app/models/synthetic_note.rb +++ b/app/models/synthetic_note.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SyntheticNote < Note + self.allow_legacy_sti_class = true + attr_accessor :resource_parent, :event def self.note_attributes(action, event, resource, resource_parent) diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 8624a1a9463..b0c3f86907a 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -5,7 +5,6 @@ class SystemNoteMetadata < ApplicationRecord include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.9', remove_after: '2024-01-13' - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found diff --git a/app/models/timelog.rb b/app/models/timelog.rb index eb088b1f582..0ae7790eef9 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -6,11 +6,8 @@ class Timelog < ApplicationRecord MAX_TOTAL_TIME_SPENT = 31557600.seconds.to_i # a year include Importable - include IgnorableColumns include Sortable - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' - before_save :set_project validates :time_spent, :user, presence: true diff --git a/app/models/todo.rb b/app/models/todo.rb index e64dbf83a4c..54c3866d703 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -4,9 +4,6 @@ class Todo < ApplicationRecord include Sortable include FromUnion include EachBatch - include IgnorableColumns - - ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' # Time to wait for todos being removed when not visible for user anymore. # Prevents TODOs being removed by mistake, for example, removing access from a user @@ -26,6 +23,7 @@ class Todo < ApplicationRecord MEMBER_ACCESS_REQUESTED = 10 REVIEW_SUBMITTED = 11 # This is an EE-only feature OKR_CHECKIN_REQUESTED = 12 # This is an EE-only feature + ADDED_APPROVER = 13 # This is an EE-only feature ACTION_NAMES = { ASSIGNED => :assigned, @@ -39,7 +37,8 @@ class Todo < ApplicationRecord MERGE_TRAIN_REMOVED => :merge_train_removed, MEMBER_ACCESS_REQUESTED => :member_access_requested, REVIEW_SUBMITTED => :review_submitted, - OKR_CHECKIN_REQUESTED => :okr_checkin_requested + OKR_CHECKIN_REQUESTED => :okr_checkin_requested, + ADDED_APPROVER => :added_approver }.freeze ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze diff --git a/app/models/user.rb b/app/models/user.rb index 4a66192e9d8..c36898aaf70 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,7 +22,6 @@ class User < MainClusterwide::ApplicationRecord include FromUnion include BatchDestroyDependentAssociations include BatchNullifyDependentAssociations - include IgnorableColumns include UpdateHighestRole include HasUserType include Gitlab::Auth::Otp::Fortinet @@ -31,16 +30,8 @@ class User < MainClusterwide::ApplicationRecord include StripAttribute include EachBatch include CrossDatabaseIgnoredTables - include IgnorableColumns include UseSqlFunctionForPrimaryKeyLookups - ignore_column %i[ - email_opted_in - email_opted_in_ip - email_opted_in_source_id - email_opted_in_at - ], remove_with: '16.6', remove_after: '2023-10-22' - # `ensure_namespace_correct` needs to be moved to an after_commit (?) cross_database_ignore_tables %w[namespaces namespace_settings], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424279' @@ -160,6 +151,7 @@ class User < MainClusterwide::ApplicationRecord # Namespace for personal projects has_one :namespace, -> { where(type: Namespaces::UserNamespace.sti_name) }, + required: true, dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent foreign_key: :owner_id, inverse_of: :owner, @@ -228,9 +220,6 @@ class User < MainClusterwide::ApplicationRecord has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :authorized_projects, through: :project_authorizations, source: :project - has_many :user_interacted_projects - has_many :project_interactions, through: :user_interacted_projects, source: :project, class_name: 'Project' - has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent @@ -245,9 +234,10 @@ class User < MainClusterwide::ApplicationRecord has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent + has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent + has_many :admin_abuse_report_assignees, class_name: "Admin::AbuseReportAssignee" + has_many :assigned_abuse_reports, class_name: "AbuseReport", through: :admin_abuse_report_assignees, source: :abuse_report has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent - has_many :assigned_abuse_reports, foreign_key: :assignee_id, class_name: "AbuseReport", inverse_of: :assignee has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by has_many :abuse_events, foreign_key: :user_id, class_name: 'Abuse::Event', inverse_of: :user has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -398,6 +388,7 @@ class User < MainClusterwide::ApplicationRecord :tab_width, :tab_width=, :sourcegraph_enabled, :sourcegraph_enabled=, :gitpod_enabled, :gitpod_enabled=, + :use_web_ide_extension_marketplace, :use_web_ide_extension_marketplace=, :setup_for_company, :setup_for_company=, :project_shortcut_buttons, :project_shortcut_buttons=, :keyboard_shortcuts_enabled, :keyboard_shortcuts_enabled=, @@ -410,6 +401,7 @@ class User < MainClusterwide::ApplicationRecord :pinned_nav_items, :pinned_nav_items=, :achievements_enabled, :achievements_enabled=, :enabled_following, :enabled_following=, + :home_organization, :home_organization_id, :home_organization_id=, to: :user_preference delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -611,8 +603,22 @@ class User < MainClusterwide::ApplicationRecord .trusted_with_spam) end + def self.supported_keyset_orderings + { + id: [:asc, :desc], + name: [:asc, :desc], + username: [:asc, :desc], + created_at: [:asc, :desc], + updated_at: [:asc, :desc] + } + end + strip_attributes! :name + def user_belongs_to_organization?(organization) + organization_users.exists?(organization: organization) + end + def preferred_language read_attribute('preferred_language').presence || Gitlab::CurrentSettings.default_preferred_language end @@ -1307,7 +1313,7 @@ class User < MainClusterwide::ApplicationRecord several_namespaces? || admin end - # rubocop: disable Style/ArgumentsForwarding + # rubocop: disable Style/ArgumentsForwarding -- https://gitlab.com/gitlab-org/gitlab/-/issues/433045 def can?(action, subject = :global, **opts) Ability.allowed?(self, action, subject, **opts) end @@ -1358,10 +1364,6 @@ class User < MainClusterwide::ApplicationRecord namespace.try :id end - def name_with_username - "#{name} (#{username})" - end - def already_forked?(project) !!fork_of(project) end @@ -1491,14 +1493,6 @@ class User < MainClusterwide::ApplicationRecord .where_exists(counts) end - def with_defaults - User.defaults.each do |k, v| - public_send("#{k}=", v) # rubocop:disable GitlabSecurity/PublicSend - end - - self - end - def can_leave_project?(project) project.namespace != namespace && project.member(self) @@ -1602,7 +1596,7 @@ class User < MainClusterwide::ApplicationRecord if namespace namespace.path = username if username_changed? namespace.name = name if name_changed? - else + elsif Feature.disabled?(:create_personal_ns_outside_model, Feature.current_request) # TODO: we should no longer need the `type` parameter once we can make the # the `has_one :namespace` association use the correct class. # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 @@ -1611,6 +1605,15 @@ class User < MainClusterwide::ApplicationRecord end end + def assign_personal_namespace + return namespace if namespace + + build_namespace(path: username, name: name) + namespace.build_namespace_settings + + namespace + end + def set_username_errors namespace_path_errors = self.errors.delete(:"namespace.path") @@ -1647,6 +1650,7 @@ class User < MainClusterwide::ApplicationRecord if should_delay_delete?(deleted_by) new_note = format(_("User deleted own account on %{timestamp}"), timestamp: Time.zone.now) self.note = "#{new_note}\n#{note}".strip + UserCustomAttribute.set_deleted_own_account_at(self) block_or_ban DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h) @@ -1778,7 +1782,7 @@ class User < MainClusterwide::ApplicationRecord def contributed_projects events = Event.select(:project_id) .contributions.where(author_id: self) - .where("created_at > ?", Time.current - 1.year) + .created_after(Time.current - 1.year) .distinct .reorder(nil) @@ -1813,6 +1817,8 @@ class User < MainClusterwide::ApplicationRecord end def owns_runner?(runner) + runner = runner.__getobj__ if runner.is_a?(Ci::RunnerPresenter) + ci_owned_runners.include?(runner) end @@ -2075,7 +2081,11 @@ class User < MainClusterwide::ApplicationRecord def terms_accepted? return true if project_bot? || service_account? || security_policy_bot? - accepted_term_id.present? + if Feature.enabled?(:enforce_acceptance_of_changed_terms) + !!ApplicationSetting::Term.latest&.accepted_by_user?(self) + else + accepted_term_id.present? + end end def required_terms_not_accepted? @@ -2248,6 +2258,10 @@ class User < MainClusterwide::ApplicationRecord namespace_commit_emails.find_by(namespace: namespace) end + def deleted_own_account? + custom_attributes.by_key(UserCustomAttribute::DELETED_OWN_ACCOUNT_AT).exists? + end + protected # override, from Devise::Validatable diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 5a592b425df..d294ea49352 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -21,6 +21,9 @@ class UserCustomAttribute < ApplicationRecord AUTO_BANNED_BY = 'auto_banned_by' IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' IDENTITY_VERIFICATION_EXEMPT = 'identity_verification_exempt' + DELETED_OWN_ACCOUNT_AT = 'deleted_own_account_at' + SKIPPED_ACCOUNT_DELETION_AT = 'skipped_account_deletion_at' + ASSUMED_HIGH_RISK_REASON = 'assumed_high_risk_reason' class << self def upsert_custom_attributes(custom_attributes) @@ -44,28 +47,46 @@ class UserCustomAttribute < ApplicationRecord def set_banned_by_abuse_report(abuse_report) return unless abuse_report - custom_attribute = { user_id: abuse_report.user.id, key: AUTO_BANNED_BY_ABUSE_REPORT_ID, value: abuse_report.id } - - upsert_custom_attributes([custom_attribute]) + upsert_custom_attribute( + user_id: abuse_report.user.id, + key: AUTO_BANNED_BY_ABUSE_REPORT_ID, + value: abuse_report.id + ) end def set_banned_by_spam_log(spam_log) return unless spam_log - custom_attribute = { user_id: spam_log.user_id, key: AUTO_BANNED_BY_SPAM_LOG_ID, value: spam_log.id } - upsert_custom_attributes([custom_attribute]) + upsert_custom_attribute(user_id: spam_log.user_id, key: AUTO_BANNED_BY_SPAM_LOG_ID, value: spam_log.id) end def set_trusted_by(user:, trusted_by:) return unless user && trusted_by - custom_attribute = { + upsert_custom_attribute( user_id: user.id, key: UserCustomAttribute::TRUSTED_BY, value: "#{trusted_by.username}/#{trusted_by.id}+#{Time.current}" - } + ) + end - upsert_custom_attributes([custom_attribute]) + def set_deleted_own_account_at(user) + return unless user + + upsert_custom_attribute(user_id: user.id, key: DELETED_OWN_ACCOUNT_AT, value: Time.zone.now.to_s) + end + + def set_skipped_account_deletion_at(user) + return unless user + + upsert_custom_attribute(user_id: user.id, key: SKIPPED_ACCOUNT_DELETION_AT, value: Time.zone.now.to_s) + end + + def set_assumed_high_risk_reason(user:, reason:) + return unless user + return unless reason + + upsert_custom_attribute(user_id: user.id, key: ASSUMED_HIGH_RISK_REASON, value: reason) end private @@ -73,5 +94,17 @@ class UserCustomAttribute < ApplicationRecord def blocked_users by_key('blocked_at').by_updated_at(Date.yesterday.all_day) end + + def upsert_custom_attribute(user_id:, key:, value:) + return unless user_id && key && value + + custom_attribute = { + user_id: user_id, + key: key, + value: value + } + + upsert_custom_attributes([custom_attribute]) + end end end diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb deleted file mode 100644 index 73bca362960..00000000000 --- a/app/models/user_interacted_project.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class UserInteractedProject < ApplicationRecord - extend SuppressCompositePrimaryKeyWarning - - belongs_to :user - belongs_to :project - - validates :project_id, presence: true - validates :user_id, presence: true - - CACHE_EXPIRY_TIME = 1.day - - class << self - def track(event) - # For events without a project, we simply don't care. - # An example of this is the creation of a snippet (which - # is not related to any project). - return unless event.project_id - - attributes = { - project_id: event.project_id, - user_id: event.author_id - } - - cached_exists?(**attributes) do - where(attributes).exists? || UserInteractedProject.insert_all([attributes], unique_by: %w[project_id user_id]) - true - end - end - - private - - def cached_exists?(project_id:, user_id:, &block) - cache_key = "user_interacted_projects:#{project_id}:#{user_id}" - Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY_TIME, &block) - end - end -end diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 59cfe9a8426..70ffe0c85f8 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -10,6 +10,7 @@ class UserPreference < MainClusterwide::ApplicationRecord TIME_DISPLAY_FORMATS = { system: 0, non_iso_format: 1, iso_format: 2 }.freeze belongs_to :user + belongs_to :home_organization, class_name: "Organizations::Organization", optional: true scope :with_user, -> { joins(:user) } scope :gitpod_enabled, -> { where(gitpod_enabled: true) } @@ -30,7 +31,8 @@ class UserPreference < MainClusterwide::ApplicationRecord validates :time_display_format, inclusion: { in: TIME_DISPLAY_FORMATS.values }, presence: true - ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' + validate :user_belongs_to_home_organization, if: :home_organization_changed? + # 2023-06-22 is after 16.1 release and during 16.2 release https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#ignoring-the-column-release-m ignore_columns :use_legacy_web_ide, remove_with: '16.2', remove_after: '2023-06-22' @@ -40,6 +42,7 @@ class UserPreference < MainClusterwide::ApplicationRecord attribute :render_whitespace_in_code, default: false attribute :project_shortcut_buttons, default: true attribute :keyboard_shortcuts_enabled, default: true + attribute :use_web_ide_extension_marketplace, default: false enum visibility_pipeline_id_type: { id: 0, iid: 1 } @@ -53,6 +56,16 @@ class UserPreference < MainClusterwide::ApplicationRecord end end + def user_belongs_to_home_organization + # If we don't ignore the default organization id below then all users need to have their corresponding entry + # with default organization id as organization id in the `organization_users` table. + # Otherwise, the user won't be able to the default organization as the home organization. + return if home_organization_id == Organizations::Organization::DEFAULT_ORGANIZATION_ID + return if user.user_belongs_to_organization?(home_organization_id) + + errors.add(:user, _("is not part of the given organization")) + end + def set_notes_filter(filter_id, issuable) # No need to update the column if the value is already set. if filter_id && NOTES_FILTERS.value?(filter_id) diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index a9880e56e8c..c32414be312 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -77,7 +77,9 @@ module Users vsd_feedback_banner: 75, # EE-only security_policy_protected_branch_modification: 76, # EE-only vulnerability_report_grouping: 77, # EE-only - new_nav_for_everyone_callout: 78 + new_nav_for_everyone_callout: 78, + code_suggestions_ga_non_owner_alert: 79, # EE-only + duo_chat_callout: 80 # EE-only } validates :feature_name, diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 5b9255f93b1..5362a726ff5 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -3,9 +3,6 @@ module Users class InProductMarketingEmail < ApplicationRecord include BulkInsertSafe - include IgnorableColumns - - ignore_column :campaign, remove_with: '16.7', remove_after: '2023-11-15' belongs_to :user @@ -49,7 +46,7 @@ module Users end def self.distinct_users_sql - name = users_table.table_name + name = users_table.name Arel.sql("DISTINCT ON(#{name}.id) #{name}.*") end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index 2256eb8ddc4..072b75a1c90 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -35,10 +35,13 @@ module Users scope :for_user, -> (user_id) { where(user_id: user_id) } def self.related_to_banned_user?(international_dial_code, phone_number) - joins(:banned_user).where( + joins(:banned_user) + .where( international_dial_code: international_dial_code, phone_number: phone_number - ).exists? + ) + .where.not(validated_at: nil) + .exists? end def self.by_reference_id(ref_id) diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 0e3fe2cc8ac..c8f9e75a389 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -5,6 +5,11 @@ class Vulnerability < ApplicationRecord include EachBatch include IgnorableColumns + ignore_column %i[due_date due_date_sourcing_milestone_id epic_id milestone_id + last_edited_at last_edited_by_id start_date start_date_sourcing_milestone_id updated_by_id], + remove_with: '16.9', + remove_after: '2024-01-19' + alias_attribute :vulnerability_id, :id scope :with_projects, -> { includes(:project) } diff --git a/app/models/work_item.rb b/app/models/work_item.rb index a62d77939bf..77f684e3578 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -12,8 +12,10 @@ class WorkItem < Issue self.inheritance_column = :_type_disabled belongs_to :namespace, inverse_of: :work_items + has_one :parent_link, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_id has_one :work_item_parent, through: :parent_link, class_name: 'WorkItem' + has_one :dates_source, class_name: 'WorkItems::DatesSource', foreign_key: 'issue_id', inverse_of: :work_item has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id has_many :work_item_children, through: :child_links, class_name: 'WorkItem', @@ -23,7 +25,6 @@ class WorkItem < Issue foreign_key: :work_item_id, source: :work_item scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) } - scope :in_namespaces, ->(namespaces) { where(namespace: namespaces) } scope :with_confidentiality_check, ->(user) { confidential_query = <<~SQL @@ -37,6 +38,10 @@ class WorkItem < Issue } class << self + def find_by_namespace_and_iid!(namespace, iid) + find_by!(namespace: namespace, iid: iid) + end + def assignee_association_name 'issue' end diff --git a/app/models/work_items/dates_source.rb b/app/models/work_items/dates_source.rb new file mode 100644 index 00000000000..89528e95007 --- /dev/null +++ b/app/models/work_items/dates_source.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module WorkItems + class DatesSource < ApplicationRecord + self.table_name = 'work_item_dates_sources' + + # namespace is required as the sharding key + belongs_to :namespace, inverse_of: :work_items_dates_source + belongs_to :work_item, foreign_key: 'issue_id', inverse_of: :dates_source + + belongs_to :due_date_sourcing_work_item, class_name: 'WorkItem' + belongs_to :start_date_sourcing_work_item, class_name: 'WorkItem' + + belongs_to :due_date_sourcing_milestone, class_name: 'Milestone' + belongs_to :start_date_sourcing_milestone, class_name: 'Milestone' + + before_validation :set_namespace + + private + + def set_namespace + return if work_item.blank? + return if work_item.namespace == namespace + + self.namespace = work_item.namespace + end + end +end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 4ccef4c93d3..38416f6cb76 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -9,6 +9,11 @@ module WorkItems self.table_name = 'work_item_types' include CacheMarkdownField + include ReactiveCaching + + self.reactive_cache_work_type = :no_dependency + self.reactive_cache_refresh_interval = 10.minutes + self.reactive_cache_lifetime = 1.hour # type name is used in restrictions DB seeder to assure restrictions for # default types are pre-filled @@ -53,8 +58,14 @@ module WorkItems has_many :widget_definitions, foreign_key: :work_item_type_id, inverse_of: :work_item_type has_many :enabled_widget_definitions, -> { where(disabled: false) }, foreign_key: :work_item_type_id, inverse_of: :work_item_type, class_name: 'WorkItems::WidgetDefinition' + has_many :child_restrictions, class_name: 'WorkItems::HierarchyRestriction', foreign_key: :parent_type_id, + inverse_of: :parent_type + has_many :allowed_child_types_by_name, -> { order_by_name_asc }, + through: :child_restrictions, class_name: 'WorkItems::Type', + foreign_key: :child_type_id, source: :child_type before_validation :strip_whitespace + after_save :clear_reactive_cache! # TODO: review validation rules # https://gitlab.com/gitlab-org/gitlab/-/issues/336919 @@ -82,7 +93,7 @@ module WorkItems end def self.allowed_types_for_issues - base_types.keys.excluding('task', 'objective', 'key_result', 'epic', 'ticket') + base_types.keys.excluding('objective', 'key_result', 'epic', 'ticket') end def default? @@ -101,6 +112,16 @@ module WorkItems name == WorkItems::Type::TYPE_NAMES[:issue] end + def calculate_reactive_cache + allowed_child_types_by_name + end + + def allowed_child_types(cache: false) + cached_data = cache ? with_reactive_cache { |query_data| query_data } : nil + + cached_data || allowed_child_types_by_name + end + private def strip_whitespace diff --git a/app/models/work_items/widgets/assignees.rb b/app/models/work_items/widgets/assignees.rb index 0707b03e647..13b6739cc16 100644 --- a/app/models/work_items/widgets/assignees.rb +++ b/app/models/work_items/widgets/assignees.rb @@ -13,6 +13,12 @@ module WorkItems def self.quick_action_params [:assignee_ids] end + + def self.can_invite_members?(user, resource_parent) + user.can?("admin_#{resource_parent.to_ability_name}_member".to_sym, resource_parent) + end end end end + +WorkItems::Widgets::Assignees.prepend_mod diff --git a/app/policies/abuse_report_policy.rb b/app/policies/abuse_report_policy.rb index 043dbd0cb89..ca200c538f2 100644 --- a/app/policies/abuse_report_policy.rb +++ b/app/policies/abuse_report_policy.rb @@ -3,6 +3,7 @@ class AbuseReportPolicy < ::BasePolicy rule { admin }.policy do enable :read_abuse_report + enable :read_note enable :create_note end end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 462afbaa475..53b0073eccc 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -57,11 +57,9 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:can_create_group) { @user&.can_create_group } - # TODO: update to check application setting - # https://gitlab.com/gitlab-org/gitlab/-/issues/423302 desc 'User can create an organization' - with_options scope: :user, score: 0 - condition(:can_create_organization) { true } + with_options scope: :global, score: 0 + condition(:can_create_organization) { Gitlab::CurrentSettings.can_create_organization } desc "The application is restricted from public visibility" condition(:restricted_public_level, scope: :global) do diff --git a/app/policies/ci/runner_manager_policy.rb b/app/policies/ci/runner_manager_policy.rb index 43e81e373fc..ef0ae9135b2 100644 --- a/app/policies/ci/runner_manager_policy.rb +++ b/app/policies/ci/runner_manager_policy.rb @@ -2,9 +2,7 @@ module Ci class RunnerManagerPolicy < BasePolicy - with_options scope: :subject, score: 0 - - condition(:can_read_runner, scope: :subject) do + condition(:can_read_runner) do can?(:read_runner, @subject.runner) end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 7b01dccff87..045c07ed2c4 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -5,6 +5,7 @@ module Ci with_options scope: :subject, score: 0 condition(:locked, scope: :subject) { @subject.locked? } + with_options scope: :subject, score: 20 condition(:owned_runner) do @user.owns_runner?(@subject) end @@ -23,6 +24,11 @@ module Ci @subject.group_type? end + with_options scope: :subject, score: 0 + condition(:is_project_runner) do + @subject.project_type? + end + with_options scope: :user, score: 5 condition(:any_developer_maintainer_owned_groups_inheriting_shared_runners) do @user.developer_maintainer_owned_groups.with_shared_runners_enabled.any? @@ -44,6 +50,27 @@ module Ci end end + with_options score: 6 + condition(:developer_in_any_associated_projects) do + # Check if runner is associated to any projects where user is a developer+ + @subject.projects.visible_to_user_and_access_level(@user, Gitlab::Access::DEVELOPER).exists? + end + + with_options score: 8 + condition(:developer_in_any_associated_groups) do + user_group_ids = @user.developer_maintainer_owned_groups.select(:id) + + # Check for direct group relationships + next true if user_group_ids.id_in(@subject.group_ids).any? + + # Check for indirect group relationships + GroupGroupLink + .with_developer_maintainer_owner_access + .groups_accessible_via(user_group_ids) + .id_in(@subject.group_ids) + .any? + end + condition(:belongs_to_multiple_projects, scope: :subject) do @subject.belongs_to_more_than_one_project? end @@ -63,6 +90,14 @@ module Ci enable :read_runner end + rule { is_project_runner & developer_in_any_associated_projects }.policy do + enable :read_runner + end + + rule { is_group_runner & developer_in_any_associated_groups }.policy do + enable :read_runner + end + rule { is_group_runner & any_associated_projects_in_group_runner_inheriting_group_runners }.policy do enable :read_runner end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index ca170133105..6ec0a46518a 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -146,6 +146,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_custom_emoji enable :read_counts enable :read_issue + enable :read_namespace end rule { achievements_enabled }.policy do @@ -173,8 +174,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { has_access }.enable :read_namespace_via_membership - rule { can?(:read_namespace_via_membership) }.enable :read_namespace - rule { developer }.policy do enable :admin_metrics_dashboard_annotation enable :create_custom_emoji diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index 090be645b21..49f9225a1d3 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -16,10 +16,6 @@ class MergeRequestPolicy < IssuablePolicy prevent :accept_merge_request end - rule { can?(:read_merge_request) }.policy do - enable :generate_diff_summary - end - rule { can_approve }.policy do enable :approve_merge_request end @@ -47,10 +43,6 @@ class MergeRequestPolicy < IssuablePolicy enable :set_merge_request_metadata end - rule { llm_bot }.policy do - enable :generate_diff_summary - end - private def can_approve? diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb index 401aa45786d..d538b786f78 100644 --- a/app/policies/organizations/organization_policy.rb +++ b/app/policies/organizations/organization_policy.rb @@ -18,6 +18,7 @@ module Organizations end rule { organization_user }.policy do + enable :admin_organization enable :read_organization enable :read_organization_user end diff --git a/app/policies/project_group_link_policy.rb b/app/policies/project_group_link_policy.rb index 7ad2985ecc5..98d5bdebdc9 100644 --- a/app/policies/project_group_link_policy.rb +++ b/app/policies/project_group_link_policy.rb @@ -1,16 +1,39 @@ # frozen_string_literal: true class ProjectGroupLinkPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass + condition(:group_owner) { group_owner? } condition(:group_owner_or_project_admin) { group_owner? || project_admin? } condition(:can_read_group) { can?(:read_group, @subject.group) } condition(:project_member) { @subject.project.member?(@user) } + condition(:can_manage_owners) { can_manage_owners? } + condition(:can_manage_group_link_with_owner_access) do + next true unless @subject.owner_access? - rule { group_owner_or_project_admin }.enable :admin_project_group_link + can_manage_owners? + end + + rule { can_manage_owners }.enable :manage_owners + + rule { can_manage_group_link_with_owner_access }.enable :manage_group_link_with_owner_access + + # `manage_destroy` specifies the very basic permission that a user needs to destroy a link. + rule { group_owner_or_project_admin }.enable :manage_destroy + + # `destroy_project_group_link` combines the basic permission, ie `manage_destroy` AND + # the specific permissions a user needs to destroy a link that has `OWNER` access level. + # link.project's owner, or link.group's owner can delete a link with any access level, including OWNER + rule { can?(:manage_destroy) & (can?(:manage_group_link_with_owner_access) | group_owner) }.policy do + enable :destroy_project_group_link + end rule { can_read_group | project_member }.enable :read_shared_with_group private + def can_manage_owners? + can?(:manage_owners, @subject.project) + end + def group_owner? can?(:admin_group, @subject.group) end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index bbb0e3df500..255538c538a 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -154,9 +154,6 @@ class ProjectPolicy < BasePolicy end with_scope :subject - condition(:restrict_job_token_enabled) { Feature.enabled?(:restrict_ci_job_token_for_public_and_internal_projects, @subject) } - - with_scope :subject condition(:forking_allowed) do @subject.feature_available?(:forking, @user) end @@ -194,7 +191,9 @@ class ProjectPolicy < BasePolicy end with_scope :subject - condition(:model_registry_enabled) { Feature.enabled?(:model_registry, @subject) } + condition(:model_registry_enabled) do + Feature.enabled?(:model_registry, @subject) && @subject.feature_available?(:model_registry, @user) + end with_scope :subject condition(:resource_access_token_feature_available) do @@ -709,7 +708,7 @@ class ProjectPolicy < BasePolicy rule { ~public_project & ~internal_access & ~project_allowed_for_job_token }.prevent_all # If this project is public or internal we want to prevent all aside from a few public policies - rule { public_or_internal & ~project_allowed_for_job_token & restrict_job_token_enabled }.policy do + rule { public_or_internal & ~project_allowed_for_job_token }.policy do prevent :guest_access prevent :public_access prevent :public_user_access @@ -719,25 +718,25 @@ class ProjectPolicy < BasePolicy prevent :owner_access end - rule { public_or_internal & job_token_container_registry & restrict_job_token_enabled }.policy do + rule { public_or_internal & job_token_container_registry }.policy do enable :build_read_container_image enable :read_container_image end - rule { public_or_internal & job_token_package_registry & restrict_job_token_enabled }.policy do + rule { public_or_internal & job_token_package_registry }.policy do enable :read_package enable :read_project end - rule { public_or_internal & job_token_builds & restrict_job_token_enabled }.policy do + rule { public_or_internal & job_token_builds }.policy do enable :read_commit_status # this is additionally needed to download artifacts end - rule { public_or_internal & job_token_releases & restrict_job_token_enabled }.policy do + rule { public_or_internal & job_token_releases }.policy do enable :read_release end - rule { public_or_internal & job_token_environments & restrict_job_token_enabled }.policy do + rule { public_or_internal & job_token_environments }.policy do enable :read_environment end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 04fbc8467c9..ccab3d9f02d 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -33,6 +33,7 @@ class UserPolicy < BasePolicy enable :read_saved_replies enable :read_user_email_address enable :admin_user_email_address + enable :make_profile_private end rule { default }.enable :read_user_profile diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 0bf4a99dcba..37dbc2918ec 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -16,7 +16,10 @@ module Ci size_limit_exceeded: 'The pipeline size limit was exceeded.', job_activity_limit_exceeded: 'The pipeline job activity limit was exceeded.', deployments_limit_exceeded: 'The pipeline deployments limit was exceeded.', - project_deleted: 'The project associated with this pipeline was deleted.' } + project_deleted: 'The project associated with this pipeline was deleted.', + filtered_by_rules: 'Pipeline will not run for the selected trigger. ' \ + 'The rules configuration prevented any jobs from being added to the pipeline.', + filtered_by_workflow_rules: 'Pipeline filtered out by workflow rules.' } end presents ::Ci::Pipeline, as: :pipeline diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 0858fad1e1a..38469be572a 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -14,7 +14,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated archived_failure: 'The job is archived and cannot be run', unmet_prerequisites: 'The job failed to complete prerequisite tasks', scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator', - data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator', + data_integrity_failure: 'There has been an unknown job problem, please contact your system administrator with the job ID to review the logs', forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run', pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines', insufficient_upstream_permissions: 'This job could not be executed because of insufficient permissions to track the upstream project.', diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index 8f7b2d5868e..7f7c323fcac 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -12,6 +12,8 @@ class EventPresenter < Gitlab::View::Presenter::Delegated # Caching `visible_to_user?` method in the presenter because it might be called multiple times. delegator_override :visible_to_user? def visible_to_user?(user = nil) + return super(user) unless user + @visible_to_user_cache.fetch(user&.id) { super(user) } end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 9403dd0814b..f9bc8c1dfa6 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -12,12 +12,12 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated issue.subscribed?(current_user, issue.project) end - def project_emails_disabled? - issue.project.emails_disabled? + def parent_emails_disabled? + issue.resource_parent.emails_disabled? end - def project_emails_enabled? - issue.project.emails_enabled? + def parent_emails_enabled? + issue.resource_parent.emails_enabled? end delegator_override :service_desk_reply_to diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index 057d3bd19d9..d881e8813c7 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -4,13 +4,13 @@ module Ml class CandidateDetailsPresenter include Rails.application.routes.url_helpers - def initialize(candidate, include_ci_job = false) + def initialize(candidate, current_user) @candidate = candidate - @include_ci_job = include_ci_job + @current_user = current_user end def present - data = { + { candidate: { info: { iid: candidate.iid, @@ -27,16 +27,18 @@ module Ml metadata: candidate.metadata } } + end - Gitlab::Json.generate(data) + def present_as_json + Gitlab::Json.generate(present.deep_transform_keys { |k| k.to_s.camelize(:lower) }) end private - attr_reader :candidate, :include_ci_job + attr_reader :candidate, :current_user def job_info - return unless include_ci_job && candidate.from_ci? + return unless candidate.from_ci? && current_user.can?(:read_build, candidate.ci_build) build = candidate.ci_build diff --git a/app/presenters/ml/candidate_presenter.rb b/app/presenters/ml/candidate_presenter.rb new file mode 100644 index 00000000000..2465238d81e --- /dev/null +++ b/app/presenters/ml/candidate_presenter.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ml + class CandidatePresenter < Gitlab::View::Presenter::Delegated + presents ::Ml::Candidate, as: :candidate + + def path + project_ml_candidate_path( + candidate.project, + candidate.iid + ) + end + + def artifact_path + return unless candidate.package_id.present? + + project_package_path(candidate.project, candidate.package_id) + end + end +end diff --git a/app/presenters/ml/model_presenter.rb b/app/presenters/ml/model_presenter.rb index 24d30af1d4e..77aba1991b6 100644 --- a/app/presenters/ml/model_presenter.rb +++ b/app/presenters/ml/model_presenter.rb @@ -14,6 +14,10 @@ module Ml model.versions.size end + def candidate_count + model.candidates.size + end + def latest_package_path latest_version&.package_path end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index c983d8623d2..5226b2c1611 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -21,8 +21,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop, :data) MAX_TOPICS_TO_SHOW = 3 - def statistic_icon(icon_name = 'plus-square-o') - sprite_icon(icon_name, css_class: 'icon gl-mr-2 gl-text-gray-500') + def statistic_default_class_list + Feature.enabled?(:project_overview_reorg) ? 'icon gl-mr-3 gl-text-gray-500' : 'icon gl-mr-2 gl-text-gray-500' + end + + def statistic_default_icon + Feature.enabled?(:project_overview_reorg) ? 'plus' : 'plus-square-o' + end + + def statistic_icon(icon_name = statistic_default_icon, class_list = statistic_default_class_list) + sprite_icon(icon_name, css_class: class_list) end def statistics_anchors(show_auto_devops_callout:) @@ -163,12 +171,12 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def storage_anchor_data - can_show_quota = can?(current_user, :admin_project, project) && !empty_repo? + return unless can?(current_user, :admin_project, project) && !empty_repo? AnchorData.new( true, statistic_icon('disk') + storage_anchor_text, - can_show_quota ? project_usage_quotas_path(project) : nil + project_usage_quotas_path(project) ) end @@ -288,13 +296,19 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated if can_current_user_push_to_default_branch? new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_main) : project_new_blob_path(project, default_branch_or_main) - AnchorData.new(false, statistic_icon + _('New file'), new_file_path, 'btn-dashed') + if Feature.enabled?(:project_overview_reorg) + AnchorData.new(false, statistic_icon('plus', 'gl-text-blue-500! gl-mr-3') + _('New file'), new_file_path) + else + AnchorData.new(false, statistic_icon + _('New file'), new_file_path, 'btn-dashed') + end end end def readme_anchor_data if can_current_user_push_to_default_branch? && readme_path.nil? - AnchorData.new(false, statistic_icon + _('Add README'), empty_repo? ? add_readme_ide_path : add_readme_path) + icon = Feature.enabled?(:project_overview_reorg) ? statistic_icon('plus', 'gl-text-blue-500! gl-mr-3') : statistic_icon + label = icon + _('Add README') + AnchorData.new(false, label, empty_repo? ? add_readme_ide_path : add_readme_path) elsif readme_path AnchorData.new( false, @@ -308,9 +322,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def changelog_anchor_data if can_current_user_push_to_default_branch? && repository.changelog.blank? + icon = Feature.enabled?(:project_overview_reorg) ? statistic_icon('plus', 'gl-mr-3') : statistic_icon + label = icon + _('Add CHANGELOG') AnchorData.new( false, - statistic_icon + _('Add CHANGELOG'), + label, empty_repo? ? add_changelog_ide_path : add_changelog_path ) elsif repository.changelog.present? @@ -336,9 +352,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated 'license' ) elsif can_current_user_push_to_default_branch? + icon = Feature.enabled?(:project_overview_reorg) ? statistic_icon('plus', 'gl-text-blue-500! gl-mr-3') : statistic_icon + label = icon + _('Add LICENSE') AnchorData.new( false, - content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'), + content_tag(:span, label, class: 'add-license-link d-flex'), empty_repo? ? add_license_ide_path : add_license_path ) end @@ -346,9 +364,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def contribution_guide_anchor_data if can_current_user_push_to_default_branch? && repository.contribution_guide.blank? + icon = Feature.enabled?(:project_overview_reorg) ? statistic_icon('plus', 'gl-text-blue-500! gl-mr-3') : statistic_icon + label = icon + _('Add CONTRIBUTING') AnchorData.new( false, - statistic_icon + _('Add CONTRIBUTING'), + label, empty_repo? ? add_contribution_guide_ide_path : add_contribution_guide_path ) elsif repository.contribution_guide.present? @@ -375,7 +395,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated else AnchorData.new( false, - statistic_icon + _('Enable Auto DevOps'), + content_tag(:span, statistic_icon('plus', 'gl-mr-3') + _('Enable Auto DevOps')), project_settings_ci_cd_path(project, anchor: 'autodevops-settings') ) end @@ -387,7 +407,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def kubernetes_cluster_anchor_data if can_instantiate_cluster? if clusters.empty? - AnchorData.new(false, statistic_icon + _('Add Kubernetes cluster'), project_clusters_path(project)) + if Feature.enabled?(:project_overview_reorg) + AnchorData.new(false, content_tag(:span, statistic_icon('plus', 'gl-mr-3') + _('Add Kubernetes cluster')), project_clusters_path(project)) + else + AnchorData.new(false, content_tag(:span, statistic_icon + _('Add Kubernetes cluster')), project_clusters_path(project)) + end else cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project) @@ -400,9 +424,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated return unless can_view_pipeline_editor?(project) if cicd_missing? - AnchorData.new(false, statistic_icon + _('Set up CI/CD'), project_ci_pipeline_editor_path(project)) + AnchorData.new(false, content_tag(:span, statistic_icon('plus', 'gl-mr-3') + _('Set up CI/CD')), project_ci_pipeline_editor_path(project)) elsif repository.gitlab_ci_yml.present? - AnchorData.new(false, statistic_icon('doc-text') + _('CI/CD configuration'), project_ci_pipeline_editor_path(project), 'btn-default') + AnchorData.new(false, statistic_icon('rocket') + _('CI/CD configuration'), project_ci_pipeline_editor_path(project), 'btn-default') end end @@ -412,7 +436,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated if project.wiki.has_home_page? AnchorData.new(false, statistic_icon('book') + _('Wiki'), project_wiki_path, 'btn-default', nil, nil) elsif can_create_wiki? - AnchorData.new(false, statistic_icon + _('Add Wiki'), project_create_wiki_path, nil, nil, nil) + icon = Feature.enabled?(:project_overview_reorg) ? statistic_icon('plus', 'gl-mr-3') : statistic_icon + label = icon + _('Add Wiki') + AnchorData.new(false, label, project_create_wiki_path, nil, nil, nil) end end @@ -457,8 +483,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def integrations_anchor_data return unless can?(current_user, :admin_project, project) - label = statistic_icon('settings') + _('Configure Integrations') - AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil) + if Feature.enabled?(:project_overview_reorg) + AnchorData.new(false, content_tag(:span, statistic_icon('plus', 'gl-blue-500! gl-mr-3') + _('Configure Integrations')), project_settings_integrations_path(project), nil, nil, nil) + else + AnchorData.new(false, content_tag(:span, statistic_icon('settings') + _('Configure Integrations')), project_settings_integrations_path(project), nil, nil, nil) + end end def cicd_missing? diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index 77b85f239f7..a654482b989 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -76,5 +76,9 @@ module Admin expose :report do |report| ReportedContentEntity.represent(report) end + + expose :upload_note_attachment_path do |report| + upload_path('abuse_report', id: report.id) + end end end diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb index 6ca782d8203..212931a2fa9 100644 --- a/app/serializers/award_emoji_entity.rb +++ b/app/serializers/award_emoji_entity.rb @@ -3,5 +3,4 @@ class AwardEmojiEntity < Grape::Entity expose :name expose :user, using: API::Entities::UserSafe - expose :url end diff --git a/app/serializers/ci/downloadable_artifact_entity.rb b/app/serializers/ci/downloadable_artifact_entity.rb index 2e8aafcee43..5c9bb351314 100644 --- a/app/serializers/ci/downloadable_artifact_entity.rb +++ b/app/serializers/ci/downloadable_artifact_entity.rb @@ -5,13 +5,12 @@ module Ci include RequestAwareEntity expose :artifacts do |pipeline, options| - artifacts = pipeline.downloadable_artifacts + downloadable_artifacts = pipeline.downloadable_artifacts + project = pipeline.project - if Feature.enabled?(:non_public_artifacts) - artifacts = artifacts.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) } - end + artifacts = downloadable_artifacts.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) } - BuildArtifactEntity.represent(artifacts, options.merge(project: pipeline.project)) + BuildArtifactEntity.represent(artifacts, options.merge(project: project)) end end end diff --git a/app/serializers/deploy_keys/basic_deploy_key_entity.rb b/app/serializers/deploy_keys/basic_deploy_key_entity.rb index 4a3dd3c8f08..805d54d641a 100644 --- a/app/serializers/deploy_keys/basic_deploy_key_entity.rb +++ b/app/serializers/deploy_keys/basic_deploy_key_entity.rb @@ -2,6 +2,8 @@ module DeployKeys class BasicDeployKeyEntity < Grape::Entity + include RequestAwareEntity + expose :id expose :user_id expose :title @@ -14,6 +16,17 @@ module DeployKeys expose :updated_at expose :can_edit expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) } + expose :edit_path, if: -> (_, opts) { opts[:project] } do |deploy_key| + edit_project_deploy_key_path(options[:project], deploy_key) + end + + expose :enable_path, if: -> (_, opts) { opts[:project] } do |deploy_key| + enable_project_deploy_key_path(options[:project], deploy_key) + end + + expose :disable_path, if: -> (_, opts) { opts[:project] } do |deploy_key| + disable_project_deploy_key_path(options[:project], deploy_key) + end private diff --git a/app/serializers/deploy_keys/deploy_key_serializer.rb b/app/serializers/deploy_keys/deploy_key_serializer.rb index b00ef65696f..2e6291a95f2 100644 --- a/app/serializers/deploy_keys/deploy_key_serializer.rb +++ b/app/serializers/deploy_keys/deploy_key_serializer.rb @@ -3,5 +3,6 @@ module DeployKeys class DeployKeySerializer < BaseSerializer entity DeployKeyEntity + include WithPagination end end diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index f8d9778a3ee..294d50f5f10 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -5,6 +5,7 @@ class DiffViewerEntity < Grape::Entity expose :render_error, as: :error expose :render_error_message, as: :error_message expose :collapsed?, as: :collapsed + expose :generated?, as: :generated expose :whitespace_only do |_, options| options[:whitespace_only] end diff --git a/app/serializers/group_link/group_group_link_entity.rb b/app/serializers/group_link/group_group_link_entity.rb index f855d89f593..f6989e8afcd 100644 --- a/app/serializers/group_link/group_group_link_entity.rb +++ b/app/serializers/group_link/group_group_link_entity.rb @@ -8,14 +8,22 @@ module GroupLink GroupEntity.represent(group_link.shared_from, only: [:id, :full_name, :web_url]) end - private + expose :valid_roles do |group_link| + group_link.class.access_options + end - def can_admin_group_link?(group_link, options) - can?(current_user, admin_permission_name, group_link.shared_from) + expose :can_update do |group_link, options| + can_admin_group_link?(group_link, options) + end + + expose :can_remove do |group_link, options| + can_admin_group_link?(group_link, options) end - def admin_permission_name - :admin_group_member + private + + def can_admin_group_link?(group_link, options) + direct_member?(group_link, options) && can?(current_user, :admin_group_member, group_link.shared_from) end end end diff --git a/app/serializers/group_link/group_link_entity.rb b/app/serializers/group_link/group_link_entity.rb index 66645e736a9..1b8313c2536 100644 --- a/app/serializers/group_link/group_link_entity.rb +++ b/app/serializers/group_link/group_link_entity.rb @@ -15,10 +15,6 @@ module GroupLink expose :group_access, as: :integer_value end - expose :valid_roles do |group_link| - group_link.class.access_options - end - expose :is_shared_with_group_private do |group_link| !can_read_shared_group?(group_link) end @@ -43,14 +39,6 @@ module GroupLink end end - expose :can_update do |group_link, options| - can_admin_shared_from?(group_link, options) - end - - expose :can_remove do |group_link, options| - direct_member?(group_link, options) && can_admin_group_link?(group_link, options) - end - expose :is_direct_member do |group_link, options| direct_member?(group_link, options) end @@ -68,10 +56,5 @@ module GroupLink def direct_member?(group_link, options) group_link.shared_from == options[:source] end - - def can_admin_shared_from?(group_link, options) - direct_member?(group_link, options) && - can?(current_user, admin_permission_name, group_link.shared_from) - end end end diff --git a/app/serializers/group_link/project_group_link_entity.rb b/app/serializers/group_link/project_group_link_entity.rb index fbad69bf2c5..d763809d123 100644 --- a/app/serializers/group_link/project_group_link_entity.rb +++ b/app/serializers/group_link/project_group_link_entity.rb @@ -8,14 +8,23 @@ module GroupLink ProjectEntity.represent(group_link.shared_from, only: [:id, :full_name]) end - private + expose :valid_roles do |group_link| + if can?(current_user, :manage_owners, group_link) + Gitlab::Access.options_with_owner + else + Gitlab::Access.options + end + end - def can_admin_group_link?(group_link, options) - can?(current_user, :admin_project_group_link, group_link) + expose :can_update do |group_link, options| + direct_member?(group_link, options) && + can?(current_user, :admin_project_member, group_link.project) && + can?(current_user, :manage_group_link_with_owner_access, group_link) end - def admin_permission_name - :admin_project_member + expose :can_remove do |group_link, options| + direct_member?(group_link, options) && + can?(current_user, :destroy_project_group_link, group_link) end end end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index cef3f4555df..3374cd46729 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -15,6 +15,8 @@ class MergeRequestPollWidgetEntity < Grape::Entity merge_request.project.merge_requests_ff_only_enabled end + expose :ff_merge_possible?, as: :ff_merge_possible + # User entities expose :merge_user, using: UserEntity diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb index 83f168682db..81f68657e38 100644 --- a/app/serializers/merge_requests/pipeline_entity.rb +++ b/app/serializers/merge_requests/pipeline_entity.rb @@ -25,12 +25,11 @@ class MergeRequests::PipelineEntity < Grape::Entity expose :artifacts do |pipeline, options| rel = pipeline.downloadable_artifacts + project = pipeline.project - if Feature.enabled?(:non_public_artifacts, type: :development) - rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) } - end + allowed_to_read_artifacts = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact) } - BuildArtifactEntity.represent(rel, options.merge(project: pipeline.project)) + BuildArtifactEntity.represent(allowed_to_read_artifacts, options.merge(project: project)) end expose :detailed_status, as: :status, with: DetailedStatusEntity do |pipeline| diff --git a/app/serializers/personal_access_token_entity.rb b/app/serializers/personal_access_token_entity.rb index 49dcdf12a6f..71c31a8191b 100644 --- a/app/serializers/personal_access_token_entity.rb +++ b/app/serializers/personal_access_token_entity.rb @@ -5,7 +5,7 @@ class PersonalAccessTokenEntity < AccessTokenEntityBase include Gitlab::Routing expose :revoke_path do |token, options| - revoke_profile_personal_access_token_path(token) + revoke_user_settings_personal_access_token_path(token) end end # rubocop: enable Gitlab/NamespacedClass diff --git a/app/services/activity_pub/projects/releases_follow_service.rb b/app/services/activity_pub/projects/releases_follow_service.rb new file mode 100644 index 00000000000..3d877a1d083 --- /dev/null +++ b/app/services/activity_pub/projects/releases_follow_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesFollowService < ReleasesSubscriptionService + def execute + unless subscriber_url + errors << "You need to provide an actor id for your subscriber" + return false + end + + return true if previous_subscription.present? + + subscription = ReleasesSubscription.new( + subscriber_url: subscriber_url, + subscriber_inbox_url: subscriber_inbox_url, + payload: payload, + project: project + ) + + unless subscription.save + errors.concat(subscription.errors.full_messages) + return false + end + + enqueue_subscription(subscription) + true + end + + private + + def subscriber_inbox_url + return unless payload['actor'].is_a?(Hash) + + payload['actor']['inbox'] + end + + def enqueue_subscription(subscription) + ReleasesSubscriptionWorker.perform_async(subscription.id) + end + end + end +end diff --git a/app/services/activity_pub/projects/releases_subscription_service.rb b/app/services/activity_pub/projects/releases_subscription_service.rb new file mode 100644 index 00000000000..27d0e19a172 --- /dev/null +++ b/app/services/activity_pub/projects/releases_subscription_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesSubscriptionService + attr_reader :errors + + def initialize(project, payload) + @project = project + @payload = payload + @errors = [] + end + + def execute + raise "not implemented: abstract class, do not use directly." + end + + private + + attr_reader :project, :payload + + def subscriber_url + return unless payload['actor'] + return payload['actor'] if payload['actor'].is_a?(String) + return unless payload['actor'].is_a?(Hash) && payload['actor']['id'].is_a?(String) + + payload['actor']['id'] + end + + def previous_subscription + @previous_subscription ||= ReleasesSubscription.find_by_project_and_subscriber(project.id, subscriber_url) + end + end + end +end diff --git a/app/services/activity_pub/projects/releases_unfollow_service.rb b/app/services/activity_pub/projects/releases_unfollow_service.rb new file mode 100644 index 00000000000..df5dcefbb87 --- /dev/null +++ b/app/services/activity_pub/projects/releases_unfollow_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesUnfollowService < ReleasesSubscriptionService + def execute + unless subscriber_url + errors << "You need to provide an actor id for your unsubscribe activity" + return false + end + + return true unless previous_subscription.present? + + previous_subscription.destroy + end + end + end +end diff --git a/app/services/admin/set_feature_flag_service.rb b/app/services/admin/set_feature_flag_service.rb index 3378be7eddd..e7969d02e0b 100644 --- a/app/services/admin/set_feature_flag_service.rb +++ b/app/services/admin/set_feature_flag_service.rb @@ -62,6 +62,7 @@ module Admin Feature.disable(name) elsif percentage_of_actors? Feature.enable_percentage_of_actors(name, percentage) + # Deprecated in favor of Feature.enabled?(name, :instance) + Feature.enable_percentage_of_actors(name, percentage) elsif percentage_of_time? Feature.enable_percentage_of_time(name, percentage) else diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 363510a41a1..7d473f9ed89 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -64,13 +64,16 @@ module Auth def self.push_pull_nested_repositories_access_token(name) name = name.chomp('/') - access_token({ - name => %w[pull push], - "#{name}/*" => %w[pull] - }) + access_token( + { + name => %w[pull push], + "#{name}/*" => %w[pull] + }, + override_project_path: name + ) end - def self.access_token(names_and_actions, type = 'repository') + def self.access_token(names_and_actions, type = 'repository', override_project_path: nil) registry = Gitlab.config.registry token = JSONWebToken::RSAToken.new(registry.key) token.issuer = registry.issuer @@ -82,7 +85,7 @@ module Auth type: type, name: name, actions: actions, - meta: access_metadata(path: name) + meta: access_metadata(path: name, override_project_path: override_project_path) }.compact end @@ -93,7 +96,9 @@ module Auth Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes end - def self.access_metadata(project: nil, path: nil) + def self.access_metadata(project: nil, path: nil, override_project_path: nil) + return { project_path: override_project_path.downcase } if override_project_path + # If the project is not given, try to infer it from the provided path if project.nil? return if path.nil? # If no path is given, return early diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb index 164594d6f6c..29f5a50d809 100644 --- a/app/services/auth/dependency_proxy_authentication_service.rb +++ b/app/services/auth/dependency_proxy_authentication_service.rb @@ -5,10 +5,11 @@ module Auth AUDIENCE = 'dependency_proxy' HMAC_KEY = 'gitlab-dependency-proxy' DEFAULT_EXPIRE_TIME = 1.minute + REQUIRED_ABILITIES = %i[read_container_image create_container_image].freeze def execute(authentication_abilities:) return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled - return error('access forbidden', 403) unless valid_user_actor? + return error('access forbidden', 403) unless valid_user_actor?(authentication_abilities) { token: authorized_token.encoded } end @@ -33,8 +34,27 @@ module Auth private - def valid_user_actor? - current_user || valid_deploy_token? + def valid_user_actor?(authentication_abilities) + feature_user = deploy_token&.user || current_user + if Feature.enabled?(:packages_dependency_proxy_containers_scope_check, feature_user) + if deploy_token + deploy_token.valid_for_dependency_proxy? + elsif current_user&.project_bot? + group_access_token&.active? && has_required_abilities?(authentication_abilities) + else + current_user + end + else + current_user || valid_deploy_token? + end + end + + def has_required_abilities?(authentication_abilities) + (REQUIRED_ABILITIES & authentication_abilities).size == REQUIRED_ABILITIES.size + end + + def group_access_token + PersonalAccessTokensFinder.new(state: 'active').find_by_token(raw_token) end def valid_deploy_token? @@ -52,5 +72,9 @@ module Auth def deploy_token params[:deploy_token] end + + def raw_token + params[:raw_token] + end end end diff --git a/app/services/bulk_imports/batched_relation_export_service.rb b/app/services/bulk_imports/batched_relation_export_service.rb index c7c01c80fbf..e239a6daa4c 100644 --- a/app/services/bulk_imports/batched_relation_export_service.rb +++ b/app/services/bulk_imports/batched_relation_export_service.rb @@ -26,7 +26,7 @@ module BulkImports start_export! export.batches.destroy_all # rubocop: disable Cop/DestroyAll enqueue_batch_exports - ensure + FinishBatchedRelationExportWorker.perform_async(export.id) end @@ -65,19 +65,27 @@ module BulkImports ) end + # rubocop:disable Cop/InBatches + # rubocop:disable CodeReuse/ActiveRecord def enqueue_batch_exports - resolved_relation.each_batch(of: BATCH_SIZE) do |batch, batch_number| + batch_number = 0 + + resolved_relation.in_batches(of: BATCH_SIZE) do |batch| + batch_number += 1 + batch_id = find_or_create_batch(batch_number).id - ids = batch.pluck(batch.model.primary_key) # rubocop:disable CodeReuse/ActiveRecord + ids = batch.pluck(batch.model.primary_key) Gitlab::Cache::Import::Caching.set_add(self.class.cache_key(export.id, batch_id), ids, timeout: CACHE_DURATION) RelationBatchExportWorker.perform_async(user.id, batch_id) end end + # rubocop:enable Cop/InBatches def find_or_create_batch(batch_number) - export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord + export.batches.find_or_create_by!(batch_number: batch_number) end + # rubocop:enable CodeReuse/ActiveRecord end end diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index cc2d544198b..8fa438a76ce 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -16,6 +16,7 @@ module BulkImports ServiceError = Class.new(StandardError) DEFAULT_ALLOWED_CONTENT_TYPES = %w[application/gzip application/octet-stream].freeze + LAST_CHUNK_CONTEXT_CHAR_LIMIT = 200 def initialize( configuration:, @@ -47,7 +48,8 @@ module BulkImports private - attr_reader :configuration, :relative_url, :tmpdir, :file_size_limit, :allowed_content_types, :response_headers + attr_reader :configuration, :relative_url, :tmpdir, :file_size_limit, :allowed_content_types, + :response_headers, :last_chunk_context, :response_code def download_file File.open(filepath, 'wb') do |file| @@ -56,7 +58,16 @@ module BulkImports http_client.stream(relative_url) do |chunk| next if bytes_downloaded == 0 && [301, 302, 303, 307, 308].include?(chunk.code) + if BulkImports::NetworkError::RETRIABLE_HTTP_CODES.include?(chunk.code) + raise BulkImports::NetworkError.new( + "Error downloading file from #{relative_url}. Error code: #{chunk.code}", + response: chunk.http_response + ) + end + + @response_code = chunk.code @response_headers ||= Gitlab::HTTP::Response::Headers.new(chunk.http_response.to_hash) + @last_chunk_context = chunk.to_s.truncate(LAST_CHUNK_CONTEXT_CHAR_LIMIT) unless @remote_content_validated validate_content_type @@ -69,21 +80,24 @@ module BulkImports validate_size!(bytes_downloaded) - if chunk.code == 200 - file.write(chunk) - else - raise(ServiceError, "File download error #{chunk.code}") - end + raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200 + + file.write(chunk) end end rescue StandardError => e - File.delete(filepath) if File.exist?(filepath) + FileUtils.rm_f(filepath) raise e end def raise_error(message) - logger.warn(message: message, response_headers: response_headers) + logger.warn( + message: message, + response_code: response_code, + response_headers: response_headers, + last_chunk_context: last_chunk_context + ) raise ServiceError, message end diff --git a/app/services/bulk_imports/process_service.rb b/app/services/bulk_imports/process_service.rb index 7a6a883f1a9..1484a6fddd3 100644 --- a/app/services/bulk_imports/process_service.rb +++ b/app/services/bulk_imports/process_service.rb @@ -32,7 +32,9 @@ module BulkImports entity.start! - BulkImports::ExportRequestWorker.perform_async(entity.id) + Gitlab::ApplicationContext.with_context(bulk_import_entity_id: entity.id) do + BulkImports::ExportRequestWorker.perform_async(entity.id) + end end end @@ -104,16 +106,11 @@ module BulkImports end def log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) - logger.info( + logger.with_entity(entity).info( message: 'Pipeline skipped as source instance version not compatible with pipeline', - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, pipeline_class: pipeline[:pipeline], minimum_source_version: minimum_version, - maximum_source_version: maximum_version, - source_version: entity.source_version.to_s + maximum_source_version: maximum_version ) end diff --git a/app/services/ci/catalog/resources/destroy_service.rb b/app/services/ci/catalog/resources/destroy_service.rb new file mode 100644 index 00000000000..feea0302ca9 --- /dev/null +++ b/app/services/ci/catalog/resources/destroy_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class DestroyService + include Gitlab::Allowable + + attr_reader :project, :current_user + + def initialize(project, user) + @current_user = user + @project = project + end + + def execute(catalog_resource) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :add_catalog_resource, + project) + + catalog_resource.destroy! + + ServiceResponse.success(message: 'Catalog Resource destroyed') + end + end + end + end +end diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb index 4f09d47b530..f83c6e30cbb 100644 --- a/app/services/ci/components/fetch_service.rb +++ b/app/services/ci/components/fetch_service.rb @@ -24,7 +24,7 @@ module Ci component_path = component_path_class.new(address: address) result = component_path.fetch_content!(current_user: current_user) - if result + if result&.content ServiceResponse.success(payload: { content: result.content, path: result.path, diff --git a/app/services/ci/create_commit_status_service.rb b/app/services/ci/create_commit_status_service.rb index e5b446a07e2..de3e7b3f7ff 100644 --- a/app/services/ci/create_commit_status_service.rb +++ b/app/services/ci/create_commit_status_service.rb @@ -93,7 +93,8 @@ module Ci protected: project.protected_for?(ref), ci_stage: stage, stage_idx: stage.position, - stage: 'external' + stage: 'external', + partition_id: pipeline.partition_id ).tap do |new_commit_status| new_commit_status.assign_attributes(optional_commit_status_params) end diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index 8beecb79fd9..e059f8acda6 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -9,19 +9,26 @@ module Ci class GenerateCoverageReportsService < CompareReportsBaseService def execute(base_pipeline, head_pipeline) merge_request = MergeRequest.find_by_id(params[:id]) + code_coverage_artifact = head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage) + return error_response(base_pipeline, head_pipeline) unless code_coverage_artifact && merge_request + { status: :parsed, key: key(base_pipeline, head_pipeline), - data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage).present.for_files(merge_request.new_paths) + data: code_coverage_artifact.present.for_files(merge_request.new_paths) } rescue StandardError => e - Gitlab::ErrorTracking.track_exception( - e, - project_id: project.id, - base_pipeline_id: base_pipeline&.id, - head_pipeline_id: head_pipeline&.id - ) + track_exception(e, base_pipeline, head_pipeline) + error_response(base_pipeline, head_pipeline) + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + private + + def error_response(base_pipeline, head_pipeline) { status: :error, key: key(base_pipeline, head_pipeline), @@ -29,12 +36,15 @@ module Ci } end - def latest?(base_pipeline, head_pipeline, data) - data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + def track_exception(error, base_pipeline, head_pipeline) + Gitlab::ErrorTracking.track_exception( + error, + project_id: project.id, + base_pipeline_id: base_pipeline&.id, + head_pipeline_id: head_pipeline&.id + ) end - private - def key(base_pipeline, head_pipeline) [ base_pipeline&.id, last_update_timestamp(base_pipeline), diff --git a/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb b/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb index 738fa19e29b..57f65f6dea3 100644 --- a/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb +++ b/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb @@ -5,7 +5,7 @@ module Ci class BulkDeleteByProjectService include BaseServiceUtility - JOB_ARTIFACTS_COUNT_LIMIT = 50 + JOB_ARTIFACTS_COUNT_LIMIT = 100 def initialize(job_artifact_ids:, project:, current_user:) @job_artifact_ids = job_artifact_ids diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index c09b0cf81f1..0791fff8545 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -128,11 +128,9 @@ module Ci def accessibility(params) accessibility = params[:accessibility] - return :public if Feature.disabled?(:non_public_artifacts, type: :development) - return accessibility if accessibility.present? - job.artifacts_public? ? :public : :private + job.artifact_is_public_in_config? ? :public : :private end def parse_artifact(artifact) diff --git a/app/services/ci/process_sync_events_service.rb b/app/services/ci/process_sync_events_service.rb index d90ee02b1c6..d3c699597b6 100644 --- a/app/services/ci/process_sync_events_service.rb +++ b/app/services/ci/process_sync_events_service.rb @@ -13,7 +13,7 @@ module Ci end def execute - # preventing parallel processing over the same event table + # To prevent parallel processing over the same event table try_obtain_lease { process_events } enqueue_worker_if_there_still_event @@ -26,7 +26,7 @@ module Ci def process_events add_result(estimated_total_events: @sync_event_class.upper_bound_count) - events = @sync_event_class.preload_synced_relation.first(BATCH_SIZE) + events = @sync_event_class.unprocessed_events.preload_synced_relation.first(BATCH_SIZE) add_result(consumable_events: events.size) @@ -42,12 +42,12 @@ module Ci end ensure add_result(processed_events: processed_events.size) - @sync_event_class.id_in(processed_events).delete_all + @sync_event_class.mark_records_processed(processed_events) end end def enqueue_worker_if_there_still_event - @sync_event_class.enqueue_worker if @sync_event_class.exists? + @sync_event_class.enqueue_worker if @sync_event_class.unprocessed_events.exists? end def lease_key diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index 835d5f9a16c..bd76f6dbda8 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -168,6 +168,7 @@ module Ci def ensure_pending_state build_state = Ci::BuildPendingState.safe_find_or_create_by( build_id: build.id, + partition_id: build.partition_id, state: params.fetch(:state), trace_checksum: trace_checksum, trace_bytesize: trace_bytesize, diff --git a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb index 04d6e04c54d..7efa95739fb 100644 --- a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb +++ b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb @@ -59,7 +59,7 @@ module Clusters return unless project_entries - allowed_projects.where_full_path_in(project_entries.keys).map do |project| + allowed_projects.where_full_path_in(project_entries.keys, use_includes: false).map do |project| { project_id: project.id, config: user_access_as } end end @@ -70,7 +70,7 @@ module Clusters return unless group_entries - allowed_groups.where_full_path_in(group_entries.keys).map do |group| + allowed_groups.where_full_path_in(group_entries.keys, use_includes: false).map do |group| { group_id: group.id, config: user_access_as } end end diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index aff36d6943e..8ed87fdb048 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -3,6 +3,8 @@ module UpdateRepositoryStorageMethods include Gitlab::Utils::StrongMemoize + MAX_ERROR_LENGTH = 256 + Error = Class.new(StandardError) attr_reader :repository_storage_move @@ -44,6 +46,7 @@ module UpdateRepositoryStorageMethods ServiceResponse.success rescue StandardError => e + repository_storage_move.update_column(:error_message, e.message.truncate(MAX_ERROR_LENGTH)) repository_storage_move.do_fail! Gitlab::ErrorTracking.track_and_raise_exception(e, container_klass: container.class.to_s, container_path: container.full_path) diff --git a/app/services/container_registry/protection/create_rule_service.rb b/app/services/container_registry/protection/create_rule_service.rb index 34ec6f42b19..6aa9bd657f6 100644 --- a/app/services/container_registry/protection/create_rule_service.rb +++ b/app/services/container_registry/protection/create_rule_service.rb @@ -4,7 +4,7 @@ module ContainerRegistry module Protection class CreateRuleService < BaseService ALLOWED_ATTRIBUTES = %i[ - container_path_pattern + repository_path_pattern push_protected_up_to_access_level delete_protected_up_to_access_level ].freeze diff --git a/app/services/container_registry/protection/delete_rule_service.rb b/app/services/container_registry/protection/delete_rule_service.rb new file mode 100644 index 00000000000..bfd91c75b8b --- /dev/null +++ b/app/services/container_registry/protection/delete_rule_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + class DeleteRuleService + include Gitlab::Allowable + + def initialize(container_registry_protection_rule, current_user:) + if container_registry_protection_rule.blank? || current_user.blank? + raise ArgumentError, + 'container_registry_protection_rule and current_user must be set' + end + + @container_registry_protection_rule = container_registry_protection_rule + @current_user = current_user + end + + def execute + unless can?(current_user, :admin_container_image, container_registry_protection_rule.project) + error_message = _('Unauthorized to delete a container registry protection rule') + return service_response_error(message: error_message) + end + + deleted_container_registry_protection_rule = container_registry_protection_rule.destroy! + + ServiceResponse.success( + payload: { container_registry_protection_rule: deleted_container_registry_protection_rule } + ) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + attr_reader :container_registry_protection_rule, :current_user + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { container_registry_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/container_registry/protection/update_rule_service.rb b/app/services/container_registry/protection/update_rule_service.rb new file mode 100644 index 00000000000..af74e542ac7 --- /dev/null +++ b/app/services/container_registry/protection/update_rule_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + class UpdateRuleService + include Gitlab::Allowable + + ALLOWED_ATTRIBUTES = %i[ + repository_path_pattern + delete_protected_up_to_access_level + push_protected_up_to_access_level + ].freeze + + def initialize(container_registry_protection_rule, current_user:, params:) + if container_registry_protection_rule.blank? || current_user.blank? + raise ArgumentError, + 'container_registry_protection_rule and current_user must be set' + end + + @container_registry_protection_rule = container_registry_protection_rule + @current_user = current_user + @params = params || {} + end + + def execute + unless can?(current_user, :admin_container_image, container_registry_protection_rule.project) + error_message = _('Unauthorized to update a container registry protection rule') + return service_response_error(message: error_message) + end + + container_registry_protection_rule.update(params.slice(*ALLOWED_ATTRIBUTES)) + + if container_registry_protection_rule.errors.present? + return service_response_error(message: container_registry_protection_rule.errors.full_messages) + end + + ServiceResponse.success(payload: { container_registry_protection_rule: container_registry_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + attr_reader :container_registry_protection_rule, :current_user, :params + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { container_registry_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index d391c13696f..3d78ad80f0f 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -172,7 +172,7 @@ module DesignManagement def copy_designs! design_attributes = attributes_config[:design_attributes] - ::DesignManagement::Design.with_project_iid_supply(target_project) do |supply| + DesignManagement::Design.with_project_iid_supply(target_project) do |supply| new_rows = designs.each_with_index.map do |design, i| design.attributes.slice(*design_attributes).merge( issue_id: target_issue.id, diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb index 62db7824592..d1a792c5c88 100644 --- a/app/services/design_management/runs_design_actions.rb +++ b/app/services/design_management/runs_design_actions.rb @@ -22,7 +22,7 @@ module DesignManagement actions: actions.map(&:gitaly_action) ) - ::DesignManagement::Version + DesignManagement::Version .create_for_designs(actions, sha, current_user) .tap { |version| post_process(version, skip_system_notes) } end @@ -31,7 +31,7 @@ module DesignManagement def post_process(version, skip_system_notes) version.run_after_commit_or_now do - ::DesignManagement::NewVersionWorker.perform_async(id, skip_system_notes) + DesignManagement::NewVersionWorker.perform_async(id, skip_system_notes) end end end diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index 4c4e34862e8..f9f2f4bf290 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -35,7 +35,7 @@ module DesignManagement attr_reader :files def upload_designs! - ::DesignManagement::Version.with_lock(project.id, repository) do + DesignManagement::Version.with_lock(project.id, repository) do actions = build_actions [ diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index a2238264295..7b68b435f14 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -12,8 +12,8 @@ module Groups noteable_owner + participants_in_noteable + all_members + - groups + - group_hierarchy_users + group_hierarchy_users + + groups render_participants_as_hash(participants.uniq) end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 6b979308d26..79557dae14a 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -73,6 +73,7 @@ module Groups end end + remove_paid_features_for_projects(old_root_ancestor_id) post_update_hooks(@updated_project_ids, old_root_ancestor_id) propagate_integrations update_pending_builds @@ -179,6 +180,10 @@ module Groups @group.reload # rubocop:disable Cop/ActiveRecordAssociationReload end + # Overridden in EE + def remove_paid_features_for_projects(old_root_ancestor_id) + end + # rubocop: disable CodeReuse/ActiveRecord def update_children_and_projects_visibility descendants = @group.descendants.where("visibility_level > ?", @new_parent_group.visibility_level) diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 86c62145a87..a96bfd74cd0 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -16,7 +16,7 @@ module Import track_access_level('github') if project.persisted? - store_import_settings(project, access_params) + store_import_settings(project) success(project) elsif project.errors[:import_source_disabled].present? error(project.errors[:import_source_disabled], :forbidden) @@ -134,13 +134,12 @@ module Import error(translated_message, http_status) end - def store_import_settings(project, access_params) + def store_import_settings(project) Gitlab::GithubImport::Settings .new(project) .write( timeout_strategy: params[:timeout_strategy] || ProjectImportData::PESSIMISTIC_TIMEOUT, - optional_stages: params[:optional_stages], - additional_access_tokens: access_params[:additional_access_tokens] + optional_stages: params[:optional_stages] ) end end diff --git a/app/services/import/gitlab_projects/create_project_service.rb b/app/services/import/gitlab_projects/create_project_service.rb index 1613c4dde25..dc0f24df0cb 100644 --- a/app/services/import/gitlab_projects/create_project_service.rb +++ b/app/services/import/gitlab_projects/create_project_service.rb @@ -25,7 +25,7 @@ module Import # Creates a project with the strategy parameters # - # @return [Services::ServiceReponse] + # @return [Services::ServiceResponse] def execute return error(errors.full_messages) unless valid? return error(project.errors.full_messages) unless project.saved? diff --git a/app/services/import_csv/preprocess_milestones_service.rb b/app/services/import_csv/preprocess_milestones_service.rb index 97fb381c58e..295c76184d3 100644 --- a/app/services/import_csv/preprocess_milestones_service.rb +++ b/app/services/import_csv/preprocess_milestones_service.rb @@ -14,8 +14,9 @@ module ImportCsv attr_reader :user, :project, :provided_titles, :results, :milestone_errors def execute - available_milestones = find_milestones_by_titles - return ServiceResponse.success if provided_titles.sort == available_milestones.sort + result = find_milestones_by_titles + available_milestones = result.map(&:title).uniq.sort + return ServiceResponse.success(payload: result) if provided_titles.sort == available_milestones milestone_errors[:missing][:header] = 'Milestone' milestone_errors[:missing][:titles] = provided_titles.difference(available_milestones) || [] @@ -29,7 +30,7 @@ module ImportCsv title: provided_titles } finder_params[:group_ids] = project.group.self_and_ancestors.select(:id) if project.group - MilestonesFinder.new(finder_params).execute.map(&:title).uniq + MilestonesFinder.new(finder_params).execute end end end diff --git a/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb b/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb new file mode 100644 index 00000000000..82bf9a41ae7 --- /dev/null +++ b/app/services/integrations/google_cloud_platform/artifact_registry/list_docker_images_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Integrations + module GoogleCloudPlatform + module ArtifactRegistry + class ListDockerImagesService < BaseProjectService + def execute(page_token: nil) + return ServiceResponse.error(message: "Access denied") unless allowed? + + ServiceResponse.success(payload: client.list_docker_images(page_token: page_token)) + end + + private + + def allowed? + can?(current_user, :read_container_image, project) + end + + def client + ::Integrations::GoogleCloudPlatform::ArtifactRegistry::Client.new( + project: project, + user: current_user, + gcp_project_id: gcp_project_id, + gcp_location: gcp_location, + gcp_repository: gcp_repository, + gcp_wlif: gcp_wlif + ) + end + + def gcp_project_id + params[:gcp_project_id] + end + + def gcp_location + params[:gcp_location] + end + + def gcp_repository + params[:gcp_repository] + end + + def gcp_wlif + params[:gcp_wlif] + end + end + end + end +end diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 95338374ca6..c26fb016f79 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -12,7 +12,8 @@ module Issuable { title: row[:title], description: row[:description], - due_date: row[:due_date] + due_date: row[:due_date], + milestone_id: find_milestone_by_title(row[:milestone]) } end @@ -34,13 +35,20 @@ module Issuable # Pre-Process Milestone if header is present return unless csv_data.lines.first.downcase.include?('milestone') - provided_titles = with_csv_lines.filter_map { |row| row[:milestone]&.strip&.downcase }.uniq + provided_titles = with_csv_lines.filter_map { |row| row[:milestone]&.strip }.uniq result = ::ImportCsv::PreprocessMilestonesService.new(user, project, provided_titles).execute + @available_milestones = result.payload return if result.success? # collate errors here and throw errors results[:preprocess_errors][:milestone_errors] = result.payload end + + def find_milestone_by_title(title) + return unless title + + @available_milestones.find { |milestone| milestone.title == title.to_s.strip } if @available_milestones + end end end end diff --git a/app/services/issue_email_participants/create_service.rb b/app/services/issue_email_participants/create_service.rb new file mode 100644 index 00000000000..52c59b2b8fe --- /dev/null +++ b/app/services/issue_email_participants/create_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module IssueEmailParticipants + class CreateService < ::BaseProjectService + include Gitlab::Utils::StrongMemoize + + MAX_NUMBER_OF_EMAILS = 6 + MAX_NUMBER_OF_RECORDS = 10 + + attr_reader :target, :emails + + def initialize(target:, current_user:, emails:) + super(project: target.project, current_user: current_user) + + @target = target + @emails = emails + end + + def execute + return error_feature_flag unless Feature.enabled?(:issue_email_participants, target.project) + return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target) + return error_no_participants unless emails.present? + + added_emails = add_participants(deduplicate_and_limit_emails) + + if added_emails.any? + message = add_system_note(added_emails) + ServiceResponse.success(message: message.upcase_first << ".") + else + error_no_participants + end + end + + private + + def deduplicate_and_limit_emails + # Compare downcase versions, but use the original email + emails.index_by { |email| [email.downcase, email] }.excluding(*existing_emails).each_value + .first(MAX_NUMBER_OF_EMAILS) + end + + def add_participants(emails_to_add) + existing_emails_count = existing_emails.size + added_emails = [] + + emails_to_add.each do |email| + if existing_emails_count >= MAX_NUMBER_OF_RECORDS + log_above_limit_count(emails_to_add.size - added_emails.size) + + return added_emails + end + + new_participant = target.issue_email_participants.create(email: email) + if new_participant.persisted? + added_emails << email + existing_emails_count += 1 + end + end + + added_emails + end + + def add_system_note(added_emails) + message = format(_("added %{emails}"), emails: added_emails.to_sentence) + ::SystemNoteService.add_email_participants(target, project, current_user, message) + + message + end + + def existing_emails + target.email_participants_emails_downcase + end + strong_memoize_attr :existing_emails + + def log_above_limit_count(above_limit_count) + Gitlab::ApplicationContext.with_context(related_class: self.class.to_s, user: current_user, project: project) do + Gitlab::AppLogger.info({ above_limit_count: above_limit_count }) + end + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_feature_flag + # Don't translate feature flag error because it's temporary. + error("Feature flag issue_email_participants is not enabled for this project.") + end + + def error_underprivileged + error(_("You don't have permission to add email participants.")) + end + + def error_no_participants + error(_("No email participants were added. Either none were provided, or they already exist.")) + end + end +end diff --git a/app/services/jira_import/users_mapper_service.rb b/app/services/jira_import/users_mapper_service.rb index 13e0dd5120e..45a607dd2b0 100644 --- a/app/services/jira_import/users_mapper_service.rb +++ b/app/services/jira_import/users_mapper_service.rb @@ -63,6 +63,7 @@ module JiraImport relations << User.by_emails(jira_emails).select("users.id, users.name, users.username, emails.email as user_email") User.from_union(relations).id_in(project_member_ids).select("users.id as user_id, users.name as name, users.username as username, user_email") + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/432608") end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index f36cad7139a..f5c5dceb611 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -119,6 +119,10 @@ module MergeRequests Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter end + def deactivate_pages_deployments(merge_request) + Pages::DeactivateMrDeploymentsWorker.perform_async(merge_request) + end + private def self.constructor_container_arg(value) diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 62928e05a89..42f5a8fd7ba 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -22,6 +22,7 @@ module MergeRequests invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches cleanup_environments(merge_request) + deactivate_pages_deployments(merge_request) abort_auto_merge(merge_request, 'merge request was closed') cleanup_refs(merge_request) trigger_merge_request_merge_status_updated(merge_request) diff --git a/app/services/merge_requests/create_ref_service.rb b/app/services/merge_requests/create_ref_service.rb index eae6845335a..1e5e127072e 100644 --- a/app/services/merge_requests/create_ref_service.rb +++ b/app/services/merge_requests/create_ref_service.rb @@ -35,8 +35,6 @@ module MergeRequests result = maybe_rebase!(**result) result = maybe_merge!(**result) - update_merge_request!(merge_request, result) - ServiceResponse.success(payload: result) rescue CreateRefError => error ServiceResponse.error(message: error.message) @@ -118,10 +116,6 @@ module MergeRequests ).compact end - def update_merge_request!(merge_request, result) - # overridden in EE - end - def safe_gitaly_operation yield rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError, ArgumentError => error diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb index b8a275b6c32..8f8ba812246 100644 --- a/app/services/merge_requests/mergeability/check_base_service.rb +++ b/app/services/merge_requests/mergeability/check_base_service.rb @@ -4,15 +4,21 @@ module MergeRequests class CheckBaseService attr_reader :merge_request, :params + class_attribute :identifier, :description + + def self.identifier(new_identifier) + self.identifier = new_identifier + end + + def self.description(new_description) + self.description = new_description + end + def initialize(merge_request:, params:) @merge_request = merge_request @params = params end - def self.identifier - failure_reason - end - def skip? raise NotImplementedError end @@ -28,10 +34,6 @@ module MergeRequests private - def failure_reason - self.class.failure_reason - end - def success(**args) Gitlab::MergeRequests::Mergeability::CheckResult .success(payload: default_payload(args)) diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb index 25293c53bb5..d432375c423 100644 --- a/app/services/merge_requests/mergeability/check_broken_status_service.rb +++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb @@ -2,13 +2,12 @@ module MergeRequests module Mergeability class CheckBrokenStatusService < CheckBaseService - def self.failure_reason - :broken_status - end + identifier :broken_status + description 'Checks whether the merge request is broken' def execute if merge_request.broken? - failure(reason: failure_reason) + failure else success end diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb index b4e60e964b7..5c1aaaaf885 100644 --- a/app/services/merge_requests/mergeability/check_ci_status_service.rb +++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true + module MergeRequests module Mergeability class CheckCiStatusService < CheckBaseService - def self.failure_reason - :ci_must_pass - end + identifier :ci_must_pass + description 'Checks whether CI has passed' def execute return inactive unless merge_request.only_allow_merge_if_pipeline_succeeds? @@ -12,7 +12,7 @@ module MergeRequests if merge_request.mergeable_ci_state? success else - failure(reason: failure_reason) + failure end end diff --git a/app/services/merge_requests/mergeability/check_conflict_status_service.rb b/app/services/merge_requests/mergeability/check_conflict_status_service.rb index 2bc253322c9..b60bb0cecb5 100644 --- a/app/services/merge_requests/mergeability/check_conflict_status_service.rb +++ b/app/services/merge_requests/mergeability/check_conflict_status_service.rb @@ -3,15 +3,14 @@ module MergeRequests module Mergeability class CheckConflictStatusService < CheckBaseService - def self.failure_reason - :conflict - end + identifier :conflict + description 'Checks whether the merge request has a conflict' def execute if merge_request.can_be_merged? success else - failure(reason: failure_reason) + failure end end diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb index f9cff5d1e5f..baff557299d 100644 --- a/app/services/merge_requests/mergeability/check_discussions_status_service.rb +++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb @@ -2,9 +2,8 @@ module MergeRequests module Mergeability class CheckDiscussionsStatusService < CheckBaseService - def self.failure_reason - :discussions_not_resolved - end + identifier :discussions_not_resolved + description 'Checks whether the merge request has open discussions' def execute return inactive unless merge_request.only_allow_merge_if_all_discussions_are_resolved? @@ -12,7 +11,7 @@ module MergeRequests if merge_request.mergeable_discussions_state? success else - failure(reason: failure_reason) + failure end end diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb index 85b67fdc629..fc0254ebe3f 100644 --- a/app/services/merge_requests/mergeability/check_draft_status_service.rb +++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb @@ -3,13 +3,12 @@ module MergeRequests module Mergeability class CheckDraftStatusService < CheckBaseService - def self.failure_reason - :draft_status - end + identifier :draft_status + description 'Checks whether the merge request is draft' def execute if merge_request.draft? - failure(reason: failure_reason) + failure else success end diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb index f5b70f18394..b9191a53ea3 100644 --- a/app/services/merge_requests/mergeability/check_open_status_service.rb +++ b/app/services/merge_requests/mergeability/check_open_status_service.rb @@ -3,15 +3,14 @@ module MergeRequests module Mergeability class CheckOpenStatusService < CheckBaseService - def self.failure_reason - :not_open - end + identifier :not_open + description 'Checks whether the merge request is open' def execute if merge_request.open? success else - failure(reason: failure_reason) + failure end end diff --git a/app/services/merge_requests/mergeability/check_rebase_status_service.rb b/app/services/merge_requests/mergeability/check_rebase_status_service.rb index 02cd0587be0..e3f003d9299 100644 --- a/app/services/merge_requests/mergeability/check_rebase_status_service.rb +++ b/app/services/merge_requests/mergeability/check_rebase_status_service.rb @@ -3,15 +3,14 @@ module MergeRequests module Mergeability class CheckRebaseStatusService < CheckBaseService - def self.failure_reason - :need_rebase - end + identifier :need_rebase + description 'Checks whether the merge request needs to be rebased' def execute return inactive unless merge_request.project.ff_merge_must_be_possible? if merge_request.should_be_rebased? - failure(reason: failure_reason) + failure else success end diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb index 92f0fb0429c..2e28ffc4363 100644 --- a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb +++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb @@ -19,12 +19,12 @@ module MergeRequests # If everything else is mergeable, but CI is not, the frontend expects two potential states to be returned # See discussion: gitlab.com/gitlab-org/gitlab/-/merge_requests/96778#note_1093063523 if check_ci_results.failed? - ci_check_failure_reason + ci_check_failed_check else :mergeable end else - check_results.payload[:failure_reason] + check_results.payload[:failed_check] end end @@ -60,11 +60,11 @@ module MergeRequests end end - def ci_check_failure_reason - if merge_request.actual_head_pipeline&.running? + def ci_check_failed_check + if merge_request.actual_head_pipeline&.active? :ci_still_running else - check_ci_results.payload.fetch(:reason) + check_ci_results.payload.fetch(:identifier) end end end diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb index 92f3e5e951a..e941c11bd7c 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -32,7 +32,7 @@ module MergeRequests message: 'Checks failed.', payload: { results: results, - failure_reason: failure_reason + failed_check: failed_check } ) end @@ -68,8 +68,10 @@ module MergeRequests results.none?(&:failed?) end - def failure_reason - results.find(&:failed?)&.payload&.fetch(:reason)&.to_sym + def failed_check + # NOTE: the identifier could be string when we retrieve it from the cache + # so let's make sure we always return symbols here. + results.find(&:failed?)&.payload&.fetch(:identifier)&.to_sym end end end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index e32895a3cb6..d2bfadc2205 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -32,6 +32,7 @@ module MergeRequests cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) cleanup_refs(merge_request) + deactivate_pages_deployments(merge_request) execute_hooks(merge_request, 'merge') end diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index f39cc1a8534..d27328f89cd 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -4,11 +4,7 @@ require 'prometheus/client/formats/text' class MetricsService def prometheus_metrics_text - if Feature.enabled?(:prom_metrics_rust) - ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path, use_rust: true) - else - ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path) - end + ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path) end def metrics_text diff --git a/app/services/ml/create_model_service.rb b/app/services/ml/create_model_service.rb index 5c179d8edf7..b87b13dd379 100644 --- a/app/services/ml/create_model_service.rb +++ b/app/services/ml/create_model_service.rb @@ -22,6 +22,12 @@ module Ml add_metadata(model, @metadata) + Gitlab::InternalEvents.track_event( + 'model_registry_ml_model_created', + project: @project, + user: @user + ) + model end end diff --git a/app/services/ml/create_model_version_service.rb b/app/services/ml/create_model_version_service.rb new file mode 100644 index 00000000000..3b8c096b5b4 --- /dev/null +++ b/app/services/ml/create_model_version_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Ml + class CreateModelVersionService + def initialize(model, params = {}) + @model = model + @version = params[:version] + @package = params[:package] + @description = params[:description] + @user = params[:user] + end + + def execute + ApplicationRecord.transaction do + @version ||= Ml::IncrementVersionService.new(@model.latest_version.try(:version)).execute + + package = @package || find_or_create_package(@model.name, @version) + + model_version = Ml::ModelVersion.create!(model: @model, project: @model.project, version: @version, + package: package, description: @description) + + model_version.candidate = ::Ml::CreateCandidateService.new( + @model.default_experiment, + { model_version: model_version } + ).execute + + Gitlab::InternalEvents.track_event( + 'model_registry_ml_model_version_created', + project: @model.project, + user: @user + ) + + model_version + end + end + + private + + def find_or_create_package(model_name, model_version) + package_params = { + name: model_name, + version: model_version + } + + ::Packages::MlModel::FindOrCreatePackageService + .new(@model.project, @user, package_params) + .execute + end + end +end diff --git a/app/services/ml/destroy_model_service.rb b/app/services/ml/destroy_model_service.rb new file mode 100644 index 00000000000..308d289fbe1 --- /dev/null +++ b/app/services/ml/destroy_model_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ml + class DestroyModelService + def initialize(model, user) + @model = model + @user = user + end + + def execute + return unless @model.destroy + + ::Packages::MarkPackagesForDestructionService.new( + packages: @model.all_packages, + current_user: @user + ).execute + end + end +end diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb index a5e9bf997cc..61782166726 100644 --- a/app/services/ml/find_or_create_model_version_service.rb +++ b/app/services/ml/find_or_create_model_version_service.rb @@ -8,19 +8,20 @@ module Ml @version = params[:version] @package = params[:package] @description = params[:description] + @user = params[:user] + @params = params end def execute - model = Ml::FindOrCreateModelService.new(@project, @name).execute + model_version = Ml::ModelVersion.by_project_id_name_and_version(@project.id, @name, @version) - model_version = Ml::ModelVersion.find_or_create!(model, @version, @package, @description) + return model_version if model_version - model_version.candidate = ::Ml::CreateCandidateService.new( - model.default_experiment, - { model_version: model_version } - ).execute + model = Ml::Model.by_project_id_and_name(@project.id, @name) - model_version + return unless model + + Ml::CreateModelVersionService.new(model, @params).execute end end end diff --git a/app/services/ml/increment_version_service.rb b/app/services/ml/increment_version_service.rb new file mode 100644 index 00000000000..ef3cbf5269b --- /dev/null +++ b/app/services/ml/increment_version_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ml + INITIAL_VERSION = '1.0.0' + ALLOWED_INCREMENT_TYPES = [:patch, :minor, :major].freeze + + class IncrementVersionService + def initialize(version, increment_type = nil) + @version = version + @increment_type = increment_type || :major + @parsed_version = Packages::SemVer.parse(@version.to_s) + + raise "Version must be in a valid SemVer format" unless @parsed_version || @version.nil? + + return if ALLOWED_INCREMENT_TYPES.include?(@increment_type) + + raise "Increment type must be one of :patch, :minor, or :major" + end + + def execute + return INITIAL_VERSION if @version.nil? + + case @increment_type + when :patch + @parsed_version.patch += 1 + when :minor + @parsed_version.minor += 1 + when :major + @parsed_version.major += 1 + end + + @parsed_version.to_s + end + end +end diff --git a/app/services/ml/model_versions/delete_service.rb b/app/services/ml/model_versions/delete_service.rb new file mode 100644 index 00000000000..4eb8d367a19 --- /dev/null +++ b/app/services/ml/model_versions/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ml + module ModelVersions + class DeleteService + def initialize(project, name, version, user) + @project = project + @name = name + @version = version + @user = user + end + + def execute + model_version = Ml::ModelVersion + .by_project_id_name_and_version(@project.id, @name, @version) + return ServiceResponse.error(message: 'Model not found') unless model_version + + if model_version.package.present? + result = ::Packages::MarkPackageForDestructionService + .new(container: model_version.package, current_user: @user) + .execute + + return ServiceResponse.error(message: result.message) unless result.success? + end + + return ServiceResponse.error(message: 'Could not destroy the model version') unless model_version.destroy + + ServiceResponse.success + end + end + end +end diff --git a/app/services/ml/model_versions/update_model_version_service.rb b/app/services/ml/model_versions/update_model_version_service.rb new file mode 100644 index 00000000000..a0de87792f8 --- /dev/null +++ b/app/services/ml/model_versions/update_model_version_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ml + module ModelVersions + class UpdateModelVersionService + def initialize(project, name, version, description) + @project = project + @name = name + @version = version + @description = description + end + + def execute + model_version = Ml::ModelVersion + .by_project_id_name_and_version(@project.id, @name, @version) + + return ServiceResponse.error(message: 'Model not found') unless model_version.present? + + result = model_version.update(description: @description) + + return ServiceResponse.error(message: 'Model update failed') unless result + + ServiceResponse.success(payload: model_version) + end + end + end +end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb deleted file mode 100644 index 14e670126c6..00000000000 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Namespaces - class InProductMarketingEmailsService - TRACKS = { - create: { - interval_days: [1, 5, 10], - completed_actions: [:created], - incomplete_actions: [:git_write] - }, - team_short: { - interval_days: [1], - completed_actions: [:git_write], - incomplete_actions: [:user_added] - }, - trial_short: { - interval_days: [2], - completed_actions: [:git_write], - incomplete_actions: [:trial_started] - }, - admin_verify: { - interval_days: [3], - completed_actions: [:git_write], - incomplete_actions: [:pipeline_created] - }, - verify: { - interval_days: [4, 8, 13], - completed_actions: [:git_write], - incomplete_actions: [:pipeline_created] - }, - trial: { - interval_days: [1, 5, 10], - completed_actions: [:git_write, :pipeline_created], - incomplete_actions: [:trial_started] - }, - team: { - interval_days: [1, 5, 10], - completed_actions: [:git_write, :pipeline_created, :trial_started], - incomplete_actions: [:user_added] - } - }.freeze - - def self.email_count_for_track(track) - interval_days = TRACKS.dig(track.to_sym, :interval_days) - interval_days&.count || 0 - end - end -end - -Namespaces::InProductMarketingEmailsService.prepend_mod diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb index cd5745cfec6..d7ab6828346 100644 --- a/app/services/namespaces/package_settings/update_service.rb +++ b/app/services/namespaces/package_settings/update_service.rb @@ -16,7 +16,8 @@ module Namespaces pypi_package_requests_forwarding lock_maven_package_requests_forwarding lock_npm_package_requests_forwarding - lock_pypi_package_requests_forwarding].freeze + lock_pypi_package_requests_forwarding + nuget_symbol_server_enabled].freeze def execute return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed? diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index a63b1cf375f..0ccb25c2335 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -212,7 +212,7 @@ module Notes end def should_track_ipynb_notes?(note) - Feature.enabled?(:ipynbdiff_notes_tracker) && note.respond_to?(:diff_file) && note.diff_file&.ipynb? + note.respond_to?(:diff_file) && note.diff_file&.ipynb? end def track_note_creation_in_ipynb(note) diff --git a/app/services/organizations/base_service.rb b/app/services/organizations/base_service.rb index 19bbc64ebdd..338416b1121 100644 --- a/app/services/organizations/base_service.rb +++ b/app/services/organizations/base_service.rb @@ -4,11 +4,24 @@ module Organizations class BaseService include BaseServiceUtility - attr_reader :current_user, :params - def initialize(current_user: nil, params: {}) @current_user = current_user @params = params.dup + + build_organization_detail_attributes + end + + private + + attr_reader :current_user, :params + + def build_organization_detail_attributes + @params[:organization_detail_attributes] ||= {} + + organization_detail_attributes = [:description, :avatar] + organization_detail_attributes.each do |attribute| + @params[:organization_detail_attributes][attribute] = @params.delete(attribute) if @params.key?(attribute) + end end end end diff --git a/app/services/organizations/create_service.rb b/app/services/organizations/create_service.rb index 89c579032d2..ab70799a095 100644 --- a/app/services/organizations/create_service.rb +++ b/app/services/organizations/create_service.rb @@ -9,7 +9,7 @@ module Organizations return error_creating(organization) unless organization.persisted? - ServiceResponse.success(payload: organization) + ServiceResponse.success(payload: { organization: organization }) end private diff --git a/app/services/organizations/update_service.rb b/app/services/organizations/update_service.rb new file mode 100644 index 00000000000..bc3a2d29abf --- /dev/null +++ b/app/services/organizations/update_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Organizations + class UpdateService < ::Organizations::BaseService + attr_reader :organization + + def initialize(organization, current_user:, params: {}) + @organization = organization + @current_user = current_user + @params = params.dup + + build_organization_detail_attributes + # TODO: Remove explicit passing of id once https://github.com/rails/rails/issues/48714 is resolved. + @params[:organization_detail_attributes][:id] = organization.id + end + + def execute + return error_no_permissions unless allowed? + + if organization.update(params) + ServiceResponse.success(payload: { organization: organization }) + else + error_updating + end + end + + private + + def allowed? + current_user&.can?(:admin_organization, organization) + end + + def error_no_permissions + ServiceResponse.error(message: [_('You have insufficient permissions to update the organization')]) + end + + def error_updating + message = organization.errors.full_messages || _('Failed to update organization') + + ServiceResponse.error(payload: { organization: organization }, message: Array(message)) + end + end +end diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb index cc9defd2e73..91a4a0d67c8 100644 --- a/app/services/packages/debian/extract_metadata_service.rb +++ b/app/services/packages/debian/extract_metadata_service.rb @@ -26,9 +26,7 @@ module Packages attr_reader :package_file def valid_package_file? - package_file && - package_file.package&.debian? && - package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + package_file && package_file.package&.debian? && !package_file.file.empty_size? end def file_type_basic diff --git a/app/services/packages/helm/extract_file_metadata_service.rb b/app/services/packages/helm/extract_file_metadata_service.rb index 77efa65f1d1..5cd1acc57dd 100644 --- a/app/services/packages/helm/extract_file_metadata_service.rb +++ b/app/services/packages/helm/extract_file_metadata_service.rb @@ -24,9 +24,7 @@ module Packages private def valid_package_file? - @package_file && - @package_file.package&.helm? && - @package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + @package_file && @package_file.package&.helm? && !@package_file.file.empty_size? end def metadata diff --git a/app/services/packages/mark_package_for_destruction_service.rb b/app/services/packages/mark_package_for_destruction_service.rb index 8ccc242ae36..b41f1c0a291 100644 --- a/app/services/packages/mark_package_for_destruction_service.rb +++ b/app/services/packages/mark_package_for_destruction_service.rb @@ -11,6 +11,7 @@ module Packages package.mark_package_files_for_destruction package.sync_maven_metadata(current_user) + package.sync_npm_metadata_cache service_response_success('Package was successfully marked as pending destruction') rescue StandardError => e diff --git a/app/services/packages/mark_packages_for_destruction_service.rb b/app/services/packages/mark_packages_for_destruction_service.rb index ade9ad2c974..2c81a52ea24 100644 --- a/app/services/packages/mark_packages_for_destruction_service.rb +++ b/app/services/packages/mark_packages_for_destruction_service.rb @@ -43,6 +43,7 @@ module Packages .update_all(status: :pending_destruction) sync_maven_metadata(loaded_packages) + sync_npm_metadata(loaded_packages) mark_package_files_for_destruction(loaded_packages) end @@ -73,6 +74,15 @@ module Packages ) end + def sync_npm_metadata(packages) + npm_packages = packages.select(&:npm?) + ::Packages::Npm::CreateMetadataCacheWorker.bulk_perform_async_with_contexts( + npm_packages, + arguments_proc: -> (package) { [package.project_id, package.name] }, + context_proc: -> (package) { { project: package.project, user: @current_user } } + ) + end + def can_destroy_packages?(packages) packages.all? do |package| can?(@current_user, :destroy_package, package) diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb index ff569a8eecf..ee2f3077e4c 100644 --- a/app/services/packages/ml_model/create_package_file_service.rb +++ b/app/services/packages/ml_model/create_package_file_service.rb @@ -4,47 +4,27 @@ module Packages module MlModel class CreatePackageFileService < BaseService def execute - ::Packages::Package.transaction do - package = find_or_create_package - find_or_create_model_version(package) + @package = params[:model_version]&.package + + return unless @package - create_package_file(package) + ::Packages::Package.transaction do + update_package + create_package_file end end private - def find_or_create_package - package_params = { - name: params[:package_name], - version: params[:package_version], - build: params[:build], - status: params[:status] - } - - package = ::Packages::MlModel::FindOrCreatePackageService - .new(project, current_user, package_params) - .execute + attr_reader :package + def update_package package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status package.create_build_infos!(params[:build]) - - package - end - - def find_or_create_model_version(package) - model_version_params = { - model_name: package.name, - version: package.version, - package: package, - user: current_user - } - - Ml::FindOrCreateModelVersionService.new(project, model_version_params).execute end - def create_package_file(package) + def create_package_file file_params = { file: params[:file], size: params[:file].size, diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb index 8eaac547f7e..240c657039f 100644 --- a/app/services/packages/npm/generate_metadata_service.rb +++ b/app/services/packages/npm/generate_metadata_service.rb @@ -105,7 +105,7 @@ module Packages end def package_tags - Packages::Tag.for_package_ids(packages) + Packages::Tag.for_package_ids_with_distinct_names(packages) .preload_package end diff --git a/app/services/packages/nuget/process_package_file_service.rb b/app/services/packages/nuget/process_package_file_service.rb index 99b59bd3322..acec926dc48 100644 --- a/app/services/packages/nuget/process_package_file_service.rb +++ b/app/services/packages/nuget/process_package_file_service.rb @@ -22,9 +22,7 @@ module Packages attr_reader :package_file def valid_package_file? - package_file && - package_file.package&.nuget? && - package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + package_file && package_file.package&.nuget? && !package_file.file.empty_size? end def with_zip_file(&block) diff --git a/app/services/packages/protection/update_rule_service.rb b/app/services/packages/protection/update_rule_service.rb new file mode 100644 index 00000000000..0dc7eb6a7b9 --- /dev/null +++ b/app/services/packages/protection/update_rule_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Packages + module Protection + class UpdateRuleService + include Gitlab::Allowable + + ALLOWED_ATTRIBUTES = %i[ + package_name_pattern + package_type + push_protected_up_to_access_level + ].freeze + + def initialize(package_protection_rule, current_user:, params:) + if package_protection_rule.blank? || current_user.blank? + raise ArgumentError, + 'package_protection_rule and current_user must be set' + end + + @package_protection_rule = package_protection_rule + @current_user = current_user + @params = params || {} + end + + def execute + unless can?(current_user, :admin_package, package_protection_rule.project) + error_message = _('Unauthorized to update a package protection rule') + return service_response_error(message: error_message) + end + + package_protection_rule.update(params.slice(*ALLOWED_ATTRIBUTES)) + + if package_protection_rule.errors.present? + return service_response_error(message: package_protection_rule.errors.full_messages) + end + + ServiceResponse.success(payload: { package_protection_rule: package_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + attr_reader :package_protection_rule, :current_user, :params + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { package_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index 1733021cbb5..c11b019cee5 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -10,6 +10,9 @@ module PagesDomains # no particular SLA, usually takes 10-15 seconds CERTIFICATE_PROCESSING_DELAY = 1.minute.freeze + # Maximum domain length for Let's Encrypt + MAX_DOMAIN_LENGTH = 64 + attr_reader :pages_domain def initialize(pages_domain) @@ -17,6 +20,11 @@ module PagesDomains end def execute + if pages_domain.domain.bytesize > MAX_DOMAIN_LENGTH + log_domain_length_error + return + end + pages_domain.acme_orders.expired.delete_all acme_order = pages_domain.acme_orders.first @@ -59,6 +67,16 @@ module PagesDomains NotificationService.new.pages_domain_auto_ssl_failed(pages_domain) end + def log_domain_length_error + Gitlab::AppLogger.error( + message: "Domain name too long for Let's Encrypt certificate", + pages_domain: pages_domain.domain, + pages_domain_bytesize: pages_domain.domain.bytesize, + max_allowed_bytesize: MAX_DOMAIN_LENGTH, + project_id: pages_domain.project_id + ) + end + def log_error(api_order) Gitlab::AppLogger.error( message: "Failed to obtain Let's Encrypt certificate", diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index 31ba88af46c..095cfadf02c 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -41,7 +41,11 @@ module PersonalAccessTokens end def pat_expiration - params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now + params[:expires_at].presence || max_expiry_date + end + + def max_expiry_date + PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now end def creation_permitted? diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index 13144a04c11..e381d86fbed 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -21,6 +21,7 @@ module PersonalAccessTokens end response = create_access_token(params) + raise ActiveRecord::Rollback unless response.success? end @@ -31,15 +32,6 @@ module PersonalAccessTokens attr_reader :current_user, :token - def create_token_params(token, params) - expires_at = params[:expires_at] || (Date.today + EXPIRATION_PERIOD) - { name: token.name, - previous_personal_access_token_id: token.id, - impersonation: token.impersonation, - scopes: token.scopes, - expires_at: expires_at } - end - def create_access_token(params) target_user = token.user @@ -63,5 +55,15 @@ module PersonalAccessTokens def error_response(message) ServiceResponse.error(message: message) end + + def create_token_params(token, params) + { name: token.name, + previous_personal_access_token_id: token.id, + impersonation: token.impersonation, + scopes: token.scopes, + expires_at: expires_at(params) } + end end end + +PersonalAccessTokens::RotateService.prepend_mod diff --git a/app/services/product_analytics/build_activity_graph_service.rb b/app/services/product_analytics/build_activity_graph_service.rb deleted file mode 100644 index 63108d76afd..00000000000 --- a/app/services/product_analytics/build_activity_graph_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ProductAnalytics - class BuildActivityGraphService < BuildGraphService - def execute - timerange = @params[:timerange].days - - results = product_analytics_events.count_collector_tstamp_by_day(timerange) - - format_results('collector_tstamp', results.transform_keys(&:to_date)) - end - end -end diff --git a/app/services/product_analytics/build_graph_service.rb b/app/services/product_analytics/build_graph_service.rb deleted file mode 100644 index da54ad4de0e..00000000000 --- a/app/services/product_analytics/build_graph_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module ProductAnalytics - class BuildGraphService - def initialize(project, params) - @project = project - @params = params - end - - def execute - graph = @params[:graph].to_sym - timerange = @params[:timerange].days - - results = product_analytics_events.count_by_graph(graph, timerange) - - format_results(graph, results) - end - - private - - def format_results(name, results) - { - id: name, - keys: results.keys, - values: results.values - } - end - - def product_analytics_events - @project.product_analytics_events - end - end -end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index 93d68eec3bc..5cd30689faf 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -38,7 +38,7 @@ module Projects end def execute - first_ensure_no_registry_tags_are_present + rename_base_repository_in_registry! expire_caches_before_rename rename_or_migrate_repository! send_move_instructions @@ -49,11 +49,24 @@ module Projects publish_event end - def first_ensure_no_registry_tags_are_present + def rename_base_repository_in_registry! return unless project.has_container_registry_tags? - raise RenameFailedError, "Project #{full_path_before} cannot be renamed because images are " \ - "present in its container registry" + ensure_registry_tags_can_be_handled + + result = ContainerRegistry::GitlabApiClient.rename_base_repository_path( + full_path_before, name: project_path) + + return if result == :ok + + rename_failed!("Renaming the base repository in the registry failed with error #{result}.") + end + + def ensure_registry_tags_can_be_handled + return if ContainerRegistry::GitlabApiClient.supports_gitlab_api? + + rename_failed!("Project #{full_path_before} cannot be renamed because images are " \ + "present in its container registry") end def expire_caches_before_rename @@ -66,7 +79,9 @@ module Projects .new(project, full_path_before) .execute - rename_failed! unless success + return if success + + rename_failed!("Repository #{full_path_before} could not be renamed to #{full_path_after}") end def send_move_instructions @@ -117,9 +132,7 @@ module Projects project.namespace.full_path end - def rename_failed! - error = "Repository #{full_path_before} could not be renamed to #{full_path_after}" - + def rename_failed!(error) log_error(error) raise RenameFailedError, error diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e0ee3683ac8..6f29c72e25a 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -17,6 +17,7 @@ module Projects @relations_block = @params.delete(:relations_block) @default_branch = @params.delete(:default_branch) @readme_template = @params.delete(:readme_template) + @repository_object_format = @params.delete(:repository_object_format) build_topics end @@ -212,6 +213,13 @@ module Projects ::Security::CiConfiguration::SastCreateService.new(@project, current_user, { initialize_with_sast: true }, commit_on_default: true).execute end + def repository_object_format + return Repository::FORMAT_SHA1 unless Feature.enabled?(:support_sha256_repositories, current_user) + return Repository::FORMAT_SHA256 if @repository_object_format == Repository::FORMAT_SHA256 + + Repository::FORMAT_SHA1 + end + def readme_content readme_attrs = { default_branch: default_branch @@ -242,7 +250,7 @@ module Projects next if @project.import? - unless @project.create_repository(default_branch: default_branch) + unless @project.create_repository(default_branch: default_branch, object_format: repository_object_format) raise 'Failed to create repository' end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 8c86646ba5c..7ba5b6119b9 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -34,7 +34,7 @@ module Projects ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted) - Projects::UnlinkForkService.new(project, current_user).execute + Projects::UnlinkForkService.new(project, current_user).execute(refresh_statistics: false) attempt_destroy(project) diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index c9642fb495a..cc7478540d2 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -11,12 +11,30 @@ module Projects super(project, user, params) end + def execute + if adding_a_group_as_owner? && cannot_assign_owner_responsibilities_to_member_in_project? + error('403 Forbidden', 403) + else + super + end + end + private delegate :root_ancestor, to: :project + def adding_a_group_as_owner? + params[:link_group_access].to_i == Gitlab::Access::OWNER + end + + def cannot_assign_owner_responsibilities_to_member_in_project? + !current_user.can?(:manage_owners, project) + end + def valid_to_create? - can?(current_user, :read_namespace_via_membership, shared_with_group) && sharing_allowed? + can?(current_user, :admin_project, project) && + can?(current_user, :read_namespace_via_membership, shared_with_group) && + sharing_allowed? end def build_link diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb index e0218ae087e..f0ac28c9216 100644 --- a/app/services/projects/group_links/destroy_service.rb +++ b/app/services/projects/group_links/destroy_service.rb @@ -4,8 +4,14 @@ module Projects module GroupLinks class DestroyService < BaseService def execute(group_link, skip_authorization: false) - unless valid_to_destroy?(group_link, skip_authorization) - return ServiceResponse.error(message: 'Not found', reason: :not_found) + return not_found! unless group_link + + unless skip_authorization + return not_found! unless allowed_to_manage_destroy?(group_link) + + unless allowed_to_destroy_link?(group_link) + return ServiceResponse.error(message: 'Forbidden', reason: :forbidden) + end end if group_link.project.private? @@ -30,11 +36,16 @@ module Projects private - def valid_to_destroy?(group_link, skip_authorization) - return false unless group_link - return true if skip_authorization + def not_found! + ServiceResponse.error(message: 'Not found', reason: :not_found) + end + + def allowed_to_manage_destroy?(group_link) + current_user.can?(:manage_destroy, group_link) + end - current_user.can?(:admin_project_group_link, group_link) + def allowed_to_destroy_link?(group_link) + current_user.can?(:destroy_project_group_link, group_link) end def refresh_project_authorizations_asynchronously(project) diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb index 04f1552d929..1d657f2396d 100644 --- a/app/services/projects/group_links/update_service.rb +++ b/app/services/projects/group_links/update_service.rb @@ -10,7 +10,13 @@ module Projects end def execute(group_link_params) - return ServiceResponse.error(message: 'Not found', reason: :not_found) unless allowed_to_update? + if group_link.blank? || !allowed_to_update? + return ServiceResponse.error(message: 'Not found', reason: :not_found) + end + + unless allowed_to_update_to_or_from_owner?(group_link_params) + return ServiceResponse.error(message: 'Forbidden', reason: :forbidden) + end group_link.update!(group_link_params) @@ -24,7 +30,13 @@ module Projects attr_reader :group_link def allowed_to_update? - current_user.can?(:admin_project_member, project) + current_user.can?(:admin_project_member, group_link.project) + end + + def allowed_to_update_to_or_from_owner?(params) + return current_user.can?(:manage_owners, group_link) if upgrading_to_owner?(params) || touching_an_owner? + + true end def refresh_authorizations @@ -41,6 +53,14 @@ module Projects def requires_authorization_refresh?(params) params.include?(:group_access) end + + def upgrading_to_owner?(params) + params[:group_access].to_i == Gitlab::Access::OWNER + end + + def touching_an_owner? + group_link.owner_access? + end end end end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index fde56d8429e..e8a684e5da4 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -171,6 +171,8 @@ module Projects project.import_url, schemes: Project::VALID_IMPORT_PROTOCOLS, ports: Project::VALID_IMPORT_PORTS, + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, dns_rebind_protection: dns_rebind_protection?) .then do |(import_url, resolved_host)| next '' if resolved_host.nil? || !import_url.scheme.in?(%w[http https]) @@ -179,6 +181,11 @@ module Projects end end + def allow_local_requests? + Rails.env.development? && # There is no known usecase for this in non-development environments + Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + def dns_rebind_protection? return false if Gitlab.http_proxy_env? diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index f8f03d481af..852f5e0222e 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -6,7 +6,7 @@ module Projects class LfsLinkService < BaseService TooManyOidsError = Class.new(StandardError) - MAX_OIDS = 100_000 + MAX_OIDS = ENV.fetch('GITLAB_LFS_MAX_OID_TO_FETCH', 100_000).to_i BATCH_SIZE = 1000 # Accept an array of oids to link diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 30d9e1922cc..49648216808 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -131,8 +131,6 @@ module Projects update_integrations - remove_paid_features - project.old_path_with_namespace = @old_path update_repository_configuration(@new_path) @@ -143,6 +141,7 @@ module Projects end end + remove_paid_features update_pending_builds post_update_hooks(project, @old_group) diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 898421364db..cdd1870858e 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -3,7 +3,7 @@ module Projects class UnlinkForkService < BaseService # Close existing MRs coming from the project and remove it from the fork network - def execute + def execute(refresh_statistics: true) fork_network = @project.fork_network forked_from = @project.forked_from_project @@ -46,6 +46,10 @@ module Projects end # rubocop: enable Cop/InBatches + if Feature.enabled?(:refresh_statistics_on_unlink_fork, @project.namespace) && refresh_statistics + ProjectCacheWorker.perform_async(project.id, [], [:repository_size]) + end + # When the project getting out of the network is a node with parent # and children, both the parent and the node needs a cache refresh. [forked_from, @project].compact.each do |project| diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index a9f6afb26c9..9c65b261274 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -9,7 +9,7 @@ module Projects private def track_repository(destination_storage_name) - project.update!(repository_storage: destination_storage_name) + project.update_column(:repository_storage, destination_storage_name) # Connect project to pool repository from the new shard project.swap_pool_repository! @@ -18,7 +18,7 @@ module Projects project.track_project_repository # Link repository from the new shard to pool repository from the new shard - project.link_pool_repository if replicate_object_pool_on_move_ff_enabled? + project.link_pool_repository end def mirror_repositories @@ -36,7 +36,6 @@ module Projects end def mirror_object_pool(destination_storage_name) - return unless replicate_object_pool_on_move_ff_enabled? return unless project.repository_exists? pool_repository = project.pool_repository @@ -92,9 +91,5 @@ module Projects state: 'ready' ) end - - def replicate_object_pool_on_move_ff_enabled? - Feature.enabled?(:replicate_object_pool_on_move, project) - end end end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 336e887c241..1366370527d 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -61,11 +61,8 @@ module Projects raise_validation_error(s_('UpdateProject|New visibility level not allowed!')) end - if renaming_project_with_container_registry_tags? - raise_validation_error(s_('UpdateProject|Cannot rename project because it contains container registry tags!')) - end - validate_default_branch_change + validate_renaming_project_with_tags end def validate_default_branch_change @@ -92,6 +89,27 @@ module Projects end end + def validate_renaming_project_with_tags + return unless renaming_project_with_container_registry_tags? + + unless ContainerRegistry::GitlabApiClient.supports_gitlab_api? + raise ValidationError, s_('UpdateProject|Cannot rename project because it contains container registry tags!') + end + + dry_run = ContainerRegistry::GitlabApiClient.rename_base_repository_path( + project.full_path, name: params[:path], dry_run: true) + + return if dry_run == :accepted + + log_error("Dry run failed for renaming project with tags: #{project.full_path}, error: #{dry_run}") + raise_validation_error( + format( + s_("UpdateProject|Cannot rename project, the container registry path rename validation failed: %{error}"), + error: dry_run.to_s.titleize + ) + ) + end + def ambiguous_head_documentation_link url = Rails.application.routes.url_helpers.help_page_path('user/project/repository/branches/index', anchor: 'error-ambiguous-head-branch-exists') diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb index 0ab46bf236c..62d5e04b499 100644 --- a/app/services/protected_branches/base_service.rb +++ b/app/services/protected_branches/base_service.rb @@ -18,9 +18,20 @@ module ProtectedBranches def refresh_cache CacheService.new(@project_or_group, @current_user, @params).refresh + refresh_cache_for_groups_projects rescue StandardError => e Gitlab::ErrorTracking.track_exception(e) end + + private + + def refresh_cache_for_groups_projects + return unless @project_or_group.is_a?(Group) + + @project_or_group.all_projects.find_each do |project| + CacheService.new(project, @current_user, @params).refresh + end + end end end diff --git a/app/services/service_desk/custom_email_verifications/base_service.rb b/app/services/service_desk/custom_email_verifications/base_service.rb index e92700022f1..0449485b197 100644 --- a/app/services/service_desk/custom_email_verifications/base_service.rb +++ b/app/services/service_desk/custom_email_verifications/base_service.rb @@ -32,10 +32,6 @@ module ServiceDesk ) end - def error_feature_flag_disabled - error_response('Feature flag service_desk_custom_email is not enabled') - end - def error_response(message) log_warning(error_message: message) ServiceResponse.error(message: message) diff --git a/app/services/service_desk/custom_email_verifications/create_service.rb b/app/services/service_desk/custom_email_verifications/create_service.rb index 9c5721446a1..16f1245a181 100644 --- a/app/services/service_desk/custom_email_verifications/create_service.rb +++ b/app/services/service_desk/custom_email_verifications/create_service.rb @@ -6,7 +6,6 @@ module ServiceDesk attr_reader :ramp_up_error def execute - return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) return error_settings_missing unless settings.present? return error_user_not_authorized unless can?(current_user, :admin_project, project) @@ -53,6 +52,9 @@ module ServiceDesk rescue Net::SMTPAuthenticationError # incorrect username or password @ramp_up_error = :invalid_credentials + rescue Net::ReadTimeout + # Server is slow to respond + @ramp_up_error = :read_timeout end def handle_error_case diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb index fbd217e3a3e..1b0e5e3c61a 100644 --- a/app/services/service_desk/custom_email_verifications/update_service.rb +++ b/app/services/service_desk/custom_email_verifications/update_service.rb @@ -6,7 +6,6 @@ module ServiceDesk EMAIL_TOKEN_REGEXP = /Verification token: ([A-Za-z0-9_-]{12})/ def execute - return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) return error_parameter_missing if settings.blank? || verification.blank? return error_already_finished if verification.finished? return error_already_failed if already_failed_and_no_mail? @@ -45,6 +44,7 @@ module ServiceDesk def verify return :mail_not_received_within_timeframe if mail_not_received_within_timeframe? + return :incorrect_forwarding_target if forwarded_to_service_desk_alias_address? return :incorrect_from if incorrect_from? return :incorrect_token if incorrect_token? @@ -56,6 +56,16 @@ module ServiceDesk mail.blank? || !verification.in_timeframe? end + def forwarded_to_service_desk_alias_address? + return false unless Gitlab::Email::ServiceDeskEmail.enabled? + + # Users must use the Service Desk address created from `incoming_email` + # so all reply by email features work as expected. + # Using the Service Desk alias address generated from `service_desk_email` + # doesn't allow to ingest email replies, so we'd always add a new issue. + addresses_from_headers.include?(project.service_desk_alias_address) + end + def incorrect_from? # Does the email forwarder preserve the FROM header? mail.from.first != settings.custom_email @@ -71,6 +81,12 @@ module ServiceDesk scan_result.first.first != verification.token end + def addresses_from_headers + # Common headers for forwarding target addresses are + # `To` and `Delivered-To`. We may expand that list if necessary. + (Array(mail.to) + Array(mail['Delivered-To']).map(&:value)).uniq + end + def error_parameter_missing error_response(s_('ServiceDesk|Service Desk setting or verification object missing')) end diff --git a/app/services/service_desk/custom_emails/base_service.rb b/app/services/service_desk/custom_emails/base_service.rb index 91f4100a8ca..d5d39268057 100644 --- a/app/services/service_desk/custom_emails/base_service.rb +++ b/app/services/service_desk/custom_emails/base_service.rb @@ -23,18 +23,10 @@ module ServiceDesk project.service_desk_custom_email_credential.present? end - def feature_flag_enabled? - Feature.enabled?(:service_desk_custom_email, project) - end - def error_user_not_authorized error_response(s_('ServiceDesk|User cannot manage project.')) end - def error_feature_flag_disabled - error_response('Feature flag service_desk_custom_email is not enabled') - end - def error_response(message) log_warning(error_message: message) ServiceResponse.error(message: message) diff --git a/app/services/service_desk/custom_emails/create_service.rb b/app/services/service_desk/custom_emails/create_service.rb index c06c836f0fa..dd01f73bac8 100644 --- a/app/services/service_desk/custom_emails/create_service.rb +++ b/app/services/service_desk/custom_emails/create_service.rb @@ -4,7 +4,6 @@ module ServiceDesk module CustomEmails class CreateService < BaseService def execute - return error_feature_flag_disabled unless feature_flag_enabled? return error_user_not_authorized unless legitimate_user? return error_params_missing unless has_required_params? return error_custom_email_exists if credential? || verification? diff --git a/app/services/service_desk/custom_emails/destroy_service.rb b/app/services/service_desk/custom_emails/destroy_service.rb index abbe39646aa..81188e87258 100644 --- a/app/services/service_desk/custom_emails/destroy_service.rb +++ b/app/services/service_desk/custom_emails/destroy_service.rb @@ -4,7 +4,6 @@ module ServiceDesk module CustomEmails class DestroyService < BaseService def execute - return error_feature_flag_disabled unless feature_flag_enabled? return error_user_not_authorized unless legitimate_user? return error_does_not_exist unless verification? || credential? || setting? diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index b51684c6899..2a9e4be91d3 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -36,6 +36,8 @@ module Users else standard_build_user end + + user.assign_personal_namespace end def admin? diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index a0e1167836b..e4b593e3140 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -53,7 +53,7 @@ module Users # Load the records. Groups are unavailable after membership is destroyed. solo_owned_groups = user.solo_owned_groups.load - user.members.each_batch { |batch| batch.destroy_all } # rubocop:disable Style/SymbolProc, Cop/DestroyAll + user.members.each_batch { |batch| batch.destroy_all } # rubocop:disable Cop/DestroyAll solo_owned_groups.each do |group| Groups::DestroyService.new(group, current_user).execute diff --git a/app/services/users/in_product_marketing_email_records.rb b/app/services/users/in_product_marketing_email_records.rb deleted file mode 100644 index fcb252536b3..00000000000 --- a/app/services/users/in_product_marketing_email_records.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Users - class InProductMarketingEmailRecords - attr_reader :records - - def initialize - @records = [] - end - - def save! - Users::InProductMarketingEmail.bulk_insert!(@records) - @records = [] - end - - def add(user, track: nil, series: nil) - @records << Users::InProductMarketingEmail.new( - user: user, - track: track, - series: series, - created_at: Time.zone.now, - updated_at: Time.zone.now - ) - end - end -end diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb index 06950292fea..bd651a3c45e 100644 --- a/app/services/users/migrate_records_to_ghost_user_service.rb +++ b/app/services/users/migrate_records_to_ghost_user_service.rb @@ -33,6 +33,8 @@ module Users attr_reader :execution_tracker def migrate_records + migrate_user_achievements + return if hard_delete migrate_issues @@ -101,6 +103,11 @@ module Users batched_migrate(Release, :author_id) end + def migrate_user_achievements + batched_migrate(Achievements::UserAchievement, :awarded_by_user_id) + batched_migrate(Achievements::UserAchievement, :revoked_by_user_id) + end + # rubocop:disable CodeReuse/ActiveRecord def batched_migrate(base_scope, column, batch_size: 50) loop do diff --git a/app/services/work_items/delete_task_service.rb b/app/services/work_items/delete_task_service.rb deleted file mode 100644 index 4c0ee2f827d..00000000000 --- a/app/services/work_items/delete_task_service.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - class DeleteTaskService - def initialize(work_item:, lock_version:, current_user: nil, task_params: {}) - @work_item = work_item - @current_user = current_user - @task_params = task_params - @lock_version = lock_version - @task = task_params[:task] - @errors = [] - end - - def execute - transaction_result = ::WorkItem.transaction do - replacement_result = TaskListReferenceRemovalService.new( - work_item: @work_item, - task: @task, - line_number_start: @task_params[:line_number_start], - line_number_end: @task_params[:line_number_end], - lock_version: @lock_version, - current_user: @current_user - ).execute - - next ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error? - - delete_result = ::WorkItems::DeleteService.new( - container: @task.project, - current_user: @current_user - ).execute(@task) - - if delete_result.error? - @errors += delete_result.errors - raise ActiveRecord::Rollback - end - - delete_result - end - - return transaction_result if transaction_result - - ::ServiceResponse.error(message: @errors, http_status: 422) - end - end -end diff --git a/app/services/work_items/task_list_reference_removal_service.rb b/app/services/work_items/task_list_reference_removal_service.rb deleted file mode 100644 index 843b03906ac..00000000000 --- a/app/services/work_items/task_list_reference_removal_service.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module WorkItems - class TaskListReferenceRemovalService - STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version' - - def initialize(work_item:, task:, line_number_start:, line_number_end:, lock_version:, current_user:) - @work_item = work_item - @task = task - @line_number_start = line_number_start - @line_number_end = line_number_end - @lock_version = lock_version - @current_user = current_user - @task_reference = /#{Regexp.escape(@task.to_reference)}(?!\d)\+/ - end - - def execute - return ::ServiceResponse.error(message: 'line_number_start must be greater than 0') if @line_number_start < 1 - return ::ServiceResponse.error(message: "Work item description can't be blank") if @work_item.description.blank? - - if @line_number_end < @line_number_start - return ::ServiceResponse.error(message: 'line_number_end must be greater or equal to line_number_start') - end - - source_lines = @work_item.description.split("\n") - - line_matches_reference = (@line_number_start..@line_number_end).any? do |line_number| - markdown_line = source_lines[line_number - 1] - - if @task_reference.match?(markdown_line) - markdown_line.sub!(@task_reference, @task.title) - end - end - - unless line_matches_reference - return ::ServiceResponse.error( - message: "Unable to detect a task on lines #{@line_number_start}-#{@line_number_end}" - ) - end - - ::WorkItems::UpdateService.new( - container: @work_item.project, - current_user: @current_user, - params: { description: source_lines.join("\n"), lock_version: @lock_version } - ).execute(@work_item) - - if @work_item.valid? - ::ServiceResponse.success - else - ::ServiceResponse.error(message: @work_item.errors.full_messages) - end - rescue ActiveRecord::StaleObjectError - ::ServiceResponse.error(message: STALE_OBJECT_MESSAGE) - end - end -end diff --git a/app/uploaders/bulk_imports/export_uploader.rb b/app/uploaders/bulk_imports/export_uploader.rb index cd6e599054b..c1b6f1e0924 100644 --- a/app/uploaders/bulk_imports/export_uploader.rb +++ b/app/uploaders/bulk_imports/export_uploader.rb @@ -2,6 +2,6 @@ module BulkImports class ExportUploader < ImportExportUploader - EXTENSION_ALLOWLIST = %w[ndjson.gz].freeze + EXTENSION_ALLOWLIST = %w[ndjson.gz tar.gz gz].freeze end end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index c1ca535b336..6e8906645d8 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -76,6 +76,10 @@ class GitlabUploader < CarrierWave::Uploader::Base file.present? end + def empty_size? + size == 0 + end + def cache_dir File.join(root, base_dir, 'tmp/cache') end diff --git a/app/validators/gitlab/emoji_name_validator.rb b/app/validators/gitlab/emoji_name_validator.rb index c034a79214b..68743530d83 100644 --- a/app/validators/gitlab/emoji_name_validator.rb +++ b/app/validators/gitlab/emoji_name_validator.rb @@ -25,8 +25,12 @@ module Gitlab def valid_custom_emoji?(record, value) resource = record.try(:resource_parent) + namespace = resource.try(:namespace) - CustomEmoji.for_resource(resource).by_name(value.to_s).any? + return unless resource.is_a?(Group) || namespace.is_a?(Group) + + Groups::CustomEmojiFinder.new(resource, { include_ancestor_groups: true }).execute + .by_name(value.to_s).any? end end end diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb index 2ef011df73e..f661392082f 100644 --- a/app/validators/json_schema_validator.rb +++ b/app/validators/json_schema_validator.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # # JsonSchemaValidator # @@ -24,11 +25,19 @@ class JsonSchemaValidator < ActiveModel::EachValidator end def validate_each(record, attribute, value) - value = value.to_h.stringify_keys if options[:hash_conversion] == true + value = value.to_h.deep_stringify_keys if options[:hash_conversion] == true value = Gitlab::Json.parse(value.to_s) if options[:parse_json] == true && !value.nil? - unless valid_schema?(value) - record.errors.add(attribute, _("must be a valid json schema")) + if options[:detail_errors] + validator.validate(value).each do |error| + message = format( + _("'%{data_pointer}' must be a valid '%{type}'"), + data_pointer: error['data_pointer'], type: error['type'] + ) + record.errors.add(attribute, message) + end + else + record.errors.add(attribute, _("must be a valid json schema")) unless valid_schema?(value) end end diff --git a/app/validators/json_schemas/cyclonedx_report.json b/app/validators/json_schemas/cyclonedx/bom-1.4.schema.json index 7b24c05a039..e12594b6e88 100644 --- a/app/validators/json_schemas/cyclonedx_report.json +++ b/app/validators/json_schemas/cyclonedx/bom-1.4.schema.json @@ -2611,4 +2611,4 @@ ] } } -}
\ No newline at end of file +} diff --git a/app/validators/json_schemas/cyclonedx/bom-1.5.schema.json b/app/validators/json_schemas/cyclonedx/bom-1.5.schema.json new file mode 100644 index 00000000000..0bb9fee766f --- /dev/null +++ b/app/validators/json_schemas/cyclonedx/bom-1.5.schema.json @@ -0,0 +1,4895 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "type": "object", + "title": "CycloneDX Software Bill of Materials Standard", + "$comment": "CycloneDX JSON schema is published under the terms of the Apache License 2.0.", + "required": [ + "bomFormat", + "specVersion", + "version" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "enum": [ + "http://cyclonedx.org/schema/bom-1.5.schema.json" + ] + }, + "bomFormat": { + "type": "string", + "title": "BOM Format", + "description": "Specifies the format of the BOM. This helps to identify the file as CycloneDX since BOMs do not have a filename convention nor does JSON schema support namespaces. This value MUST be \"CycloneDX\".", + "enum": [ + "CycloneDX" + ] + }, + "specVersion": { + "type": "string", + "title": "CycloneDX Specification Version", + "description": "The version of the CycloneDX specification a BOM conforms to (starting at version 1.2).", + "examples": [ + "1.5" + ] + }, + "serialNumber": { + "type": "string", + "title": "BOM Serial Number", + "description": "Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. If specified, the serial number MUST conform to RFC-4122. Use of serial numbers are RECOMMENDED.", + "examples": [ + "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79" + ], + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "version": { + "type": "integer", + "title": "BOM Version", + "description": "Whenever an existing BOM is modified, either manually or through automated processes, the version of the BOM SHOULD be incremented by 1. When a system is presented with multiple BOMs with identical serial numbers, the system SHOULD use the most recent version of the BOM. The default version is '1'.", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + }, + "metadata": { + "$ref": "#/definitions/metadata", + "title": "BOM Metadata", + "description": "Provides additional information about a BOM." + }, + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components." + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true, + "title": "Services", + "description": "A list of services. This may include microservices, function-as-a-service, and other types of network or intra-process services." + }, + "externalReferences": { + "type": "array", + "items": { + "$ref": "#/definitions/externalReference" + }, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/definitions/dependency" + }, + "uniqueItems": true, + "title": "Dependencies", + "description": "Provides the ability to document dependency relationships." + }, + "compositions": { + "type": "array", + "items": { + "$ref": "#/definitions/compositions" + }, + "uniqueItems": true, + "title": "Compositions", + "description": "Compositions describe constituent parts (including components, services, and dependency relationships) and their completeness. The completeness of vulnerabilities expressed in a BOM may also be described." + }, + "vulnerabilities": { + "type": "array", + "items": { + "$ref": "#/definitions/vulnerability" + }, + "uniqueItems": true, + "title": "Vulnerabilities", + "description": "Vulnerabilities identified in components or services." + }, + "annotations": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations" + }, + "uniqueItems": true, + "title": "Annotations", + "description": "Comments made by people, organizations, or tools about any object with a bom-ref, such as components, services, vulnerabilities, or the BOM itself. Unlike inventory information, annotations may contain opinion or commentary from various stakeholders. Annotations may be inline (with inventory) or externalized via BOM-Link, and may optionally be signed." + }, + "formulation": { + "type": "array", + "items": { + "$ref": "#/definitions/formula" + }, + "uniqueItems": true, + "title": "Formulation", + "description": "Describes how a component or service was manufactured or deployed. This is achieved through the use of formulas, workflows, tasks, and steps, which declare the precise steps to reproduce along with the observed formulas describing the steps which transpired in the manufacturing process." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + }, + "definitions": { + "refType": { + "description": "Identifier for referable and therefore interlink-able elements.", + "type": "string", + "minLength": 1, + "$comment": "value SHOULD not start with the BOM-Link intro 'urn:cdx:'" + }, + "refLinkType": { + "description": "Descriptor for an element identified by the attribute 'bom-ref' in the same BOM document.\nIn contrast to `bomLinkElementType`.", + "allOf": [ + { + "$ref": "#/definitions/refType" + } + ] + }, + "bomLinkDocumentType": { + "title": "BOM-Link Document", + "description": "Descriptor for another BOM document. See https://cyclonedx.org/capabilities/bomlink/", + "type": "string", + "format": "iri-reference", + "pattern": "^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*$", + "$comment": "part of the pattern is based on `bom.serialNumber`'s pattern" + }, + "bomLinkElementType": { + "title": "BOM-Link Element", + "description": "Descriptor for an element in a BOM document. See https://cyclonedx.org/capabilities/bomlink/", + "type": "string", + "format": "iri-reference", + "pattern": "^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*#.+$", + "$comment": "part of the pattern is based on `bom.serialNumber`'s pattern" + }, + "bomLink": { + "anyOf": [ + { + "title": "BOM-Link Document", + "$ref": "#/definitions/bomLinkDocumentType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "metadata": { + "type": "object", + "title": "BOM Metadata Object", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the BOM was created." + }, + "lifecycles": { + "type": "array", + "title": "Lifecycles", + "description": "", + "items": { + "type": "object", + "title": "Lifecycle", + "description": "The product lifecycle(s) that this BOM represents.", + "oneOf": [ + { + "required": [ + "phase" + ], + "additionalProperties": false, + "properties": { + "phase": { + "type": "string", + "title": "Phase", + "description": "A pre-defined phase in the product lifecycle.\n\n* __design__ = BOM produced early in the development lifecycle containing inventory of components and services that are proposed or planned to be used. The inventory may need to be procured, retrieved, or resourced prior to use.\n* __pre-build__ = BOM consisting of information obtained prior to a build process and may contain source files and development artifacts and manifests. The inventory may need to be resolved and retrieved prior to use.\n* __build__ = BOM consisting of information obtained during a build process where component inventory is available for use. The precise versions of resolved components are usually available at this time as well as the provenance of where the components were retrieved from.\n* __post-build__ = BOM consisting of information obtained after a build process has completed and the resulting components(s) are available for further analysis. Built components may exist as the result of a CI/CD process, may have been installed or deployed to a system or device, and may need to be retrieved or extracted from the system or device.\n* __operations__ = BOM produced that represents inventory that is running and operational. This may include staging or production environments and will generally encompass multiple SBOMs describing the applications and operating system, along with HBOMs describing the hardware that makes up the system. Operations Bill of Materials (OBOM) can provide full-stack inventory of runtime environments, configurations, and additional dependencies.\n* __discovery__ = BOM consisting of information observed through network discovery providing point-in-time enumeration of embedded, on-premise, and cloud-native services such as server applications, connected devices, microservices, and serverless functions.\n* __decommission__ = BOM containing inventory that will be, or has been retired from operations.", + "enum": [ + "design", + "pre-build", + "build", + "post-build", + "operations", + "discovery", + "decommission" + ] + } + } + }, + { + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the lifecycle phase" + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the lifecycle phase" + } + } + } + ] + } + }, + "tools": { + "oneOf": [ + { + "type": "object", + "title": "Creation Tools", + "description": "The tool(s) used in the creation of the BOM.", + "additionalProperties": false, + "properties": { + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components used as tools" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true, + "title": "Services", + "description": "A list of services used as tools. This may include microservices, function-as-a-service, and other types of network or intra-process services." + } + } + }, + { + "type": "array", + "title": "Creation Tools (legacy)", + "description": "[Deprecated] The tool(s) used in the creation of the BOM.", + "items": { + "$ref": "#/definitions/tool" + } + } + ] + }, + "authors": { + "type": "array", + "title": "Authors", + "description": "The person(s) who created the BOM. Authors are common in BOMs created through manual processes. BOMs created through automated means may not have authors.", + "items": { + "$ref": "#/definitions/organizationalContact" + } + }, + "component": { + "title": "Component", + "description": "The component that the BOM describes.", + "$ref": "#/definitions/component" + }, + "manufacture": { + "title": "Manufacture", + "description": "The organization that manufactured the component that the BOM describes.", + "$ref": "#/definitions/organizationalEntity" + }, + "supplier": { + "title": "Supplier", + "description": " The organization that supplied the component that the BOM describes. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "licenses": { + "title": "BOM License(s)", + "$ref": "#/definitions/licenseChoice" + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "tool": { + "type": "object", + "title": "Tool", + "description": "[Deprecated] - DO NOT USE. This will be removed in a future version. This will be removed in a future version. Use component or service instead. Information about the automated or manual tool used", + "additionalProperties": false, + "properties": { + "vendor": { + "type": "string", + "title": "Tool Vendor", + "description": "The name of the vendor who created the tool" + }, + "name": { + "type": "string", + "title": "Tool Name", + "description": "The name of the tool" + }, + "version": { + "type": "string", + "title": "Tool Version", + "description": "The version of the tool" + }, + "hashes": { + "type": "array", + "items": { + "$ref": "#/definitions/hash" + }, + "title": "Hashes", + "description": "The hashes of the tool (if applicable)." + }, + "externalReferences": { + "type": "array", + "items": { + "$ref": "#/definitions/externalReference" + }, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + } + } + }, + "organizationalEntity": { + "type": "object", + "title": "Organizational Entity Object", + "description": "", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the organization", + "examples": [ + "Example Inc." + ] + }, + "url": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "URL", + "description": "The URL of the organization. Multiple URLs are allowed.", + "examples": [ + "https://example.com" + ] + }, + "contact": { + "type": "array", + "title": "Contact", + "description": "A contact at the organization. Multiple contacts are allowed.", + "items": { + "$ref": "#/definitions/organizationalContact" + } + } + } + }, + "organizationalContact": { + "type": "object", + "title": "Organizational Contact Object", + "description": "", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the object elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of a contact", + "examples": [ + "Contact name" + ] + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "Email Address", + "description": "The email address of the contact.", + "examples": [ + "firstname.lastname@example.com" + ] + }, + "phone": { + "type": "string", + "title": "Phone", + "description": "The phone number of the contact.", + "examples": [ + "800-555-1212" + ] + } + } + }, + "component": { + "type": "object", + "title": "Component Object", + "required": [ + "type", + "name" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "application", + "framework", + "library", + "container", + "platform", + "operating-system", + "device", + "device-driver", + "firmware", + "file", + "machine-learning-model", + "data" + ], + "title": "Component Type", + "description": "Specifies the type of component. For software components, classify as application if no more specific appropriate classification is available or cannot be determined for the component. Types include:\n\n* __application__ = A software application. Refer to [https://en.wikipedia.org/wiki/Application_software](https://en.wikipedia.org/wiki/Application_software) for information about applications.\n* __framework__ = A software framework. Refer to [https://en.wikipedia.org/wiki/Software_framework](https://en.wikipedia.org/wiki/Software_framework) for information on how frameworks vary slightly from libraries.\n* __library__ = A software library. Refer to [https://en.wikipedia.org/wiki/Library_(computing)](https://en.wikipedia.org/wiki/Library_(computing))\n for information about libraries. All third-party and open source reusable components will likely be a library. If the library also has key features of a framework, then it should be classified as a framework. If not, or is unknown, then specifying library is RECOMMENDED.\n* __container__ = A packaging and/or runtime format, not specific to any particular technology, which isolates software inside the container from software outside of a container through virtualization technology. Refer to [https://en.wikipedia.org/wiki/OS-level_virtualization](https://en.wikipedia.org/wiki/OS-level_virtualization)\n* __platform__ = A runtime environment which interprets or executes software. This may include runtimes such as those that execute bytecode or low-code/no-code application platforms.\n* __operating-system__ = A software operating system without regard to deployment model (i.e. installed on physical hardware, virtual machine, image, etc) Refer to [https://en.wikipedia.org/wiki/Operating_system](https://en.wikipedia.org/wiki/Operating_system)\n* __device__ = A hardware device such as a processor, or chip-set. A hardware device containing firmware SHOULD include a component for the physical hardware itself, and another component of type 'firmware' or 'operating-system' (whichever is relevant), describing information about the software running on the device.\n See also the list of [known device properties](https://github.com/CycloneDX/cyclonedx-property-taxonomy/blob/main/cdx/device.md).\n* __device-driver__ = A special type of software that operates or controls a particular type of device. Refer to [https://en.wikipedia.org/wiki/Device_driver](https://en.wikipedia.org/wiki/Device_driver)\n* __firmware__ = A special type of software that provides low-level control over a devices hardware. Refer to [https://en.wikipedia.org/wiki/Firmware](https://en.wikipedia.org/wiki/Firmware)\n* __file__ = A computer file. Refer to [https://en.wikipedia.org/wiki/Computer_file](https://en.wikipedia.org/wiki/Computer_file) for information about files.\n* __machine-learning-model__ = A model based on training data that can make predictions or decisions without being explicitly programmed to do so.\n* __data__ = A collection of discrete values that convey information.", + "examples": [ + "library" + ] + }, + "mime-type": { + "type": "string", + "title": "Mime-Type", + "description": "The optional mime-type of the component. When used on file components, the mime-type can provide additional context about the kind of file being represented such as an image, font, or executable. Some library or framework components may also have an associated mime-type.", + "examples": [ + "image/jpeg" + ], + "pattern": "^[-+a-z0-9.]+/[-+a-z0-9.]+$" + }, + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "supplier": { + "title": "Component Supplier", + "description": " The organization that supplied the component. The supplier may often be the manufacturer, but may also be a distributor or repackager.", + "$ref": "#/definitions/organizationalEntity" + }, + "author": { + "type": "string", + "title": "Component Author", + "description": "The person(s) or organization(s) that authored the component", + "examples": [ + "Acme Inc" + ] + }, + "publisher": { + "type": "string", + "title": "Component Publisher", + "description": "The person(s) or organization(s) that published the component", + "examples": [ + "Acme Inc" + ] + }, + "group": { + "type": "string", + "title": "Component Group", + "description": "The grouping name or identifier. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. Whitespace and special characters should be avoided. Examples include: apache, org.apache.commons, and apache.org.", + "examples": [ + "com.acme" + ] + }, + "name": { + "type": "string", + "title": "Component Name", + "description": "The name of the component. This will often be a shortened, single name of the component. Examples: commons-lang3 and jquery", + "examples": [ + "tomcat-catalina" + ] + }, + "version": { + "type": "string", + "title": "Component Version", + "description": "The component version. The version should ideally comply with semantic versioning but is not enforced.", + "examples": [ + "9.0.14" + ] + }, + "description": { + "type": "string", + "title": "Component Description", + "description": "Specifies a description for the component" + }, + "scope": { + "type": "string", + "enum": [ + "required", + "optional", + "excluded" + ], + "title": "Component Scope", + "description": "Specifies the scope of the component. If scope is not specified, 'required' scope SHOULD be assumed by the consumer of the BOM.", + "default": "required" + }, + "hashes": { + "type": "array", + "title": "Component Hashes", + "items": { + "$ref": "#/definitions/hash" + } + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "Component License(s)" + }, + "copyright": { + "type": "string", + "title": "Component Copyright", + "description": "A copyright notice informing users of the underlying claims to copyright ownership in a published work.", + "examples": [ + "Acme Inc" + ] + }, + "cpe": { + "type": "string", + "title": "Component Common Platform Enumeration (CPE)", + "description": "Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. See [https://nvd.nist.gov/products/cpe](https://nvd.nist.gov/products/cpe)", + "examples": [ + "cpe:2.3:a:acme:component_framework:-:*:*:*:*:*:*:*" + ] + }, + "purl": { + "type": "string", + "title": "Component Package URL (purl)", + "description": "Specifies the package-url (purl). The purl, if specified, MUST be valid and conform to the specification defined at: [https://github.com/package-url/purl-spec](https://github.com/package-url/purl-spec)", + "examples": [ + "pkg:maven/com.acme/tomcat-catalina@9.0.14?packaging=jar" + ] + }, + "swid": { + "$ref": "#/definitions/swid", + "title": "SWID Tag", + "description": "Specifies metadata and content for [ISO-IEC 19770-2 Software Identification (SWID) Tags](https://www.iso.org/standard/65666.html)." + }, + "modified": { + "type": "boolean", + "title": "Component Modified From Original", + "description": "[Deprecated] - DO NOT USE. This will be removed in a future version. Use the pedigree element instead to supply information on exactly how the component was modified. A boolean value indicating if the component has been modified from the original. A value of true indicates the component is a derivative of the original. A value of false indicates the component has not been modified from the original." + }, + "pedigree": { + "type": "object", + "title": "Component Pedigree", + "description": "Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation may not be known.", + "additionalProperties": false, + "properties": { + "ancestors": { + "type": "array", + "title": "Ancestors", + "description": "Describes zero or more components in which a component is derived from. This is commonly used to describe forks from existing projects where the forked version contains a ancestor node containing the original component it was forked from. For example, Component A is the original component. Component B is the component being used and documented in the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the original component from which Component B is derived from.", + "items": { + "$ref": "#/definitions/component" + } + }, + "descendants": { + "type": "array", + "title": "Descendants", + "description": "Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of an original or root component.", + "items": { + "$ref": "#/definitions/component" + } + }, + "variants": { + "type": "array", + "title": "Variants", + "description": "Variants describe relations where the relationship between the components are not known. For example, if Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is derived from the other, or if they share a common ancestor.", + "items": { + "$ref": "#/definitions/component" + } + }, + "commits": { + "type": "array", + "title": "Commits", + "description": "A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, descendant, or variant.", + "items": { + "$ref": "#/definitions/commit" + } + }, + "patches": { + "type": "array", + "title": "Patches", + "description": ">A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. Patches may be complimentary to commits or may be used in place of commits.", + "items": { + "$ref": "#/definitions/patch" + } + }, + "notes": { + "type": "string", + "title": "Notes", + "description": "Notes, observations, and other non-structured commentary describing the components pedigree." + } + } + }, + "externalReferences": { + "type": "array", + "items": { + "$ref": "#/definitions/externalReference" + }, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components included in the parent component. This is not a dependency tree. It provides a way to specify a hierarchical representation of component assemblies, similar to system → subsystem → parts assembly in physical supply chains." + }, + "evidence": { + "$ref": "#/definitions/componentEvidence", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "modelCard": { + "$ref": "#/definitions/modelCard", + "title": "Machine Learning Model Card" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/componentData" + }, + "title": "Data", + "description": "This object SHOULD be specified for any component of type `data` and MUST NOT be specified for other component types." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "swid": { + "type": "object", + "title": "SWID Tag", + "description": "Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags.", + "required": [ + "tagId", + "name" + ], + "additionalProperties": false, + "properties": { + "tagId": { + "type": "string", + "title": "Tag ID", + "description": "Maps to the tagId of a SoftwareIdentity." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Maps to the name of a SoftwareIdentity." + }, + "version": { + "type": "string", + "title": "Version", + "default": "0.0", + "description": "Maps to the version of a SoftwareIdentity." + }, + "tagVersion": { + "type": "integer", + "title": "Tag Version", + "default": 0, + "description": "Maps to the tagVersion of a SoftwareIdentity." + }, + "patch": { + "type": "boolean", + "title": "Patch", + "default": false, + "description": "Maps to the patch of a SoftwareIdentity." + }, + "text": { + "title": "Attachment text", + "description": "Specifies the metadata and content of the SWID tag.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the SWID file.", + "format": "iri-reference" + } + } + }, + "attachment": { + "type": "object", + "title": "Attachment", + "description": "Specifies the metadata and content for an attachment.", + "required": [ + "content" + ], + "additionalProperties": false, + "properties": { + "contentType": { + "type": "string", + "title": "Content-Type", + "description": "Specifies the content type of the text. Defaults to text/plain if not specified.", + "default": "text/plain" + }, + "encoding": { + "type": "string", + "title": "Encoding", + "description": "Specifies the optional encoding the text is represented in.", + "enum": [ + "base64" + ] + }, + "content": { + "type": "string", + "title": "Attachment Text", + "description": "The attachment data. Proactive controls such as input validation and sanitization should be employed to prevent misuse of attachment text." + } + } + }, + "hash": { + "type": "object", + "title": "Hash Objects", + "required": [ + "alg", + "content" + ], + "additionalProperties": false, + "properties": { + "alg": { + "$ref": "#/definitions/hash-alg" + }, + "content": { + "$ref": "#/definitions/hash-content" + } + } + }, + "hash-alg": { + "type": "string", + "enum": [ + "MD5", + "SHA-1", + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-256", + "SHA3-384", + "SHA3-512", + "BLAKE2b-256", + "BLAKE2b-384", + "BLAKE2b-512", + "BLAKE3" + ], + "title": "Hash Algorithm" + }, + "hash-content": { + "type": "string", + "title": "Hash Content (value)", + "examples": [ + "3942447fac867ae5cdb3229b658f4d48" + ], + "pattern": "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$" + }, + "license": { + "type": "object", + "title": "License Object", + "oneOf": [ + { + "required": [ + "id" + ] + }, + { + "required": [ + "name" + ] + } + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the license elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "id": { + "title": "License ID (SPDX)", + "description": "A valid SPDX license ID", + "examples": [ + "Apache-2.0" + ], + "type": "string", + "enum": [ + "0BSD", + "AAL", + "Abstyles", + "AdaCore-doc", + "Adobe-2006", + "Adobe-Glyph", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "Afmparse", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Aladdin", + "AMDPLPA", + "AML", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APAFML", + "APL-1.0", + "App-s2p", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Arphic-1999", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "ASWF-Digital-Assets-1.0", + "ASWF-Digital-Assets-1.1", + "Baekmuk", + "Bahyph", + "Barr", + "Beerware", + "Bitstream-Charter", + "Bitstream-Vera", + "BitTorrent-1.0", + "BitTorrent-1.1", + "blessing", + "BlueOak-1.0.0", + "Boehm-GC", + "Borceux", + "Brian-Gladman-3-Clause", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-Views", + "BSD-3-Clause", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Military-License", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-4.3RENO", + "BSD-4.3TAHOE", + "BSD-Advertising-Acknowledgement", + "BSD-Attribution-HPND-disclaimer", + "BSD-Protection", + "BSD-Source-Code", + "BSL-1.0", + "BUSL-1.1", + "bzip2-1.0.5", + "bzip2-1.0.6", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "Caldera", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-2.5-AU", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-DE", + "CC-BY-3.0-IGO", + "CC-BY-3.0-NL", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-3.0-DE", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-DE", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.0-DE", + "CC-BY-NC-SA-2.0-FR", + "CC-BY-NC-SA-2.0-UK", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-3.0-DE", + "CC-BY-NC-SA-3.0-IGO", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-3.0-DE", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-3.0-DE", + "CC-BY-SA-3.0-IGO", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Permissive-2.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "CFITSIO", + "checkmk", + "ClArtistic", + "Clips", + "CMU-Mach", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "COIL-1.0", + "Community-Spec-1.0", + "Condor-1.1", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "Cornell-Lossless-JPEG", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "Crossword", + "CrystalStacker", + "CUA-OPL-1.0", + "Cube", + "curl", + "D-FSL-1.0", + "diffmark", + "DL-DE-BY-2.0", + "DOC", + "Dotseqn", + "DRL-1.0", + "DSDP", + "dtoa", + "dvipdfm", + "ECL-1.0", + "ECL-2.0", + "eCos-2.0", + "EFL-1.0", + "EFL-2.0", + "eGenix", + "Elastic-2.0", + "Entessa", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ErlPL-1.1", + "etalab-2.0", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Eurosym", + "Fair", + "FDK-AAC", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "FSFAP", + "FSFUL", + "FSFULLR", + "FSFULLRWD", + "FTL", + "GD", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "Giftware", + "GL2PS", + "Glide", + "Glulxe", + "GLWTPL", + "gnuplot", + "GPL-1.0", + "GPL-1.0+", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "Graphics-Gems", + "gSOAP-1.3b", + "HaskellReport", + "Hippocratic-2.1", + "HP-1986", + "HPND", + "HPND-export-US", + "HPND-Markus-Kuhn", + "HPND-sell-variant", + "HPND-sell-variant-MIT-disclaimer", + "HTMLTIDY", + "IBM-pibs", + "ICU", + "IEC-Code-Components-EULA", + "IJG", + "IJG-short", + "ImageMagick", + "iMatix", + "Imlib2", + "Info-ZIP", + "Inner-Net-2.0", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "IPA", + "IPL-1.0", + "ISC", + "Jam", + "JasPer-2.0", + "JPL-image", + "JPNIC", + "JSON", + "Kazlib", + "Knuth-CTAN", + "LAL-1.2", + "LAL-1.3", + "Latex2e", + "Latex2e-translated-notice", + "Leptonica", + "LGPL-2.0", + "LGPL-2.0+", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPLLR", + "Libpng", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "libutil-David-Nugent", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Linux-man-pages-1-para", + "Linux-man-pages-copyleft", + "Linux-man-pages-copyleft-2-para", + "Linux-man-pages-copyleft-var", + "Linux-OpenIB", + "LOOP", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "LZMA-SDK-9.11-to-9.20", + "LZMA-SDK-9.22", + "MakeIndex", + "Martin-Birgmeier", + "metamail", + "Minpack", + "MirOS", + "MIT", + "MIT-0", + "MIT-advertising", + "MIT-CMU", + "MIT-enna", + "MIT-feh", + "MIT-Festival", + "MIT-Modern-Variant", + "MIT-open-group", + "MIT-Wu", + "MITNFA", + "Motosoto", + "mpi-permissive", + "mpich2", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "mplus", + "MS-LPL", + "MS-PL", + "MS-RL", + "MTLL", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NCGL-UK-2.0", + "NCSA", + "Net-SNMP", + "NetCDF", + "Newsletr", + "NGPL", + "NICTA-1.0", + "NIST-PD", + "NIST-PD-fallback", + "NIST-Software", + "NLOD-1.0", + "NLOD-2.0", + "NLPL", + "Nokia", + "NOSL", + "Noweb", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTP", + "NTP-0", + "Nunit", + "O-UDA-1.0", + "OCCT-PL", + "OCLC-2.0", + "ODbL-1.0", + "ODC-By-1.0", + "OFFIS", + "OFL-1.0", + "OFL-1.0-no-RFN", + "OFL-1.0-RFN", + "OFL-1.1", + "OFL-1.1-no-RFN", + "OFL-1.1-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OLFL-1.3", + "OML", + "OpenPBS-2.3", + "OpenSSL", + "OPL-1.0", + "OPL-UK-3.0", + "OPUBL-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "Parity-6.0.0", + "Parity-7.0.0", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "Plexus", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "PSF-2.0", + "psfrag", + "psutils", + "Python-2.0", + "Python-2.0.1", + "Qhull", + "QPL-1.0", + "QPL-1.0-INRIA-2004", + "Rdisc", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Ruby", + "SAX-PD", + "Saxpath", + "SCEA", + "SchemeReport", + "Sendmail", + "Sendmail-8.23", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SGP4", + "SHL-0.5", + "SHL-0.51", + "SimPL-2.0", + "SISSL", + "SISSL-1.2", + "Sleepycat", + "SMLNJ", + "SMPPL", + "SNIA", + "snprintf", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "SPL-1.0", + "SSH-OpenSSH", + "SSH-short", + "SSPL-1.0", + "StandardML-NJ", + "SugarCRM-1.1.3", + "SunPro", + "SWL", + "Symlinks", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TermReadKey", + "TMate", + "TORQUE-1.1", + "TOSL", + "TPDL", + "TPL-1.0", + "TTWL", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "UCAR", + "UCL-1.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "UnixCrypt", + "Unlicense", + "UPL-1.0", + "Vim", + "VOSTROM", + "VSL-1.0", + "W3C", + "W3C-19980720", + "W3C-20150513", + "w3m", + "Watcom-1.0", + "Widget-Workshop", + "Wsuipa", + "WTFPL", + "wxWindows", + "X11", + "X11-distribute-modifications-variant", + "Xdebug-1.03", + "Xerox", + "Xfig", + "XFree86-1.1", + "xinetd", + "xlock", + "Xnet", + "xpp", + "XSkat", + "YPL-1.0", + "YPL-1.1", + "Zed", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "zlib-acknowledgement", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "389-exception", + "Asterisk-exception", + "Autoconf-exception-2.0", + "Autoconf-exception-3.0", + "Autoconf-exception-generic", + "Autoconf-exception-macro", + "Bison-exception-2.2", + "Bootloader-exception", + "Classpath-exception-2.0", + "CLISP-exception-2.0", + "cryptsetup-OpenSSL-exception", + "DigiRule-FOSS-exception", + "eCos-exception-2.0", + "Fawkes-Runtime-exception", + "FLTK-exception", + "Font-exception-2.0", + "freertos-exception-2.0", + "GCC-exception-2.0", + "GCC-exception-3.1", + "GNAT-exception", + "gnu-javamail-exception", + "GPL-3.0-interface-exception", + "GPL-3.0-linking-exception", + "GPL-3.0-linking-source-exception", + "GPL-CC-1.0", + "GStreamer-exception-2005", + "GStreamer-exception-2008", + "i2p-gpl-java-exception", + "KiCad-libraries-exception", + "LGPL-3.0-linking-exception", + "libpri-OpenH323-exception", + "Libtool-exception", + "Linux-syscall-note", + "LLGPL", + "LLVM-exception", + "LZMA-exception", + "mif-exception", + "Nokia-Qt-exception-1.1", + "OCaml-LGPL-linking-exception", + "OCCT-exception-1.0", + "OpenJDK-assembly-exception-1.0", + "openvpn-openssl-exception", + "PS-or-PDF-font-exception-20170817", + "QPL-1.0-INRIA-2004-exception", + "Qt-GPL-exception-1.0", + "Qt-LGPL-exception-1.1", + "Qwt-exception-1.0", + "SHL-2.0", + "SHL-2.1", + "SWI-exception", + "Swift-exception", + "u-boot-exception-2.0", + "Universal-FOSS-exception-1.0", + "vsftpd-openssl-exception", + "WxWindows-exception-3.1", + "x11vnc-openssl-exception" + ] + }, + "name": { + "type": "string", + "title": "License Name", + "description": "If SPDX does not define the license used, this field may be used to provide the license name", + "examples": [ + "Acme Software License" + ] + }, + "text": { + "title": "License text", + "description": "An optional way to include the textual content of a license.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "License URL", + "description": "The URL to the license file. If specified, a 'license' externalReference should also be specified for completeness", + "examples": [ + "https://www.apache.org/licenses/LICENSE-2.0.txt" + ], + "format": "iri-reference" + }, + "licensing": { + "type": "object", + "title": "Licensing information", + "description": "Licensing details describing the licensor/licensee, license type, renewal and expiration dates, and other important metadata", + "additionalProperties": false, + "properties": { + "altIds": { + "type": "array", + "title": "Alternate License Identifiers", + "description": "License identifiers that may be used to manage licenses and their lifecycle", + "items": { + "type": "string" + } + }, + "licensor": { + "title": "Licensor", + "description": "The individual or organization that grants a license to another individual or organization", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Licensor (Organization)", + "description": "The organization that granted the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Licensor (Individual)", + "description": "The individual, not associated with an organization, that granted the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "individual" + ] + } + ] + }, + "licensee": { + "title": "Licensee", + "description": "The individual or organization for which a license was granted to", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Licensee (Organization)", + "description": "The organization that was granted the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Licensee (Individual)", + "description": "The individual, not associated with an organization, that was granted the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "individual" + ] + } + ] + }, + "purchaser": { + "title": "Purchaser", + "description": "The individual or organization that purchased the license", + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Purchaser (Organization)", + "description": "The organization that purchased the license", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "title": "Purchaser (Individual)", + "description": "The individual, not associated with an organization, that purchased the license", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "individual" + ] + } + ] + }, + "purchaseOrder": { + "type": "string", + "title": "Purchase Order", + "description": "The purchase order identifier the purchaser sent to a supplier or vendor to authorize a purchase" + }, + "licenseTypes": { + "type": "array", + "title": "License Type", + "description": "The type of license(s) that was granted to the licensee\n\n* __academic__ = A license that grants use of software solely for the purpose of education or research.\n* __appliance__ = A license covering use of software embedded in a specific piece of hardware.\n* __client-access__ = A Client Access License (CAL) allows client computers to access services provided by server software.\n* __concurrent-user__ = A Concurrent User license (aka floating license) limits the number of licenses for a software application and licenses are shared among a larger number of users.\n* __core-points__ = A license where the core of a computer's processor is assigned a specific number of points.\n* __custom-metric__ = A license for which consumption is measured by non-standard metrics.\n* __device__ = A license that covers a defined number of installations on computers and other types of devices.\n* __evaluation__ = A license that grants permission to install and use software for trial purposes.\n* __named-user__ = A license that grants access to the software to one or more pre-defined users.\n* __node-locked__ = A license that grants access to the software on one or more pre-defined computers or devices.\n* __oem__ = An Original Equipment Manufacturer license that is delivered with hardware, cannot be transferred to other hardware, and is valid for the life of the hardware.\n* __perpetual__ = A license where the software is sold on a one-time basis and the licensee can use a copy of the software indefinitely.\n* __processor-points__ = A license where each installation consumes points per processor.\n* __subscription__ = A license where the licensee pays a fee to use the software or service.\n* __user__ = A license that grants access to the software or service by a specified number of users.\n* __other__ = Another license type.\n", + "items": { + "type": "string", + "enum": [ + "academic", + "appliance", + "client-access", + "concurrent-user", + "core-points", + "custom-metric", + "device", + "evaluation", + "named-user", + "node-locked", + "oem", + "perpetual", + "processor-points", + "subscription", + "user", + "other" + ] + } + }, + "lastRenewal": { + "type": "string", + "format": "date-time", + "title": "Last Renewal", + "description": "The timestamp indicating when the license was last renewed. For new purchases, this is often the purchase or acquisition date. For non-perpetual licenses or subscriptions, this is the timestamp of when the license was last renewed." + }, + "expiration": { + "type": "string", + "format": "date-time", + "title": "Expiration", + "description": "The timestamp indicating when the current license expires (if applicable)." + } + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "licenseChoice": { + "title": "License Choice", + "description": "EITHER (list of SPDX licenses and/or named licenses) OR (tuple of one SPDX License Expression)", + "type": "array", + "oneOf": [ + { + "title": "Multiple licenses", + "description": "A list of SPDX licenses and/or named licenses.", + "type": "array", + "items": { + "type": "object", + "required": [ + "license" + ], + "additionalProperties": false, + "properties": { + "license": { + "$ref": "#/definitions/license" + } + } + } + }, + { + "title": "SPDX License Expression", + "description": "A tuple of exactly one SPDX License Expression.", + "type": "array", + "additionalItems": false, + "minItems": 1, + "maxItems": 1, + "items": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "expression" + ], + "properties": { + "expression": { + "type": "string", + "title": "SPDX License Expression", + "examples": [ + "Apache-2.0 AND (MIT OR GPL-2.0-only)", + "GPL-3.0-only WITH Classpath-exception-2.0" + ] + }, + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the license elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + } + } + } + ] + } + ] + }, + "commit": { + "type": "object", + "title": "Commit", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "uid": { + "type": "string", + "title": "UID", + "description": "A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision numbers whereas git uses commit hashes." + }, + "url": { + "type": "string", + "title": "URL", + "description": "The URL to the commit. This URL will typically point to a commit in a version control system.", + "format": "iri-reference" + }, + "author": { + "title": "Author", + "description": "The author who created the changes in the commit", + "$ref": "#/definitions/identifiableAction" + }, + "committer": { + "title": "Committer", + "description": "The person who committed or pushed the commit", + "$ref": "#/definitions/identifiableAction" + }, + "message": { + "type": "string", + "title": "Message", + "description": "The text description of the contents of the commit" + } + } + }, + "patch": { + "type": "object", + "title": "Patch", + "description": "Specifies an individual patch", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "unofficial", + "monkey", + "backport", + "cherry-pick" + ], + "title": "Type", + "description": "Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or functionality.\n\n* __unofficial__ = A patch which is not developed by the creators or maintainers of the software being patched. Refer to [https://en.wikipedia.org/wiki/Unofficial_patch](https://en.wikipedia.org/wiki/Unofficial_patch)\n* __monkey__ = A patch which dynamically modifies runtime behavior. Refer to [https://en.wikipedia.org/wiki/Monkey_patch](https://en.wikipedia.org/wiki/Monkey_patch)\n* __backport__ = A patch which takes code from a newer version of software and applies it to older versions of the same software. Refer to [https://en.wikipedia.org/wiki/Backporting](https://en.wikipedia.org/wiki/Backporting)\n* __cherry-pick__ = A patch created by selectively applying commits from other versions or branches of the same software." + }, + "diff": { + "title": "Diff", + "description": "The patch file (or diff) that show changes. Refer to [https://en.wikipedia.org/wiki/Diff](https://en.wikipedia.org/wiki/Diff)", + "$ref": "#/definitions/diff" + }, + "resolves": { + "type": "array", + "items": { + "$ref": "#/definitions/issue" + }, + "title": "Resolves", + "description": "A collection of issues the patch resolves" + } + } + }, + "diff": { + "type": "object", + "title": "Diff", + "description": "The patch file (or diff) that show changes. Refer to https://en.wikipedia.org/wiki/Diff", + "additionalProperties": false, + "properties": { + "text": { + "title": "Diff text", + "description": "Specifies the optional text of the diff", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "URL", + "description": "Specifies the URL to the diff", + "format": "iri-reference" + } + } + }, + "issue": { + "type": "object", + "title": "Diff", + "description": "An individual issue that has been resolved.", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "defect", + "enhancement", + "security" + ], + "title": "Type", + "description": "Specifies the type of issue" + }, + "id": { + "type": "string", + "title": "ID", + "description": "The identifier of the issue assigned by the source of the issue" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the issue" + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the issue" + }, + "source": { + "type": "object", + "title": "Source", + "description": "The source of the issue where it is documented", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source. For example 'National Vulnerability Database', 'NVD', and 'Apache'" + }, + "url": { + "type": "string", + "title": "URL", + "description": "The url of the issue documentation as provided by the source", + "format": "iri-reference" + } + } + }, + "references": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "References", + "description": "A collection of URL's for reference. Multiple URLs are allowed.", + "examples": [ + "https://example.com" + ] + } + } + }, + "identifiableAction": { + "type": "object", + "title": "Identifiable Action", + "description": "Specifies an individual commit", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The timestamp in which the action occurred" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the individual who performed the action" + }, + "email": { + "type": "string", + "format": "idn-email", + "title": "E-mail", + "description": "The email address of the individual who performed the action" + } + } + }, + "externalReference": { + "type": "object", + "title": "External Reference", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM.", + "required": [ + "url", + "type" + ], + "additionalProperties": false, + "properties": { + "url": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link", + "$ref": "#/definitions/bomLink" + } + ], + "title": "URL", + "description": "The URI (URL or URN) to the external reference. External references are URIs and therefore can accept any URL scheme including https ([RFC-7230](https://www.ietf.org/rfc/rfc7230.txt)), mailto ([RFC-2368](https://www.ietf.org/rfc/rfc2368.txt)), tel ([RFC-3966](https://www.ietf.org/rfc/rfc3966.txt)), and dns ([RFC-4501](https://www.ietf.org/rfc/rfc4501.txt)). External references may also include formally registered URNs such as [CycloneDX BOM-Link](https://cyclonedx.org/capabilities/bomlink/) to reference CycloneDX BOMs or any object within a BOM. BOM-Link transforms applicable external references into relationships that can be expressed in a BOM or across BOMs." + }, + "comment": { + "type": "string", + "title": "Comment", + "description": "An optional comment describing the external reference" + }, + "type": { + "type": "string", + "title": "Type", + "description": "Specifies the type of external reference.\n\n* __vcs__ = Version Control System\n* __issue-tracker__ = Issue or defect tracking system, or an Application Lifecycle Management (ALM) system\n* __website__ = Website\n* __advisories__ = Security advisories\n* __bom__ = Bill of Materials (SBOM, OBOM, HBOM, SaaSBOM, etc)\n* __mailing-list__ = Mailing list or discussion group\n* __social__ = Social media account\n* __chat__ = Real-time chat platform\n* __documentation__ = Documentation, guides, or how-to instructions\n* __support__ = Community or commercial support\n* __distribution__ = Direct or repository download location\n* __distribution-intake__ = The location where a component was published to. This is often the same as \"distribution\" but may also include specialized publishing processes that act as an intermediary\n* __license__ = The URL to the license file. If a license URL has been defined in the license node, it should also be defined as an external reference for completeness\n* __build-meta__ = Build-system specific meta file (i.e. pom.xml, package.json, .nuspec, etc)\n* __build-system__ = URL to an automated build system\n* __release-notes__ = URL to release notes\n* __security-contact__ = Specifies a way to contact the maintainer, supplier, or provider in the event of a security incident. Common URIs include links to a disclosure procedure, a mailto (RFC-2368) that specifies an email address, a tel (RFC-3966) that specifies a phone number, or dns (RFC-4501) that specifies the records containing DNS Security TXT\n* __model-card__ = A model card describes the intended uses of a machine learning model, potential limitations, biases, ethical considerations, training parameters, datasets used to train the model, performance metrics, and other relevant data useful for ML transparency\n* __log__ = A record of events that occurred in a computer system or application, such as problems, errors, or information on current operations\n* __configuration__ = Parameters or settings that may be used by other components or services\n* __evidence__ = Information used to substantiate a claim\n* __formulation__ = Describes how a component or service was manufactured or deployed\n* __attestation__ = Human or machine-readable statements containing facts, evidence, or testimony\n* __threat-model__ = An enumeration of identified weaknesses, threats, and countermeasures, dataflow diagram (DFD), attack tree, and other supporting documentation in human-readable or machine-readable format\n* __adversary-model__ = The defined assumptions, goals, and capabilities of an adversary.\n* __risk-assessment__ = Identifies and analyzes the potential of future events that may negatively impact individuals, assets, and/or the environment. Risk assessments may also include judgments on the tolerability of each risk.\n* __vulnerability-assertion__ = A Vulnerability Disclosure Report (VDR) which asserts the known and previously unknown vulnerabilities that affect a component, service, or product including the analysis and findings describing the impact (or lack of impact) that the reported vulnerability has on a component, service, or product.\n* __exploitability-statement__ = A Vulnerability Exploitability eXchange (VEX) which asserts the known vulnerabilities that do not affect a product, product family, or organization, and optionally the ones that do. The VEX should include the analysis and findings describing the impact (or lack of impact) that the reported vulnerability has on the product, product family, or organization.\n* __pentest-report__ = Results from an authorized simulated cyberattack on a component or service, otherwise known as a penetration test\n* __static-analysis-report__ = SARIF or proprietary machine or human-readable report for which static analysis has identified code quality, security, and other potential issues with the source code\n* __dynamic-analysis-report__ = Dynamic analysis report that has identified issues such as vulnerabilities and misconfigurations\n* __runtime-analysis-report__ = Report generated by analyzing the call stack of a running application\n* __component-analysis-report__ = Report generated by Software Composition Analysis (SCA), container analysis, or other forms of component analysis\n* __maturity-report__ = Report containing a formal assessment of an organization, business unit, or team against a maturity model\n* __certification-report__ = Industry, regulatory, or other certification from an accredited (if applicable) certification body\n* __quality-metrics__ = Report or system in which quality metrics can be obtained\n* __codified-infrastructure__ = Code or configuration that defines and provisions virtualized infrastructure, commonly referred to as Infrastructure as Code (IaC)\n* __poam__ = Plans of Action and Milestones (POAM) compliment an \"attestation\" external reference. POAM is defined by NIST as a \"document that identifies tasks needing to be accomplished. It details resources required to accomplish the elements of the plan, any milestones in meeting the tasks and scheduled completion dates for the milestones\".\n* __other__ = Use this if no other types accurately describe the purpose of the external reference", + "enum": [ + "vcs", + "issue-tracker", + "website", + "advisories", + "bom", + "mailing-list", + "social", + "chat", + "documentation", + "support", + "distribution", + "distribution-intake", + "license", + "build-meta", + "build-system", + "release-notes", + "security-contact", + "model-card", + "log", + "configuration", + "evidence", + "formulation", + "attestation", + "threat-model", + "adversary-model", + "risk-assessment", + "vulnerability-assertion", + "exploitability-statement", + "pentest-report", + "static-analysis-report", + "dynamic-analysis-report", + "runtime-analysis-report", + "component-analysis-report", + "maturity-report", + "certification-report", + "codified-infrastructure", + "quality-metrics", + "poam", + "other" + ] + }, + "hashes": { + "type": "array", + "items": { + "$ref": "#/definitions/hash" + }, + "title": "Hashes", + "description": "The hashes of the external reference (if applicable)." + } + } + }, + "dependency": { + "type": "object", + "title": "Dependency", + "description": "Defines the direct dependencies of a component or service. Components or services that do not have their own dependencies MUST be declared as empty elements within the graph. Components or services that are not represented in the dependency graph MAY have unknown dependencies. It is RECOMMENDED that implementations assume this to be opaque and not an indicator of a object being dependency-free. It is RECOMMENDED to leverage compositions to indicate unknown dependency graphs.", + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "$ref": "#/definitions/refLinkType", + "title": "Reference", + "description": "References a component or service by its bom-ref attribute" + }, + "dependsOn": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/refLinkType" + }, + "title": "Depends On", + "description": "The bom-ref identifiers of the components or services that are dependencies of this dependency object." + } + } + }, + "service": { + "type": "object", + "title": "Service Object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the service elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "provider": { + "title": "Provider", + "description": "The organization that provides the service.", + "$ref": "#/definitions/organizationalEntity" + }, + "group": { + "type": "string", + "title": "Service Group", + "description": "The grouping name, namespace, or identifier. This will often be a shortened, single name of the company or project that produced the service or domain name. Whitespace and special characters should be avoided.", + "examples": [ + "com.acme" + ] + }, + "name": { + "type": "string", + "title": "Service Name", + "description": "The name of the service. This will often be a shortened, single name of the service.", + "examples": [ + "ticker-service" + ] + }, + "version": { + "type": "string", + "title": "Service Version", + "description": "The service version.", + "examples": [ + "1.0.0" + ] + }, + "description": { + "type": "string", + "title": "Service Description", + "description": "Specifies a description for the service" + }, + "endpoints": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + }, + "title": "Endpoints", + "description": "The endpoint URIs of the service. Multiple endpoints are allowed.", + "examples": [ + "https://example.com/api/v1/ticker" + ] + }, + "authenticated": { + "type": "boolean", + "title": "Authentication Required", + "description": "A boolean value indicating if the service requires authentication. A value of true indicates the service requires authentication prior to use. A value of false indicates the service does not require authentication." + }, + "x-trust-boundary": { + "type": "boolean", + "title": "Crosses Trust Boundary", + "description": "A boolean value indicating if use of the service crosses a trust zone or boundary. A value of true indicates that by using the service, a trust boundary is crossed. A value of false indicates that by using the service, a trust boundary is not crossed." + }, + "trustZone": { + "type": "string", + "title": "Trust Zone", + "description": "The name of the trust zone the service resides in." + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/serviceData" + }, + "title": "Data", + "description": "Specifies information about the data including the directional flow of data and the data classification." + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "Component License(s)" + }, + "externalReferences": { + "type": "array", + "items": { + "$ref": "#/definitions/externalReference" + }, + "title": "External References", + "description": "External references provide a way to document systems, sites, and information that may be relevant, but are not included with the BOM. They may also establish specific relationships within or external to the BOM." + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true, + "title": "Services", + "description": "A list of services included or deployed behind the parent service. This is not a dependency tree. It provides a way to specify a hierarchical representation of service assemblies." + }, + "releaseNotes": { + "$ref": "#/definitions/releaseNotes", + "title": "Release notes", + "description": "Specifies optional release notes." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "serviceData": { + "type": "object", + "title": "Hash Objects", + "required": [ + "flow", + "classification" + ], + "additionalProperties": false, + "properties": { + "flow": { + "$ref": "#/definitions/dataFlowDirection", + "title": "Directional Flow", + "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known." + }, + "classification": { + "$ref": "#/definitions/dataClassification" + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name for the defined data", + "examples": [ + "Credit card reporting" + ] + }, + "description": { + "type": "string", + "title": "Description", + "description": "Short description of the data content and usage", + "examples": [ + "Credit card information being exchanged in between the web app and the database" + ] + }, + "governance": { + "type": "object", + "title": "Data Governance", + "$ref": "#/definitions/dataGovernance" + }, + "source": { + "type": "array", + "items": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "Source", + "description": "The URI, URL, or BOM-Link of the components or services the data came in from" + }, + "destination": { + "type": "array", + "items": { + "anyOf": [ + { + "title": "URL", + "type": "string", + "format": "iri-reference" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "Destination", + "description": "The URI, URL, or BOM-Link of the components or services the data is sent to" + } + } + }, + "dataFlowDirection": { + "type": "string", + "enum": [ + "inbound", + "outbound", + "bi-directional", + "unknown" + ], + "title": "Data flow direction", + "description": "Specifies the flow direction of the data. Direction is relative to the service. Inbound flow states that data enters the service. Outbound flow states that data leaves the service. Bi-directional states that data flows both ways, and unknown states that the direction is not known." + }, + "copyright": { + "type": "object", + "title": "Copyright", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "title": "Copyright Text" + } + } + }, + "componentEvidence": { + "type": "object", + "title": "Evidence", + "description": "Provides the ability to document evidence collected through various forms of extraction or analysis.", + "additionalProperties": false, + "properties": { + "identity": { + "type": "object", + "description": "Evidence that substantiates the identity of a component.", + "required": [ + "field" + ], + "additionalProperties": false, + "properties": { + "field": { + "type": "string", + "enum": [ + "group", + "name", + "version", + "purl", + "cpe", + "swid", + "hash" + ], + "title": "Field", + "description": "The identity field of the component which the evidence describes." + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Confidence", + "description": "The overall confidence of the evidence from 0 - 1, where 1 is 100% confidence." + }, + "methods": { + "type": "array", + "title": "Methods", + "description": "The methods used to extract and/or analyze the evidence.", + "items": { + "type": "object", + "required": [ + "technique", + "confidence" + ], + "additionalProperties": false, + "properties": { + "technique": { + "title": "Technique", + "description": "The technique used in this method of analysis.", + "type": "string", + "enum": [ + "source-code-analysis", + "binary-analysis", + "manifest-analysis", + "ast-fingerprint", + "hash-comparison", + "instrumentation", + "dynamic-analysis", + "filename", + "attestation", + "other" + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "title": "Confidence", + "description": "The confidence of the evidence from 0 - 1, where 1 is 100% confidence. Confidence is specific to the technique used. Each technique of analysis can have independent confidence." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value or contents of the evidence." + } + } + } + }, + "tools": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "BOM References", + "description": "The object in the BOM identified by its bom-ref. This is often a component or service, but may be any object type supporting bom-refs. Tools used for analysis should already be defined in the BOM, either in the metadata/tools, components, or formulation." + } + } + }, + "occurrences": { + "type": "array", + "title": "Occurrences", + "description": "Evidence of individual instances of a component spread across multiple locations.", + "items": { + "type": "object", + "required": [ + "location" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the occurrence elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "location": { + "type": "string", + "title": "Location", + "description": "The location or path to where the component was found." + } + } + } + }, + "callstack": { + "type": "object", + "description": "Evidence of the components use through the callstack.", + "additionalProperties": false, + "properties": { + "frames": { + "type": "array", + "title": "Methods", + "items": { + "type": "object", + "required": [ + "module" + ], + "additionalProperties": false, + "properties": { + "package": { + "title": "Package", + "description": "A package organizes modules into namespaces, providing a unique namespace for each type it contains.", + "type": "string" + }, + "module": { + "title": "Module", + "description": "A module or class that encloses functions/methods and other code.", + "type": "string" + }, + "function": { + "title": "Function", + "description": "A block of code designed to perform a particular task.", + "type": "string" + }, + "parameters": { + "title": "Parameters", + "description": "Optional arguments that are passed to the module or function.", + "type": "array", + "items": { + "type": "string" + } + }, + "line": { + "title": "Line", + "description": "The line number the code that is called resides on.", + "type": "integer" + }, + "column": { + "title": "Column", + "description": "The column the code that is called resides.", + "type": "integer" + }, + "fullFilename": { + "title": "Full Filename", + "description": "The full path and filename of the module.", + "type": "string" + } + } + } + } + } + }, + "licenses": { + "$ref": "#/definitions/licenseChoice", + "title": "Component License(s)" + }, + "copyright": { + "type": "array", + "items": { + "$ref": "#/definitions/copyright" + }, + "title": "Copyright" + } + } + }, + "compositions": { + "type": "object", + "title": "Compositions", + "required": [ + "aggregate" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the composition elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "aggregate": { + "$ref": "#/definitions/aggregateType", + "title": "Aggregate", + "description": "Specifies an aggregate type that describe how complete a relationship is.\n\n* __complete__ = The relationship is complete. No further relationships including constituent components, services, or dependencies are known to exist.\n* __incomplete__ = The relationship is incomplete. Additional relationships exist and may include constituent components, services, or dependencies.\n* __incomplete_first_party_only__ = The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented.\n* __incomplete_first_party_proprietary_only__ = The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented, limited specifically to those that are proprietary.\n* __incomplete_first_party_opensource_only__ = The relationship is incomplete. Only relationships for first-party components, services, or their dependencies are represented, limited specifically to those that are opensource.\n* __incomplete_third_party_only__ = The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented.\n* __incomplete_third_party_proprietary_only__ = The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented, limited specifically to those that are proprietary.\n* __incomplete_third_party_opensource_only__ = The relationship is incomplete. Only relationships for third-party components, services, or their dependencies are represented, limited specifically to those that are opensource.\n* __unknown__ = The relationship may be complete or incomplete. This usually signifies a 'best-effort' to obtain constituent components, services, or dependencies but the completeness is inconclusive.\n* __not_specified__ = The relationship completeness is not specified.\n" + }, + "assemblies": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Assemblies refer to nested relationships whereby a constituent part may include other constituent parts. References do not cascade to child parts. References are explicit for the specified constituent part only." + }, + "dependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the components or services being described. Dependencies refer to a relationship whereby an independent constituent part requires another independent constituent part. References do not cascade to transitive dependencies. References are explicit for the specified dependency only." + }, + "vulnerabilities": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "title": "BOM references", + "description": "The bom-ref identifiers of the vulnerabilities being described." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "aggregateType": { + "type": "string", + "default": "not_specified", + "enum": [ + "complete", + "incomplete", + "incomplete_first_party_only", + "incomplete_first_party_proprietary_only", + "incomplete_first_party_opensource_only", + "incomplete_third_party_only", + "incomplete_third_party_proprietary_only", + "incomplete_third_party_opensource_only", + "unknown", + "not_specified" + ] + }, + "property": { + "type": "object", + "title": "Lightweight name-value pair", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the property. Duplicate names are allowed, each potentially having a different value." + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value of the property." + } + } + }, + "localeType": { + "type": "string", + "pattern": "^([a-z]{2})(-[A-Z]{2})?$", + "title": "Locale", + "description": "Defines a syntax for representing two character language code (ISO-639) followed by an optional two character country code. The language code MUST be lower case. If the country code is specified, the country code MUST be upper case. The language code and country code MUST be separated by a minus sign. Examples: en, en-US, fr, fr-CA" + }, + "releaseType": { + "type": "string", + "examples": [ + "major", + "minor", + "patch", + "pre-release", + "internal" + ], + "description": "The software versioning type. It is RECOMMENDED that the release type use one of 'major', 'minor', 'patch', 'pre-release', or 'internal'. Representing all possible software release types is not practical, so standardizing on the recommended values, whenever possible, is strongly encouraged.\n\n* __major__ = A major release may contain significant changes or may introduce breaking changes.\n* __minor__ = A minor release, also known as an update, may contain a smaller number of changes than major releases.\n* __patch__ = Patch releases are typically unplanned and may resolve defects or important security issues.\n* __pre-release__ = A pre-release may include alpha, beta, or release candidates and typically have limited support. They provide the ability to preview a release prior to its general availability.\n* __internal__ = Internal releases are not for public consumption and are intended to be used exclusively by the project or manufacturer that produced it." + }, + "note": { + "type": "object", + "title": "Note", + "description": "A note containing the locale and content.", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "locale": { + "$ref": "#/definitions/localeType", + "title": "Locale", + "description": "The ISO-639 (or higher) language code and optional ISO-3166 (or higher) country code. Examples include: \"en\", \"en-US\", \"fr\" and \"fr-CA\"" + }, + "text": { + "title": "Release note content", + "description": "Specifies the full content of the release note.", + "$ref": "#/definitions/attachment" + } + } + }, + "releaseNotes": { + "type": "object", + "title": "Release notes", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/definitions/releaseType", + "title": "Type", + "description": "The software versioning type the release note describes." + }, + "title": { + "type": "string", + "title": "Title", + "description": "The title of the release." + }, + "featuredImage": { + "type": "string", + "format": "iri-reference", + "title": "Featured image", + "description": "The URL to an image that may be prominently displayed with the release note." + }, + "socialImage": { + "type": "string", + "format": "iri-reference", + "title": "Social image", + "description": "The URL to an image that may be used in messaging on social media platforms." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A short description of the release." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the release note was created." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Aliases", + "description": "One or more alternate names the release may be referred to. This may include unofficial terms used by development and marketing teams (e.g. code names)." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Tags", + "description": "One or more tags that may aid in search or retrieval of the release note." + }, + "resolves": { + "type": "array", + "items": { + "$ref": "#/definitions/issue" + }, + "title": "Resolves", + "description": "A collection of issues that have been resolved." + }, + "notes": { + "type": "array", + "items": { + "$ref": "#/definitions/note" + }, + "title": "Notes", + "description": "Zero or more release notes containing the locale and content. Multiple note objects may be specified to support release notes in a wide variety of languages." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "advisory": { + "type": "object", + "title": "Advisory", + "description": "Title and location where advisory information can be obtained. An advisory is a notification of a threat to a component, service, or system.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "title": "Title", + "description": "An optional name of the advisory." + }, + "url": { + "type": "string", + "title": "URL", + "format": "iri-reference", + "description": "Location where the advisory can be obtained." + } + } + }, + "cwe": { + "type": "integer", + "minimum": 1, + "title": "CWE", + "description": "Integer representation of a Common Weaknesses Enumerations (CWE). For example 399 (of https://cwe.mitre.org/data/definitions/399.html)" + }, + "severity": { + "type": "string", + "title": "Severity", + "description": "Textual representation of the severity of the vulnerability adopted by the analysis method. If the analysis method uses values other than what is provided, the user is expected to translate appropriately.", + "enum": [ + "critical", + "high", + "medium", + "low", + "info", + "none", + "unknown" + ] + }, + "scoreMethod": { + "type": "string", + "title": "Method", + "description": "Specifies the severity or risk scoring methodology or standard used.\n\n* CVSSv2 - [Common Vulnerability Scoring System v2](https://www.first.org/cvss/v2/)\n* CVSSv3 - [Common Vulnerability Scoring System v3](https://www.first.org/cvss/v3-0/)\n* CVSSv31 - [Common Vulnerability Scoring System v3.1](https://www.first.org/cvss/v3-1/)\n* CVSSv4 - [Common Vulnerability Scoring System v4](https://www.first.org/cvss/v4-0/)\n* OWASP - [OWASP Risk Rating Methodology](https://owasp.org/www-community/OWASP_Risk_Rating_Methodology)\n* SSVC - [Stakeholder Specific Vulnerability Categorization](https://github.com/CERTCC/SSVC) (all versions)", + "enum": [ + "CVSSv2", + "CVSSv3", + "CVSSv31", + "CVSSv4", + "OWASP", + "SSVC", + "other" + ] + }, + "impactAnalysisState": { + "type": "string", + "title": "Impact Analysis State", + "description": "Declares the current state of an occurrence of a vulnerability, after automated or manual analysis. \n\n* __resolved__ = the vulnerability has been remediated. \n* __resolved\\_with\\_pedigree__ = the vulnerability has been remediated and evidence of the changes are provided in the affected components pedigree containing verifiable commit history and/or diff(s). \n* __exploitable__ = the vulnerability may be directly or indirectly exploitable. \n* __in\\_triage__ = the vulnerability is being investigated. \n* __false\\_positive__ = the vulnerability is not specific to the component or service and was falsely identified or associated. \n* __not\\_affected__ = the component or service is not affected by the vulnerability. Justification should be specified for all not_affected cases.", + "enum": [ + "resolved", + "resolved_with_pedigree", + "exploitable", + "in_triage", + "false_positive", + "not_affected" + ] + }, + "impactAnalysisJustification": { + "type": "string", + "title": "Impact Analysis Justification", + "description": "The rationale of why the impact analysis state was asserted. \n\n* __code\\_not\\_present__ = the code has been removed or tree-shaked. \n* __code\\_not\\_reachable__ = the vulnerable code is not invoked at runtime. \n* __requires\\_configuration__ = exploitability requires a configurable option to be set/unset. \n* __requires\\_dependency__ = exploitability requires a dependency that is not present. \n* __requires\\_environment__ = exploitability requires a certain environment which is not present. \n* __protected\\_by\\_compiler__ = exploitability requires a compiler flag to be set/unset. \n* __protected\\_at\\_runtime__ = exploits are prevented at runtime. \n* __protected\\_at\\_perimeter__ = attacks are blocked at physical, logical, or network perimeter. \n* __protected\\_by\\_mitigating\\_control__ = preventative measures have been implemented that reduce the likelihood and/or impact of the vulnerability.", + "enum": [ + "code_not_present", + "code_not_reachable", + "requires_configuration", + "requires_dependency", + "requires_environment", + "protected_by_compiler", + "protected_at_runtime", + "protected_at_perimeter", + "protected_by_mitigating_control" + ] + }, + "rating": { + "type": "object", + "title": "Rating", + "description": "Defines the severity or risk ratings of a vulnerability.", + "additionalProperties": false, + "properties": { + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that calculated the severity or risk rating of the vulnerability." + }, + "score": { + "type": "number", + "title": "Score", + "description": "The numerical score of the rating." + }, + "severity": { + "$ref": "#/definitions/severity", + "description": "Textual representation of the severity that corresponds to the numerical score of the rating." + }, + "method": { + "$ref": "#/definitions/scoreMethod" + }, + "vector": { + "type": "string", + "title": "Vector", + "description": "Textual representation of the metric values used to score the vulnerability" + }, + "justification": { + "type": "string", + "title": "Justification", + "description": "An optional reason for rating the vulnerability as it was" + } + } + }, + "vulnerabilitySource": { + "type": "object", + "title": "Source", + "description": "The source of vulnerability information. This is often the organization that published the vulnerability.", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "title": "URL", + "description": "The url of the vulnerability documentation as provided by the source.", + "examples": [ + "https://nvd.nist.gov/vuln/detail/CVE-2021-39182" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the source.", + "examples": [ + "NVD", + "National Vulnerability Database", + "OSS Index", + "VulnDB", + "GitHub Advisories" + ] + } + } + }, + "vulnerability": { + "type": "object", + "title": "Vulnerability", + "description": "Defines a weakness in a component or service that could be exploited or triggered by a threat source.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the vulnerability elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "id": { + "type": "string", + "title": "ID", + "description": "The identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + }, + "references": { + "type": "array", + "title": "References", + "description": "Zero or more pointers to vulnerabilities that are the equivalent of the vulnerability specified. Often times, the same vulnerability may exist in multiple sources of vulnerability intelligence, but have different identifiers. References provide a way to correlate vulnerabilities across multiple sources of vulnerability intelligence.", + "items": { + "type": "object", + "required": [ + "id", + "source" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "An identifier that uniquely identifies the vulnerability.", + "examples": [ + "CVE-2021-39182", + "GHSA-35m5-8cvj-8783", + "SNYK-PYTHON-ENROCRYPT-1912876" + ] + }, + "source": { + "$ref": "#/definitions/vulnerabilitySource", + "description": "The source that published the vulnerability." + } + } + } + }, + "ratings": { + "type": "array", + "title": "Ratings", + "description": "List of vulnerability ratings", + "items": { + "$ref": "#/definitions/rating" + } + }, + "cwes": { + "type": "array", + "title": "CWEs", + "description": "List of Common Weaknesses Enumerations (CWEs) codes that describes this vulnerability. For example 399 (of https://cwe.mitre.org/data/definitions/399.html)", + "examples": [ + 399 + ], + "items": { + "$ref": "#/definitions/cwe" + } + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the vulnerability as provided by the source." + }, + "detail": { + "type": "string", + "title": "Details", + "description": "If available, an in-depth description of the vulnerability as provided by the source organization. Details often include information useful in understanding root cause." + }, + "recommendation": { + "type": "string", + "title": "Recommendation", + "description": "Recommendations of how the vulnerability can be remediated or mitigated." + }, + "workaround": { + "type": "string", + "title": "Workarounds", + "description": "A bypass, usually temporary, of the vulnerability that reduces its likelihood and/or impact. Workarounds often involve changes to configuration or deployments." + }, + "proofOfConcept": { + "type": "object", + "title": "Proof of Concept", + "description": "Evidence used to reproduce the vulnerability.", + "properties": { + "reproductionSteps": { + "type": "string", + "title": "Steps to Reproduce", + "description": "Precise steps to reproduce the vulnerability." + }, + "environment": { + "type": "string", + "title": "Environment", + "description": "A description of the environment in which reproduction was possible." + }, + "supportingMaterial": { + "type": "array", + "title": "Supporting Material", + "description": "Supporting material that helps in reproducing or understanding how reproduction is possible. This may include screenshots, payloads, and PoC exploit code.", + "items": { + "$ref": "#/definitions/attachment" + } + } + } + }, + "advisories": { + "type": "array", + "title": "Advisories", + "description": "Published advisories of the vulnerability if provided.", + "items": { + "$ref": "#/definitions/advisory" + } + }, + "created": { + "type": "string", + "format": "date-time", + "title": "Created", + "description": "The date and time (timestamp) when the vulnerability record was created in the vulnerability database." + }, + "published": { + "type": "string", + "format": "date-time", + "title": "Published", + "description": "The date and time (timestamp) when the vulnerability record was first published." + }, + "updated": { + "type": "string", + "format": "date-time", + "title": "Updated", + "description": "The date and time (timestamp) when the vulnerability record was last updated." + }, + "rejected": { + "type": "string", + "format": "date-time", + "title": "Rejected", + "description": "The date and time (timestamp) when the vulnerability record was rejected (if applicable)." + }, + "credits": { + "type": "object", + "title": "Credits", + "description": "Individuals or organizations credited with the discovery of the vulnerability.", + "additionalProperties": false, + "properties": { + "organizations": { + "type": "array", + "title": "Organizations", + "description": "The organizations credited with vulnerability discovery.", + "items": { + "$ref": "#/definitions/organizationalEntity" + } + }, + "individuals": { + "type": "array", + "title": "Individuals", + "description": "The individuals, not associated with organizations, that are credited with vulnerability discovery.", + "items": { + "$ref": "#/definitions/organizationalContact" + } + } + } + }, + "tools": { + "oneOf": [ + { + "type": "object", + "title": "Tools", + "description": "The tool(s) used to identify, confirm, or score the vulnerability.", + "additionalProperties": false, + "properties": { + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true, + "title": "Components", + "description": "A list of software and hardware components used as tools" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true, + "title": "Services", + "description": "A list of services used as tools. This may include microservices, function-as-a-service, and other types of network or intra-process services." + } + } + }, + { + "type": "array", + "title": "Tools (legacy)", + "description": "[Deprecated] The tool(s) used to identify, confirm, or score the vulnerability.", + "items": { + "$ref": "#/definitions/tool" + } + } + ] + }, + "analysis": { + "type": "object", + "title": "Impact Analysis", + "description": "An assessment of the impact and exploitability of the vulnerability.", + "additionalProperties": false, + "properties": { + "state": { + "$ref": "#/definitions/impactAnalysisState" + }, + "justification": { + "$ref": "#/definitions/impactAnalysisJustification" + }, + "response": { + "type": "array", + "title": "Response", + "description": "A response to the vulnerability by the manufacturer, supplier, or project responsible for the affected component or service. More than one response is allowed. Responses are strongly encouraged for vulnerabilities where the analysis state is exploitable.", + "items": { + "type": "string", + "enum": [ + "can_not_fix", + "will_not_fix", + "update", + "rollback", + "workaround_available" + ] + } + }, + "detail": { + "type": "string", + "title": "Detail", + "description": "Detailed description of the impact including methods used during assessment. If a vulnerability is not exploitable, this field should include specific details on why the component or service is not impacted by this vulnerability." + }, + "firstIssued": { + "type": "string", + "format": "date-time", + "title": "First Issued", + "description": "The date and time (timestamp) when the analysis was first issued." + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "title": "Last Updated", + "description": "The date and time (timestamp) when the analysis was last updated." + } + } + }, + "affects": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "required": [ + "ref" + ], + "additionalProperties": false, + "properties": { + "ref": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ], + "title": "Reference", + "description": "References a component or service by the objects bom-ref" + }, + "versions": { + "type": "array", + "title": "Versions", + "description": "Zero or more individual versions or range of versions.", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "version" + ] + }, + { + "required": [ + "range" + ] + } + ], + "additionalProperties": false, + "properties": { + "version": { + "description": "A single version of a component or service.", + "$ref": "#/definitions/version" + }, + "range": { + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst", + "$ref": "#/definitions/range" + }, + "status": { + "description": "The vulnerability status for the version or range of versions.", + "$ref": "#/definitions/affectedStatus", + "default": "affected" + } + } + } + } + } + }, + "title": "Affects", + "description": "The components or services that are affected by the vulnerability." + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "affectedStatus": { + "description": "The vulnerability status of a given version or range of versions of a product. The statuses 'affected' and 'unaffected' indicate that the version is affected or unaffected by the vulnerability. The status 'unknown' indicates that it is unknown or unspecified whether the given version is affected. There can be many reasons for an 'unknown' status, including that an investigation has not been undertaken or that a vendor has not disclosed the status.", + "type": "string", + "enum": [ + "affected", + "unaffected", + "unknown" + ] + }, + "version": { + "description": "A single version of a component or service.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "range": { + "description": "A version range specified in Package URL Version Range syntax (vers) which is defined at https://github.com/package-url/purl-spec/VERSION-RANGE-SPEC.rst", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "annotations": { + "type": "object", + "title": "Annotations", + "description": "A comment, note, explanation, or similar textual content which provides additional context to the object(s) being annotated.", + "required": [ + "subjects", + "annotator", + "timestamp", + "text" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the annotation elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "subjects": { + "type": "array", + "uniqueItems": true, + "items": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "title": "BOM References", + "description": "The object in the BOM identified by its bom-ref. This is often a component or service, but may be any object type supporting bom-refs." + }, + "annotator": { + "type": "object", + "title": "Annotator", + "description": "The organization, person, component, or service which created the textual content of the annotation.", + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "individual" + ] + }, + { + "required": [ + "component" + ] + }, + { + "required": [ + "service" + ] + } + ], + "additionalProperties": false, + "properties": { + "organization": { + "description": "The organization that created the annotation", + "$ref": "#/definitions/organizationalEntity" + }, + "individual": { + "description": "The person that created the annotation", + "$ref": "#/definitions/organizationalContact" + }, + "component": { + "description": "The tool or component that created the annotation", + "$ref": "#/definitions/component" + }, + "service": { + "description": "The service that created the annotation", + "$ref": "#/definitions/service" + } + } + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp", + "description": "The date and time (timestamp) when the annotation was created." + }, + "text": { + "type": "string", + "title": "Text", + "description": "The textual content of the annotation." + }, + "signature": { + "$ref": "#/definitions/signature", + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html)." + } + } + }, + "modelCard": { + "$comment": "Model card support in CycloneDX is derived from TensorFlow Model Card Toolkit released under the Apache 2.0 license and available from https://github.com/tensorflow/model-card-toolkit/blob/main/model_card_toolkit/schema/v0.0.2/model_card.schema.json. In addition, CycloneDX model card support includes portions of VerifyML, also released under the Apache 2.0 license and available from https://github.com/cylynx/verifyml/blob/main/verifyml/model_card_toolkit/schema/v0.0.4/model_card.schema.json.", + "type": "object", + "title": "Model Card", + "description": "A model card describes the intended uses of a machine learning model and potential limitations, including biases and ethical considerations. Model cards typically contain the training parameters, which datasets were used to train the model, performance metrics, and other relevant data useful for ML transparency. This object SHOULD be specified for any component of type `machine-learning-model` and MUST NOT be specified for other component types.", + "additionalProperties": false, + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the model card elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "modelParameters": { + "type": "object", + "title": "Model Parameters", + "description": "Hyper-parameters for construction of the model.", + "additionalProperties": false, + "properties": { + "approach": { + "type": "object", + "title": "Approach", + "description": "The overall approach to learning used by the model for problem solving.", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "title": "Learning Type", + "description": "Learning types describing the learning problem or hybrid learning problem.", + "enum": [ + "supervised", + "unsupervised", + "reinforcement-learning", + "semi-supervised", + "self-supervised" + ] + } + } + }, + "task": { + "type": "string", + "title": "Task", + "description": "Directly influences the input and/or output. Examples include classification, regression, clustering, etc." + }, + "architectureFamily": { + "type": "string", + "title": "Architecture Family", + "description": "The model architecture family such as transformer network, convolutional neural network, residual neural network, LSTM neural network, etc." + }, + "modelArchitecture": { + "type": "string", + "title": "Model Architecture", + "description": "The specific architecture of the model such as GPT-1, ResNet-50, YOLOv3, etc." + }, + "datasets": { + "type": "array", + "title": "Datasets", + "description": "The datasets used to train and evaluate the model.", + "items": { + "oneOf": [ + { + "title": "Inline Component Data", + "$ref": "#/definitions/componentData" + }, + { + "type": "object", + "title": "Data Component Reference", + "additionalProperties": false, + "properties": { + "ref": { + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ], + "title": "Reference", + "description": "References a data component by the components bom-ref attribute" + } + } + } + ] + } + }, + "inputs": { + "type": "array", + "title": "Inputs", + "description": "The input format(s) of the model", + "items": { + "$ref": "#/definitions/inputOutputMLParameters" + } + }, + "outputs": { + "type": "array", + "title": "Outputs", + "description": "The output format(s) from the model", + "items": { + "$ref": "#/definitions/inputOutputMLParameters" + } + } + } + }, + "quantitativeAnalysis": { + "type": "object", + "title": "Quantitative Analysis", + "description": "A quantitative analysis of the model", + "additionalProperties": false, + "properties": { + "performanceMetrics": { + "type": "array", + "title": "Performance Metrics", + "description": "The model performance metrics being reported. Examples may include accuracy, F1 score, precision, top-3 error rates, MSC, etc.", + "items": { + "$ref": "#/definitions/performanceMetric" + } + }, + "graphics": { + "$ref": "#/definitions/graphicsCollection" + } + } + }, + "considerations": { + "type": "object", + "title": "Considerations", + "description": "What considerations should be taken into account regarding the model's construction, training, and application?", + "additionalProperties": false, + "properties": { + "users": { + "type": "array", + "title": "Users", + "description": "Who are the intended users of the model?", + "items": { + "type": "string" + } + }, + "useCases": { + "type": "array", + "title": "Use Cases", + "description": "What are the intended use cases of the model?", + "items": { + "type": "string" + } + }, + "technicalLimitations": { + "type": "array", + "title": "Technical Limitations", + "description": "What are the known technical limitations of the model? E.g. What kind(s) of data should the model be expected not to perform well on? What are the factors that might degrade model performance?", + "items": { + "type": "string" + } + }, + "performanceTradeoffs": { + "type": "array", + "title": "Performance Tradeoffs", + "description": "What are the known tradeoffs in accuracy/performance of the model?", + "items": { + "type": "string" + } + }, + "ethicalConsiderations": { + "type": "array", + "title": "Ethical Considerations", + "description": "What are the ethical (or environmental) risks involved in the application of this model?", + "items": { + "$ref": "#/definitions/risk" + } + }, + "fairnessAssessments": { + "type": "array", + "title": "Fairness Assessments", + "description": "How does the model affect groups at risk of being systematically disadvantaged? What are the harms and benefits to the various affected groups?", + "items": { + "$ref": "#/definitions/fairnessAssessment" + } + } + } + }, + "properties": { + "type": "array", + "title": "Properties", + "description": "Provides the ability to document properties in a name-value store. This provides flexibility to include data not officially supported in the standard without having to use additional namespaces or create extensions. Unlike key-value stores, properties support duplicate names, each potentially having different values. Property names of interest to the general public are encouraged to be registered in the [CycloneDX Property Taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). Formal registration is OPTIONAL.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "inputOutputMLParameters": { + "type": "object", + "title": "Input and Output Parameters", + "additionalProperties": false, + "properties": { + "format": { + "description": "The data format for input/output to the model. Example formats include string, image, time-series", + "type": "string" + } + } + }, + "componentData": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "bom-ref": { + "$ref": "#/definitions/refType", + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the dataset elsewhere in the BOM. Every bom-ref MUST be unique within the BOM." + }, + "type": { + "type": "string", + "title": "Type of Data", + "description": "The general theme or subject matter of the data being specified.\n\n* __source-code__ = Any type of code, code snippet, or data-as-code.\n* __configuration__ = Parameters or settings that may be used by other components.\n* __dataset__ = A collection of data.\n* __definition__ = Data that can be used to create new instances of what the definition defines.\n* __other__ = Any other type of data that does not fit into existing definitions.", + "enum": [ + "source-code", + "configuration", + "dataset", + "definition", + "other" + ] + }, + "name": { + "description": "The name of the dataset.", + "type": "string" + }, + "contents": { + "type": "object", + "title": "Data Contents", + "description": "The contents or references to the contents of the data being described.", + "additionalProperties": false, + "properties": { + "attachment": { + "title": "Data Attachment", + "description": "An optional way to include textual or encoded data.", + "$ref": "#/definitions/attachment" + }, + "url": { + "type": "string", + "title": "Data URL", + "description": "The URL to where the data can be retrieved.", + "format": "iri-reference" + }, + "properties": { + "type": "array", + "title": "Configuration Properties", + "description": "Provides the ability to document name-value parameters used for configuration.", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "classification": { + "$ref": "#/definitions/dataClassification" + }, + "sensitiveData": { + "type": "array", + "description": "A description of any sensitive data in a dataset.", + "items": { + "type": "string" + } + }, + "graphics": { + "$ref": "#/definitions/graphicsCollection" + }, + "description": { + "description": "A description of the dataset. Can describe size of dataset, whether it's used for source code, training, testing, or validation, etc.", + "type": "string" + }, + "governance": { + "type": "object", + "title": "Data Governance", + "$ref": "#/definitions/dataGovernance" + } + } + }, + "dataGovernance": { + "type": "object", + "title": "Data Governance", + "additionalProperties": false, + "properties": { + "custodians": { + "type": "array", + "title": "Data Custodians", + "description": "Data custodians are responsible for the safe custody, transport, and storage of data.", + "items": { + "$ref": "#/definitions/dataGovernanceResponsibleParty" + } + }, + "stewards": { + "type": "array", + "title": "Data Stewards", + "description": "Data stewards are responsible for data content, context, and associated business rules.", + "items": { + "$ref": "#/definitions/dataGovernanceResponsibleParty" + } + }, + "owners": { + "type": "array", + "title": "Data Owners", + "description": "Data owners are concerned with risk and appropriate access to data.", + "items": { + "$ref": "#/definitions/dataGovernanceResponsibleParty" + } + } + } + }, + "dataGovernanceResponsibleParty": { + "type": "object", + "additionalProperties": false, + "properties": { + "organization": { + "title": "Organization", + "$ref": "#/definitions/organizationalEntity" + }, + "contact": { + "title": "Individual", + "$ref": "#/definitions/organizationalContact" + } + }, + "oneOf": [ + { + "required": [ + "organization" + ] + }, + { + "required": [ + "contact" + ] + } + ] + }, + "graphicsCollection": { + "type": "object", + "title": "Graphics Collection", + "description": "A collection of graphics that represent various measurements.", + "additionalProperties": false, + "properties": { + "description": { + "description": "A description of this collection of graphics.", + "type": "string" + }, + "collection": { + "description": "A collection of graphics.", + "type": "array", + "items": { + "$ref": "#/definitions/graphic" + } + } + } + }, + "graphic": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "The name of the graphic.", + "type": "string" + }, + "image": { + "title": "Graphic Image", + "description": "The graphic (vector or raster). Base64 encoding MUST be specified for binary images.", + "$ref": "#/definitions/attachment" + } + } + }, + "performanceMetric": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "description": "The type of performance metric.", + "type": "string" + }, + "value": { + "description": "The value of the performance metric.", + "type": "string" + }, + "slice": { + "description": "The name of the slice this metric was computed on. By default, assume this metric is not sliced.", + "type": "string" + }, + "confidenceInterval": { + "description": "The confidence interval of the metric.", + "type": "object", + "additionalProperties": false, + "properties": { + "lowerBound": { + "description": "The lower bound of the confidence interval.", + "type": "string" + }, + "upperBound": { + "description": "The upper bound of the confidence interval.", + "type": "string" + } + } + } + } + }, + "risk": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "The name of the risk.", + "type": "string" + }, + "mitigationStrategy": { + "description": "Strategy used to address this risk.", + "type": "string" + } + } + }, + "fairnessAssessment": { + "type": "object", + "title": "Fairness Assessment", + "description": "Information about the benefits and harms of the model to an identified at risk group.", + "additionalProperties": false, + "properties": { + "groupAtRisk": { + "type": "string", + "description": "The groups or individuals at risk of being systematically disadvantaged by the model." + }, + "benefits": { + "type": "string", + "description": "Expected benefits to the identified groups." + }, + "harms": { + "type": "string", + "description": "Expected harms to the identified groups." + }, + "mitigationStrategy": { + "type": "string", + "description": "With respect to the benefits and harms outlined, please describe any mitigation strategy implemented." + } + } + }, + "dataClassification": { + "type": "string", + "title": "Data Classification", + "description": "Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed." + }, + "formula": { + "title": "Formula", + "description": "Describes workflows and resources that captures rules and other aspects of how the associated BOM component or service was formed.", + "type": "object", + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the formula elsewhere in the BOM. Every bom-ref MUST be unique within the BOM.", + "$ref": "#/definitions/refType" + }, + "components": { + "title": "Components", + "description": "Transient components that are used in tasks that constitute one or more of this formula's workflows", + "type": "array", + "items": { + "$ref": "#/definitions/component" + }, + "uniqueItems": true + }, + "services": { + "title": "Services", + "description": "Transient services that are used in tasks that constitute one or more of this formula's workflows", + "type": "array", + "items": { + "$ref": "#/definitions/service" + }, + "uniqueItems": true + }, + "workflows": { + "title": "Workflows", + "description": "List of workflows that can be declared to accomplish specific orchestrated goals and independently triggered.", + "$comment": "Different workflows can be designed to work together to perform end-to-end CI/CD builds and deployments.", + "type": "array", + "items": { + "$ref": "#/definitions/workflow" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "workflow": { + "title": "Workflow", + "description": "A specialized orchestration task.", + "$comment": "Workflow are as task themselves and can trigger other workflow tasks. These relationships can be modeled in the taskDependencies graph.", + "type": "object", + "required": [ + "bom-ref", + "uid", + "taskTypes" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the workflow elsewhere in the BOM. Every bom-ref MUST be unique within the BOM.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "tasks": { + "title": "Tasks", + "description": "The tasks that comprise the workflow.", + "$comment": "Note that tasks can appear more than once as different instances (by name or UID).", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/task" + } + }, + "taskDependencies": { + "title": "Task dependency graph", + "description": "The graph of dependencies between tasks within the workflow.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/dependency" + } + }, + "taskTypes": { + "title": "Task types", + "description": "Indicates the types of activities performed by the set of workflow tasks.", + "$comment": "Currently, these types reflect common CI/CD actions.", + "type": "array", + "items": { + "$ref": "#/definitions/taskType" + } + }, + "trigger": { + "title": "Trigger", + "description": "The trigger that initiated the task.", + "$ref": "#/definitions/trigger" + }, + "steps": { + "title": "Steps", + "description": "The sequence of steps for the task.", + "type": "array", + "items": { + "$ref": "#/definitions/step" + }, + "uniqueItems": true + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": [ + "a `configuration` file which was declared as a local `component` or `externalReference`" + ], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": [ + "a log file or metrics data produced by the task" + ], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "timeStart": { + "title": "Time start", + "description": "The date and time (timestamp) when the task started.", + "type": "string", + "format": "date-time" + }, + "timeEnd": { + "title": "Time end", + "description": "The date and time (timestamp) when the task ended.", + "type": "string", + "format": "date-time" + }, + "workspaces": { + "title": "Workspaces", + "description": "A set of named filesystem or data resource shareable by workflow tasks.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/workspace" + } + }, + "runtimeTopology": { + "title": "Runtime topology", + "description": "A graph of the component runtime topology for workflow's instance.", + "$comment": "A description of the runtime component and service topology. This can describe a partial or complete topology used to host and execute the task (e.g., hardware, operating systems, configurations, etc.),", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/dependency" + } + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "task": { + "title": "Task", + "description": "Describes the inputs, sequence of steps and resources used to accomplish a task and its output.", + "$comment": "Tasks are building blocks for constructing assemble CI/CD workflows or pipelines.", + "type": "object", + "required": [ + "bom-ref", + "uid", + "taskTypes" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the task elsewhere in the BOM. Every bom-ref MUST be unique within the BOM.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "taskTypes": { + "title": "Task types", + "description": "Indicates the types of activities performed by the set of workflow tasks.", + "$comment": "Currently, these types reflect common CI/CD actions.", + "type": "array", + "items": { + "$ref": "#/definitions/taskType" + } + }, + "trigger": { + "title": "Trigger", + "description": "The trigger that initiated the task.", + "$ref": "#/definitions/trigger" + }, + "steps": { + "title": "Steps", + "description": "The sequence of steps for the task.", + "type": "array", + "items": { + "$ref": "#/definitions/step" + }, + "uniqueItems": true + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": [ + "a `configuration` file which was declared as a local `component` or `externalReference`" + ], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": [ + "a log file or metrics data produced by the task" + ], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "timeStart": { + "title": "Time start", + "description": "The date and time (timestamp) when the task started.", + "type": "string", + "format": "date-time" + }, + "timeEnd": { + "title": "Time end", + "description": "The date and time (timestamp) when the task ended.", + "type": "string", + "format": "date-time" + }, + "workspaces": { + "title": "Workspaces", + "description": "A set of named filesystem or data resource shareable by workflow tasks.", + "type": "array", + "items": { + "$ref": "#/definitions/workspace" + }, + "uniqueItems": true + }, + "runtimeTopology": { + "title": "Runtime topology", + "description": "A graph of the component runtime topology for task's instance.", + "$comment": "A description of the runtime component and service topology. This can describe a partial or complete topology used to host and execute the task (e.g., hardware, operating systems, configurations, etc.),", + "type": "array", + "items": { + "$ref": "#/definitions/dependency" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "step": { + "type": "object", + "description": "Executes specific commands or tools in order to accomplish its owning task as part of a sequence.", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "A name for the step.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the step.", + "type": "string" + }, + "commands": { + "title": "Commands", + "description": "Ordered list of commands or directives for the step", + "type": "array", + "items": { + "$ref": "#/definitions/command" + } + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "command": { + "type": "object", + "additionalProperties": false, + "properties": { + "executed": { + "title": "Executed", + "description": "A text representation of the executed command.", + "type": "string" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "workspace": { + "title": "Workspace", + "description": "A named filesystem or data resource shareable by workflow tasks.", + "type": "object", + "required": [ + "bom-ref", + "uid" + ], + "additionalProperties": false, + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the workspace elsewhere in the BOM. Every bom-ref MUST be unique within the BOM.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "aliases": { + "title": "Aliases", + "description": "The names for the workspace as referenced by other workflow tasks. Effectively, a name mapping so other tasks can use their own local name in their steps.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "accessMode": { + "title": "Access mode", + "description": "Describes the read-write access control for the workspace relative to the owning resource instance.", + "type": "string", + "enum": [ + "read-only", + "read-write", + "read-write-once", + "write-once", + "write-only" + ] + }, + "mountPath": { + "title": "Mount path", + "description": "A path to a location on disk where the workspace will be available to the associated task's steps.", + "type": "string" + }, + "managedDataType": { + "title": "Managed data type", + "description": "The name of a domain-specific data type the workspace represents.", + "$comment": "This property is for CI/CD frameworks that are able to provide access to structured, managed data at a more granular level than a filesystem.", + "examples": [ + "ConfigMap", + "Secret" + ], + "type": "string" + }, + "volumeRequest": { + "title": "Volume request", + "description": "Identifies the reference to the request for a specific volume type and parameters.", + "examples": [ + "a kubernetes Persistent Volume Claim (PVC) name" + ], + "type": "string" + }, + "volume": { + "title": "Volume", + "description": "Information about the actual volume instance allocated to the workspace.", + "$comment": "The actual volume allocated may be different than the request.", + "examples": [ + "see https://kubernetes.io/docs/concepts/storage/persistent-volumes/" + ], + "$ref": "#/definitions/volume" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "volume": { + "title": "Volume", + "description": "An identifiable, logical unit of data storage tied to a physical device.", + "type": "object", + "additionalProperties": false, + "properties": { + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the volume instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the volume instance", + "type": "string" + }, + "mode": { + "title": "Mode", + "description": "The mode for the volume instance.", + "type": "string", + "enum": [ + "filesystem", + "block" + ], + "default": "filesystem" + }, + "path": { + "title": "Path", + "description": "The underlying path created from the actual volume.", + "type": "string" + }, + "sizeAllocated": { + "title": "Size allocated", + "description": "The allocated size of the volume accessible to the associated workspace. This should include the scalar size as well as IEC standard unit in either decimal or binary form.", + "examples": [ + "10GB", + "2Ti", + "1Pi" + ], + "type": "string" + }, + "persistent": { + "title": "Persistent", + "description": "Indicates if the volume persists beyond the life of the resource it is associated with.", + "type": "boolean" + }, + "remote": { + "title": "Remote", + "description": "Indicates if the volume is remotely (i.e., network) attached.", + "type": "boolean" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "trigger": { + "title": "Trigger", + "description": "Represents a resource that can conditionally activate (or fire) tasks based upon associated events and their data.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "bom-ref", + "uid" + ], + "properties": { + "bom-ref": { + "title": "BOM Reference", + "description": "An optional identifier which can be used to reference the trigger elsewhere in the BOM. Every bom-ref MUST be unique within the BOM.", + "$ref": "#/definitions/refType" + }, + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier for the resource instance within its deployment context.", + "type": "string" + }, + "name": { + "title": "Name", + "description": "The name of the resource instance.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the resource instance.", + "type": "string" + }, + "resourceReferences": { + "title": "Resource references", + "description": "References to component or service resources that are used to realize the resource instance.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/resourceReferenceChoice" + } + }, + "type": { + "title": "Type", + "description": "The source type of event which caused the trigger to fire.", + "type": "string", + "enum": [ + "manual", + "api", + "webhook", + "scheduled" + ] + }, + "event": { + "title": "Event", + "description": "The event data that caused the associated trigger to activate.", + "$ref": "#/definitions/event" + }, + "conditions": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/condition" + } + }, + "timeActivated": { + "title": "Time activated", + "description": "The date and time (timestamp) when the trigger was activated.", + "type": "string", + "format": "date-time" + }, + "inputs": { + "title": "Inputs", + "description": "Represents resources and data brought into a task at runtime by executor or task commands", + "examples": [ + "a `configuration` file which was declared as a local `component` or `externalReference`" + ], + "type": "array", + "items": { + "$ref": "#/definitions/inputType" + }, + "uniqueItems": true + }, + "outputs": { + "title": "Outputs", + "description": "Represents resources and data output from a task at runtime by executor or task commands", + "examples": [ + "a log file or metrics data produced by the task" + ], + "type": "array", + "items": { + "$ref": "#/definitions/outputType" + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "event": { + "title": "Event", + "description": "Represents something that happened that may trigger a response.", + "type": "object", + "additionalProperties": false, + "properties": { + "uid": { + "title": "Unique Identifier (UID)", + "description": "The unique identifier of the event.", + "type": "string" + }, + "description": { + "title": "Description", + "description": "A description of the event.", + "type": "string" + }, + "timeReceived": { + "title": "Time Received", + "description": "The date and time (timestamp) when the event was received.", + "type": "string", + "format": "date-time" + }, + "data": { + "title": "Data", + "description": "Encoding of the raw event data.", + "$ref": "#/definitions/attachment" + }, + "source": { + "title": "Source", + "description": "References the component or service that was the source of the event", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "References the component or service that was the target of the event", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "inputType": { + "title": "Input type", + "description": "Type that represents various input data types and formats.", + "type": "object", + "oneOf": [ + { + "required": [ + "resource" + ] + }, + { + "required": [ + "parameters" + ] + }, + { + "required": [ + "environmentVars" + ] + }, + { + "required": [ + "data" + ] + } + ], + "additionalProperties": false, + "properties": { + "source": { + "title": "Source", + "description": "A references to the component or service that provided the input to the task (e.g., reference to a service with data flow value of `inbound`)", + "examples": [ + "source code repository", + "database" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "A reference to the component or service that received or stored the input if not the task itself (e.g., a local, named storage workspace)", + "examples": [ + "workspace", + "directory" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "resource": { + "title": "Resource", + "description": "A reference to an independent resource provided as an input to a task by the workflow runtime.", + "examples": [ + "reference to a configuration file in a repository (i.e., a bom-ref)", + "reference to a scanning service used in a task (i.e., a bom-ref)" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "parameters": { + "title": "Parameters", + "description": "Inputs that have the form of parameters with names and values.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/parameter" + } + }, + "environmentVars": { + "title": "Environment variables", + "description": "Inputs that have the form of parameters with names and values.", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/property" + }, + { + "type": "string" + } + ] + } + }, + "data": { + "title": "Data", + "description": "Inputs that have the form of data.", + "$ref": "#/definitions/attachment" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "outputType": { + "type": "object", + "oneOf": [ + { + "required": [ + "resource" + ] + }, + { + "required": [ + "environmentVars" + ] + }, + { + "required": [ + "data" + ] + } + ], + "additionalProperties": false, + "properties": { + "type": { + "title": "Type", + "description": "Describes the type of data output.", + "type": "string", + "enum": [ + "artifact", + "attestation", + "log", + "evidence", + "metrics", + "other" + ] + }, + "source": { + "title": "Source", + "description": "Component or service that generated or provided the output from the task (e.g., a build tool)", + "$ref": "#/definitions/resourceReferenceChoice" + }, + "target": { + "title": "Target", + "description": "Component or service that received the output from the task (e.g., reference to an artifactory service with data flow value of `outbound`)", + "examples": [ + "a log file described as an `externalReference` within its target domain." + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "resource": { + "title": "Resource", + "description": "A reference to an independent resource generated as output by the task.", + "examples": [ + "configuration file", + "source code", + "scanning service" + ], + "$ref": "#/definitions/resourceReferenceChoice" + }, + "data": { + "title": "Data", + "description": "Outputs that have the form of data.", + "$ref": "#/definitions/attachment" + }, + "environmentVars": { + "title": "Environment variables", + "description": "Outputs that have the form of environment variables.", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/property" + }, + { + "type": "string" + } + ] + }, + "uniqueItems": true + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "resourceReferenceChoice": { + "title": "Resource reference choice", + "description": "A reference to a locally defined resource (e.g., a bom-ref) or an externally accessible resource.", + "$comment": "Enables reference to a resource that participates in a workflow; using either internal (bom-ref) or external (externalReference) types.", + "type": "object", + "additionalProperties": false, + "properties": { + "ref": { + "title": "BOM Reference", + "description": "References an object by its bom-ref attribute", + "anyOf": [ + { + "title": "Ref", + "$ref": "#/definitions/refLinkType" + }, + { + "title": "BOM-Link Element", + "$ref": "#/definitions/bomLinkElementType" + } + ] + }, + "externalReference": { + "title": "External reference", + "description": "Reference to an externally accessible resource.", + "$ref": "#/definitions/externalReference" + } + }, + "oneOf": [ + { + "required": [ + "ref" + ] + }, + { + "required": [ + "externalReference" + ] + } + ] + }, + "condition": { + "title": "Condition", + "description": "A condition that was used to determine a trigger should be activated.", + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "title": "Description", + "description": "Describes the set of conditions which cause the trigger to activate.", + "type": "string" + }, + "expression": { + "title": "Expression", + "description": "The logical expression that was evaluated that determined the trigger should be fired.", + "type": "string" + }, + "properties": { + "type": "array", + "title": "Properties", + "items": { + "$ref": "#/definitions/property" + } + } + } + }, + "taskType": { + "type": "string", + "enum": [ + "copy", + "clone", + "lint", + "scan", + "merge", + "build", + "test", + "deliver", + "deploy", + "release", + "clean", + "other" + ] + }, + "parameter": { + "title": "Parameter", + "description": "A representation of a functional parameter.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "description": "The name of the parameter.", + "type": "string" + }, + "value": { + "title": "Value", + "description": "The value of the parameter.", + "type": "string" + }, + "dataType": { + "title": "Data type", + "description": "The data type of the parameter.", + "type": "string" + } + } + }, + "signature": { + "title": "Signature", + "description": "Enveloped signature in [JSON Signature Format (JSF)](https://cyberphone.github.io/doc/security/jsf.html).", + "type": "object", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "signers": { + "type": "array", + "title": "Signature", + "description": "Unique top level property for Multiple Signatures. (multisignature)", + "items": { + "$ref": "#/definitions/signer" + } + } + } + }, + { + "additionalProperties": false, + "properties": { + "chain": { + "type": "array", + "title": "Signature", + "description": "Unique top level property for Signature Chains. (signaturechain)", + "items": { + "$ref": "#/definitions/signer" + } + } + } + }, + { + "title": "Signature", + "description": "Unique top level property for simple signatures. (signaturecore)", + "$ref": "#/definitions/signer" + } + ] + }, + "signer": { + "type": "object", + "title": "Signature", + "required": [ + "algorithm", + "value" + ], + "additionalProperties": false, + "properties": { + "algorithm": { + "oneOf": [ + { + "type": "string", + "title": "Algorithm", + "description": "Signature algorithm. The currently recognized JWA [RFC7518] and RFC8037 [RFC8037] asymmetric key algorithms. Note: Unlike RFC8037 [RFC8037] JSF requires explicit Ed* algorithm names instead of \"EdDSA\".", + "enum": [ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + "Ed25519", + "Ed448", + "HS256", + "HS384", + "HS512" + ] + }, + { + "type": "string", + "title": "Algorithm", + "description": "Signature algorithm. Note: If proprietary signature algorithms are added, they must be expressed as URIs.", + "format": "uri" + } + ] + }, + "keyId": { + "type": "string", + "title": "Key ID", + "description": "Optional. Application specific string identifying the signature key." + }, + "publicKey": { + "title": "Public key", + "description": "Optional. Public key object.", + "$ref": "#/definitions/publicKey" + }, + "certificatePath": { + "type": "array", + "title": "Certificate path", + "description": "Optional. Sorted array of X.509 [RFC5280] certificates, where the first element must contain the signature certificate. The certificate path must be contiguous but is not required to be complete.", + "items": { + "type": "string" + } + }, + "excludes": { + "type": "array", + "title": "Excludes", + "description": "Optional. Array holding the names of one or more application level properties that must be excluded from the signature process. Note that the \"excludes\" property itself, must also be excluded from the signature process. Since both the \"excludes\" property and the associated data it points to are unsigned, a conforming JSF implementation must provide options for specifying which properties to accept.", + "items": { + "type": "string" + } + }, + "value": { + "type": "string", + "title": "Signature", + "description": "The signature data. Note that the binary representation must follow the JWA [RFC7518] specifications." + } + } + }, + "keyType": { + "type": "string", + "title": "Key type", + "description": "Key type indicator.", + "enum": [ + "EC", + "OKP", + "RSA" + ] + }, + "publicKey": { + "title": "Public key", + "description": "Optional. Public key object.", + "type": "object", + "required": [ + "kty" + ], + "additionalProperties": true, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + } + }, + "allOf": [ + { + "if": { + "properties": { + "kty": { + "const": "EC" + } + } + }, + "then": { + "required": [ + "kty", + "crv", + "x", + "y" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "crv": { + "type": "string", + "title": "Curve name", + "description": "EC curve name.", + "enum": [ + "P-256", + "P-384", + "P-521" + ] + }, + "x": { + "type": "string", + "title": "Coordinate", + "description": "EC curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-521\", the decoded argument must be 66 bytes." + }, + "y": { + "type": "string", + "title": "Coordinate", + "description": "EC curve point Y. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-256\", the decoded argument must be 32 bytes." + } + } + } + }, + { + "if": { + "properties": { + "kty": { + "const": "OKP" + } + } + }, + "then": { + "required": [ + "kty", + "crv", + "x" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "crv": { + "type": "string", + "title": "Curve name", + "description": "EdDSA curve name.", + "enum": [ + "Ed25519", + "Ed448" + ] + }, + "x": { + "type": "string", + "title": "Coordinate", + "description": "EdDSA curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"Ed25519\", the decoded argument must be 32 bytes." + } + } + } + }, + { + "if": { + "properties": { + "kty": { + "const": "RSA" + } + } + }, + "then": { + "required": [ + "kty", + "n", + "e" + ], + "additionalProperties": false, + "properties": { + "kty": { + "$ref": "#/definitions/keyType" + }, + "n": { + "type": "string", + "title": "Modulus", + "description": "RSA modulus." + }, + "e": { + "type": "string", + "title": "Exponent", + "description": "RSA exponent." + } + } + } + } + ] + } + } +} diff --git a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json index 5a81c61ede4..4885e5266c1 100644 --- a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json +++ b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json @@ -15,7 +15,7 @@ "require_password_to_approve": { "type": "boolean" }, - "block_unprotecting_branches": { + "block_branch_modification": { "type": "boolean" } } diff --git a/app/validators/kubernetes_container_resources_validator.rb b/app/validators/kubernetes_container_resources_validator.rb new file mode 100644 index 00000000000..f261f8de27d --- /dev/null +++ b/app/validators/kubernetes_container_resources_validator.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# KubernetesPodContainerResourcesValidator +# +# Validates that value is a Kubernetes resource specifying cpu and memory. +# +# Example: +# +# class Group < ActiveRecord::Base +# validates :resource, presence: true, kubernetes_pod_container_resources: true +# end + +class KubernetesContainerResourcesValidator < ActiveModel::EachValidator # rubocop:disable Gitlab/NamespacedClass -- This is a globally shareable validator, but it's unclear what namespace it should belong in + # https://kubernetes.io/docs/tasks/configure-pod-container/assign-cpu-resource/#cpu-units + # The CPU resource is measured in CPU units. Fractional values are allowed. You can use the suffix m to mean milli. + # (\d+m|\d+(\.\d*)?): Two alternatives separated by |: + # \d+m: Matches positive whole numbers followed by "m". + # \d+(\.\d*)?: Matches positive decimal numbers. + CPU_UNITS = /^(\d+m|\d+(\.\d*)?)$/ + + # https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/#memory-units + # The memory resource is measured in bytes. You can express memory as a plain integer or a fixed-point integer + # with one of these suffixes: E, P, T, G, M, K, Ei, Pi, Ti, Gi, Mi, Ki. + # \d+(\.\d*)?: Matches positive decimal numbers. + # ([EPTGMK]|[EPTGMK][i])?: Optional suffix part, where: + # [EPTGMK]: Matches a single character from the set E, P, T, G, M, K. + # [EPTGMK]i: Matches characters from the set followed by an "i". + MEMORY_UNITS = /^\d+(\.\d*)?([EPTGMK]|[EPTGMK]i)?$/ + + def validate_each(record, attribute, value) + unless value.is_a?(Hash) + record.errors.add(attribute, _("must be a hash")) + return + end + + if value == {} + record.errors.add( + attribute, + _("must be a hash containing 'cpu' and 'memory' attribute of type string") + ) + return + end + + cpu = value.deep_symbolize_keys.fetch(:cpu, nil) + unless cpu.is_a?(String) + record.errors.add( + attribute, + format(_("'cpu: %{cpu}' must be a string"), cpu: cpu) + ) + end + + if cpu.is_a?(String) && !CPU_UNITS.match?(cpu) + record.errors.add( + attribute, + format(_("'cpu: %{cpu}' must match the regex '%{cpu_regex}'"), cpu: cpu, cpu_regex: CPU_UNITS.source) + ) + end + + memory = value.deep_symbolize_keys.fetch(:memory, nil) + unless memory.is_a?(String) + record.errors.add( + attribute, + format(_("'memory: %{memory}' must be a string"), memory: memory) + ) + end + + if memory.is_a?(String) && !MEMORY_UNITS.match?(memory) # rubocop:disable Style/GuardClause -- Easier to read this way + record.errors.add( + attribute, + format(_("'memory: %{memory}' must match the regex '%{memory_regex}'"), + memory: memory, + memory_regex: MEMORY_UNITS.source + ) + ) + end + end +end diff --git a/app/validators/ssh_key_validator.rb b/app/validators/ssh_key_validator.rb new file mode 100644 index 00000000000..74e86fc6644 --- /dev/null +++ b/app/validators/ssh_key_validator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# SshKeyValidator +# +# Custom validator for SSH keys. +# +# class Project < ActiveRecord::Base +# validates :key, ssh_key: true +# end +# +class SshKeyValidator < ActiveModel::EachValidator # rubocop:disable Gitlab/NamespacedClass -- Allow setting ssh_key by convention + def validate_each(record, attribute, value) + public_key = Gitlab::SSHPublicKey.new(value) + + restriction = Gitlab::CurrentSettings.key_restriction_for(public_key.type) + + if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE + record.errors.add(attribute, forbidden_key_type_message) + elsif public_key.bits < restriction + record.errors.add(attribute, "must be at least #{restriction} bits") + end + end + + private + + def forbidden_key_type_message + allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase) + + "type is forbidden. Must be #{Gitlab::Sentence.to_exclusive_sentence(allowed_types)}" + end +end diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml index 638984ae97a..bac781675e2 100644 --- a/app/views/admin/application_settings/_git_lfs_limits.html.haml +++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml @@ -15,4 +15,4 @@ = f.label :throttle_authenticated_git_lfs_period_in_seconds, _('Authenticated Git LFS rate limit period in seconds'), class: 'gl-font-weight-bold' = f.number_field :throttle_authenticated_git_lfs_period_in_seconds, class: 'form-control gl-form-input' - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml index 269a1497324..9e6f4bbd6a0 100644 --- a/app/views/admin/application_settings/_import_export_limits.html.haml +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -34,4 +34,4 @@ = f.label :group_download_export_limit, _('Maximum group export download requests per minute'), class: 'label-bold' = f.number_field :group_download_export_limit, class: 'form-control gl-form-input' - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml index 147aab443b2..e78c0209737 100644 --- a/app/views/admin/application_settings/_issue_limits.html.haml +++ b/app/views/admin/application_settings/_issue_limits.html.haml @@ -6,4 +6,4 @@ = f.label :issues_create_limit, _('Maximum number of requests per minute') = f.number_field :issues_create_limit, class: 'form-control gl-form-input' - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml index 300180f7b9a..1a45cbd33cf 100644 --- a/app/views/admin/application_settings/_network_rate_limits.html.haml +++ b/app/views/admin/application_settings/_network_rate_limits.html.haml @@ -8,7 +8,6 @@ .form-group = f.gitlab_ui_checkbox_component :"throttle_unauthenticated_#{setting_fragment}_enabled", _('Enable unauthenticated API request rate limit'), - checkbox_options: { data: { qa_selector: "throttle_unauthenticated_#{setting_fragment}_checkbox" } }, label_options: { class: 'label-bold' } .form-group = f.label :"throttle_unauthenticated_#{setting_fragment}_requests_per_period", _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold' @@ -21,7 +20,6 @@ .form-group = f.gitlab_ui_checkbox_component :"throttle_authenticated_#{setting_fragment}_enabled", _('Enable authenticated API request rate limit'), - checkbox_options: { data: { qa_selector: "throttle_authenticated_#{setting_fragment}_checkbox" } }, label_options: { class: 'label-bold' } .form-group = f.label :"throttle_authenticated_#{setting_fragment}_requests_per_period", _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold' @@ -30,4 +28,4 @@ = f.label :"throttle_authenticated_#{setting_fragment}_period_in_seconds", _('Authenticated API rate limit period in seconds'), class: 'label-bold' = f.number_field :"throttle_authenticated_#{setting_fragment}_period_in_seconds", class: 'form-control gl-form-input' - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { testid: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml index 99cf0ebc669..ed56f35dee4 100644 --- a/app/views/admin/application_settings/_note_limits.html.haml +++ b/app/views/admin/application_settings/_note_limits.html.haml @@ -12,4 +12,4 @@ = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_pipeline_limits.html.haml b/app/views/admin/application_settings/_pipeline_limits.html.haml index b7dffe63777..15942c980c2 100644 --- a/app/views/admin/application_settings/_pipeline_limits.html.haml +++ b/app/views/admin/application_settings/_pipeline_limits.html.haml @@ -6,4 +6,4 @@ = f.label :pipeline_limit_per_project_user_sha, _('Maximum number of requests per minute') = f.number_field :pipeline_limit_per_project_user_sha, class: 'form-control gl-form-input' - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml index c9eff76916a..a937a528e80 100644 --- a/app/views/admin/application_settings/_projects_api_limits.html.haml +++ b/app/views/admin/application_settings/_projects_api_limits.html.haml @@ -18,4 +18,4 @@ .form-text.gl-text-gray-600 = _("Set to 0 to disable the limit.") - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml index b318f7e5a20..98ce675937b 100644 --- a/app/views/admin/application_settings/_search_limits.html.haml +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -19,4 +19,4 @@ = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_security_txt.html.haml b/app/views/admin/application_settings/_security_txt.html.haml new file mode 100644 index 00000000000..3448d5911e6 --- /dev/null +++ b/app/views/admin/application_settings/_security_txt.html.haml @@ -0,0 +1,21 @@ +%section.settings.as-security-txt.no-animate#js-security-txt-settings{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('SecurityTxt|Add security contact information') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = s_('SecurityTxt|Configure a %{codeOpen}security.txt%{codeClose} file.').html_safe % {codeOpen: '<code>'.html_safe, codeClose: '</code>'.html_safe} + = link_to _('Learn more.'), help_page_path('administration/settings/security_contact_information'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + + = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-security-txt-settings'), html: { class: 'fieldset-form', id: 'security-txt-settings' } do |f| + = form_errors(@application_setting) + + .form-group + = f.label :security_txt_content do + = s_("SecurityTxt|Content for security.txt") + = f.text_area :security_txt_content, class: 'form-control gl-form-input', rows: 8 + .form-text.text-muted + = s_('SecurityTxt|When present, this will be publicly available at %{codeOpen}https://gitlab.example.com/.well-known/security.txt%{codeClose}. Maximum 2048 characters.').html_safe % {codeOpen: '<code>'.html_safe, codeClose: '</code>'.html_safe} + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml index 7058a4b5cca..96267237677 100644 --- a/app/views/admin/application_settings/_sentry.html.haml +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -1,8 +1,12 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f| = form_errors(@application_setting) - %fieldset.gl-text-secondary - = safe_format(s_('AdminSettings|GitLab uses the %{bold_start}Rails%{bold_end} and %{bold_start}Browser JavaScript%{bold_end} Sentry SDKs to send events to Sentry. For changes to Rails integration settings to take effect, restart GitLab.'), tag_pair(tag.b, :bold_start, :bold_end)) + - if Feature.disabled?(:enable_new_sentry_integration) || Feature.disabled?(:enable_new_sentry_clientside_integration, current_user) + %fieldset.gl-text-secondary + = safe_format(s_('AdminSettings|GitLab uses the %{bold_start}Rails%{bold_end} and %{bold_start}Browser JavaScript%{bold_end} Sentry SDKs to send events to Sentry. For changes to Rails integration settings to take effect, enable the %{code_start}enable_new_sentry_integration%{code_end} and %{code_start}enable_new_sentry_clientside_integration%{code_end} feature flags and restart GitLab.'), tag_pair(tag.b, :bold_start, :bold_end), tag_pair(tag.code, :code_start, :code_end)) + - else + %fieldset.gl-text-secondary + = safe_format(s_('AdminSettings|GitLab uses the %{bold_start}Rails%{bold_end} and %{bold_start}Browser JavaScript%{bold_end} Sentry SDKs to send events to Sentry. For changes to Rails integration settings to take effect, restart GitLab.'), tag_pair(tag.b, :bold_start, :bold_end)) %fieldset .form-group diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 0f20864fc68..2b972a2d7f1 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -29,12 +29,12 @@ .form-text.text-muted = _('Maximum time that users are allowed to skip the setup of two-factor authentication (in hours). Set to 0 (zero) to enforce at next sign in.') .form-group - = f.label :admin_mode, _('Admin mode'), class: 'label-bold' + = f.label :admin_mode, _('Admin Mode'), class: 'label-bold' = sprite_icon('lock', css_class: 'gl-icon') - help_text = _('Require additional authentication for administrative tasks.') - help_link = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :admin_mode, - _('Enable admin mode'), + _('Enable Admin Mode'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } .form-group = f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml index c21d1ec47e6..4fb65c20daf 100644 --- a/app/views/admin/application_settings/_user_restrictions.html.haml +++ b/app/views/admin/application_settings/_user_restrictions.html.haml @@ -3,6 +3,8 @@ .form-group = label_tag _('User restrictions') = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: form + - if Feature.enabled?(:ui_for_organizations, current_user) + = form.gitlab_ui_checkbox_component :can_create_organization, _("Allow users to create organizations") = form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups") = form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default") = render_if_exists 'admin/application_settings/allow_account_deletion', form: form diff --git a/app/views/admin/application_settings/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml index ca6f1113c4a..7dad581f885 100644 --- a/app/views/admin/application_settings/_users_api_limits.html.haml +++ b/app/views/admin/application_settings/_users_api_limits.html.haml @@ -11,4 +11,4 @@ .form-text.text-muted{ id: 'users-api-limit-users-allowlist-field-description' } = _('List of users who are allowed to exceed the rate limit. Example: username1, username2') - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 1124277d5b3..ae2e7adda89 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -7,5 +7,5 @@ = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') + = s_('CiVariables|Variables store information that you can use in job scripts. All projects on the instance can use these variables.') = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'for-an-instance'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index d84fbe94f65..39f1ec7056c 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -120,4 +120,8 @@ = render_if_exists 'admin/application_settings/add_license' = render 'admin/application_settings/jira_connect' = render 'admin/application_settings/slack' -= render_if_exists 'admin/application_settings/ai_access' +- if Feature.enabled?(:updated_ai_powered_features_menu_for_sm) + = render_if_exists 'admin/application_settings/ai_powered' +- else + = render_if_exists 'admin/application_settings/ai_access' += render 'admin/application_settings/security_txt', expanded: expanded_by_default? diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 23f536bd6d4..efab8bc9432 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -52,14 +52,13 @@ .settings-content = render 'usage' -- if Feature.enabled?(:configure_sentry_in_application_settings) - %section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Sentry') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded_by_default? ? _('Collapse') : _('Expand') - %p.gl-text-secondary - = _('Configure Sentry integration for error tracking') - .settings-content - = render 'sentry' +%section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _('Sentry') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = _('Configure Sentry integration for error tracking') + .settings-content + = render 'sentry' diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml index 99cb63709f5..423eb4b7eb7 100644 --- a/app/views/admin/background_migrations/_migration.html.haml +++ b/app/views/admin/background_migrations/_migration.html.haml @@ -22,7 +22,13 @@ href: resume_admin_background_migration_path(migration, database: params[:database]), button_options: { class: 'has-tooltip', title: _('Resume'), 'aria-label' => _('Resume') }) - elsif migration.failed? - = render Pajamas::ButtonComponent.new(icon: 'retry', - method: :post, - href: retry_admin_background_migration_path(migration, database: params[:database]), - button_options: { class: 'has-tooltip', title: _('Retry'), 'aria-label' => _('Retry') }) + = render Pajamas::ButtonComponent.new(category: :tertiary, + size: :small, + icon: 'ellipsis_v', + button_options: { class: 'js-label-options-dropdown gl-ml-3', 'aria_label': _('Label actions dropdown'), title: _('Label actions dropdown'), data: { toggle: 'dropdown' } }) + .dropdown-menu.dropdown-menu-right + %ul + %li + = link_button_to _('Retry'), retry_admin_background_migration_path(migration, database: params[:database]), method: :post, icon: 'retry', category: :tertiary, title: _('Retry') + %li + = clipboard_button text: migration.finalize_command, variant: :default, size: :medium, title: _('Copy command to finalize manually'), category: :tertiary, button_text: _('Copy command to finalize manually') diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml index 0a5a425397f..059460ae5b2 100644 --- a/app/views/admin/dashboard/stats.html.haml +++ b/app/views/admin/dashboard/stats.html.haml @@ -8,7 +8,7 @@ %p.gl-font-weight-bold.gl-mt-8 = s_('AdminArea|Totals') -%table.table.gl-text-gray-500 +%table.gl-table.gl-text-gray-500 = render_if_exists 'admin/dashboard/stats_active_users_row', users_statistics: @users_statistics %tr.bg-gray-light.gl-text-gray-900 diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index 3d73b255a5e..ef314bf7d6a 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -4,6 +4,6 @@ = gitlab_ui_form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .gl-display-flex.gl-mt-6.gl-gap-3 - = f.submit 'Create', data: { qa_selector: "add_deploy_key_button" }, pajamas_button: true + = f.submit 'Create', pajamas_button: true = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do = _('Cancel') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 6be5aa003fc..1af5df56157 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -210,5 +210,6 @@ locals: { membership_source: @project, group: @group, current_user_is_group_owner: current_user_is_group_owner } - - c.with_footer do - = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' + - unless @project_members.size < Kaminari.config.default_per_page + - c.with_footer do + = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index c61be1182e0..138608edb1f 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -31,8 +31,7 @@ .form-group.gl-mt-3.gl-mb-3 = f.label :avatar, _('Topic avatar'), class: 'gl-display-block' - if @topic.avatar? - .avatar-container.rect-avatar.s90 - = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90') + = render Pajamas::AvatarComponent.new(@topic, alt: _('Topic avatar'), class: 'gl-float-left gl-mr-5', size: 96) = render 'shared/choose_avatar_button', f: f - if @topic.avatar? .js-remove-topic-avatar{ data: { path: admin_topic_avatar_path(@topic), name: @topic.name } } @@ -45,7 +44,7 @@ - else .form-actions - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true = render Pajamas::ButtonComponent.new(href: admin_topics_path) do = _('Cancel') diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index ffe7e128d60..22af1801fc5 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -64,7 +64,7 @@ = f.text_field :linkedin, class: 'form-control gl-form-input gl-form-input-lg' .form-group.gl-form-group{ role: 'group' } - = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' + = f.label :twitter, _('X (formerly Twitter)'), class: 'gl-display-block col-form-label' = f.text_field :twitter, class: 'form-control gl-form-input gl-form-input-lg' .form-group.gl-form-group{ role: 'group' } diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml index df0a59ccfc3..456d27b3a4f 100644 --- a/app/views/admin/users/_profile.html.haml +++ b/app/views/admin/users/_profile.html.haml @@ -20,7 +20,7 @@ %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}" - unless user.twitter.blank? %li - %span.light= _('Twitter:') + %span.light= _('X (formerly Twitter):') %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}" - unless user.website_url.blank? %li diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index bee7e10906b..46fe6bed05e 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -128,6 +128,11 @@ %strong = Gitlab::Access.human_access_with_none(@user.highest_role) + %li + %span.light= _("Email reset removed at:") + %strong + = @user.email_reset_offered_at || _('never') + = render_if_exists 'admin/users/using_license_seat', user: @user - if @user.ldap_user? diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index c46aabf2604..29c2e364c37 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -13,7 +13,7 @@ %br = _("And this registration token:") %br - %code#registration_token{ data: {testid: 'registration_token' } }= registration_token + %code#registration_token= registration_token = deprecated_clipboard_button(target: '#registration_token', title: _("Copy token")) .gl-mt-3.gl-mb-3 diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index f7ab495111a..3509714501b 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,2 +1,2 @@ -= format(s_('CiVariables|Variables store information, like passwords and secret keys, that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe += format(s_('CiVariables|Variables store information that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe = link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/clusters/agents/dashboard/index.html.haml b/app/views/clusters/agents/dashboard/index.html.haml new file mode 100644 index 00000000000..00382166469 --- /dev/null +++ b/app/views/clusters/agents/dashboard/index.html.haml @@ -0,0 +1,12 @@ +- breadcrumb_title s_('KubernetesDashboard|Kubernetes Dashboard') +- page_title s_('KubernetesDashboard|Kubernetes Dashboard') + += render Pajamas::EmptyStateComponent.new(svg_path: 'illustrations/empty-state/empty-search-md.svg', + title: s_("KubernetesDashboard|No agent selected"), + primary_button_text: s_("KubernetesDashboard|View projects"), + primary_button_link: dashboard_projects_path, + secondary_button_text: s_("KubernetesDashboard|Learn more"), + secondary_button_link: help_page_path('ci/environments/kubernetes_dashboard')) do |c| + + - c.with_description do + = s_("KubernetesDashboard|You can select an agent from a project's environment page.") diff --git a/app/views/clusters/agents/dashboard/show.html.haml b/app/views/clusters/agents/dashboard/show.html.haml new file mode 100644 index 00000000000..5b6cfdf8c03 --- /dev/null +++ b/app/views/clusters/agents/dashboard/show.html.haml @@ -0,0 +1,6 @@ +- breadcrumb_title s_('KubernetesDashboard|Dashboard') +- add_to_breadcrumbs s_('KubernetesDashboard|Agents'), kubernetes_dashboard_path +- page_title s_('KubernetesDashboard|Dashboard') + +.js-kubernetes-app{ data: { base_path: kubernetes_dashboard_path, agent: @agent.to_json, kas_tunnel_url: ::Gitlab::Kas.tunnel_url } } + = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 658632b70a6..91cec50226b 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -22,7 +22,11 @@ .top-area = render 'shared/issuable/nav', type: :merge_requests, display_count: !(@no_filters_set || @search_timeout_occurred) -= render 'shared/issuable/search_bar', type: :merge_requests, disable_target_branch: true += render 'shared/issuable/search_bar', + type: :merge_requests, + disable_target_branch: true, + disable_releases: true, + disable_environments: true - if current_user && @no_filters_set = render 'no_filter_selected' diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index 1d2e6e1e332..c3d8da0f9a0 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -1,6 +1,8 @@ - link_classes = "blank-state blank-state-link gl-text-body gl-display-flex gl-align-items-center gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base gl-mb-5" .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between + = render_if_exists "dashboard/projects/blank_state_extra_info" + - if has_start_trial? = render_if_exists "dashboard/projects/blank_state_ee_trial" diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 032c5206d99..15048d29146 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -1,6 +1,8 @@ - link_classes = "blank-state blank-state-link gl-text-body gl-display-flex gl-align-items-center gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base gl-mb-5" .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between + = render_if_exists "dashboard/projects/blank_state_extra_info" + - if current_user.can_create_project? = link_to new_project_path, class: link_classes, data: { testid: 'new-project-button' } do .blank-state-icon diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 35ee9a7679a..7dd4d119a62 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -7,13 +7,13 @@ = f.hidden_field :reset_password_token .form-group.gl-px-5 = f.label _('New password'), for: "user_password" - = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top js-password-complexity-validation", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'} + = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top js-password-complexity-validation", required: true, title: _('This field is required.'), data: { testid: 'password-field'} = render_if_exists 'shared/password_requirements_list' .form-group.gl-px-5 = f.label _('Confirm new password'), for: "user_password_confirmation" - = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true + = f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { testid: 'password-confirmation-field' }, required: true .clearfix.gl-px-5.gl-pb-5 - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'change_password_button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'change-password-button' } }) do = _('Change your password') .clearfix.prepend-top-20 diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 88dd4fd1721..d9b7c986a9f 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,11 +1,10 @@ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'gl-p-5 gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| .form-group = f.label :login, _('Username or primary email') - = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input js-username-field', autocomplete: 'username', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } + = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input js-username-field', autocomplete: 'username', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { testid: 'username-field' } .form-group = f.label :password, _('Password') = f.password_field :password, class: 'form-control gl-form-input js-password', data: { id: "#{resource_name}_password", - qa_selector: 'password_field', testid: 'password-field', name: "#{resource_name}[password]" } .form-text.gl-text-right @@ -22,5 +21,5 @@ .form-group = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: 'js-sign-in-button', data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: 'js-sign-in-button', data: { testid: 'sign-in-button' } }) do = _('Sign in') diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 471cc053e6e..db7b7a4f729 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -6,13 +6,13 @@ = gitlab_ui_form_for(provider, url: omniauth_callback_path(:user, provider), html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }, data: { testid: 'new_ldap_user' }}) do |f| .form-group = f.label :username, _('Username') - = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { qa_selector: 'username_field' }, required: true + = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { testid: 'username-field' }, required: true .form-group = f.label :password, _('Password') - %input.form-control.gl-form-input.js-password{ data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' } } + %input.form-control.gl-form-input.js-password{ data: { id: "#{provider}_password", name: 'password', testid: 'password-field' } } - if render_remember_me = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' } - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'sign_in_button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'sign-in-button' } }) do = submit_message diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index acfb16b64cd..e7ebe6d808c 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -23,12 +23,12 @@ - if Feature.enabled?(:restyle_login_page, @project) && Gitlab::CurrentSettings.current_application_settings.terms %p.gl-px-5 - = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, + = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - if allow_signup? %p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" } = _("Don't have an account yet?") - = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' } + = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { testid: 'register-link' } - if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled? = render 'devise/shared/omniauth_box' diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index e3457040e6c..96f6f5cb095 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -5,7 +5,7 @@ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f| .form-group = f.label :otp_attempt, _('Enter verification code') - = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' } + = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { testid: 'two-fa-code-field' } %p.form-text.text-muted.hint = _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.") @@ -13,7 +13,7 @@ - resource_params = params[resource_name].presence || params = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'verify_code_button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'verify-code-button' } }) do = _("Verify code") - if @user.two_factor_webauthn_enabled? diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 73b9a3d5c5a..45062745b77 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -11,7 +11,7 @@ = _('Sign in with') - enabled_button_based_providers.each do |provider| - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { qa_selector: "#{qa_selector_for_provider(provider)}" }, class: "btn gl-button btn-default gl-mb-2 js-oauth-login gl-w-full", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { testid: "#{test_id_for_provider(provider)}" }, class: "btn gl-button btn-default gl-mb-2 js-oauth-login gl-w-full", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do - if has_icon = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/devise/shared/_signup_box_form.html.haml b/app/views/devise/shared/_signup_box_form.html.haml index 246036b72e1..2920aeecce0 100644 --- a/app/views/devise/shared/_signup_box_form.html.haml +++ b/app/views/devise/shared/_signup_box_form.html.haml @@ -6,6 +6,13 @@ = render 'devise/shared/error_messages', resource: resource - if Gitlab::CurrentSettings.invisible_captcha_enabled = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) + - if @invite_email.present? + .form-group + = f.label :email, _('Email'), class: 'gl-display-block' + .gl-font-weight-bold{ 'data-testid': 'invite-email' } + = @invite_email + = f.hidden_field :email, value: @invite_email + = hidden_field_tag :invite_email, @invite_email .name.form-row .col.form-group = f.label :first_name, _('First name'), for: 'new_user_first_name' @@ -36,17 +43,18 @@ %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.') %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') - .form-group - = f.label :email, _('Email') - = f.email_field :email, - class: 'form-control gl-form-input middle js-validate-email', - data: { testid: 'new-user-email-field' }, - required: true, - title: _('Please provide a valid email address.') - %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') - %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') - -# This is used for providing entry to Jihu on email verification - = render_if_exists 'devise/shared/signup_email_additional_info' + - unless @invite_email.present? + .form-group + = f.label :email, _('Email') + = f.email_field :email, + class: 'form-control gl-form-input middle js-validate-email', + data: { testid: 'new-user-email-field' }, + required: true, + title: _('Please provide a valid email address.') + %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') + %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') + -# This is used for providing entry to Jihu on email verification + = render_if_exists 'devise/shared/signup_email_additional_info' .form-group.gl-mb-5 = f.label :password, _('Password') %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password", diff --git a/app/views/devise/shared/_signup_omniauth_provider_button.haml b/app/views/devise/shared/_signup_omniauth_provider_button.haml new file mode 100644 index 00000000000..74f009a97d3 --- /dev/null +++ b/app/views/devise/shared/_signup_omniauth_provider_button.haml @@ -0,0 +1,8 @@ +- data = { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label } +- button_options = { class: 'js-oauth-login', data: data, id: "oauth-login-#{provider}" } + += render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true, button_options: button_options) do + - if provider_has_icon?(provider) + = provider_image_tag(provider) + %span.gl-button-text + = label_for_provider(provider) diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index b9efcaa11b4..9916d3fa026 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -2,23 +2,20 @@ .gl-text-center.gl-pt-5 %label.gl-font-weight-normal = _("Register with:") - .gl-text-center.gl-ml-auto.gl-mr-auto + .gl-display-flex.gl-flex-direction-column.gl-gap-3 - providers.each do |provider| - = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do - - if provider_has_icon?(provider) - = provider_image_tag(provider) - %span.gl-button-text - = label_for_provider(provider) + = render 'devise/shared/signup_omniauth_provider_button', + href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), + provider: provider, + tracking_label: tracking_label + + - else %label.gl-font-weight-bold = _("Create an account using:") - .gl-display-flex.gl-justify-content-between.gl-flex-wrap + .gl-display-flex.gl-flex-direction-column.gl-gap-3 - providers.each do |provider| - = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), - class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", - data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, - id: "oauth-login-#{provider}" do - - if provider_has_icon?(provider) - = provider_image_tag(provider) - %span.gl-button-text - = label_for_provider(provider) + = render 'devise/shared/signup_omniauth_provider_button', + href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), + provider: provider, + tracking_label: tracking_label diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml index b7ba8870df5..9348a5e3451 100644 --- a/app/views/devise/shared/_tab_single.html.haml +++ b/app/views/devise/shared/_tab_single.html.haml @@ -1,2 +1,2 @@ = 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', data: { qa_selector: 'sign_in_tab', testid: 'sign-in-tab' } } + = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1', data: { testid: 'sign-in-tab' } } diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index 76c4cf41a2d..e6bc38ba6dd 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -8,13 +8,13 @@ = render_if_exists "devise/shared/kerberos_tab" - ldap_servers.each_with_index do |server, i| %li.nav-item - = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab', testid: 'ldap-tab' }, role: 'tab' + = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', testid: 'ldap-tab' }, role: 'tab' = render_if_exists 'devise/shared/tab_smartcard' - if show_password_form %li.nav-item - = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' }, role: 'tab' + = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', testid: 'standard-tab' }, role: 'tab' - if render_signup_link && allow_signup? %li.nav-item - = link_to _('Register'), '#register-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab' + = link_to _('Register'), '#register-pane', class: 'nav-link', data: { toggle: 'tab', testid: 'register-tab' }, role: 'tab' diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml index c19d64e789d..3749dc66a04 100644 --- a/app/views/devise/shared/_terms_of_service_notice.html.haml +++ b/app/views/devise/shared/_terms_of_service_notice.html.haml @@ -3,15 +3,15 @@ %p.gl-text-gray-500.gl-mt-5.gl-mb-0 - if Feature.enabled?(:restyle_login_page, @project) - if Gitlab.com? - = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}")) % { button_text: button_text, + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text, link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - else - = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}")) % { button_text: button_text, + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Statement and Cookie Policy%{link_end}")) % { button_text: button_text, link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - else - if Gitlab.com? - = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Statement%{link_end}")) % { button_text: button_text, link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - else - = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Statement%{link_end}")) % { button_text: button_text, link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index fd5088e04b0..c290554a74d 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -51,5 +51,5 @@ = hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method = render Pajamas::ButtonComponent.new(type: :submit, variant: :danger, - button_options: { id: 'commit-changes', class: 'gl-ml-3', qa_selector: 'authorization_button'}) do + button_options: { id: 'commit-changes', class: 'gl-ml-3', testid: 'authorization-button'}) do = _("Authorize") diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index c28fe7c8330..1d1d85246b5 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,7 +1,7 @@ - event = event.present - if event.visible_to_user?(current_user) - .event-item{ class: current_path?('users#activity') ? 'user-profile-activity gl-border-bottom-0 gl-pl-7! gl-pb-3' : '' } + .event-item.gl-border-bottom-0.gl-pb-3{ class: current_path?('users#activity') ? 'user-profile-activity gl-pl-7!' : 'project-activity-item' } .event-item-timestamp.gl-font-sm #{time_ago_with_tooltip(event.created_at)} diff --git a/app/views/external_redirect/external_redirect/index.html.haml b/app/views/external_redirect/external_redirect/index.html.haml index 36bf98cba02..9ef2b21d84c 100644 --- a/app/views/external_redirect/external_redirect/index.html.haml +++ b/app/views/external_redirect/external_redirect/index.html.haml @@ -1,4 +1,3 @@ -- add_page_specific_style 'page_bundles/external_redirect' - page_title _("You're about to leave GitLab") - url = local_assigns.fetch(:url) diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 269a7309ec2..0cc15ef2de3 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -3,24 +3,14 @@ - emails_disabled = @group.emails_disabled? .group-home-panel - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-md-flex-direction-row.gl-gap-3.gl-my-5 - .home-panel-title-row.gl-display-flex.gl-align-items-center - .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } - = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') - %div - %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ itemprop: 'name' } - = @group.name - %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) - = render_if_exists 'shared/tier_badge', source: @group, source_type: 'Group' - .home-panel-metadata.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' } - - if can?(current_user, :read_group, @group) - %span.gl-display-inline-block.gl-vertical-align-middle - = s_("GroupPage|Group ID: %{group_id}") % { group_id: @group.id } - = clipboard_button(title: s_('GroupPage|Copy group ID'), text: @group.id) - - if current_user - %span.gl-ml-3.gl-mb-3 - = render 'shared/members/access_request_links', source: @group + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-sm-flex-direction-row.gl-gap-3.gl-my-5 + .home-panel-title-row.gl-display-flex + = render Pajamas::AvatarComponent.new(@group, alt: @group.name, size: 48, class: 'float-none gl-align-self-start gl-flex-shrink-0 gl-mr-3', avatar_options: { itemprop: 'logo' }) + %h1.home-panel-title.gl-heading-1.gl-mt-3.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-gap-3.gl-word-break-word{ class: 'gl-mb-0!', itemprop: 'name' } + = @group.name + %span.visibility-icon.gl-text-secondary.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } + = visibility_level_icon(@group.visibility_level, options: { class: 'icon' }) + = render_if_exists 'shared/tier_badge', source: @group, namespace_to_track: @group - if current_user .home-panel-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3{ data: { testid: 'group-buttons' } } @@ -30,7 +20,7 @@ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-vertical-align-top', no_flip: 'true' } } - if can_create_subgroups .gl-sm-w-auto.gl-w-full - = render Pajamas::ButtonComponent.new(href: new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), button_options: { data: { qa_selector: 'new_subgroup_button' }, class: 'gl-sm-w-auto gl-w-full'}) do + = render Pajamas::ButtonComponent.new(href: new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), button_options: { data: { testid: 'new-subgroup-button' }, class: 'gl-sm-w-auto gl-w-full'}) do = _("New subgroup") - if can_create_projects @@ -38,9 +28,11 @@ = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { testid: 'new-project-button' }, class: 'gl-sm-w-auto gl-w-full' }) do = _('New project') + = render 'shared/groups_projects_more_actions_dropdown', source: @group + - if @group.description.present? .group-home-desc.mt-1 - .home-panel-description + .home-panel-description.text-break .home-panel-description-markdown.read-more-container{ itemprop: 'description' } = markdown_field(@group, :description) = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-read-more-trigger gl-lg-display-none' }) do diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index 6c5a27e68c4..9cf52115f4f 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -27,16 +27,19 @@ - docs_link = link_to('', help_page_path('user/group/import/index', anchor: 'migrated-group-items'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('GroupsNew|Not all group items are migrated. %{docs_link_start}What items are migrated%{docs_link_end}?'), tag_pair(docs_link, :docs_link_start, :docs_link_end)) - %p.gl-mt-3 - = s_('GroupsNew|Provide credentials for the source instance to import from. You can provide this instance as a source to move groups in this instance.') - .form-group.gl-display-flex.gl-flex-direction-column - = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source instance URL'), for: 'import_gitlab_url' + %p.gl-mt-5.gl-mb-3 + - url_link = link_to('', help_page_path('user/group/import/index', anchor: 'connect-the-source-gitlab-instance'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('GroupsNew|Provide credentials for the %{url_link_start}source instance%{url_link_end} to import from. You can provide this instance as a source to move groups within this instance.'), tag_pair(url_link, :url_link_start, :url_link_end)) + .form-group.gl-form-group.gl-display-flex.gl-flex-direction-column + = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source instance base URL'), for: 'import_gitlab_url' = f.text_field :bulk_import_gitlab_url, disabled: bulk_imports_disabled, placeholder: 'https://gitlab.example.com', class: 'gl-form-input col-xs-12 col-sm-8', required: true, title: s_('GroupsNew|Enter the URL for the source instance.'), id: 'import_gitlab_url', data: { testid: 'import-gitlab-url' } - .form-group.gl-display-flex.gl-flex-direction-column + %small.form-text.text-gl-muted + = s_('Import|Must only contain the base URL of the source GitLab instance.') + .form-group.gl-form-group.gl-display-flex.gl-flex-direction-column = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token', class: 'col-form-label' .gl-font-weight-normal - pat_link = link_to('', help_page_path('user/profile/personal_access_tokens'), target: '_blank') diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index b2ea15d0e47..5c09fdcd021 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -17,7 +17,7 @@ .settings-content = render 'groups/settings/general' -%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content', testid: 'permissions-settings' } } +%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { testid: 'permissions-settings' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Permissions and group features') diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 2f2edec2d80..050c5135344 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -19,8 +19,7 @@ %ul.content-list{ class: 'gl-px-3!' } - @projects.each do |project| %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } - .avatar-container.rect-avatar.s40.gl-flex-shrink-0 - = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) + = render Pajamas::AvatarComponent.new(project, alt: project.name, size: 48, class: 'gl-flex-shrink-0 gl-mr-5') .gl-min-w-0.gl-flex-grow-1 .title = link_to project_path(project), class: 'js-prefetch-document' do @@ -29,7 +28,7 @@ - if project.namespace = project.namespace.human_name \/ - %span.project-name{ data: { qa_selector: 'project_name_content', qa_project_name: project.name } } + %span.project-name = project.name = visibility_level_content(project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon') diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index 059426fd596..ff1d76f470c 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -29,7 +29,7 @@ %li= _('Runner tokens') %li= _('SAML discovery tokens') - if group.export_file_exists? - = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do + = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get } }) do = _('Download export') = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post } }) do = _('Regenerate export') diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 22ed6ea4403..56ee2af1562 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -6,7 +6,7 @@ .row .form-group.col-md-5 = f.label :name, s_('Groups|Group name'), class: 'label-bold' - = f.text_field :name, class: 'form-control', data: { qa_selector: 'group_name_field' } + = f.text_field :name, class: 'form-control', data: { testid: 'group-name-field' } .text-muted = s_('Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.') @@ -17,7 +17,7 @@ .row.gl-mt-3 .form-group.col-md-9 = f.label :description, s_('Groups|Group description (optional)'), class: 'label-bold' - = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + = f.text_area :description, class: 'form-control', rows: 3, maxlength: 500 .row.gl-mt-3 .form-group.col-md-5 @@ -36,4 +36,4 @@ = link_button_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger, category: :secondary .form-group.gl-form-group = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - = f.submit s_('Groups|Save changes'), pajamas_button: true, class: 'js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } + = f.submit s_('Groups|Save changes'), pajamas_button: true, class: 'js-dirty-submit', data: { testid: 'save-name-visibility-settings-button' } diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml index 74f9298133b..81d64d983a0 100644 --- a/app/views/groups/settings/_lfs.html.haml +++ b/app/views/groups/settings/_lfs.html.haml @@ -9,4 +9,4 @@ = f.gitlab_ui_checkbox_component :lfs_enabled, _('Projects in this group can use Git LFS'), help_text: _('Possible to override in each project.'), - checkbox_options: { checked: @group.lfs_enabled?, data: { qa_selector: 'lfs_checkbox' } } + checkbox_options: { checked: @group.lfs_enabled?, data: { testid: 'lfs-checkbox' } } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 8ea80700340..4334c4996f2 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -44,9 +44,11 @@ = render 'groups/settings/subgroup_creation_level', f: f, group: @group = render_if_exists 'groups/settings/prevent_forking', f: f, group: @group = render_if_exists 'groups/settings/service_access_tokens_expiration_enforced', f: f, group: @group + = render_if_exists 'groups/settings/enforce_ssh_certificates', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f, group: @group = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group + = render_if_exists 'groups/settings/security_policies_custom_ci', f: f, group: @group %h5= _('Customer relations') .form-group.gl-mb-3 @@ -55,4 +57,4 @@ checkbox_options: { checked: @group.crm_enabled? }, help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.') - = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mt-3 js-dirty-submit', data: { testid: 'save-permissions-changes-button' } diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml index ef535b8a21c..e0c62d3b800 100644 --- a/app/views/groups/settings/_project_creation_level.html.haml +++ b/app/views/groups/settings/_project_creation_level.html.haml @@ -1,3 +1,3 @@ .form-group = f.label s_('ProjectCreationLevel|Roles allowed to create projects'), class: 'label-bold' - = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control', data: { qa_selector: 'project_creation_level_dropdown' } + = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control', data: { testid: 'project-creation-level-dropdown' } diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml index 03813f6f8a2..cf44f2b69b1 100644 --- a/app/views/groups/settings/_two_factor_auth.html.haml +++ b/app/views/groups/settings/_two_factor_auth.html.haml @@ -9,7 +9,7 @@ .form-group = f.gitlab_ui_checkbox_component :require_two_factor_authentication, _('All users in this group must set up two-factor authentication'), - checkbox_options: { data: { qa_selector: 'require_2fa_checkbox' } } + checkbox_options: { data: { testid: 'require-2fa-checkbox' } } .form-group = f.label :two_factor_grace_period, _('Delay 2FA enforcement (hours)') = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto gl-form-input gl-mb-3' diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 6758598d4dd..32975562875 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -7,7 +7,7 @@ - if show_invite_banner?(@group) = content_for :group_invite_members_banner do .container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" } - .js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/empty-state/empty-merge-requests-md.svg'), + .js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/add-user-sm.svg'), track_label: 'invite_members_banner', invite_members_path: group_group_members_path(@group), callouts_path: group_callouts_path, diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index 4b16c0199ba..7a14c270ae6 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -1,7 +1,15 @@ - page_title _("IDE"), @project.full_name - add_page_specific_style 'page_bundles/web_ide_loader' +// The block below is for the Web IDE +// See: https://gitlab.com/groups/gitlab-org/-/epics/7683 - unless use_new_web_ide? + - @breadcrumb_title = _("IDE") + - @breadcrumb_link = '#' + - @no_container = true + - @content_wrapper_class = 'pb-0' + - add_to_breadcrumbs(s_('Navigation|Your work'), root_path) + - nav 'your_work' # Couldn't get the `project` nav to work easily - add_page_specific_style 'page_bundles/build' - add_page_specific_style 'page_bundles/ide' diff --git a/app/views/ide/oauth_redirect.html.haml b/app/views/ide/oauth_redirect.html.haml new file mode 100644 index 00000000000..e0ce9f9768c --- /dev/null +++ b/app/views/ide/oauth_redirect.html.haml @@ -0,0 +1,3 @@ +- page_title _("IDE") + += render partial: 'shared/ide_root', locals: { data: new_ide_oauth_data, loading_text: _('Authenticating...') } diff --git a/app/views/import/bulk_imports/details.html.haml b/app/views/import/bulk_imports/details.html.haml index 511bf2c38a1..871ea65f6d6 100644 --- a/app/views/import/bulk_imports/details.html.haml +++ b/app/views/import/bulk_imports/details.html.haml @@ -1,5 +1,8 @@ - add_to_breadcrumbs _('New group'), new_group_path - add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane') -- page_title s_('Import|GitLab Migration details') +- add_to_breadcrumbs s_('BulkImport|Direct transfer history'), history_import_bulk_imports_path +- if params[:id].present? + - add_to_breadcrumbs params[:id], history_import_bulk_imports_path(bulk_import_id: params[:id]) +- page_title format(s_('Import|Failures for %{id}'), id: params[:entity_id]) .js-bulk-import-details diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml index 57e3e60a702..483cdc1485f 100644 --- a/app/views/import/bulk_imports/history.html.haml +++ b/app/views/import/bulk_imports/history.html.haml @@ -1,6 +1,9 @@ - add_to_breadcrumbs _('New group'), new_group_path - add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane') +- if params[:bulk_import_id].present? + - add_to_breadcrumbs s_('BulkImport|Direct transfer history'), history_import_bulk_imports_path + - breadcrumb_title params[:bulk_import_id] +- page_title s_('BulkImport|Direct transfer history') - add_page_specific_style 'page_bundles/import' -- page_title _('Import history') #import-history-mount-element{ data: { details_path: details_import_bulk_imports_path, realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } } diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml index e1547920708..7a9dabcc902 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -1,6 +1,8 @@ - add_to_breadcrumbs _('New group'), new_group_path +- add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane') +- breadcrumb_title s_('BulkImport|Direct transfer') +- page_title s_('BulkImport|Import groups by direct transfer') - add_page_specific_style 'page_bundles/import' -- page_title _('Import groups') #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), default_target_namespace: @namespace&.id, diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 24369ff3d39..5893687605c 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -3,13 +3,12 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('github', css_class: 'gl-mr-3', size: 48) - = title -%hr +.gl-border-solid.gl-border-gray-100.gl-border-0.gl-border-b-1 + %h1.gl-font-size-h1.gl-my-0.gl-py-4.gl-display-flex.gl-align-items-center.gl-gap-3 + = sprite_icon('github', size: 24) + %span= title -%p +%p.gl-mt-5 = import_github_authorize_message - if github_import_configured? && !has_ci_cd_only_params? @@ -21,8 +20,9 @@ %hr - unless github_import_configured? || has_ci_cd_only_params? - .bs-callout.bs-callout-info - = import_configure_github_admin_message + = render Pajamas::AlertComponent.new(variant: :info, dismissible: false) do |c| + - c.with_body do + = import_configure_github_admin_message = form_tag personal_access_token_import_github_path, method: :post do .form-group diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index f1a61d72771..97acafe24d0 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -1,9 +1,10 @@ - title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') - page_title title -%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center - .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('github', css_class: 'gl-mr-3', size: 48) - = _('Import repositories from GitHub') + +.gl-border-solid.gl-border-gray-100.gl-border-0.gl-border-b-1 + %h1.gl-font-size-h1.gl-my-0.gl-py-4.gl-display-flex.gl-align-items-center.gl-gap-3 + = sprite_icon('github', size: 24) + %span= _('Import repositories from GitHub') = render 'import/githubish_status', provider: 'github', paginatable: true, diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index c08abfeb813..409b3dbd7cc 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -4,7 +4,7 @@ -# We currently only support `alert`, `notice`, `success`, `warning`, 'toast', and 'raw' - type_to_variant = {'alert' => 'danger', 'notice' => 'info', 'success' => 'success', 'warning' => 'warning'} - closable = %w[alert notice success] -.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' }, class: flash_container_class } +.flash-container.flash-container-page.sticky{ data: { testid: 'flash-container' }, class: flash_container_class } - flash.each do |key, value| - if key == 'toast' && value .js-toast-message{ data: { message: value } } diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 41f663c7c06..5f038ac467d 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -50,7 +50,7 @@ = webpack_bundle_tag 'legacy_sentry' = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? - - if vite_enabled + - if vite_enabled? %meta{ name: 'controller-path', content: controller_full_path } - if Rails.env.development? = vite_client_tag @@ -62,7 +62,7 @@ = yield :page_specific_javascripts - = webpack_bundle_tag 'super_sidebar' if show_super_sidebar? + = webpack_bundle_tag 'super_sidebar' = webpack_controller_bundle_tags diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml deleted file mode 100644 index add518723e5..00000000000 --- a/app/views/layouts/_header_search.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -#js-header-search.header-search-form.is-not-active.gl-relative.gl-w-full{ data: { 'search-context' => header_search_context.to_json, -'search-path' => search_path, -'issues-path' => issues_dashboard_path, -'mr-path' => merge_requests_dashboard_path, -'autocomplete-path' => search_autocomplete_path } } - = form_tag search_path, method: :get do |_f| - .gl-search-box-by-type - = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon') - %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), - class: 'form-control gl-form-input gl-search-box-by-type-input', - autocomplete: 'off', - data: { testid: 'search_box' } } - - = hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group] - = hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project] - - - if header_search_context[:group] || header_search_context[:project] - = hidden_field_tag :scope, header_search_context[:scope] - = hidden_field_tag :search_code, header_search_context[:code_search] - - = hidden_field_tag :snippets, header_search_context[:for_snippets] - = hidden_field_tag :repository_ref, header_search_context[:ref] - = hidden_field_tag :nav_source, 'navbar' - - -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb - - if ENV['RAILS_ENV'] == 'test' - %noscript= button_tag 'Search' - %kbd.gl-absolute.gl-right-3.gl-top-0.keyboard-shortcut-helper.gl-z-index-1.has-tooltip{ data: { html: 'true', - placement: 'bottom' }, - title: html_escape(s_('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } } - = '/' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index fe2c2e968e8..a7caa797a46 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,6 +1,4 @@ -- if show_super_sidebar? - - @left_sidebar = true -.layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class } +.layout-page{ class: page_with_sidebar_class } - if show_super_sidebar? -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new. - group = @parent_group || @group @@ -9,9 +7,6 @@ - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - - if display_whats_new? - #whats-new-app{ data: { version_digest: whats_new_version_digest } } - = render_if_exists "layouts/tanuki_bot_chat" - elsif defined?(nav) && nav @@ -50,5 +45,3 @@ = yield :after_content -# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab-cn/-/issues/81) = render_if_exists "shared/footer/global_footer" - -= render "layouts/nav/top_nav_responsive", class: 'layout-page' if !show_super_sidebar? || !current_user diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 85fff22a3b7..3cb7d55f1ef 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,7 +1,6 @@ - page_title _("Admin Area") - header_title _("Admin Area"), admin_root_path - nav "admin" -- @left_sidebar = true -# This active_nav_link check is also used in `app/views/layouts/nav/sidebar/_admin.html.haml` - is_application_settings = active_nav_link?(controller: [:application_settings, :integrations]) diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 5a66cc0ddb5..78fa40167f8 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -12,11 +12,8 @@ = render 'peek/bar' = header_message - - if show_super_sidebar? - - if !current_user - = render partial: "layouts/header/super_sidebar_logged_out" - - else - = render partial: "layouts/header/default", locals: { project: @project, group: @group } + - if !current_user + = render partial: "layouts/header/super_sidebar_logged_out" = render 'layouts/page', sidebar: sidebar, nav: nav = footer_message diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml index 39f4a755340..58e6eb44be0 100644 --- a/app/views/layouts/dashboard.html.haml +++ b/app/views/layouts/dashboard.html.haml @@ -1,6 +1,5 @@ - header_title _("Your work"), root_path -- @left_sidebar = true - nav (@parent_group ? "group" : "your_work") = render template: "layouts/application" diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6a65b31a002..920771bf4c2 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,7 @@ !!! 5 %html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head", { startup_filename: 'signin' } - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page', testid: 'login-page' } } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, testid: 'login-page' } } = header_message = render "layouts/init_client_detection_flags" - if Feature.enabled?(:restyle_login_page, @project) diff --git a/app/views/layouts/explore.html.haml b/app/views/layouts/explore.html.haml index 02c00a53316..3bae5c50a7e 100644 --- a/app/views/layouts/explore.html.haml +++ b/app/views/layouts/explore.html.haml @@ -1,6 +1,5 @@ - header_title _("Explore"), explore_root_path -- @left_sidebar = true - nav "explore" = render template: "layouts/application" diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index f168c742085..d17212bd61b 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,16 +1,10 @@ -- minimal = local_assigns.fetch(:minimal, false) !!! 5 %html{ class: [user_application_theme, page_class], lang: I18n.locale } = render "layouts/head" %body{ class: "#{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message - - unless minimal - = render partial: "layouts/header/default", locals: { project: @project, group: @group } - .mobile-overlay - .hide-when-top-nav-responsive-open.gl--flex-full.gl-h-full{ class: nav ? ["layout-page", page_with_sidebar_class, "gl-mt-0!"]: '' } - - if defined?(nav) && nav - = render "layouts/nav/sidebar/#{nav}" + .gl--flex-full.gl-h-full .gl--flex-full.gl-flex-direction-column.gl-w-full .alert-wrapper = render 'shared/outdated_browser' @@ -19,6 +13,4 @@ = render "layouts/flash", flash_container_no_margin: true .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch gl-p-0" } = yield - - unless minimal - = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" = footer_message diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 1d67ac942fa..498e9216894 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -3,7 +3,6 @@ - header_title group_title(@group) unless header_title - nav "group" - display_subscription_banner! -- @left_sidebar = true - base_layout = local_assigns[:base_layout] - content_for :flash_message do @@ -23,5 +22,6 @@ = dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert" = dispensable_render_if_exists "shared/free_user_cap_alert", source: @group = dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group += dispensable_render_if_exists "shared/code_suggestions_ga_non_owner_alert", resource: @group = render template: base_layout || "layouts/application" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml deleted file mode 100644 index 75de13d4862..00000000000 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ /dev/null @@ -1,51 +0,0 @@ -- return unless current_user - -%ul - %li.current-user - - if current_user_menu?(:profile) - = link_to current_user, class: 'gl-line-height-20!', data: { user: current_user.username, testid: 'user-profile-link', track_action: "click_link", track_label: "user_profile", track_property: "navigation_top" } do - = render 'layouts/header/current_user_dropdown_item' - - else - .gl-py-3.gl-px-4 - = render 'layouts/header/current_user_dropdown_item' - %li.divider - - if can?(current_user, :update_user_status, current_user) - %li - = render Pajamas::ButtonComponent.new(button_options: { class: 'menu-item js-set-status-modal-trigger' }) do - - if current_user.status&.busy? || current_user.status&.customized? - = s_('SetStatusModal|Edit status') - - else - = s_('SetStatusModal|Set status') - = dispensable_render_if_exists 'layouts/header/start_trial' - - if current_user_menu?(:settings) - %li - = link_to s_("CurrentUser|Edit profile"), profile_path, data: { testid: 'edit_profile_link', track_action: "click_link", track_label: "user_edit_profile", track_property: "navigation_top" } - %li - = link_to s_("CurrentUser|Preferences"), profile_preferences_path, data: { track_action: "click_link", track_label: "user_preferences", track_property: "navigation_top" } - = render_if_exists 'layouts/header/buy_pipeline_minutes', project: @project, namespace: @group - - - if current_user_menu?(:help) - %li.divider.d-md-none - %li.d-md-none - = link_to _("Help"), help_path, data: {track_action: 'click_link', track_label: 'help', track_property: 'navigation_top'} - %li.d-md-none - = link_to _("Support"), support_url, data: {track_action: 'click_link', track_label: 'support', track_property: 'navigation_top'} - %li.d-md-none - = render 'shared/help_dropdown_forum_link' - %li.d-md-none - = link_to _("Submit feedback"), Gitlab::Utils.append_path(promo_url, "submit-feedback"), data: {track_action: 'click_link', track_label: 'submit_feedback', track_property: 'navigation_top'} - - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) - %li.d-md-none - = render 'shared/user_dropdown_contributing_link' - = render 'shared/user_dropdown_instance_review' - - if Gitlab.com_but_not_canary? - %li.d-md-none - = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url, data: { track_action: "click_link", track_label: "switch_to_canary", track_property: "navigation_top" } - - %li.divider - .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_path} } - - - if current_user_menu?(:sign_out) - %li.divider - %li - = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { testid: 'sign_out_link', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" } diff --git a/app/views/layouts/header/_current_user_dropdown_item.html.haml b/app/views/layouts/header/_current_user_dropdown_item.html.haml deleted file mode 100644 index fa0a6364a15..00000000000 --- a/app/views/layouts/header/_current_user_dropdown_item.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.gl-font-weight-bold - = current_user.name - - if current_user.status&.busy? - = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning') -= current_user.to_reference -- if current_user.status - .user-status.d-flex.align-items-center.gl-mt-2.gl-mr-0.gl-font-sm.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } } - - if current_user.status.customized? - .user-status-emoji.d-flex.align-items-center - = emoji_icon current_user.status.emoji - %span.user-status-message.str-truncated - = current_user.status.message_html.html_safe diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml deleted file mode 100644 index 993094c6889..00000000000 --- a/app/views/layouts/header/_default.html.haml +++ /dev/null @@ -1,140 +0,0 @@ -- has_impersonation_link = header_link?(:admin_impersonation) -- user_status_data = user_status_properties(current_user) - -%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar.legacy-top-bar{ data: { testid: 'navbar' } } - %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content - .container-fluid - .header-content.js-header-content - .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 - = render 'layouts/header/title' - - - if current_user - .gl-display-none.gl-sm-display-block - = render "layouts/nav/top_nav" - - else - - if Gitlab.com? - = render 'layouts/header/marketing_links' - - else - .gl-display-none.gl-sm-display-block - = render "layouts/nav/top_nav" - - - if top_nav_show_search - .navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open - - search_menu_item = top_nav_search_menu_item_attrs - %ul.nav.navbar-nav.gl-w-full.gl-align-items-center - %li.nav-item.header-search.gl-display-none.gl-lg-display-block.gl-w-full - - unless current_controller?(:search) - = render 'layouts/header_search' - %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } - = link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, - data: { toggle: 'tooltip', placement: 'bottom', container: 'body', - track_action: 'click_link', - track_label: 'global_search', - track_property: 'navigation_top' } do - = sprite_icon(search_menu_item.fetch(:icon)) - - .navbar-collapse.gl-transition-medium.collapse - %ul.nav.navbar-nav.gl-w-full.gl-align-items-center.gl-justify-content-end - - if current_user - = render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block gl-white-space-nowrap gl-text-right' - - 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 js-prefetch-document', aria: { label: _('Issues') }, - data: { testid: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', - track_label: 'main_navigation', - track_action: 'click_issues_link', - track_property: 'navigation_top', - container: 'body' } do - = sprite_icon('issues') - - issues_count = assigned_issuables_count(:issues) - = gl_badge_tag({ size: :sm, variant: :success }, { class: "gl-ml-n2 #{'gl-display-none' if issues_count == 0}", "aria-label": n_("%d assigned issue", "%d assigned issues", issues_count) % issues_count }) do - = assigned_open_issues_count_text - - if header_link?(:merge_requests) - = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do - - top_level_link = assigned_mrs_dashboard_path - = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests has-tooltip', title: _('Merge requests'), aria: { label: _('Merge requests') }, - data: { testid: 'merge_requests_shortcut_button', - toggle: "dropdown", - placement: 'bottom', - track_label: 'merge_requests_menu', - track_action: 'click_dropdown', - track_property: 'navigation_top', - container: 'body' } do - = sprite_icon('git-merge') - = gl_badge_tag({ size: :sm, variant: :warning }, { class: "js-merge-requests-count gl-ml-n2 #{'gl-display-none' if user_merge_requests_counts[:total] == 0}", "aria-label": n_("%d merge request", "%d merge requests", user_merge_requests_counts[:total]) % user_merge_requests_counts[:total] }) do - = number_with_delimiter(user_merge_requests_counts[:total]) - = sprite_icon('chevron-down', css_class: 'caret-down gl-mx-0!') - .dropdown-menu.dropdown-menu-right - %ul - %li.dropdown-header - = _('Merge requests') - %li - = link_to assigned_mrs_dashboard_path, - class: 'gl-display-flex! gl-align-items-center js-prefetch-document', - data: {track_action: 'click_link', track_label: 'merge_requests_assigned', track_property: 'navigation_top'} do - = _('Assigned') - = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-assigned-mr-count gl-ml-auto" }) do - = user_merge_requests_counts[:assigned] - %li - = link_to reviewer_mrs_dashboard_path, - class: 'dashboard-shortcuts-review_requests gl-display-flex! gl-align-items-center js-prefetch-document', - data: {track_action: 'click_link', track_label: 'merge_requests_to_review', track_property: 'navigation_top'} do - = _('Review requests') - = gl_badge_tag({ variant: :neutral, size: :sm }, { class: "js-reviewer-mr-count gl-ml-auto" }) do - = 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 js-prefetch-document', - data: { testid: 'todos-shortcut-button', toggle: 'tooltip', placement: 'bottom', - track_label: 'main_navigation', - track_action: 'click_to_do_link', - track_property: 'navigation_top', - container: 'body' } do - = sprite_icon('todo-done') - -# The todos' counter badge's visibility is being toggled by adding or removing the .hidden class in Js. - -# We'll eventually migrate to .gl-display-none: https://gitlab.com/gitlab-org/gitlab/-/issues/351792. - = gl_badge_tag({ size: :sm, variant: :info }, { class: "js-todos-count gl-ml-n2 #{'hidden' if todos_pending_count == 0}", "aria-label": _("Todos count") }) do - = todos_count_format(todos_pending_count) - %li.nav-item.header-help.dropdown.d-none.d-md-block - = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown", track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation_top' } do - %span.gl-sr-only - = s_('Nav|Help') - = sprite_icon('question-o') - %span.notification-dot.rounded-circle.gl-absolute - = sprite_icon('chevron-down', css_class: 'caret-down') - .dropdown-menu.dropdown-menu-right - = render 'layouts/header/help_dropdown' - - if !current_user && Gitlab.com? - %li.nav-item.gl-display-none.gl-sm-display-block - = render "layouts/nav/top_nav" - - if header_link?(:user_dropdown) - %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { testid: 'user-dropdown' }, class: ('mr-0' if has_impersonation_link) } - = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown", track_label: "profile_dropdown", track_action: "click_dropdown", track_property: "navigation_top" } do - = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { testid: 'user_avatar_content' } }) - = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group - = sprite_icon('chevron-down', css_class: 'caret-down') - .dropdown-menu.dropdown-menu-right - = render 'layouts/header/current_user_dropdown' - - if has_impersonation_link - %li.nav-item.impersonation.ml-0 - = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', testid: 'stop_impersonation_btn' } }) - - if header_link?(:sign_in) - - if allow_signup? - %li.nav-item - = render Pajamas::ButtonComponent.new(href: new_user_registration_path) do - = _('Register') - %li.nav-item{ class: 'gl-flex-grow-0! gl-flex-basis-half!' } - = link_to _('Sign in'), new_session_path(:user, redirect_to_referer: 'yes') - - %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'mobile_navbar_button' } } - %span.sr-only= _('Toggle navigation') - %span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold - %span.gl-pr-2= _('Menu') - = sprite_icon('hamburger', size: 16) - = sprite_icon('close', size: 12, css_class: 'close-icon') - -- if display_whats_new? - #whats-new-app{ data: { version_digest: whats_new_version_digest } } - -- if can?(current_user, :update_user_status, current_user) - .js-set-status-modal-wrapper{ data: user_status_data } diff --git a/app/views/layouts/header/_gitlab_version.html.haml b/app/views/layouts/header/_gitlab_version.html.haml deleted file mode 100644 index 22771ac09c9..00000000000 --- a/app/views/layouts/header/_gitlab_version.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- return unless show_version_check? - -%a{ - class: 'gl-display-flex! gl-flex-direction-column gl-px-4! gl-py-3! gl-line-height-24!', - href: help_page_path('update/index'), - data: { - testid: 'gitlab-version-container', - track_action: 'click_link', - track_label: 'version_help_dropdown', - track_property: 'navigation_top' - } - } - %span - = s_("VersionCheck|Your GitLab Version") - = emoji_icon('rocket') - %span - %span.gl-font-sm.gl-text-gray-500 - #{Gitlab.version_info.major}.#{Gitlab.version_info.minor} - %span.gl-ml-2 - .js-gitlab-version-check-badge{ data: { "size": "sm", "version": gitlab_version_check.to_json } } diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml deleted file mode 100644 index 38b9a9a5383..00000000000 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -%ul - - if current_user_menu?(:help) - %li - = render 'layouts/header/gitlab_version' - = render 'layouts/header/whats_new_dropdown_item' - %li - = link_to _("Help"), help_path, data: {track_action: 'click_link', track_label: 'help', track_property: 'navigation_top'} - %li - = link_to _("Support"), support_url, data: {track_action: 'click_link', track_label: 'support', track_property: 'navigation_top'} - %li - = render 'shared/help_dropdown_forum_link' - %li - %button.js-shortcuts-modal-trigger{ type: "button", data: {track_action: 'click_button', track_label: 'keyboard_shortcuts_help', track_property: 'navigation_top'} } - = _("Keyboard shortcuts") - %kbd.flat.float-right{ "aria-hidden": "true" }= '?'.html_safe - %li.divider - %li - = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback", data: {track_action: 'click_link', track_label: 'submit_feedback', track_property: 'navigation_top'} - - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) - %li - = render 'shared/user_dropdown_contributing_link' - = render 'shared/user_dropdown_instance_review' - - if Gitlab.com_but_not_canary? - %li - = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url, data: {track_action: 'click_link', track_label: 'gitlab_next', track_property: 'navigation_top'} diff --git a/app/views/layouts/header/_marketing_links.html.haml b/app/views/layouts/header/_marketing_links.html.haml deleted file mode 100644 index c33229e4ec4..00000000000 --- a/app/views/layouts/header/_marketing_links.html.haml +++ /dev/null @@ -1,34 +0,0 @@ -%ul.nav.navbar-sub-nav.gl-display-none.gl-lg-display-flex.gl-align-items-center - %li.dropdown.gl-mr-3 - %button{ type: "button", data: { toggle: "dropdown" } } - = s_('LoggedOutMarketingHeader|About GitLab') - = sprite_icon('chevron-down', css_class: 'caret-down') - .dropdown-menu - %ul - %li - = link_to Gitlab::Utils.append_path(promo_url, 'stages-devops-lifecycle') do - = s_('LoggedOutMarketingHeader|GitLab: the DevOps platform') - %li - = link_to explore_root_path do - = s_('LoggedOutMarketingHeader|Explore GitLab') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'install') do - = s_('LoggedOutMarketingHeader|Install GitLab') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'is-it-any-good') do - = s_('LoggedOutMarketingHeader|How GitLab compares') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'get-started') do - = s_('LoggedOutMarketingHeader|Get started') - %li - = link_to Gitlab::Saas::doc_url do - = s_('LoggedOutMarketingHeader|GitLab docs') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'learn') do - = s_('LoggedOutMarketingHeader|GitLab Learn') - %li.gl-mr-3 - = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do - = s_('LoggedOutMarketingHeader|Pricing') - %li.gl-mr-3 - = link_to Gitlab::Utils.append_path(promo_url, 'sales') do - = s_('LoggedOutMarketingHeader|Talk to an expert') diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml deleted file mode 100644 index 3fe2894f236..00000000000 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ /dev/null @@ -1,38 +0,0 @@ -- view_model = new_dropdown_view_model(project: @project, group: @group) -- menu_sections = view_model.fetch(:menu_sections) -- title = view_model.fetch(:title) -- show_headers = menu_sections.length > 1 -- top_class = local_assigns.fetch(:class, nil) - -- return if menu_sections.empty? - -%li.header-new.gl-flex-grow-1.gl-flex-shrink-1.dropdown{ class: top_class, - data: { track_label: "new_dropdown", track_action: "click_dropdown", track_property: "navigation_top" } } - = link_to new_project_path, - class: "header-new-dropdown-toggle has-tooltip gl-display-flex", - id: "js-onboarding-new-project-link", - title: title, ref: 'tooltip', aria: { label: title }, - data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', - testid: 'new-menu-toggle' } do - = sprite_icon('plus-square') - = sprite_icon('chevron-down', css_class: 'caret-down') - .dropdown-menu.dropdown-menu-right.dropdown-extended-height - %ul - - menu_sections.each_with_index do |section, index| - - if index > 0 - %li.divider - - if show_headers - %li.dropdown-bold-header - = section.fetch(:title) - - section.fetch(:menu_items).each do |menu_item| - %li< - - if menu_item.fetch(:partial).present? - = render partial: menu_item.fetch(:partial), - locals: { display_text: menu_item.fetch(:title), - icon: menu_item.fetch(:icon), - data: menu_item.fetch(:data) } - - else - = link_to menu_item.fetch(:title), - menu_item.fetch(:href), - class: menu_item.fetch(:css_class), - data: menu_item.fetch(:data) diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml index 76c7ea03c2a..fc63400e011 100644 --- a/app/views/layouts/header/_super_sidebar_logged_out.haml +++ b/app/views/layouts/header/_super_sidebar_logged_out.haml @@ -1,47 +1,53 @@ -%header.navbar.navbar-gitlab.super-sidebar-logged-out{ data: { testid: 'navbar' } } +%header.header-logged-out{ data: { testid: 'navbar' } } %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid - %nav.header-content.gl-displax-flex{ 'aria-label': s_('LoggedOutMarketingHeader|Explore GitLab') } - .title-container.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 - = render 'layouts/header/title' + %nav.header-logged-out-nav.gl-display-flex.gl-gap-3.gl-justify-content-space-between{ 'aria-label': s_('LoggedOutMarketingHeader|Explore GitLab') } + .header-logged-out-logo.gl-display-flex.gl-align-items-center + %span.gl-sr-only GitLab + = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', aria: { label: _('Homepage') }, **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do + = brand_header_logo + .gl-display-flex.gl-align-items-center + - if Gitlab.com_and_canary? + = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do + = _('Next') - %ul.nav.navbar-sub-nav.gl-align-items-center.gl-display-flex.gl-flex-direction-row.gl-flex-grow-1 - - if Gitlab.com? - %li.nav-item.dropdown.gl-mr-3.gl-md-display-none - %button{ type: "button", data: { toggle: "dropdown" } } - %span.gl-sr-only - = _('Menu') - = sprite_icon('hamburger', size: 16) - .dropdown-menu - %ul - %li - = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do - = s_('LoggedOutMarketingHeader|Why GitLab') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do - = s_('LoggedOutMarketingHeader|Pricing') - %li - = link_to Gitlab::Utils.append_path(promo_url, 'sales') do - = s_('LoggedOutMarketingHeader|Contact Sales') - %li - = link_to _("Explore"), explore_root_path - %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block - = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do - = s_('LoggedOutMarketingHeader|Why GitLab') - %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block - = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do - = s_('LoggedOutMarketingHeader|Pricing') - %li.nav-item.gl-mr-3.gl-display-none.gl-md-display-inline-block - = link_to Gitlab::Utils.append_path(promo_url, 'sales') do - = s_('LoggedOutMarketingHeader|Contact Sales') - %li.nav-item{ class: ('gl-display-none gl-md-display-inline-block' if Gitlab.com?) } - = link_to _("Explore"), explore_root_path, class: '' + %ul.gl-list-style-none.gl-p-0.gl-m-0.gl-display-flex.gl-gap-3.gl-align-items-center.gl-flex-grow-1 + - if Gitlab.com? + %li.header-logged-out-nav-item.header-logged-out-dropdown.gl-md-display-none + %button.header-logged-out-toggle{ type: "button", data: { toggle: "dropdown" } } + %span.gl-sr-only + = _('Menu') + = sprite_icon('hamburger', size: 16) + .dropdown-menu + %ul + %li + = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do + = s_('LoggedOutMarketingHeader|Why GitLab') + %li + = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do + = s_('LoggedOutMarketingHeader|Pricing') + %li + = link_to Gitlab::Utils.append_path(promo_url, 'sales') do + = s_('LoggedOutMarketingHeader|Contact Sales') + %li + = link_to _("Explore"), explore_root_path + %li.header-logged-out-nav-item.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'why-gitlab') do + = s_('LoggedOutMarketingHeader|Why GitLab') + %li.header-logged-out-nav-item.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do + = s_('LoggedOutMarketingHeader|Pricing') + %li.header-logged-out-nav-item.gl-display-none.gl-md-display-inline-block + = link_to Gitlab::Utils.append_path(promo_url, 'sales') do + = s_('LoggedOutMarketingHeader|Contact Sales') + %li.header-logged-out-nav-item{ class: ('gl-display-none gl-md-display-inline-block' if Gitlab.com?) } + = link_to _("Explore"), explore_root_path, class: '' - if header_link?(:sign_in) - %ul.nav.navbar-nav.gl-align-items-center.gl-justify-content-end.gl-flex-direction-row - %li.nav-item.gl-mr-3 + %ul.gl-list-style-none.gl-p-0.gl-m-0.gl-display-flex.gl-gap-3.gl-align-items-center.gl-justify-content-end + %li.header-logged-out-nav-item = link_to _('Sign in'), new_session_path(:user, redirect_to_referer: 'yes') - if allow_signup? - %li + %li.header-logged-out-nav-item = render Pajamas::ButtonComponent.new(href: new_user_registration_path, variant: :confirm) do = Gitlab.com? ? _('Get free trial') : _('Register') diff --git a/app/views/layouts/header/_title.html.haml b/app/views/layouts/header/_title.html.haml deleted file mode 100644 index 59141cfa2a3..00000000000 --- a/app/views/layouts/header/_title.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -.title - %span.gl-sr-only GitLab - = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', aria: { label: _('Homepage') }, **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do - = brand_header_logo - .gl-display-flex.gl-align-items-center - - if Gitlab.com_and_canary? - = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do - = _('Next') diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml deleted file mode 100644 index 6473d9c8dd4..00000000000 --- a/app/views/layouts/header/_whats_new_dropdown_item.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- if display_whats_new? - %li - %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' } - = _("What's new") - = gl_badge_tag whats_new_most_recent_release_items_count, { size: :sm }, { class: 'js-whats-new-notification-count' } diff --git a/app/views/layouts/help.html.haml b/app/views/layouts/help.html.haml index 68426f71879..89467efcc6e 100644 --- a/app/views/layouts/help.html.haml +++ b/app/views/layouts/help.html.haml @@ -1,7 +1,6 @@ - @breadcrumb_title = _("Help") - page_title _("Help") - header_title _("Help"), help_path -- if show_super_sidebar? - - @force_desktop_expanded_sidebar = true +- @force_desktop_expanded_sidebar = true = render template: "layouts/application" diff --git a/app/views/layouts/mailer/devise.html.haml b/app/views/layouts/mailer/devise.html.haml index beaaaa5cd68..1912d4267b7 100644 --- a/app/views/layouts/mailer/devise.html.haml +++ b/app/views/layouts/mailer/devise.html.haml @@ -7,7 +7,7 @@ %div = link_to _('Blog'), 'https://about.gitlab.com/blog/', style: "color:#3777b0;text-decoration:none;" · - = link_to _('Twitter'), 'https://twitter.com/gitlab', style: "color:#3777b0;text-decoration:none;" + = link_to _('X (formerly Twitter)'), 'https://twitter.com/gitlab', style: "color:#3777b0;text-decoration:none;" · = link_to _('Facebook'), 'https://www.facebook.com/gitlab/', style: "color:#3777b0;text-decoration:none;" · diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml index c938cad5c42..af88d38fe3e 100644 --- a/app/views/layouts/nav/_top_bar.html.haml +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -1,15 +1,5 @@ -- if show_super_sidebar? - - top_bar_class = 'top-bar-fixed container-fluid' - - top_bar_container_class = nil -- else - - top_bar_class = [@no_top_bar_container ? 'container-fluid' : container_class, @content_class] - - top_bar_container_class = 'gl-border-b' - -%div{ class: top_bar_class, data: { testid: 'top-bar' } } - .top-bar-container.gl-display-flex.gl-align-items-center.gl-gap-2{ :class => top_bar_container_class } - - if show_super_sidebar? - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', aria: { controls: 'super-sidebar', expanded: 'false', label: _('Primary navigation sidebar') } }) - - elsif defined?(@left_sidebar) - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle-mobile-nav-button' }, aria: { label: _('Open sidebar') } }) +%div{ class: 'top-bar-fixed container-fluid', data: { testid: 'top-bar' } } + .top-bar-container.gl-display-flex.gl-align-items-center.gl-gap-2 + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', aria: { controls: 'super-sidebar', expanded: 'false', label: _('Primary navigation sidebar') } }) = render "layouts/nav/breadcrumbs/breadcrumbs" = render_if_exists "layouts/nav/ask_duo_button" diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml deleted file mode 100644 index aa1c462d2bf..00000000000 --- a/app/views/layouts/nav/_top_nav.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- view_model = top_nav_view_model(project: @project, group: @group) -%ul.list-unstyled.nav.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } } - %li - %a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } } - = sprite_icon('hamburger') - - if view_model[:menuTitle] - .gl-ml-3= view_model[:menuTitle] - -.hidden - - view_model[:shortcuts].each do |shortcut| - = link_to shortcut[:href], class: shortcut[:css_class] do - = shortcut[:title] diff --git a/app/views/layouts/nav/_top_nav_responsive.html.haml b/app/views/layouts/nav/_top_nav_responsive.html.haml deleted file mode 100644 index 22a260b5c0c..00000000000 --- a/app/views/layouts/nav/_top_nav_responsive.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- top_class = local_assigns.fetch(:class, nil) -- view_model = top_nav_responsive_view_model(project: @project, group: @group) - -.top-nav-responsive{ class: top_class } - .cloak-startup - #js-top-nav-responsive{ data: { view_model: view_model.to_json } } diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml index 040793d616f..417df51e984 100644 --- a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml @@ -2,8 +2,8 @@ - unless @skip_current_level_breadcrumb - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) -%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } } - %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list +%nav.breadcrumbs.gl-breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } } + %ul.breadcrumb.gl-breadcrumb-list.js-breadcrumbs-list - unless hide_top_links = header_title - if @breadcrumbs_extra_links @@ -11,8 +11,8 @@ = breadcrumb_list_item link_to(extra[:text], extra[:link]) = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after - unless @skip_current_level_breadcrumb - %li{ data: { testid: 'breadcrumb-current-link' } } - = link_to @breadcrumb_title, breadcrumb_title_link + %li.gl-breadcrumb-item{ data: { testid: 'breadcrumb-current-link' } } + = link_to(@breadcrumb_title, breadcrumb_title_link) -# haml-lint:disable InlineJavaScript %script{ type: 'application/ld+json' } :plain diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml index dd2d23320be..3894501bbbb 100644 --- a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml @@ -1,11 +1,9 @@ - dropdown_location = local_assigns.fetch(:location, nil) - button_tooltip = local_assigns.fetch(:title, _("Show all breadcrumbs")) - if defined?(@breadcrumb_collapsed_links) && @breadcrumb_collapsed_links.key?(dropdown_location) - %li.expander + %li.expander.gl-breadcrumb-item %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip } = sprite_icon("ellipsis_h", size: 12) - = sprite_icon("chevron-lg-right", size: 8, css_class: "breadcrumbs-list-angle") - @breadcrumb_collapsed_links[dropdown_location].each_with_index do |link, index| - %li{ :class => "gl-display-none! breadcrumbs-detail-item" } + %li.gl-breadcrumb-item{ :class => "gl-display-none!" } = link - = sprite_icon("chevron-lg-right", size: 8, css_class: "breadcrumbs-list-angle") diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml deleted file mode 100644 index bffc030dbd9..00000000000 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil)) diff --git a/app/views/layouts/nav/sidebar/_explore.html.haml b/app/views/layouts/nav/sidebar/_explore.html.haml deleted file mode 100644 index ccbcb434af1..00000000000 --- a/app/views/layouts/nav/sidebar/_explore.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil)) diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml deleted file mode 100644 index fd0e47b543f..00000000000 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- group = @parent_group || @group -= render partial: 'shared/nav/sidebar', object: Sidebars::Groups::Panel.new(group_sidebar_context(group, current_user)) diff --git a/app/views/layouts/nav/sidebar/_organization.html.haml b/app/views/layouts/nav/sidebar/_organization.html.haml deleted file mode 100644 index de6c87f97d7..00000000000 --- a/app/views/layouts/nav/sidebar/_organization.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::Organizations::Panel.new(organization_sidebar_context(@organization, current_user)) diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml deleted file mode 100644 index d53316442f8..00000000000 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::UserSettings::Panel.new(Sidebars::Context.new(current_user: current_user, container: current_user)) diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml deleted file mode 100644 index 67c3cd9cc54..00000000000 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::Projects::Panel.new(project_sidebar_context(@project, current_user, current_ref, ref_type: @ref_type)) diff --git a/app/views/layouts/nav/sidebar/_search.html.haml b/app/views/layouts/nav/sidebar/_search.html.haml deleted file mode 100644 index 956079c351a..00000000000 --- a/app/views/layouts/nav/sidebar/_search.html.haml +++ /dev/null @@ -1 +0,0 @@ --# if this file is missing empty or not the old left menu throws error diff --git a/app/views/layouts/nav/sidebar/_user_profile.html.haml b/app/views/layouts/nav/sidebar/_user_profile.html.haml deleted file mode 100644 index b24334f48c4..00000000000 --- a/app/views/layouts/nav/sidebar/_user_profile.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::UserProfile::Panel.new(Sidebars::Context.new(current_user: current_user, container: @user)) diff --git a/app/views/layouts/nav/sidebar/_your_work.html.haml b/app/views/layouts/nav/sidebar/_your_work.html.haml deleted file mode 100644 index 0da66c2e14e..00000000000 --- a/app/views/layouts/nav/sidebar/_your_work.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::YourWork::Panel.new(your_work_sidebar_context(current_user)) diff --git a/app/views/layouts/organization.html.haml b/app/views/layouts/organization.html.haml index 7e1bf228876..a4485bb791e 100644 --- a/app/views/layouts/organization.html.haml +++ b/app/views/layouts/organization.html.haml @@ -1,6 +1,5 @@ - page_title @organization.name if @organization - header_title @organization.name, organization_path(@organization) if @organization - nav(%w[index new].include?(params[:action]) ? "your_work" : "organization") -- @left_sidebar = true = render template: "layouts/application" diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml index 1b6e78b7b3d..bb3c02cabdb 100644 --- a/app/views/layouts/profile.html.haml +++ b/app/views/layouts/profile.html.haml @@ -2,7 +2,6 @@ - header_title _("User Settings"), profile_path unless header_title - sidebar "dashboard" - nav "profile" -- @left_sidebar = true - enable_search_settings locals: { container_class: 'gl-my-5' } diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 1e85bb6cc3a..e95d645769e 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -4,7 +4,6 @@ - nav "project" - page_itemtype 'http://schema.org/SoftwareSourceCode' - display_subscription_banner! -- @left_sidebar = true - @content_class = [@content_class, project_classes(@project)].compact.join(" ") - content_for :flash_message do @@ -22,8 +21,10 @@ - content_for :before_content do = render 'projects/invite_members_modal', project: @project += dispensable_render_if_exists "projects/importing_alert", project: @project = dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert" = dispensable_render_if_exists "projects/free_user_cap_alert", project: @project = dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project += dispensable_render_if_exists 'projects/code_suggestions_ga_non_owner_alert', project: @project = render template: "layouts/application" diff --git a/app/views/layouts/search.html.haml b/app/views/layouts/search.html.haml index 885fda10744..06192469833 100644 --- a/app/views/layouts/search.html.haml +++ b/app/views/layouts/search.html.haml @@ -1,7 +1,6 @@ - page_title _("Search") - header_title _("Search"), search_path - add_page_specific_style 'page_bundles/search' -- if show_super_sidebar? - - @force_desktop_expanded_sidebar = true +- @force_desktop_expanded_sidebar = true = render template: "layouts/application" diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index ad566f262cf..a9790802b82 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -2,7 +2,6 @@ - header_title _("Your work"), root_path - add_to_breadcrumbs _("Snippets"), dashboard_snippets_path - snippets_upload_path = snippets_upload_path(@snippet, current_user) -- @left_sidebar = true - if current_user - nav "your_work" diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 09b5407ecdb..b63fa7a699c 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -24,6 +24,6 @@ .gl-text-right.gl-line-height-normal .gl-font-weight-bold= current_user.name .gl-text-gray-700 @#{current_user.username} - = render Pajamas::AvatarComponent.new(current_user, size: 32, avatar_options: { data: { qa_selector: 'user_avatar_content' } }) + = render Pajamas::AvatarComponent.new(current_user, size: 32, avatar_options: { data: { testid: 'user-avatar-content' } }) - c.with_body do = yield diff --git a/app/views/notify/provisioned_member_access_granted_email.html.haml b/app/views/notify/provisioned_member_access_granted_email.html.haml deleted file mode 100644 index 515254d1454..00000000000 --- a/app/views/notify/provisioned_member_access_granted_email.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer', class: :highlight) -- confirmation_link = confirmation_url(@user, confirmation_token: @user.confirmation_token) - -%tr - %td.text-content - %p - = _('An Enterprise User GitLab account has been created for you by your organization:') - %p - = _('Username: %{username}') % { username: @user.username } - %br - = _('Email: %{email}') % { email: @user.email } - %br - = _('GitLab group: %{source_link}').html_safe % { source_link: source_link } - -%tr - %td.text-content - %p - = _('By authenticating with an account tied to an Enterprise e-mail address, it is understood that this account is an Enterprise User. ') - = _('To ensure no loss of personal content, this account should only be used for matters related to %{group_name}.') % { group_name: member_source.human_name } - = _('For individual use, create a separate account under your personal email address, not tied to the Enterprise email domain or group.') - - unless @user.confirmed? - %p - = _('To get started, click the link below to confirm your account.') - %p - = link_to 'Confirm your account', confirmation_link diff --git a/app/views/notify/provisioned_member_access_granted_email.text.erb b/app/views/notify/provisioned_member_access_granted_email.text.erb deleted file mode 100644 index b143b8d6f54..00000000000 --- a/app/views/notify/provisioned_member_access_granted_email.text.erb +++ /dev/null @@ -1,15 +0,0 @@ -<% source_link = member_source.web_url %> - -<%= _('An Enterprise User GitLab account has been created for you by your organization:') %> -<%= _('Username: %{username}') % { username: @user.username } %> -<%= _('Email: %{email}') % { email: @user.email } %> -<%= _('GitLab group: %{source_link}').html_safe % { source_link: source_link } %> - - -<%= _('By authenticating with an account tied to an Enterprise e-mail address, it is understood that this account is an Enterprise User. ') %> -<%= _('To ensure no loss of personal content, this account should only be used for matters related to %{group_name}.') % { group_name: member_source.human_name } %> -<%= _('For individual use, create a separate account under your personal email address, not tied to the Enterprise email domain or group.') %> -<%- unless @user.confirmed? %> - <%= _('To get started, click the link below to confirm your account.') %> - <%= confirmation_url(@user, confirmation_token: @user.confirmation_token) %> -<%- end %> diff --git a/app/views/notify/request_review_merge_request_email.text.erb b/app/views/notify/request_review_merge_request_email.text.erb index dc1746d3a8c..14ca59767a8 100644 --- a/app/views/notify/request_review_merge_request_email.text.erb +++ b/app/views/notify/request_review_merge_request_email.text.erb @@ -1,2 +1,2 @@ -<%= sanitize_name(@updated_by.name) %> requested a new review on <%= merge_request_reference_link(@merge_request) %>. +<%= sanitize_name(@updated_by.name) %> requested a new review on <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> <%= render_if_exists 'notify/diff_summary' -%> diff --git a/app/views/notify/service_desk_verification_result_email.html.haml b/app/views/notify/service_desk_verification_result_email.html.haml index c072744c43c..dfb5a1785e7 100644 --- a/app/views/notify/service_desk_verification_result_email.html.haml +++ b/app/views/notify/service_desk_verification_result_email.html.haml @@ -11,6 +11,7 @@ - verify_email_address = @service_desk_setting.custom_email_address_for_verification - code_open = '<code>'.html_safe - code_end = '</code>'.html_safe +- service_desk_incoming_address = @service_desk_setting.project.service_desk_incoming_address %tr %td.text-content @@ -35,7 +36,7 @@ %p %b = s_('Notify|Invalid credentials:') - = s_('Notify|The given credentials (username and password) were rejected by the SMTP server.') + = s_('Notify|The given credentials (username and password) were rejected by the SMTP server, or you need to explicitly set an authentication method.') - if @verification.mail_not_received_within_timeframe? %p %b @@ -54,5 +55,15 @@ %b = s_('Notify|Incorrect verification token:') = s_('Notify|We could not verify that we received the email we sent to your email inbox.') + - if @verification.read_timeout? + %p + %b + = s_('Notify|Read timeout:') + = s_('Notify|The SMTP server did not respond in time.') + - if @verification.incorrect_forwarding_target? + %p + %b + = s_('Notify|Incorrect forwarding target:') + = html_escape(s_('Notify|Forward all emails to the custom email address to %{code_open}%{service_desk_incoming_address}%{code_end}.')) % { code_open: code_open, service_desk_incoming_address: service_desk_incoming_address, code_end: code_end } %p = html_escape(s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end } diff --git a/app/views/notify/service_desk_verification_result_email.text.erb b/app/views/notify/service_desk_verification_result_email.text.erb index 65b0cba5616..89487dcf185 100644 --- a/app/views/notify/service_desk_verification_result_email.text.erb +++ b/app/views/notify/service_desk_verification_result_email.text.erb @@ -1,6 +1,7 @@ <% project_name = @service_desk_setting.project.human_name %> <% email_address = @service_desk_setting.custom_email %> <% verify_email_address = @service_desk_setting.custom_email_address_for_verification %> +<% service_desk_incoming_address = @service_desk_setting.project.service_desk_incoming_address %> <% if @verification.finished? %> <%= s_("Notify|Email successfully verified") %> @@ -18,7 +19,7 @@ <%= s_('Notify|We were not able to make a connection to the specified host or there was an SSL issue.') %> <% elsif @verification.invalid_credentials? %> <%= s_('Notify|Invalid credentials:') %> - <%= s_('Notify|The given credentials (username and password) were rejected by the SMTP server.') %> + <%= s_('Notify|The given credentials (username and password) were rejected by the SMTP server, or you need to explicitly set an authentication method.') %> <% elsif @verification.mail_not_received_within_timeframe? %> <%= s_('Notify|Verification email not received within timeframe:') %> <%= s_('Notify|We did not receive the verification email we sent out to %{strong_open}%{email_address}%{strong_close} in time.') % { email_address: verify_email_address, strong_open: '', strong_close: '' } %> @@ -32,6 +33,12 @@ <% elsif @verification.incorrect_token? %> <%= s_('Notify|Incorrect verification token:') %> <%= s_('Notify|We could not verify that we received the email we sent to your email inbox.') %> + <% elsif @verification.read_timeout? %> + <%= s_('Notify|Read timeout:') %> + <%= s_('Notify|The SMTP server did not respond in time.') %> + <% elsif @verification.incorrect_forwarding_target? %> + <%= s_('Notify|Incorrect forwarding target:') %> + <%= s_('Notify|Forward all emails to the custom email address to %{code_open}%{service_desk_incoming_address}%{code_end}.') % { code_open: '', service_desk_incoming_address: service_desk_incoming_address, code_end: '' } %> <% end %> <%= s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %> diff --git a/app/views/organizations/organizations/users.html.haml b/app/views/organizations/organizations/users.html.haml index 5fb9d786e0b..0ed15d8e4d0 100644 --- a/app/views/organizations/organizations/users.html.haml +++ b/app/views/organizations/organizations/users.html.haml @@ -1,4 +1,4 @@ - page_title _('Users') -#js-organizations-users{ data: organization_user_app_data(@organization) } +#js-organizations-users{ data: { app_data: organization_user_app_data(@organization) } } diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 799dfaae8c5..6da1121fe7b 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -30,7 +30,7 @@ = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path) do = _('Manage two-factor authentication') - else - = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { testid: 'enable-2fa-button' }}) do = _('Enable two-factor authentication') - if display_providers_on_profile? @@ -76,7 +76,7 @@ = render 'users/deletion_guidance', user: current_user -# Delete button here - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { testid: 'delete-account-button' }}) do = s_('Profiles|Delete account') #delete-account-modal{ data: { action_url: user_registration_path, @@ -93,7 +93,7 @@ = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - elsif !current_user.can_remove_self? %p - = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} + = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_user_settings_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} %p = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe} - else diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index b1df63a72ab..b79013b33e1 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -5,12 +5,12 @@ .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' - = f.text_area :key, class: "form-control gl-form-input js-add-ssh-key-validation-input", rows: 8, required: true, data: { supported_algorithms: Gitlab::SSHPublicKey.supported_algorithms, qa_selector: 'key_public_key_field' } + = f.text_area :key, class: "form-control gl-form-input js-add-ssh-key-validation-input", rows: 8, required: true, data: { supported_algorithms: Gitlab::SSHPublicKey.supported_algorithms, testid: 'key-public-key-field' } %p.form-text.text-muted= s_('Profiles|Begins with %{ssh_key_algorithms}.') % { ssh_key_algorithms: ssh_key_allowed_algorithms } .form-row .col.form-group = f.label :title, s_('Profiles|Title'), class: 'label-bold' - = f.text_field :title, class: "form-control gl-form-input input-lg", required: true, placeholder: s_('Profiles|Example: MacBook key'), data: { qa_selector: 'key_title_field' } + = f.text_field :title, class: "form-control gl-form-input input-lg", required: true, placeholder: s_('Profiles|Example: MacBook key'), data: { testid: 'key-title-field' } %p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.') .form-row .col.form-group @@ -25,13 +25,14 @@ %p.form-text.text-muted= ssh_key_expires_field_description .js-add-ssh-key-validation-warning.hide - .bs-callout.bs-callout-warning.gl-mt-0{ role: 'alert', aria_live: 'assertive' } - %strong= _('Oops, are you sure?') - %p= s_("Profiles|Publicly visible private SSH keys can compromise your system.") - = render Pajamas::ButtonComponent.new(variant: :confirm, - button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do - = _("Yes, add it") + = render Pajamas::AlertComponent.new(title: _('Are you sure?'), variant: :warning, dismissible: false) do |c| + - c.with_body do + = s_("Profiles|Publicly visible private SSH keys can compromise your system.") + - c.with_actions do + = render Pajamas::ButtonComponent.new(variant: :confirm, + button_options: { class: 'js-add-ssh-key-validation-confirm-submit' }) do + = _("Yes, add it") - = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { qa_selector: 'add_key_button' } + = f.submit s_('Profiles|Add key'), class: "js-add-ssh-key-validation-original-submit", pajamas_button: true, data: { testid: 'add-key-button' } = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-add-ssh-key-validation-cancel gl-ml-2 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml index cfe507ad65d..efd59503041 100644 --- a/app/views/profiles/keys/_key_table.html.haml +++ b/app/views/profiles/keys/_key_table.html.haml @@ -3,7 +3,7 @@ - if @keys.any? .table-holder - %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { qa_selector: 'ssh_keys_list' } } + %table.table.b-table.gl-table.b-table-stacked-md.gl-mt-n1.gl-mb-n2.ssh-keys-list{ data: { testid: 'ssh-keys-list' } } %thead.d-none.d-md-table-header-group %tr %th= _('Title') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 405364b6792..79b2726ed2d 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -103,7 +103,7 @@ %small.form-text.text-gl-muted = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") .form-group.gl-form-group - = f.label :twitter + = f.label :twitter, _('X (formerly Twitter)') = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username") .form-group.gl-form-group - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page') @@ -122,13 +122,12 @@ allow_empty: true} %small.form-text.text-gl-muted = external_accounts_docs_link - - if Feature.enabled?(:mastodon_social_ui, @user) - .form-group.gl-form-group - = f.label :mastodon - = f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@robin@example.com" + .form-group.gl-form-group + = f.label :mastodon + = f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@robin@example.com" .form-group.gl-form-group - = f.label :website_url, s_('Profiles|Website url') + = f.label :website_url, s_('Profiles|Website URL') = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com") .form-group.gl-form-group = f.label :location, s_('Profiles|Location') diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 7c42053a376..408daef6034 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -29,7 +29,7 @@ = _("To add the entry manually, provide the following details to the application on your phone.") %p.gl-mt-0.gl-mb-0 = _('Account: %{account}') % { account: @account_string } - %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } } + %p.gl-mt-0.gl-mb-0{ data: { testid: 'otp-secret-content' } } = _('Key:') %code.two-factor-secret= current_user.otp_secret.scan(/.{4}/).join(' ') %p.gl-mb-0.two-factor-new-manual-content @@ -46,14 +46,14 @@ - if current_password_required? .form-group = label_tag :current_password, _('Current password'), class: 'label-bold' - = password_field_tag :current_password, nil, autocomplete: 'current-password', required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + = password_field_tag :current_password, nil, autocomplete: 'current-password', required: true, class: 'form-control gl-form-input', data: { testid: 'current-password-field' } %p.form-text.text-muted = _('Your current password is required to register a two-factor authenticator app.') .form-group = label_tag :pin_code, _('Enter verification code'), class: "label-bold" - = text_field_tag :pin_code, nil, autocomplete: 'off', inputmode: 'numeric', class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } + = text_field_tag :pin_code, nil, autocomplete: 'off', inputmode: 'numeric', class: "form-control gl-form-input", required: true, data: { testid: 'pin-code-field' } .gl-mt-3 - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { data: { qa_selector: 'register_2fa_app_button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { data: { testid: 'register-2fa-app-button' } }) do = _('Register with two-factor app') %hr diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 00da6c73081..68e73a017a7 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -5,8 +5,8 @@ .controls.gl-display-flex = link_button_to nil, project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'd-none d-sm-inline-flex has-tooltip', icon: 'rss' - if is_project_overview && can?(current_user, :download_code, @project) - .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 - = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' + .project-code-holder.d-none.d-md-inline-flex.gl-ml-2 + = render "projects/buttons/code", dropdown_class: 'dropdown-menu-right', ref: @ref .content_list.project-activity{ :"data-href" => activity_project_path(@project) } .loading diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 7445a403865..5c9448a30a4 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,24 +7,28 @@ - if readme_path = @project.repository.readme_path - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") -#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } +#tree-holder.tree-holder.clearfix.js-per-page.gl-mt-5{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } + - if Feature.enabled?(:project_overview_reorg) + .nav-block.gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-align-items-stretch + = render 'projects/tree/tree_header', tree: @tree + .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5 #js-last-commit.gl-m-auto{ data: {ref_type: @ref_type.to_s} } = gl_loading_icon(size: 'md') - if project.licensed_feature_available?(:code_owners) #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } } - .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch - = render 'projects/tree/tree_header', tree: @tree + - if Feature.disabled?(:project_overview_reorg) + .nav-block.gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-align-items-stretch + = render 'projects/tree/tree_header', tree: @tree - if project.forked? #js-fork-info{ data: vue_fork_divergence_data(project, ref) } - - if is_project_overview && has_project_shortcut_buttons + - if Feature.disabled?(:project_overview_reorg) && is_project_overview && has_project_shortcut_buttons .project-buttons.gl-mb-5.js-show-on-project-root{ data: { testid: 'project-buttons' } } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true #js-tree-list{ data: vue_file_list_data(project, ref) } - if can_edit_tree? = render 'projects/blob/new_dir' - diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index e41a0d3d262..63226838166 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,29 +1,21 @@ - empty_repo = @project.empty_repo? -- show_auto_devops_callout = show_auto_devops_callout?(@project) - emails_disabled = @project.emails_disabled? +- ff_reorg_disabled = Feature.disabled?(:project_overview_reorg) -.project-home-panel.js-show-on-project-root.gl-mt-4.gl-mb-5{ class: [("empty-project" if empty_repo)] } - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-md-flex-direction-row.gl-mb-3.gl-gap-5 +%header.project-home-panel.js-show-on-project-root.gl-mt-5{ class: [("empty-project" if empty_repo)] } + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-md-flex-direction-row.gl-gap-5 .home-panel-title-row.gl-display-flex.gl-align-items-center - %div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' } - = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image') - %div - %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { testid: 'project-name-content' }, itemprop: 'name' } - = @project.name - = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-mx-2', icon_css_class: 'icon') - = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-mx-2' - - if @project.catalog_resource - = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(@project, @project.catalog_resource), css_class: 'gl-mx-2' } - - if @project.group - = render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project' - .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { testid: 'project-id-content' }, itemprop: 'identifier' } - - if can?(current_user, :read_project, @project) - %span.gl-display-inline-block.gl-vertical-align-middle - = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - = clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id) - - if current_user - %span.gl-ml-3.gl-mb-3 - = render 'shared/members/access_request_links', source: @project + = render Pajamas::AvatarComponent.new(@project, alt: @project.name, class: 'gl-align-self-start gl-flex-shrink-0 gl-mr-3', size: 48, avatar_options: { itemprop: 'image' }) + %h1.home-panel-title.gl-heading-1.gl-mt-3.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-gap-3.gl-word-break-word{ class: 'gl-mb-0!', data: { testid: 'project-name-content' }, itemprop: 'name' } + = @project.name + = visibility_level_content(@project, css_class: 'visibility-icon gl-display-inline-flex gl-text-secondary', icon_css_class: 'icon') + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center' + - if @project.catalog_resource + = render partial: 'shared/ci_catalog_badge', locals: { href: explore_catalog_path(@project.catalog_resource), css_class: 'gl-mx-0' } + - if @project.group + = render_if_exists 'shared/tier_badge', source: @project, namespace_to_track: @project.namespace + .gl-text-secondary + = render_if_exists "projects/home_mirror" .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 - if current_user @@ -34,26 +26,31 @@ = render 'projects/buttons/star' = render 'projects/buttons/fork' + = render 'shared/groups_projects_more_actions_dropdown', source: @project - - if can?(current_user, :read_code, @project) - %nav.project-stats - - if @project.empty_repo? - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - - else - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - .gl-my-3 - = render "shared/projects/topics", project: @project - .home-panel-home-desc.mt-1 - - if @project.description.present? - .home-panel-description.text-break - .home-panel-description-markdown.read-more-container{ itemprop: 'description' } - = markdown_field(@project, :description) - %button.btn.gl-button.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } - = _("Read more") + - if ff_reorg_disabled + - if can?(current_user, :read_code, @project) + - show_auto_devops_callout = show_auto_devops_callout?(@project) + + %nav.project-stats.gl-mt-3 + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors + - else + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + .gl-my-3 + = render "shared/projects/topics", project: @project + + .home-panel-home-desc.mt-1 + - if @project.description.present? + .home-panel-description.text-break + .home-panel-description-markdown.read-more-container{ itemprop: 'description' } + = markdown_field(@project, :description) + = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link, button_options: { class: 'js-read-more-trigger gl-lg-display-none' }) do + = _("Read more") = render_if_exists "projects/home_mirror" - - if @project.badges.present? + - if ff_reorg_disabled && @project.badges.present? .project-badges.mb-2{ data: { testid: 'project-badges-content' } } - @project.badges.each do |badge| - badge_link_url = badge.rendered_link_url(@project) diff --git a/app/views/projects/_invite_members_empty_project.html.haml b/app/views/projects/_invite_members_empty_project.html.haml index 14b0e82e021..730021f345a 100644 --- a/app/views/projects/_invite_members_empty_project.html.haml +++ b/app/views/projects/_invite_members_empty_project.html.haml @@ -1,9 +1,20 @@ -%h4.gl-mt-0.gl-mb-3{ data: { testid: 'invite-member-section', - track_label: 'invite_members_empty_project', - track_action: 'render' } } - = s_('InviteMember|Invite your team') -%p= s_('InviteMember|Add members to this project and start collaborating with your team.') -.js-invite-members-trigger{ data: { variant: 'confirm', - classes: 'gl-mb-8 gl-w-full gl-sm-w-auto', - display_text: s_('InviteMember|Invite members'), - trigger_source: 'project_empty_page' } } +- if Feature.enabled?(:project_overview_reorg) + %p.gl-font-weight-bold.gl-text-gray-900.gl-mt-0.gl-mt-n1.gl-mb-3{ data: { testid: 'invite-member-section', + track_label: 'invite_members_empty_project', + track_action: 'render' } } + = s_('InviteMember|Invite your team') + %p.gl-mb-3= s_('InviteMember|Add members to this project and start collaborating with your team.') + .js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-mb-3 gl-w-full gl-sm-w-auto', + display_text: s_('InviteMember|Invite members'), + trigger_source: 'project_empty_page' } } +- else + %h4.gl-mt-0.gl-mb-3{ data: { testid: 'invite-member-section', + track_label: 'invite_members_empty_project', + track_action: 'render' } } + = s_('InviteMember|Invite your team') + %p= s_('InviteMember|Add members to this project and start collaborating with your team.') + .js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-mb-8 gl-w-full gl-sm-w-auto', + display_text: s_('InviteMember|Invite members'), + trigger_source: 'project_empty_page' } } diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index ca1fef6eb32..25001314c07 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -3,6 +3,7 @@ - hide_init_with_readme = local_assigns.fetch(:hide_init_with_readme, false) - include_description = local_assigns.fetch(:include_description, true) - track_label = local_assigns.fetch(:track_label, 'blank_project') +- display_sha256_repository = Feature.enabled?(:support_sha256_repositories, current_user) .row{ id: project_name_id } = f.hidden_field :ci_cd_only, value: ci_cd_only @@ -25,7 +26,7 @@ input_name: 'project[namespace_id]', root_url: root_url, track_label: track_label, - user_namespace_id: current_user.namespace.id } } + user_namespace_id: current_user.namespace_id } } - else .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' } .input-group-text.border-0 @@ -95,6 +96,14 @@ = 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' } + - if display_sha256_repository + .form-group + = render Pajamas::CheckboxTagComponent.new(name: 'project[use_sha256_repository]') do |c| + - c.with_label do + = s_('ProjectsNew|Use SHA-256 as the repository hashing algorithm') + - c.with_help_text do + = s_('ProjectsNew|Default hashing algorithm is SHA-1.') + -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675 = render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label = f.submit _('Create project'), class: "js-create-project-button", data: { testid: 'project-create-button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index aee61624f69..d6b28e94645 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -4,7 +4,7 @@ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') - - link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe + - link_start = "<a href='#{help_page_path('user/project/service_desk/index')}' target='_blank' rel='noopener noreferrer'>".html_safe %p.gl-text-secondary= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .settings-content - if ::Gitlab::ServiceDesk.supported? @@ -18,6 +18,7 @@ selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", + reopen_issue_on_external_participant_note: "#{@project.service_desk_setting&.reopen_issue_on_external_participant_note}", add_external_participants_from_cc: "#{@project.service_desk_setting&.add_external_participants_from_cc}", templates: available_service_desk_templates_for(@project), public_project: "#{@project.public?}", diff --git a/app/views/projects/_sidebar.html.haml b/app/views/projects/_sidebar.html.haml new file mode 100644 index 00000000000..565f14d01d9 --- /dev/null +++ b/app/views/projects/_sidebar.html.haml @@ -0,0 +1,61 @@ +- has_project_shortcut_buttons = !current_user || current_user.project_shortcut_buttons +- show_auto_devops_callout = show_auto_devops_callout?(@project) + +%aside.project-page-sidebar + - if @project.description.present? || @project.badges.present? + .project-page-sidebar-block.home-panel-home-desc.gl-py-4.gl-border-b.gl-border-gray-50 + -# Project description + - if @project.description.present? + .gl-display-flex.gl-justify-content-space-between.gl-mt-1.gl-pr-2 + %p.gl-font-weight-bold.gl-text-gray-900.gl-m-0= s_('ProjectPage|Project information') + = render Pajamas::ButtonComponent.new(href: edit_project_path(@project), + category: :tertiary, + icon: 'settings', + size: :small, + button_options: { class: 'has-tooltip', title: s_('ProjectPage|Project settings'), 'aria-label' => s_('ProjectPage|Project settings') }) + .home-panel-description.text-break + .home-panel-description-markdown{ itemprop: 'description' } + = markdown_field(@project, :description) + + -# Topics + - if @project.topics.present? + .gl-mb-5 + = render "shared/projects/topics", project: @project + + -# Programming languages + - if can?(current_user, :read_code, @project) && @project.repository_languages.present? + .gl-mb-2{ class: ('gl-mb-4!' if @project.badges.present?) } + = repository_languages_bar(@project.repository_languages) + + -# Badges + - if @project.badges.present? + .project-badges.gl-mb-2{ data: { testid: 'project-badges-content' } } + - @project.badges.each do |badge| + - badge_link_url = badge.rendered_link_url(@project) + %a.gl-mr-3{ href: badge_link_url, + target: '_blank', + rel: 'noopener noreferrer', + data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: 'Project badge' }> + + -# Invite members + - if @project.empty_repo? + .project-page-sidebar-block.gl-py-4.gl-border-b.gl-border-gray-50 + = render "invite_members_empty_project" if can_admin_project_member?(@project) + + -# Buttons + - if can?(current_user, :read_code, @project) && !@project.empty_repo? + .project-page-sidebar-block.gl-py-4.gl-border-b.gl-border-gray-50 + %nav.project-stats + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + + -# Buttons + - if has_project_shortcut_buttons + .project-page-sidebar-block.gl-py-4 + .project-buttons.gl-mb-2.js-show-on-project-root{ data: { testid: 'project-buttons' } } + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true + - else + = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 1409b28e735..2f8ae788c01 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -1,9 +1,11 @@ - anchors = local_assigns.fetch(:anchors, []) - project_buttons = local_assigns.fetch(:project_buttons, false) +- ff_reorg_enabled = Feature.enabled?(:project_overview_reorg) - return unless anchors.any? -%ul.nav{ class: (project_buttons ? 'gl-gap-3' : 'gl-gap-5') } + +%ul.nav.gl-row-gap-2.gl-column-gap-5 - anchors.each do |anchor| %li.nav-item = link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do - .stat-text.d-flex.align-items-center{ class: ('btn gl-button btn-default disabled' if project_buttons) }= anchor.label + .stat-text.d-flex.align-items-center{ class: "#{'btn gl-button btn-default disabled' if project_buttons} #{'gl-px-0! gl-pb-2!' if ff_reorg_enabled}" }= anchor.label diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index 43bb3627c4f..d8b1931756e 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -8,14 +8,14 @@ .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= _('Transfer project') %p.gl-new-card-description - - link = link_to('', help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/settings/migrate_projects', anchor: 'transfer-a-project-to-another-namespace'), target: '_blank', rel: 'noopener noreferrer') = safe_format(_("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - c.with_body do = form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f| .form-group.gl-mb-0 %p - - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/working_with_projects', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) %p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.') %p diff --git a/app/views/projects/artifacts/external_file.html.haml b/app/views/projects/artifacts/external_file.html.haml index 67f6ccd5695..37faea3a86f 100644 --- a/app/views/projects/artifacts/external_file.html.haml +++ b/app/views/projects/artifacts/external_file.html.haml @@ -1,4 +1,7 @@ - external_url = @blob.external_url(@build) +- external_url_text = external_url +- if Gitlab.config.pages.namespace_in_path + - external_url_text = "#{external_url} (Experimental)" - page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') = render "projects/jobs/header" @@ -9,7 +12,7 @@ %h2= _("You are being redirected away from GitLab") %p= _("This page is hosted on GitLab pages but contains user-generated content and may contain malicious code. Do not accept unless you trust the author and source.") - = link_to external_url, + = link_to external_url_text, external_url, target: '_blank', title: _('Opens in a new window'), diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index bd0f4577a32..8a11d4e9a84 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -12,7 +12,7 @@ url.searchParams.set('page', 2); return fetch(url).then(response => response.body); })(); -- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_pagination.per_page, total_extra_pages: @blame_pagination.total_extra_pages - 1, pages_url: blame_streaming_url } +- dataset = { testid: 'blob-content-holder', per_page: @blame_pagination.per_page, total_extra_pages: @blame_pagination.total_extra_pages - 1, pages_url: blame_streaming_url } #blob-content-holder.tree-holder.js-per-page{ data: dataset } = render "projects/blob/breadcrumb", blob: @blob, blame: true diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index c140eecd8c1..ddea80a3983 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -17,7 +17,7 @@ - else = link_to title, project_tree_path(@project, tree_join(@ref, path), ref_type: @ref_type) - .tree-controls.gl-children-ml-sm-3< + .tree-controls.gl-display-flex.gl-flex-wrap.gl-sm-flex-nowrap.gl-align-items-baseline.gl-gap-3 = render 'projects/find_file_link' -# only show normal/blame view links for text files - if blob.readable_text? diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 0753a021f1f..a08d17a3efa 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -1,17 +1,22 @@ -- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' +- action = current_action?(:edit, :update) ? 'edit' : 'create' - file_name = params[:id].split("/").last ||= "" - is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) .file-holder.file.gl-mb-3 .js-file-title.file-title.gl-display-flex.gl-align-items-center.gl-rounded-top-base{ data: { current_action: action } } - .editor-ref.block-truncated.has-tooltip{ title: ref } + .editor-ref.block-truncated.has-tooltip{ title: current_action?(:edit, :update) ? ref : params[:id] } = sprite_icon('branch', size: 12) - = ref - - if current_action?(:edit) || current_action?(:update) + - if current_action?(:edit, :update) + %span#editor_ref + = ref + - if current_action?(:new, :create) + %span#editor_path + = params[:id] + - if current_action?(:edit, :update) - input_options = { id: 'file_path', name: 'file_path', value: (params[:file_path] || @path), class: 'new-file-path js-file-path-name-input' } = render 'filepath_form', input_options: input_options - - if current_action?(:new) || current_action?(:create) + - if current_action?(:new, :create) - input_options = { id: 'file_name', name: 'file_name', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : ''), required: true, placeholder: "Filename", testid: 'file-name-field', class: 'new-file-name js-file-path-name-input' } = render 'filepath_form', input_options: input_options - if should_suggest_gitlab_ci_yml? diff --git a/app/views/projects/branch_defaults/_default_branch_fields.html.haml b/app/views/projects/branch_defaults/_default_branch_fields.html.haml index 78ce43ca8c9..f3a7e93a5f7 100644 --- a/app/views/projects/branch_defaults/_default_branch_fields.html.haml +++ b/app/views/projects/branch_defaults/_default_branch_fields.html.haml @@ -1,3 +1,13 @@ +- change_default_disabled = @default_branch_blocked_by_security_policy +- popover_data = {} + +- if change_default_disabled + - tag_pair_security_policies_page = tag_pair(link_to('', namespace_project_security_policies_path, target: '_blank', rel: 'noopener noreferrer'), :security_policies_link_start, :security_policies_link_end) + - tag_pair_security_policies_docs = tag_pair(link_to('', help_page_path('user/application_security/policies/scan-result-policies'), target: '_blank', rel: 'noopener noreferrer'), :learn_more_link_start, :learn_more_link_end) + - popover_content = safe_format(s_("SecurityOrchestration|You can't change the default branch because its protection is enforced by one or more %{security_policies_link_start}security policies%{security_policies_link_end}. %{learn_more_link_start}Learn more%{learn_more_link_end}."), tag_pair_security_policies_docs, tag_pair_security_policies_page) + - popover_title = s_("SecurityOrchestration|Security policy overwrites this setting") + - popover_data = { container: 'body', toggle: 'popover', html: 'true', triggers: 'hover', title: popover_title, content: popover_content } + %fieldset#default-branch-settings - if @project.empty_repo? .text-secondary @@ -6,8 +16,8 @@ .form-group = f.label :default_branch, _("Default branch"), class: 'label-bold' %p= s_('ProjectSettings|All merge requests and commits are made against this branch unless you specify a different one.') - .gl-form-input-xl - .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id } } + .gl-form-input-xl{ data: { **popover_data } } + .js-select-default-branch{ data: { default_branch: @project.default_branch, project_id: @project.id, disabled: change_default_disabled.to_s } } .form-group - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.") diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_code.html.haml index 0e645eda678..a78e3861e94 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_code.html.haml @@ -1,34 +1,35 @@ - project = project || @project - dropdown_class = local_assigns.fetch(:dropdown_class, '') +- ref = local_assigns.fetch(:ref) - if can?(current_user, :download_code, @project) .git-clone-holder.js-git-clone-holder - = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { id: 'clone-dropdown', class: 'clone-dropdown-btn', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }) do - %span.gl-mr-2.js-clone-dropdown-label - = _('Clone') + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { id: 'clone-dropdown', class: 'clone-dropdown-btn', data: { toggle: 'dropdown', testid: 'clone-dropdown' } }) do + %span.js-clone-dropdown-label + = _('Code') = sprite_icon("chevron-down", css_class: "icon") - %ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown{ class: dropdown_class, data: { qa_selector: 'clone_dropdown_content' } } + %ul.dropdown-menu.dropdown-menu-large.clone-options-dropdown{ class: dropdown_class, data: { testid: 'clone-dropdown-content' } } - if ssh_enabled? - %li{ class: 'gl-px-4!' } + %li.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' } %label.label-bold = _('Clone with SSH') .input-group.btn-group - = text_field_tag :ssh_project_clone, ssh_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } + = text_field_tag :ssh_project_clone, ssh_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { testid: 'ssh-clone-url-content' } .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' - if http_enabled? - %li.pt-2{ class: 'gl-px-4!' } + %li.pt-2.gl-dropdown-item.js-clone-links{ class: 'gl-px-4!' } %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group.btn-group - = text_field_tag :http_project_clone, http_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } + = text_field_tag :http_project_clone, http_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { testid: 'http-clone-url-content' } .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' %li.divider.mt-2 - %li.pt-2.gl-dropdown-item + %li.pt-2.gl-dropdown-item.js-clone-links %label.label-bold{ class: 'gl-px-4!' } = _('Open in your IDE') - if ssh_enabled? @@ -53,3 +54,6 @@ %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } .gl-dropdown-item-text-wrapper = _("Xcode") + - if !project.empty_repo? && can?(current_user, :download_code, project) + %li.divider.mt-2 + = render 'projects/buttons/download_menu_items', project: project, ref: ref diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index b3282742407..5091fd1c646 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,27 +1,12 @@ - project = local_assigns.fetch(:project) - ref = local_assigns.fetch(:ref) -- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) } - css_class = local_assigns.fetch(:css_class, '') - if !project.empty_repo? && can?(current_user, :download_code, project) - - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.gl-dropdown.inline{ class: css_class }> = render Pajamas::ButtonComponent.new(button_options: { class: 'dropdown-toggle gl-dropdown-toggle dropdown-icon-only has-tooltip', title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { testid: 'download-source-code-button' } }) do = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') = sprite_icon('chevron-down', css_class: 'gl-icon dropdown-chevron') - .dropdown-menu.dropdown-menu-right{ role: 'menu' } - %section - %h5.m-0.dropdown-bold-header= _('Download source code') - .dropdown-menu-content - = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - .js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - - if pipeline && pipeline.latest_builds_with_artifacts.any? - %section.border-top.pt-1.mt-1 - %h5.m-0.dropdown-bold-header= _('Download artifacts') - - unless pipeline.latest? - %span.unclickable= ci_status_for_statuseable(project.latest_pipeline(ref)) - %h6.m-0.dropdown-header= _('Previous Artifacts') - %ul - - pipeline.latest_builds_with_artifacts.each do |job| - %li= link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' + %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } + = render 'projects/buttons/download_menu_items', project: project, ref: ref diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index 31185fc1532..7035f3b3792 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,4 +1,5 @@ -.btn-group.ml-0.w-100 - - Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| - - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) - = link_button_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', variant: index == 0 ? :confirm : :default, size: :small +- Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| + - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) + + %a.dropdown-item.open-with-link{ href: external_storage_url_or_path(archive_path), rel: 'nofollow', download: '' } + .gl-dropdown-item-text-wrapper= fmt diff --git a/app/views/projects/buttons/_download_menu_items.html.haml b/app/views/projects/buttons/_download_menu_items.html.haml new file mode 100644 index 00000000000..f5f8efca073 --- /dev/null +++ b/app/views/projects/buttons/_download_menu_items.html.haml @@ -0,0 +1,8 @@ +- project = local_assigns.fetch(:project) +- ref = local_assigns.fetch(:ref) +- archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" + +%li.gl-dropdown-item{ role: 'menuitem' } + %h3.h5.m-0.dropdown-bold-header= _('Download source code') + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil +.js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 42482a773be..be0e5a428b4 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -19,7 +19,7 @@ #{time_ago_with_tooltip(@commit.committed_date)} #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } } - = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-w-full gl-sm-w-auto gl-xs-mb-3' + = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-w-full gl-sm-w-auto gl-mb-3 gl-sm-mb-0' #js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) } .commit-box{ data: { project_path: project_path(@project) } } diff --git a/app/views/projects/commit/x509/_certificate_details.html.haml b/app/views/projects/commit/x509/_certificate_details.html.haml index cea216d0d9d..22d297248f8 100644 --- a/app/views/projects/commit/x509/_certificate_details.html.haml +++ b/app/views/projects/commit/x509/_certificate_details.html.haml @@ -1,17 +1,18 @@ -.gpg-popover-certificate-details - %strong= _('Certificate Subject') - - if signature.x509_certificate.revoked? - %strong.cred= _('(revoked)') - %ul - - x509_subject(signature.x509_certificate.subject, ["CN", "O"]).map do |key, value| - %li= key + "=" + value - %li= _('Subject Key Identifier:') - %li.unstyled= signature.x509_certificate.subject_key_identifier.gsub(":", " ") +- if signature.x509_certificate + .gpg-popover-certificate-details + %strong= _('Certificate Subject') + - if signature.x509_certificate.revoked? + %strong.cred= _('(revoked)') + %ul + - x509_subject(signature.x509_certificate.subject, ["CN", "O"]).map do |key, value| + %li= key + "=" + value + %li= _('Subject Key Identifier:') + %li.unstyled= signature.x509_certificate.subject_key_identifier.gsub(":", " ") -.gpg-popover-certificate-details - %strong= _('Certificate Issuer') - %ul - - x509_subject(signature.x509_certificate.x509_issuer.subject, ["CN", "OU", "O"]).map do |key, value| - %li= key + "=" + value - %li= _('Subject Key Identifier:') - %li.unstyled= signature.x509_certificate.x509_issuer.subject_key_identifier.gsub(":", " ") + .gpg-popover-certificate-details + %strong= _('Certificate Issuer') + %ul + - x509_subject(signature.x509_certificate.x509_issuer.subject, ["CN", "OU", "O"]).map do |key, value| + %li= key + "=" + value + %li= _('Subject Key Identifier:') + %li.unstyled= signature.x509_certificate.x509_issuer.subject_key_identifier.gsub(":", " ") diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index fd0dc1178f7..9269369c83e 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -46,7 +46,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - = s_('ProjectSettings|Customize this project\'s badges.') + = s_('ProjectSettings|Add badges to display information about this project.') = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') .settings-content = render 'shared/badges/badge_settings' @@ -90,16 +90,18 @@ .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= _('Change path') %p.gl-new-card-description - - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/working_with_projects', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - c.with_body do = render 'projects/errors' = gitlab_ui_form_for @project do |f| .form-group - %p - %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") - = _('You will need to update your local repositories to point to the new location.') + %ul + %li= _("Be careful. Renaming a project's repository can have unintended side effects.") + %li= _('You will need to update your local repositories to point to the new location.') + - if ContainerRegistry::GitlabApiClient.supports_gitlab_api? + %li= s_('ContainerRegistry|While the rename is in progress, new uploads to the container registry are blocked. Ongoing uploads may fail and need to be retried.') - if @project.deployment_platform.present? %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') = f.label :path, _('Path'), class: 'label-bold' diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 902a5df9394..684ea8242f7 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/projects' - default_branch_name = @project.default_branch_or_main - escaped_default_branch_name = default_branch_name.shellescape - @skip_current_level_breadcrumb = true @@ -8,68 +9,142 @@ = render "home_panel" = render "archived_notice", project: @project -= render "invite_members_empty_project" if can_admin_project_member?(@project) +- if Feature.enabled?(:project_overview_reorg) + - add_page_specific_style 'page_bundles/project' -%h4.gl-mt-0.gl-mb-3 - = _('The repository for this project is empty') + .project-page-indicator.js-show-on-project-root -- if @project.can_current_user_push_code? - %p - = _('You can get started by cloning the repository or start adding files to it with one of the following options.') + .project-page-layout + .project-page-layout-content.gl-mt-5 + .project-buttons.gl-mb-5{ data: { testid: 'quick-actions-container' } } + .project-clone-holder.d-block.d-sm-none + = render "shared/mobile_clone_panel" -.project-buttons{ data: { testid: 'quick-actions-container' } } - .project-clone-holder.d-block.d-md-none.gl-mt-3.gl-mr-3 - = render "shared/mobile_clone_panel" + .project-clone-holder.gl-display-none.gl-sm-display-flex.gl-justify-content-end.gl-w-full.gl-mt-2 + = render "projects/buttons/code", ref: @ref - .project-clone-holder.d-none.d-md-inline-block.gl-mb-3.gl-mr-3.float-left - = render "projects/buttons/clone" - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-new-card-body gl-bg-gray-10 gl-p-5' }) do |c| + - c.with_body do + %h4.gl-font-lg.gl-mt-0.gl-mb-2= _('The repository for this project is empty') + - if @project.can_current_user_push_code? + %p.gl-m-0.gl-text-secondary= _('You can get started by cloning the repository or start adding files to it with one of the following options.') -- if can?(current_user, :push_code, @project) - .empty-wrapper.gl-mt-4 - %h3#repo-command-line-instructions.page-title-empty - = _('Command line instructions') + - if can?(current_user, :push_code, @project) + = render Pajamas::CardComponent.new(header_options: { class: 'gl-py-4' }) do |c| + - c.with_header do + %h5.gl-font-lg.gl-m-0= _('Command line instructions') + - c.with_body do + %p + = _('You can also upload existing files from your computer using the instructions below.') + .git-empty.js-git-empty + %h5= _('Git global setup') + %pre.gl-bg-gray-10 + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" + + %h5= _('Create a new repository') + %pre.gl-bg-gray-10 + :preserve + git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + cd #{h @project.path} + git switch --create #{h escaped_default_branch_name} + touch README.md + git add README.md + git commit -m "add README" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin #{h escaped_default_branch_name } + + %h5= _('Push an existing folder') + %pre.gl-bg-gray-10 + :preserve + cd existing_folder + git init --initial-branch=#{h escaped_default_branch_name} + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin #{h escaped_default_branch_name } + + %h5= _('Push an existing Git repository') + %pre.gl-bg-gray-10 + :preserve + cd existing_repo + git remote rename origin old-origin + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin --all + git push --set-upstream origin --tags + + .project-page-layout-sidebar.js-show-on-project-root.gl-mt-5 + = render "sidebar" + +- else + = render "invite_members_empty_project" if can_admin_project_member?(@project) + + %h4.gl-mt-0.gl-mb-3 + = _('The repository for this project is empty') + + - if @project.can_current_user_push_code? %p - = _('You can also upload existing files from your computer using the instructions below.') - .git-empty.js-git-empty - %h5= _('Git global setup') - %pre.bg-light - :preserve - git config --global user.name "#{h git_user_name}" - git config --global user.email "#{h git_user_email}" - - %h5= _('Create a new repository') - %pre.bg-light - :preserve - git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - cd #{h @project.path} - git switch --create #{h escaped_default_branch_name} - touch README.md - git add README.md - git commit -m "add README" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push --set-upstream origin #{h escaped_default_branch_name } - - %h5= _('Push an existing folder') - %pre.bg-light - :preserve - cd existing_folder - git init --initial-branch=#{h escaped_default_branch_name} - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - git add . - git commit -m "Initial commit" - - if @project.can_current_user_push_to_default_branch? - %span>< - git push --set-upstream origin #{h escaped_default_branch_name } - - %h5= _('Push an existing Git repository') - %pre.bg-light - :preserve - cd existing_repo - git remote rename origin old-origin - git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} - - if @project.can_current_user_push_to_default_branch? - %span>< - git push --set-upstream origin --all - git push --set-upstream origin --tags + = _('You can get started by cloning the repository or start adding files to it with one of the following options.') + + .project-buttons{ data: { testid: 'quick-actions-container' } } + .project-clone-holder.d-block.d-sm-none.gl-mt-3.gl-mr-3 + = render "shared/mobile_clone_panel" + + .project-clone-holder.d-none.d-sm-inline-block.gl-mb-3.gl-mr-3.float-left + = render "projects/buttons/code", ref: @ref + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true + + - if can?(current_user, :push_code, @project) + .empty-wrapper.gl-mt-4 + %h3#repo-command-line-instructions.page-title-empty + = _('Command line instructions') + %p + = _('You can also upload existing files from your computer using the instructions below.') + .git-empty.js-git-empty + %h5= _('Git global setup') + %pre.bg-light + :preserve + git config --global user.name "#{h git_user_name}" + git config --global user.email "#{h git_user_email}" + + %h5= _('Create a new repository') + %pre.bg-light + :preserve + git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + cd #{h @project.path} + git switch --create #{h escaped_default_branch_name} + touch README.md + git add README.md + git commit -m "add README" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin #{h escaped_default_branch_name } + + %h5= _('Push an existing folder') + %pre.bg-light + :preserve + cd existing_folder + git init --initial-branch=#{h escaped_default_branch_name} + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + git add . + git commit -m "Initial commit" + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin #{h escaped_default_branch_name } + + %h5= _('Push an existing Git repository') + %pre.bg-light + :preserve + cd existing_repo + git remote rename origin old-origin + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} + - if @project.can_current_user_push_to_default_branch? + %span>< + git push --set-upstream origin --all + git push --set-upstream origin --tags diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 997e7b7f24d..4f7fed2ac2c 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -6,7 +6,7 @@ - blob_path = project_blob_path(@project, @ref) .file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ data: { file_find_url: "#{escape_javascript(project_files_path(@project, @ref, ref_type: @ref_type, format: :json))}", find_tree_url: escape_javascript(tree_path), blob_url_template: escape_javascript(blob_path), ref_type: @ref_type } } .nav-block.gl-xs-mr-0 - .tree-ref-holder.gl-xs-mb-3.gl-max-w-26 + .tree-ref-holder.gl-mb-3.gl-sm-mb-0.gl-max-w-26 #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, ref_type: @ref_type, namespace: "/-/find_file" } } %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap %li.breadcrumb-item.gl-white-space-nowrap diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index fe7d2c9d198..98055534a27 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -1,3 +1,4 @@ +- page_title _("Forks") - sort_value = @sort || sort_value_recently_created - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] - created_at = { value: sort_value_created_date, text: sort_title_created_date, href: page_filter_path(sort: sort_value_recently_created, without: excluded_filters) } diff --git a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml new file mode 100644 index 00000000000..0118fe94810 --- /dev/null +++ b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image.html.haml @@ -0,0 +1,33 @@ +.gl-display-flex.gl-flex-direction-column + .gl-display-flex.gl-flex-direction-column.gl-border-b-solid.gl-border-t-solid.gl-border-t-1.gl-border-b-1.gl-border-t-transparent.gl-border-b-gray-100 + .gl-display-flex.gl-align-items-center.gl-py-3 + .gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-justify-content-space-between.gl-align-items-stretch.gl-flex-grow-1 + .gl-display-flex.gl-flex-direction-column.gl-mb-3.gl-sm-mb-0.gl-min-w-0.gl-flex-grow-1 + .gl-display-flex.gl-align-items-center.gl-text-body.gl-font-weight-bold.gl-font-size-h2 + %span.gl-text-body.gl-font-weight-bold= docker_image.short_name + .gl-bg-gray-50.gl-inset-border-1-gray-100.gl-rounded-base.gl-pt-6 + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('information-o', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Full name: #{docker_image.name} + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('earth', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + URI: + %a{ href: docker_image.uri, target: 'blank', rel: 'noopener noreferrer' }= docker_image.uri + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('doc-code', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Media Type: #{docker_image.media_type} + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('archive', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Size: #{number_to_human_size(docker_image.image_size_bytes)} + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('calendar', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Built at: #{docker_image.built_at&.to_fs} + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('calendar', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Uploaded at: #{docker_image.uploaded_at&.to_fs} + .gl-display-flex.gl-align-items-top.gl-font-monospace.gl-font-sm.gl-word-break-all.gl-p-4.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 + = sprite_icon('calendar', css_class: 'gl-text-gray-500 gl-mr-3 gl-icon s16') + Updated at: #{docker_image.updated_at&.to_fs} + - if docker_image.tags.present? + .gl-display-flex.gl-align-items-center.gl-text-gray-500.gl-min-h-6.gl-min-w-0.gl-flex-grow-1.gl-pt-4 + = render partial: 'docker_image_tag', collection: docker_image.tags diff --git a/app/views/projects/gcp/artifact_registry/docker_images/_docker_image_tag.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image_tag.html.haml new file mode 100644 index 00000000000..a030cd7d634 --- /dev/null +++ b/app/views/projects/gcp/artifact_registry/docker_images/_docker_image_tag.html.haml @@ -0,0 +1 @@ +%a.gl-button.btn.btn-md.btn-default.gl-mr-3!= docker_image_tag diff --git a/app/views/projects/gcp/artifact_registry/docker_images/_pagination.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/_pagination.html.haml new file mode 100644 index 00000000000..df98ba8d68e --- /dev/null +++ b/app/views/projects/gcp/artifact_registry/docker_images/_pagination.html.haml @@ -0,0 +1,13 @@ +.gl-display-flex.gl-justify-content-center + %nav.gl-pagination.gl-mt-3 + .gl-keyset-pagination.btn-group + - if @page > 1 + = link_to 'Prev', namespace_project_gcp_artifact_registry_docker_images_path(params[:namespace_id], params[:project_id], page_token: @previous_page_token, page_tokens: @page_tokens, page: @page - 1, gcp_project_id: params[:gcp_project_id], gcp_location: params[:gcp_location], gcp_ar_repository: params[:gcp_ar_repository], gcp_wlif_url: params[:gcp_wlif_url]), class: 'btn btn-default btn-md gl-button' + - else + %span.btn.btn-default.btn-md.gl-button.disabled= 'Prev' + - if @next_page_token.present? + = link_to 'Next', namespace_project_gcp_artifact_registry_docker_images_path(params[:namespace_id], params[:project_id], page_token: @next_page_token, page_tokens: @page_tokens, page: @page + 1, gcp_project_id: params[:gcp_project_id], gcp_location: params[:gcp_location], gcp_ar_repository: params[:gcp_ar_repository], gcp_wlif_url: params[:gcp_wlif_url]), class: 'btn btn-default btn-md gl-button' + - else + %span.btn.btn-default.btn-md.gl-button.disabled= 'Next' + + diff --git a/app/views/projects/gcp/artifact_registry/docker_images/index.html.haml b/app/views/projects/gcp/artifact_registry/docker_images/index.html.haml new file mode 100644 index 00000000000..b487a175691 --- /dev/null +++ b/app/views/projects/gcp/artifact_registry/docker_images/index.html.haml @@ -0,0 +1,23 @@ +- page_title 'Artifact Registry Docker Images' + +- unless @error + .gl-display-flex.gl-flex-direction-column + .gl-display-flex.gl-justify-content-space-between.gl-py-3 + .gl-flex-direction-column.gl-flex-grow-1 + .gl-display-flex + .gl-display-flex.gl-flex-direction-column + %h2.gl-font-size-h1.gl-mt-3.gl-mb-0 Docker Images of #{@artifact_repository_name} + = render partial: 'pagination' + = render partial: 'docker_image', collection: @docker_images + = render partial: 'pagination' +- else + .flash-container.flash-container-page.sticky + .gl-alert.flash-notice.gl-alert-info + .gl-alert-icon-container + = sprite_icon('information-o', css_class: 's16 gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + - if @error + = @error + - else + Nothing to show here. diff --git a/app/views/projects/gcp/artifact_registry/setup/new.html.haml b/app/views/projects/gcp/artifact_registry/setup/new.html.haml new file mode 100644 index 00000000000..39ce0093372 --- /dev/null +++ b/app/views/projects/gcp/artifact_registry/setup/new.html.haml @@ -0,0 +1,31 @@ +- page_title 'Artifact Registry Setup' + +- if @error.present? + .flash-container.flash-container-page.sticky + .gl-alert.flash-notice.gl-alert-info + .gl-alert-icon-container + = sprite_icon('information-o', css_class: 's16 gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body= @error +- else + %p + + = form_tag namespace_project_gcp_artifact_registry_docker_images_path , method: :get do + .form-group.row + = label_tag :gcp_project_id, 'Google Project ID', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :gcp_project_id, nil, class: 'form-control gl-form-input gl-mr-3' + .form-group.row + = label_tag :gcp_location, 'Google Project Location', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :gcp_location, nil, class: 'form-control gl-form-input gl-mr-3' + .form-group.row + = label_tag :gcp_ar_repository, 'Artifact Registry Repository Name', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :gcp_ar_repository, nil, class: 'form-control gl-form-input gl-mr-3' + .form-group.row + = label_tag :gcp_wlif_url, 'Worflow Identity Federation url', class: 'col-form-label col-md-2' + .col-md-4 + = text_field_tag :gcp_wlif_url, nil, class: 'form-control gl-form-input gl-mr-3' + .form-actions + = submit_tag 'Setup', class: 'gl-button btn btn-confirm' diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 9d6f67bd190..97909dc8c18 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,4 +1,4 @@ -- page_title _('Contributor statistics') +- page_title _('Contributor analytics') - graph_path = project_graph_path(@project, current_ref, ref_type: @ref_type, format: :json) - commits_path = project_commits_path(@project, current_ref, ref_type: @ref_type) diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml deleted file mode 100644 index 165e414f75b..00000000000 --- a/app/views/projects/integrations/shimos/show.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- 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/empty-state/empty-wiki-md.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_button_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', title: s_('Shimo|Go to Shimo Workspace'), variant: :confirm do - = s_('Shimo|Go to Shimo Workspace') diff --git a/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml index b087a1d0151..3af8bbafa0f 100644 --- a/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml +++ b/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml @@ -1,5 +1,5 @@ - return unless show_moved_service_desk_issue_warning?(issue) -- service_desk_link_url = help_page_path('user/project/service_desk') +- service_desk_link_url = help_page_path('user/project/service_desk/index') - service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url } = render Pajamas::AlertComponent.new(variant: :warning, diff --git a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml index 831bd107961..919810413cd 100644 --- a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml @@ -17,7 +17,7 @@ %code= @project.service_desk_address %span= s_("ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.") - = link_to _('Learn more.'), help_page_path('user/project/service_desk') + = link_to _('Learn more.'), help_page_path('user/project/service_desk/index') - if can_edit_project_settings && !service_desk_enabled .text-center diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml index 093a47e63be..97bfb2f9f62 100644 --- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml @@ -17,7 +17,7 @@ %code= @project.service_desk_address %span= s_("ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.") - = link_to _('Learn more.'), help_page_path('user/project/service_desk') + = link_to _('Learn more.'), help_page_path('user/project/service_desk/index') - if can_edit_project_settings && !service_desk_enabled .gl-mt-3 diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 9ec4363fa9a..721446eb017 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -22,6 +22,7 @@ window.gl.mrWidgetData.can_view_false_positive = '#{@merge_request.project.licensed_feature_available?(:sast_fp_reduction).to_s}'; window.gl.mrWidgetData.user_preferences_gitpod_path = '#{profile_preferences_path(anchor: 'user_gitpod_enabled')}'; window.gl.mrWidgetData.user_profile_enable_gitpod_path = '#{profile_path(user: { gitpod_enabled: true })}'; + window.gl.mrWidgetData.saml_approval_path = window.gl.mrWidgetData.saml_approval_path %h2#merge-request-widgets-heading.gl-sr-only = _("Merge request reports") diff --git a/app/views/projects/missing_default_branch.html.haml b/app/views/projects/missing_default_branch.html.haml new file mode 100644 index 00000000000..66a466d8890 --- /dev/null +++ b/app/views/projects/missing_default_branch.html.haml @@ -0,0 +1,10 @@ +- @skip_current_level_breadcrumb = true + += render Pajamas::AlertComponent.new(alert_options: { class: 'gl-my-5' }, + variant: :danger, + dismissible: false, + title: s_('ProjectPage|Unable to load default branch')) do |c| + - c.with_body do + = s_('ProjectPage|The default branch was not able to be found. Please contact your administrator.') + += render 'home_panel' diff --git a/app/views/projects/ml/candidates/show.html.haml b/app/views/projects/ml/candidates/show.html.haml index 979bc107fd2..41b29d3c0b8 100644 --- a/app/views/projects/ml/candidates/show.html.haml +++ b/app/views/projects/ml/candidates/show.html.haml @@ -3,6 +3,6 @@ - add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid) - breadcrumb_title "Candidate #{@candidate.iid}" - add_page_specific_style 'page_bundles/ml_experiment_tracking' -- presenter = ::Ml::CandidateDetailsPresenter.new(@candidate, @include_ci_info) +- presenter = ::Ml::CandidateDetailsPresenter.new(@candidate, current_user) -#js-show-ml-candidate{ data: { view_model: presenter.present } } +#js-show-ml-candidate{ data: { view_model: presenter.present_as_json } } diff --git a/app/views/projects/ml/model_versions/show.html.haml b/app/views/projects/ml/model_versions/show.html.haml index 0b3d5462a89..1b4bdd29842 100644 --- a/app/views/projects/ml/model_versions/show.html.haml +++ b/app/views/projects/ml/model_versions/show.html.haml @@ -3,4 +3,4 @@ - breadcrumb_title @model_version.version - page_title "#{@model_version.name} / #{@model_version.version}" -= render(Projects::Ml::ShowMlModelVersionComponent.new(model_version: @model_version)) += render(Projects::Ml::ShowMlModelVersionComponent.new(model_version: @model_version, current_user: current_user)) diff --git a/app/views/projects/ml/models/show.html.haml b/app/views/projects/ml/models/show.html.haml index be611e55304..e0067143450 100644 --- a/app/views/projects/ml/models/show.html.haml +++ b/app/views/projects/ml/models/show.html.haml @@ -2,4 +2,4 @@ - breadcrumb_title @model.name - page_title @model.name -= render(Projects::Ml::ShowMlModelComponent.new(model: @model)) += render(Projects::Ml::ShowMlModelComponent.new(model: @model, current_user: current_user)) diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 1e18e528665..53a4fb4389c 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -1,12 +1,15 @@ - if @project.pages_deployed? - pages_url = build_pages_url(@project, with_unique_domain: true) + - pages_url_text = pages_url + - if Gitlab.config.pages.namespace_in_path + - pages_url_text = "#{pages_url} (Experimental)" = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-page-container' } }, footer_options: { class: 'gl-alert-warning' }) do |c| - c.with_header do = s_('GitLabPages|Access pages') - c.with_body do %p - = external_link(pages_url, pages_url) + = external_link(pages_url_text, pages_url) - @project.pages_domains.each do |domain| %p diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml index 1aa8148dfed..4c18609ad59 100644 --- a/app/views/projects/pages/_pages_settings.html.haml +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -24,10 +24,12 @@ .form-group = f.fields_for :project_setting do |settings| = settings.gitlab_ui_checkbox_component :pages_multiple_versions_enabled, - s_('GitLabPages|Use multiple versions'), + s_('GitLabPages|Use multiple deployments'), label_options: { class: 'label-bold' } %p.gl-pl-6 - = s_("GitLabPages|When enabled, you can create multiple versions of your pages site.").html_safe + = safe_format(s_("GitLabPages|When enabled, you can create multiple deployments of your pages site. %{docs_link_start}Learn More.%{link_end}"), + tag_pair(tag.a(href: help_page_path('user/project/pages/index', anchor: 'create-multiple-deployments'), target: '_blank'), + :docs_link_start, :link_end)) .gl-mt-3 = f.submit s_('GitLabPages|Save changes'), pajamas_button: true diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index c3d6d0c5971..818184903d6 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -7,5 +7,5 @@ failed_pipelines_link: project_pipelines_path(@project, page: '1', scope: 'all', status: 'failed'), coverage_chart_path: charts_project_graph_path(@project, @project.default_branch), test_runs_empty_state_image_path: image_path('illustrations/pipeline.svg'), - project_quality_summary_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg'), + project_quality_summary_feedback_image_path: image_path('illustrations/chat-sm.svg'), default_branch: @project.default_branch } } diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index dcb37541a04..10ba19591fe 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -1,6 +1,6 @@ - return unless can?(current_user, :archive_project, @project) -= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - c.with_header do .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title @@ -11,12 +11,12 @@ - c.with_body do - if @project.archived? - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/migrate_projects', anchor: 'unarchive-a-project') } %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), testid: 'unarchive-project-link' } }) do = _('Unarchive project') - else - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/migrate_projects', anchor: 'archive-a-project') } %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), testid: 'archive-project-link', 'confirm-btn-variant': 'confirm' } }) do = _('Archive project') diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 7011595e075..f674bf3b43b 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -26,7 +26,7 @@ - auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : '' = form.gitlab_ui_checkbox_component :enabled, (s_('CICD|Default to Auto DevOps pipeline') + auto_devops_badge).html_safe, - checkbox_options: { class: 'js-toggle-extra-settings', checked: auto_devops_enabled, data: { qa_selector: 'enable_autodevops_checkbox' } }, + checkbox_options: { class: 'js-toggle-extra-settings', checked: auto_devops_enabled, data: { testid: 'enable-autodevops-checkbox' } }, help_text: (s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') + ' ' + autodevops_help_link).html_safe - c.with_footer do - if @project.all_clusters.empty? @@ -41,4 +41,4 @@ = form.gitlab_ui_radio_component :deploy_strategy, 'timed_incremental', (s_('CICD|Continuous deployment to production using timed incremental rollout') + ' ' + help_link_timed).html_safe = form.gitlab_ui_radio_component :deploy_strategy, 'manual', (s_('CICD|Automatic deployment to staging, manual deployment to production') + ' ' + help_link_incremental).html_safe - = f.submit _('Save changes'), class: "gl-mt-5", data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), class: "gl-mt-5", data: { testid: 'save-changes-button' }, pajamas_button: true diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index d51acc5e708..0efd55f2e50 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -36,6 +36,8 @@ s_("CICD|Use separate caches for protected branches"), help_text: (s_('CICD|Unprotected branches will not have access to the cache from protected branches.') + ' ' + help_link_separated_caches).html_safe + = render_if_exists 'projects/settings/ci_cd/pipeline_cancelation', form: f + .form-group = f.label :ci_config_path, _('CI/CD configuration file'), class: 'label-bold' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 17953e3bc14..ab052a73ce0 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -16,7 +16,7 @@ .settings-content = render 'form' -%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'autodevops_settings_content' } } +%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'autodevops-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('CICD|Auto DevOps') @@ -34,7 +34,7 @@ = render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded - expand_runners = expanded || params[:expand_runners] -%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expand_runners), data: { qa_selector: 'runners_settings_content' } } +%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expand_runners), data: { testid: 'runners-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _("Runners") @@ -58,7 +58,7 @@ .settings-content #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/jobs/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } } -%section.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } } +%section.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { testid: 'variables-settings-content' } } .settings-header = render 'ci/variables/header', expanded: expanded .settings-content diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 6f38a3ace92..d6c2228a6c2 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -2,4 +2,4 @@ - page_title _('Packages and registries settings') - @force_desktop_expanded_sidebar = true -#js-registry-settings{ data: settings_data } +#js-registry-settings{ data: settings_data(@project) } diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index c76fa5e2220..26ef47e9a7a 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -15,15 +15,35 @@ = render "home_panel" -- if can?(current_user, :read_code, @project) && @project.repository_languages.present? - - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) - = repository_languages_bar(@project.repository_languages) +- if Feature.enabled?(:project_overview_reorg) + .project-page-indicator.js-show-on-project-root -= 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 + .project-page-layout + .project-page-layout-sidebar.js-show-on-project-root.gl-mt-5 + = render "sidebar" -- view_path = @project.default_view + .project-page-layout-content + - if can?(current_user, :read_code, @project) && @project.repository_languages.present? + - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) -%div{ class: project_child_container_class(view_path) } - = render view_path, is_project_overview: true + = 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 + + - view_path = @project.default_view + + %div{ class: project_child_container_class(view_path) } + = render view_path, is_project_overview: true +- else + - if can?(current_user, :read_code, @project) && @project.repository_languages.present? + - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) + = repository_languages_bar(@project.repository_languages) + + = 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 + + - view_path = @project.default_view + + %div{ class: project_child_container_class(view_path) } + = render view_path, is_project_overview: true diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 7ff798d7324..da7afad041b 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -1,4 +1,3 @@ -- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml index 16ae003255c..68d283b70ad 100644 --- a/app/views/projects/starrers/_starrer.html.haml +++ b/app/views/projects/starrers/_starrer.html.haml @@ -1,11 +1,11 @@ - starrer = local_assigns.fetch(:starrer) .col-lg-3.col-md-4.col-sm-12 - = render Pajamas::CardComponent.new(body_options: { class: 'gl-display-flex' }) do |c| + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-4' }, body_options: { class: 'gl-display-flex' }) do |c| - c.with_body do = render Pajamas::AvatarComponent.new(starrer.user, size: 48, alt: "", class: 'gl-mr-3') - .user-info + .user-info.gl-overflow-hidden .block-truncated = link_to starrer.user.name, user_path(starrer.user), class: 'user js-user-link', data: { user_id: starrer.user.id } @@ -15,5 +15,5 @@ - if starrer.user == current_user = gl_badge_tag _("It's you"), variant: :success, size: :sm, class: 'gl-ml-2' - .block-truncated + .block-truncated.gl-text-secondary.gl-font-sm = time_ago_with_tooltip(starrer.starred_since) diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml index 2d5581fc1c5..38455f1022d 100644 --- a/app/views/projects/starrers/index.html.haml +++ b/app/views/projects/starrers/index.html.haml @@ -10,8 +10,8 @@ = form_tag request.original_url, method: :get, class: 'form-inline user-search-form flex-users-form' do .form-group .position-relative - = search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false } - %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } + = search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control gl-pr-7', spellcheck: false } + %button.user-search-btn{ class: 'gl-p-2 gl-absolute gl-right-3', type: "submit", "aria-label" => _("Submit search") } = sprite_icon('search') - starrers_sort_options = starrers_sort_options_hash.map { |value, text| { value: value, text: text, href: filter_starrer_path(sort: value) } } = gl_redirect_listbox_tag starrers_sort_options, @sort, class: 'gl-ml-3', data: { placement: 'right' } diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index b0be748eb36..842e0744d4e 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -11,7 +11,7 @@ #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } } = link_button_to nil, project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'has-tooltip gl-ml-auto', icon: 'rss' - if can?(current_user, :admin_tag, @project) - = link_button_to new_project_tag_path(@project), data: { qa_selector: "new_tag_button" }, variant: :confirm do + = link_button_to new_project_tag_path(@project), data: { testid: "new-tag-button" }, variant: :confirm do = s_('TagsPage|New tag') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 281eac6c773..9e5dd2cfb6d 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -18,7 +18,7 @@ .form-group.row .col-sm-12 = label_tag :tag_name, _('Tag name') - = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" } + = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { testid: 'tag-name-field' } .form-group.row .col-sm-auto.create-from = label_tag :ref, _('Create from') @@ -28,12 +28,12 @@ .form-group.row .col-sm-12 = label_tag :message, _('Message') - = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" } + = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { testid: 'tag-message-field' } .form-text.text-muted = tag_description_help_text .gl-display-flex - = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3', data: { qa_selector: "create_tag_button" }, type: 'submit' }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3', data: { testid: 'create-tag-button' }, type: 'submit' }) do = s_('TagsPage|Create tag') = render Pajamas::ButtonComponent.new(href: project_tags_path(@project)) do = s_('TagsPage|Cancel') diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 1649e56043e..d76627f3337 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -9,7 +9,7 @@ .top-area.multi-line.flex-wrap .nav-text .title - %span.item-title.ref-name{ data: { qa_selector: 'tag_name_content' } } + %span.item-title.ref-name{ data: { testid: 'tag-name-content' } } = sprite_icon('tag') = @tag.name - if protected_tag?(@project, @tag) @@ -52,7 +52,7 @@ = render 'projects/buttons/remove_tag', project: @project, tag: @tag - if @tag.message.present? - %pre.wrap{ data: { qa_selector: 'tag_message_content' } } + %pre.wrap{ data: { testid: 'tag-message-content' } } = strip_signature(@tag.message) - if can?(current_user, :admin_tag, @project) diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index bed37d9cb63..0da6017419c 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -8,18 +8,18 @@ #js-blob-controls .tree-controls - .d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3.gl-first-child-ml-sm-0< + .gl-display-flex.gl-flex-wrap.gl-gap-3.gl-mb-3.gl-sm-mb-0 = render_if_exists 'projects/tree/lock_link' = render 'projects/buttons/compare', project: @project, ref: @ref, root_ref: @repository&.root_ref #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } } = render 'projects/find_file_link' - = render 'shared/web_ide_button', blob: nil - = render 'projects/buttons/download', project: @project, ref: @ref + = render 'shared/web_ide_button', blob: nil, css_classes: 'gl-w-full gl-sm-w-auto' - .project-clone-holder.d-none.d-md-inline-block> - = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' + .project-code-holder.d-none.d-sm-inline-block + = render "projects/buttons/code", dropdown_class: 'dropdown-menu-right', ref: @ref - .project-clone-holder.d-block.d-md-none.mt-sm-2.mt-md-0.ml-md-2> - = render "shared/mobile_clone_panel" + .project-code-holder.gl-display-flex.gl-gap-3{ class: 'gl-sm-display-none!' } + = render 'projects/buttons/download', project: @project, ref: @ref + = render "shared/mobile_clone_panel", ref: @ref diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml index bb1d56dcc61..48c3752e826 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -39,7 +39,7 @@ = render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle', label: s_("ProtectedBranch|Allowed to force push"), label_position: :hidden) do - - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') + - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-pushing') - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index fb96672cf99..dad352e376b 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -8,9 +8,7 @@ - elsif @search_objects.blank? = render partial: "search/results/empty" - else - - statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' - - .section{ class: statusBarClass } + .section - if @scope == 'commits' %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index 8417b66eb34..3c42126e480 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -1,7 +1,4 @@ -- statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' -- statusBarClass = statusBarClass + ' gl-lg-display-none' if @search_objects.to_a.empty? - -.section{ class: statusBarClass } +.section{ class: ('gl-lg-display-none' if @search_objects.to_a.empty?) } .search-results-status .gl-display-flex.gl-flex-direction-column .gl-p-5.gl-display-flex.gl-flex-wrap diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 4fda5379876..ec4f362dde3 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -8,7 +8,6 @@ = hidden_field_tag :project_id, params[:project_id] - group_attributes = @group&.attributes&.slice('id', 'name')&.merge(full_name: @group&.full_name) - project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) -- search_bar_classes = !show_super_sidebar? ? 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' : '' - if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?) - if @search_service_presenter.without_count? @@ -17,11 +16,8 @@ - page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term }) - page_card_attributes("Namespace" => @group&.full_path, "Project" => @project&.full_path) -.gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-pt-6.gl-pb-5 - = render_if_exists 'search/form_elasticsearch' - -#js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "default-branch-name": @project&.default_branch } } +#js-search-topbar{ data: { "default-branch-name": @project&.default_branch } } .results.gl-lg-display-flex.gl-mt-0 - #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json, search_type: search_service.search_type } } + #js-search-sidebar{ data: { navigation_json: search_navigation_json, search_type: search_service.search_type, search_level: search_service.level, group_initial_json: group_attributes.to_json, project_initial_json: project_attributes.to_json, } } - if @search_term = render 'search/results' diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml index ab5f2fb1772..c7df9354b11 100644 --- a/app/views/shared/_allow_request_access.html.haml +++ b/app/views/shared/_allow_request_access.html.haml @@ -1,3 +1,3 @@ = form.gitlab_ui_checkbox_component :request_access_enabled, _('Users can request access (if visibility is public or internal)'), - checkbox_options: { data: { qa_selector: 'request_access_checkbox' } } + checkbox_options: { data: { testid: 'request-access-checkbox' } } diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 3f613a1b383..af09b62c229 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,6 +1,4 @@ -- container = @no_top_bar_container ? 'container-fluid' : container_class - -%div{ class: [container, @content_class, 'gl-pt-5!'] } +%div{ class: [container_class, @content_class, 'gl-pt-5!'] } = render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'), button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), svg_path: 'illustrations/autodevops.svg', diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 4b39ec52837..7dce8737eb4 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -10,9 +10,9 @@ = default_clone_protocol.upcase = sprite_icon('chevron-down', css_class: 'gl-icon') %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown{ data: { testid: 'clone-dropdown-content' } } - %li + %li.js-clone-links = ssh_clone_button(container) - %li + %li.js-clone-links = http_clone_button(container) = render_if_exists 'shared/kerberos_clone_button', container: container diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml index 82b4a314b59..9d55db102f9 100644 --- a/app/views/shared/_event_filter.html.haml +++ b/app/views/shared/_event_filter.html.haml @@ -1,6 +1,6 @@ - show_group_events = local_assigns.fetch(:show_group_events, false) -.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill +.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } = sprite_icon('chevron-lg-left', size: 12) %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') } diff --git a/app/views/shared/_groups_projects_more_actions_dropdown.html.haml b/app/views/shared/_groups_projects_more_actions_dropdown.html.haml new file mode 100644 index 00000000000..5189dc54f0c --- /dev/null +++ b/app/views/shared/_groups_projects_more_actions_dropdown.html.haml @@ -0,0 +1,16 @@ +- dropdown_data = groups_projects_more_actions_dropdown_data(source) + +- if dropdown_data[:is_group] && can?(current_user, :read_group, @group) + - id = @group.id + + %span.gl-sr-only{ itemprop: 'identifier', data: { testid: 'group-id-content' } } + = s_('GroupPage|Group ID: %{id}') % { id: id } + +- elsif can?(current_user, :read_project, @project) + - id = @project.id + + %span.gl-sr-only{ itemprop: 'identifier', data: { testid: 'project-id-content' } } + = s_('ProjectPage|Project ID: %{id}') % { id: id } + +- if id || current_user + .js-groups-projects-more-actions-dropdown{ data: dropdown_data } diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml deleted file mode 100644 index 6d65f2e61bd..00000000000 --- a/app/views/shared/_help_dropdown_forum_link.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', - rel: 'noopener noreferrer', data: { 'track_action': 'click_link', 'track_label': 'community_forum', 'track_property': 'navigation_top' } diff --git a/app/views/shared/_ide_root.html.haml b/app/views/shared/_ide_root.html.haml index db3e76e188c..173b081d693 100644 --- a/app/views/shared/_ide_root.html.haml +++ b/app/views/shared/_ide_root.html.haml @@ -5,6 +5,6 @@ -# 100vh because of the presence of the bottom bar #ide.gl-h-full{ data: data } - .web-ide-loader.gl-display-flex.gl-justify-content-center.gl-align-items-center.gl-flex-direction-column.gl-h-full.gl-mr-auto.gl-ml-auto + .web-ide-loader.gl-display-flex.gl-justify-content-center.gl-align-items-center.gl-flex-direction-column.gl-h-full.gl-mx-auto = brand_header_logo %h3.clblack.gl-mt-6= loading_text diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index c594cee326e..2f7fb348c17 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -8,9 +8,9 @@ = sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } - if ssh_enabled? - %li + %li.js-clone-links = dropdown_item_with_description(ssh_copy_label, ssh_clone_url_to_repo(project), href: ssh_clone_url_to_repo(project), data: { clone_type: 'ssh' }, default: true) - if http_enabled? - %li + %li.js-clone-links = dropdown_item_with_description(http_copy_label, http_clone_url_to_repo(project), href: http_clone_url_to_repo(project), data: { clone_type: 'http' }) = render_if_exists 'shared/mobile_kerberos_clone' diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml deleted file mode 100644 index 4cdf1340d64..00000000000 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%a.toggle-sidebar-button.js-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } - = sprite_icon('chevron-double-lg-left', size: 12, css_class: 'icon-chevron-double-lg-left') - %span.collapse-text.gl-ml-3= _("Collapse sidebar") - -= button_tag class: 'close-nav-button', type: 'button' do - = sprite_icon('close') - %span.collapse-text.gl-ml-3= _("Close sidebar") diff --git a/app/views/shared/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml deleted file mode 100644 index 70d9db998fc..00000000000 --- a/app/views/shared/_user_dropdown_contributing_link.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap', data: {track_action: 'click_link', track_label: 'contribute_to_gitlab', track_property: 'navigation_top'} do - = _("Contribute to GitLab") diff --git a/app/views/shared/_user_dropdown_instance_review.html.haml b/app/views/shared/_user_dropdown_instance_review.html.haml deleted file mode 100644 index 1a02f9958b0..00000000000 --- a/app/views/shared/_user_dropdown_instance_review.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- return unless instance_review_permitted? - -%li.divider -%li - = link_to admin_instance_review_path, target: '_blank', class: 'text-nowrap', data: {track_action: 'click_link', track_label: 'instance_review', track_property: 'navigation_top'} do - = _("Get a free instance review") diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml index 803f6f9efce..5f281946d3c 100644 --- a/app/views/shared/_web_ide_button.html.haml +++ b/app/views/shared/_web_ide_button.html.haml @@ -1,5 +1,6 @@ - type = blob ? 'blob' : 'tree' - button_data = web_ide_button_data({ blob: blob }) - fork_options = fork_modal_options(@project, blob) +- css_classes = false unless local_assigns[:css_classes] -.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json, web_ide_promo_popover_img: image_path('web-ide-promo-popover.svg') }, id: "js-#{type}-web-ide-link" } +.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json, web_ide_promo_popover_img: image_path('web-ide-promo-popover.svg'), css_classes: css_classes }, id: "js-#{type}-web-ide-link" } diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index 3bf85da83b1..79c4bfca630 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -15,7 +15,7 @@ .form-group = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold' - = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' + = f.text_field :name, class: 'form-control gl-form-input gl-form-input-xl', required: true, data: { testid: 'access-token-name-field' }, :'aria-describedby' => 'access_token_help_text' %span.form-text.text-muted#access_token_help_text - if resource - resource_type = resource.is_a?(Group) ? "group" : "project" @@ -42,6 +42,6 @@ = render 'shared/tokens/scopes_form', prefix: prefix, description_prefix: description_prefix, token: token, scopes: scopes, f: f .gl-mt-3 - = f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true + = f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { testid: 'create-token-button' }, pajamas_button: true = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 882730f536d..7ea2062eea1 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -1,5 +1,4 @@ - board = local_assigns.fetch(:board, nil) -- @no_top_bar_container = true - @no_container = true - @content_wrapper_class = "#{@content_wrapper_class} gl-relative gl-pb-0" - @content_class = "issue-boards-content js-focus-mode-board" diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index bbaf5bf9627..f36de252c01 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -17,6 +17,10 @@ - link_end = '</a>' = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { testid: 'deploy-key-field' } + + .form-group + = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' + = form.gitlab_ui_datepicker :expires_at - else - if deploy_key.fingerprint_sha256.present? .form-group @@ -26,10 +30,10 @@ .form-group = form.label :fingerprint, _('Fingerprint (MD5)') = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly' - -.form-group - = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly' + - if deploy_key.expires_at.present? + .form-group + = form.label :expires_at, _('Expiration date'), class: 'label-bold' + = form.gitlab_ui_datepicker :expires_at, class: 'form-control gl-form-input', readonly: 'readonly' - if deploy_keys_project.present? = form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form| diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index f28cc64b969..c1aeff9acee 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -61,8 +61,9 @@ = _("You don't have any applications.") - else - .bs-callout.bs-callout-disabled - = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission.') + = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + = s_('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission.') - if oauth_authorized_applications_enabled = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card oauth-authorized-applications' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index f8304d5e44e..375e10de065 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -2,9 +2,9 @@ - access = user&.max_member_access_for_group(group.id) %li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" } - .avatar-container.rect-avatar.s40.gl-flex-shrink-0 + .avatar-container.rect-avatar.s48.gl-flex-shrink-0 = link_to group do - = group_icon(group, class: "avatar s40") + = render Pajamas::AvatarComponent.new(group, alt: group.name, size: 48) .gl-min-w-0.gl-flex-grow-1 .title = link_to group.full_name, group, class: 'group-name' @@ -17,11 +17,11 @@ = markdown_field(group, :description) .stats.gl-text-gray-500.gl-flex-shrink-0 - %span.gl-ml-5 - = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom') + %span.gl-ml-5.has-tooltip{ title: _('Projects') } + = sprite_icon('project', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.projects.non_archived.count) - %span.gl-ml-5 + %span.gl-ml-5.has-tooltip{ title: _('Users') } = sprite_icon('users', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.users.count) diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index 2afac0ad733..f8e400ab688 100644 --- a/app/views/shared/groups/_search_form.html.haml +++ b/app/views/shared/groups/_search_form.html.haml @@ -1,2 +1,2 @@ = form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f| - = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', data: { qa_selector: 'groups_filter_field' }, spellcheck: false, id: 'group-filter-form-field' + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', data: { testid: 'groups-filter-field' }, spellcheck: false, id: 'group-filter-form-field' diff --git a/app/views/shared/icons/_caret_down.svg b/app/views/shared/icons/_caret_down.svg deleted file mode 100644 index fd80fd0f651..00000000000 --- a/app/views/shared/icons/_caret_down.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="caret-down" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 10.243l-4.95-4.95a1 1 0 0 0-1.414 1.414l5.657 5.657a.997.997 0 0 0 1.414 0l5.657-5.657a1 1 0 0 0-1.414-1.414L8 10.243z"/></svg> diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 52c8a4d4123..8db7f7345f4 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,6 +1,8 @@ - type = local_assigns.fetch(:type) - show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) - disable_target_branch = local_assigns.fetch(:disable_target_branch, false) +- disable_releases = local_assigns.fetch(:disable_releases, false) +- disable_environments = local_assigns.fetch(:disable_environments, false) - placeholder = local_assigns[:placeholder] || _('Search or filter results…') - block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : '' @@ -111,19 +113,20 @@ %button.btn.btn-link.js-data-value{ type: 'button' } {{title}} = render_if_exists 'shared/issuable/filter_iteration', type: type - #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value{ type: 'button' } - {{title}} + - unless disable_releases + #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } @@ -190,11 +193,12 @@ %li.filter-dropdown-item %button.btn.btn-link.js-data-value.monospace {{title}} - #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value{ type: 'button' } - {{title}} + - unless disable_environments + #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} = render_if_exists 'shared/issuable/filter_weight', type: type diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index f018e4f122e..9477d36c9b9 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -15,7 +15,7 @@ %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" } - .issuable-sidebar-header{ class: "gl-pb-4! #{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } + .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled} #{'gl-mt-2' if notifications_todos_buttons_enabled?}" , type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do = sidebar_gutter_toggle_icon - if signed_in @@ -51,7 +51,7 @@ - if in_group_context_with_iterations .block.gl-collapse-empty{ data: { 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 + = 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, issue_id: issuable_sidebar[:id] - if issuable_sidebar[:show_crm_contacts] .block.contact diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml deleted file mode 100644 index 0d692ee753a..00000000000 --- a/app/views/shared/members/_access_request_links.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- model_name = source.model_name.to_s.downcase - -- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord - - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') - = link_to link_text, polymorphic_path([:leave, source, :members]), - method: :delete, - aria: { label: link_text }, - data: { confirm: leave_confirmation_message(source), confirm_btn_variant: 'danger', qa_selector: 'leave_group_link' }, - class: 'js-leave-link' -- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord - - if can?(current_user, :withdraw_member_access_request, requester) - = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), - method: :delete, - data: { confirm: remove_member_message(requester) } -- elsif source.request_access_enabled && can?(current_user, :request_access, source) - = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), - method: :post, - data: { testid: 'request-access-link' } diff --git a/app/views/shared/nav/_admin_scope_header.html.haml b/app/views/shared/nav/_admin_scope_header.html.haml deleted file mode 100644 index 3a18b3660d4..00000000000 --- a/app/views/shared/nav/_admin_scope_header.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%li.context-header - = link_to admin_root_path, title: _('Admin Area'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - %span.avatar-container.icon-avatar.rect-avatar.s32 - = sprite_icon('admin', size: 18) - %span.sidebar-context-title - = _('Admin Area') diff --git a/app/views/shared/nav/_explore_scope_header.html.haml b/app/views/shared/nav/_explore_scope_header.html.haml deleted file mode 100644 index da22d6dbcf2..00000000000 --- a/app/views/shared/nav/_explore_scope_header.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%li.context-header - = link_to explore_root_url, title: _('Explore'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - %span.avatar-container.icon-avatar.rect-avatar.s32 - = sprite_icon('compass', size: 18) - %span.sidebar-context-title - = _('Explore') diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml deleted file mode 100644 index 4e570086bf8..00000000000 --- a/app/views/shared/nav/_scope_menu.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -= nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) 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 - = scope_menu.title diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml deleted file mode 100644 index 91b0582e04a..00000000000 --- a/app/views/shared/nav/_sidebar.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -%aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label } - .nav-sidebar-inner-scroll - %ul.sidebar-top-level-items{ data: { testid: sidebar_qa_selector(sidebar.container) } } - - if sidebar.render_raw_scope_menu_partial - = render sidebar.render_raw_scope_menu_partial - - elsif sidebar.scope_menu - = render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu - - - if sidebar.renderable_menus.any? - = render partial: 'shared/nav/sidebar_menu', collection: sidebar.renderable_menus - - - if sidebar.render_raw_menus_partial - = render sidebar.render_raw_menus_partial - - = render partial: 'shared/nav/sidebar_hidden_menu_item', collection: sidebar.hidden_menu&.renderable_items - = render 'shared/sidebar_toggle_button' diff --git a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml deleted file mode 100644 index d0ae5e99707..00000000000 --- a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%li.hidden - = 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 deleted file mode 100644 index 27f77ed4813..00000000000 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -= nav_link(**sidebar_menu.all_active_routes, html_options: sidebar_menu.nav_link_html_options) do - - 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.link_html_options, data: { testid: '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 - = image_tag(sidebar_menu.image_path, **sidebar_menu.image_html_options) - - elsif sidebar_menu.sprite_icon - = sprite_icon(sidebar_menu.sprite_icon, **sidebar_menu.sprite_icon_html_options) - - %span.nav-item-name{ **sidebar_menu.title_html_options } - = sidebar_menu.title - - if sidebar_menu.has_pill? - = gl_badge_tag({ variant: :info, size: :sm }, { class: "count #{sidebar_menu.pill_html_options[:class]}" }) do - = number_with_delimiter(sidebar_menu.pill_count) - - = render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu } diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml deleted file mode 100644 index ef488d06e87..00000000000 --- a/app/views/shared/nav/_sidebar_menu_item.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -= 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.link_html_options, data: { testid: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do - %span.gl-flex-grow-1 - = sidebar_menu_item.title - - if sidebar_menu_item.sprite_icon - = sprite_icon(sidebar_menu_item.sprite_icon, **sidebar_menu_item.sprite_icon_html_options) - - if sidebar_menu_item.has_pill? - = gl_badge_tag({ size: :sm, variant: :neutral }, { class: "count fly-out-badge gl-ml-3" }) do - = number_with_delimiter(sidebar_menu_item.pill_count) - - if sidebar_menu_item.show_hint? - .js-feature-highlight{ **sidebar_menu_item.hint_html_options } diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml deleted file mode 100644 index 33b48470020..00000000000 --- a/app/views/shared/nav/_sidebar_submenu.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } - = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' }) do - %span.fly-out-top-item-container - %strong.fly-out-top-item-name - = sidebar_menu.title - - if sidebar_menu.has_pill? - = gl_badge_tag({ variant: :info, size: :sm }, { class: "count fly-out-badge #{sidebar_menu.pill_html_options[:class]}" }) do - = number_with_delimiter(sidebar_menu.pill_count) - - - if sidebar_menu.has_renderable_items? - %li.divider.fly-out-top-item - = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items diff --git a/app/views/shared/nav/_user_settings_scope_header.html.haml b/app/views/shared/nav/_user_settings_scope_header.html.haml deleted file mode 100644 index c1601822736..00000000000 --- a/app/views/shared/nav/_user_settings_scope_header.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%li.context-header - = link_to profile_path, title: _('User Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - = render Pajamas::AvatarComponent.new(current_user, size: 32, alt: current_user.name, class: 'gl-mr-3 js-sidebar-user-avatar', avatar_options: { data: { testid: 'sidebar-user-avatar' } }) - %span.sidebar-context-title= _('User Settings') diff --git a/app/views/shared/nav/_your_work_scope_header.html.haml b/app/views/shared/nav/_your_work_scope_header.html.haml deleted file mode 100644 index cdd0be3c682..00000000000 --- a/app/views/shared/nav/_your_work_scope_header.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%li.context-header - = link_to root_path, title: _('Your work'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do - %span.avatar-container.icon-avatar.rect-avatar.s32 - = sprite_icon('work', size: 18) - %span.sidebar-context-title - = _('Your work') diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index d4dec49c367..76737ff99c5 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -1,3 +1,4 @@ +.js-snippets-note-edit-form-holder .snippets.note-edit-form = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index e65dcd68f66..5bb15c2f5ca 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -38,7 +38,7 @@ = visibility_level_content(project, css_class: 'gl-mr-2') - if project.catalog_resource - = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(project, project.catalog_resource), css_class: 'gl-mr-2' } + = render partial: 'shared/ci_catalog_badge', locals: { href: explore_catalog_path(project.catalog_resource), css_class: 'gl-mr-2' } - if explore_projects_tab? && project_license_name(project) %span.gl-display-inline-flex.gl-align-items-center.gl-mr-3 diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index c4524125a21..33e4ac58fa5 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -2,8 +2,9 @@ - if project.topics.present? .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' } - %span.gl-p-2.gl-text-gray-500 - = _('Topics') + ':' + - if Feature.disabled?(:project_overview_reorg) + %span.gl-p-2.gl-text-gray-500 + = _('Topics') + ':' - project.topics_to_show.each do |topic| - explore_project_topic_path = topic_explore_projects_cleaned_path(topic_name: topic[:name]) - if topic[:title].length > max_project_topic_length diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index dc689303f77..6f918ae8103 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -19,4 +19,4 @@ %p = s_("Runners|Tags control which type of jobs a runner can handle. By tagging a runner, you make sure shared runners only handle the jobs they are equipped to run.") - = link_to _("Learn more."), help_page_path("ci/runners/configure_runners", anchor: "use-tags-to-control-which-jobs-a-runner-can-run"), target: '_blank' + = link_to _("Learn more."), help_page_path("ci/runners/configure_runners", anchor: "how-the-runner-uses-tags"), target: '_blank' diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 9767f7929d0..56cdc30e760 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -6,6 +6,9 @@ = link_to gitlab_snippet_path(snippet), class: "title" do = snippet.title + - if snippet.hidden_due_to_author_ban? + %span{ class: 'has-tooltip gl-bg-orange-50 gl-text-orange-600 border-radius-default gl-p-2', title: s_("Snippets|This snippet is hidden because its author has been banned") } + = sprite_icon('spam', size: '16') %ul.controls{ data: { testid: 'snippet-file-count-content', qa_snippet_files: snippet.statistics&.file_count } } %li diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml index 9a76069e8f6..2b9a64d6d04 100644 --- a/app/views/shared/wikis/_main_links.html.haml +++ b/app/views/shared/wikis/_main_links.html.haml @@ -1,6 +1,4 @@ - if @page&.persisted? - = link_button_to wiki_page_path(@wiki, @page, action: :history), role: "button", data: { testid: 'page-history-button' } do - = s_("Wiki|Page history") - if can?(current_user, :create_wiki, @wiki.container) = link_button_to wiki_path(@wiki, action: :new), role: "button", data: { testid: 'new-page-button' }, variant: :confirm, category: :secondary do = s_("Wiki|New page") diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml index 710ecf6196e..afb26cf1d67 100644 --- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml @@ -2,6 +2,6 @@ %li{ class: active_when(params[:id] == wiki_page.slug) } .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } } - = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) + = render Pajamas::ButtonComponent.new(icon: 'plus', size: :small, href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-2' }) = link_to wiki_path, data: { testid: 'wiki-page-link', qa_page_name: wiki_page.human_title } do = wiki_page.human_title diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index 8b0b6dbd8f7..d939208811a 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,10 +1,10 @@ - wiki_path = wiki_page_path(@wiki, wiki_directory) -%li{ class: ['wiki-directory', active_when(params[:id] == wiki_directory.slug)], data: { testid: 'wiki-directory-content' } } +%li{ data: { testid: 'wiki-directory-content' } } .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }< = sprite_icon('chevron-right', css_class: 'js-wiki-list-expand-button wiki-list-expand-button gl-mr-2 gl-cursor-pointer') = sprite_icon('chevron-down', css_class: 'js-wiki-list-collapse-button wiki-list-collapse-button gl-mr-2 gl-cursor-pointer') - = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) + = render Pajamas::ButtonComponent.new(icon: 'plus', size: :small, href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-2' }) = link_to wiki_path, data: { testid: 'wiki-dir-page-link', qa_page_name: wiki_directory.title } do = wiki_directory.title %ul.gl-pl-8 diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml index 67772ec40c1..700d8a903a4 100644 --- a/app/views/shared/wikis/diff.html.haml +++ b/app/views/shared/wikis/diff.html.haml @@ -12,7 +12,7 @@ = _('Changes') .nav-controls.pb-md-3.pb-lg-0 - = link_button_to wiki_page_path(@wiki, @page, action: :history), role: 'button', data: { qa_selector: 'page_history_button' } do + = link_button_to wiki_page_path(@wiki, @page, action: :history), role: 'button', data: { testid: 'page-history-button' } do = s_('Wiki|Page history') .page-content-header diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml index fc56a191cad..ce8c7782c7f 100644 --- a/app/views/shared/wikis/edit.html.haml +++ b/app/views/shared/wikis/edit.html.haml @@ -6,13 +6,13 @@ - if @error #js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } } -.js-wiki-edit-page.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row +.js-wiki-edit-page.wiki-page-header.has-sidebar-toggle.flex-column.flex-lg-row = wiki_sidebar_toggle_button %h1.page-title.gl-font-size-h-display.gl-flex-grow-1 - if @page.persisted? = link_to_wiki_page @page - %span.light + %span.gl-text-secondary · = s_("Wiki|Edit Page") - else diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml index 052bbb3b410..4560f374fdf 100644 --- a/app/views/shared/wikis/history.html.haml +++ b/app/views/shared/wikis/history.html.haml @@ -11,7 +11,7 @@ = _('History') .prepend-top-default.gl-mb-3 - .table-holder + .table-holder{ data: { testid: 'wiki-history-table' } } %table.table.wiki-history %thead %tr diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 2cd03c20080..a896aa29f52 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -1,7 +1,8 @@ - wiki_page_title @page - add_page_specific_style 'page_bundles/wiki' +- page_history = @page&.persisted? ? wiki_page_path(@wiki, @page, action: :history) : '' -.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row +.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row.gl-border-b-0 = wiki_sidebar_toggle_button .nav-text.flex-fill @@ -11,8 +12,12 @@ = time_ago_with_tooltip(@page.last_version.authored_date) .nav-controls.pb-md-3.pb-lg-0 + - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), button_options: { class: 'js-wiki-edit', data: { testid: 'wiki-edit-button' }}) do + = _('Edit') = render 'shared/wikis/main_links' - #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } } + + #js-export-actions{ data: { options: { history: page_history, print: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] } }.to_json } } - if @page.historical? = render Pajamas::AlertComponent.new(variant: :warning, @@ -26,12 +31,9 @@ = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :history)) do = s_('WikiHistoricalPage|Browse history') -.gl-mt-5.gl-mb-3 +.gl-mb-3 .gl-display-flex.gl-justify-content-space-between %h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page.human_title - %div - - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding - = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { testid: 'wiki-edit-button' }}) .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/user_settings/active_sessions/_active_session.html.haml index e91c28e6e84..e91c28e6e84 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/user_settings/active_sessions/_active_session.html.haml diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/user_settings/active_sessions/index.html.haml index baca9559e08..fb1ca1a431c 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/user_settings/active_sessions/index.html.haml @@ -12,4 +12,4 @@ = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c| - c.with_body do %ul.list-group.list-group-flush - = render partial: 'profiles/active_sessions/active_session', collection: @sessions + = render partial: 'user_settings/active_sessions/active_session', collection: @sessions diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/user_settings/passwords/edit.html.haml index 4848a9dc595..afe6ee2c0b3 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/user_settings/passwords/edit.html.haml @@ -12,7 +12,7 @@ = _('Change your password.') - else = _('Change your password or recover your current one.') - = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| + = gitlab_ui_form_for @user, url: user_settings_password_path, method: :put, html: {class: "update-password"} do |f| = form_errors(@user) - unless @user.password_automatically_set? @@ -31,5 +31,5 @@ .gl-mt-3.gl-mb-3 = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true - unless @user.password_automatically_set? - = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do + = render Pajamas::ButtonComponent.new(href: reset_user_settings_password_path, variant: :link, method: :put) do = _('I forgot my password') diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/user_settings/passwords/new.html.haml index a0a9077afe4..3616c9ec252 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/user_settings/passwords/new.html.haml @@ -3,7 +3,7 @@ %h1.page-title.gl-font-size-h-display= _('Set up new password') %hr -= gitlab_ui_form_for @user, url: profile_password_path, method: :post do |f| += gitlab_ui_form_for @user, url: user_settings_password_path, method: :post do |f| %p.slead = _('Please set a new password before proceeding.') %br diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/user_settings/personal_access_tokens/index.html.haml index 0457561b283..dc87407adc7 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/user_settings/personal_access_tokens/index.html.haml @@ -32,7 +32,7 @@ = render 'shared/access_tokens/form', ajax: true, type: type, - path: profile_personal_access_tokens_path, + path: user_settings_personal_access_tokens_path, token: @personal_access_token, scopes: @scopes, help_path: help_page_path('user/profile/personal_access_tokens', anchor: 'personal-access-token-scopes') diff --git a/app/views/profiles/_event_table.html.haml b/app/views/user_settings/user_settings/_event_table.haml index 67c649a9fce..67c649a9fce 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/user_settings/user_settings/_event_table.haml diff --git a/app/views/profiles/audit_log.html.haml b/app/views/user_settings/user_settings/authentication_log.haml index d47f1ea7c25..d47f1ea7c25 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/user_settings/user_settings/authentication_log.haml diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index e23555428aa..99097ac397c 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -6,10 +6,8 @@ - page_itemtype 'http://schema.org/Person' - add_page_specific_style 'page_bundles/profile' - add_page_specific_style 'page_bundles/projects' -- if show_super_sidebar? - - @left_sidebar = true - - @force_desktop_expanded_sidebar = true - - nav "user_profile" +- @force_desktop_expanded_sidebar = true +- nav "user_profile" = content_for :meta_tags do = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") @@ -89,13 +87,13 @@ = sprite_icon('linkedin', css_class: 'linkedin-icon') - if @user.twitter.present? = render 'middle_dot_divider', breakpoint: 'sm' do - = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('twitter', css_class: 'twitter-icon') + = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: _("X (formerly Twitter)"), target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('x', css_class: 'x-icon') - if @user.discord.present? = render 'middle_dot_divider', breakpoint: 'sm' do = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do = sprite_icon('discord', css_class: 'discord-icon') - - if Feature.enabled?(:mastodon_social_ui, @user) && @user.mastodon.present? + - if @user.mastodon.present? = render 'middle_dot_divider', breakpoint: 'sm' do = link_to mastodon_url(@user), class: 'gl-hover-text-decoration-none', title: "Mastodon", target: '_blank', rel: 'noopener noreferrer nofollow' do = sprite_icon('mastodon', css_class: 'mastodon-icon') diff --git a/app/workers/abuse/trust_score_worker.rb b/app/workers/abuse/trust_score_worker.rb new file mode 100644 index 00000000000..061042ffa8a --- /dev/null +++ b/app/workers/abuse/trust_score_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Abuse + class TrustScoreWorker + include ApplicationWorker + + data_consistency :delayed + + idempotent! + feature_category :instance_resiliency + urgency :low + + def perform(user_id, source, score, correlation_id = '') + user = User.find_by_id(user_id) + unless user + logger.info(structured_payload(message: "User not found.", user_id: user_id)) + return + end + + Abuse::TrustScore.create!(user: user, source: source, score: score.to_f, correlation_id_value: correlation_id) + end + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 0bb88efe183..ec5156bb1d0 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -246,6 +246,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:ci_catalog_resources_process_sync_events + :worker_name: Ci::Catalog::Resources::ProcessSyncEventsWorker + :feature_category: :pipeline_composition + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:ci_delete_unit_tests :worker_name: Ci::DeleteUnitTestsWorker :feature_category: :code_testing @@ -275,7 +284,7 @@ :tags: [] - :name: cronjob:ci_runners_reconcile_existing_runner_versions_cron :worker_name: Ci::Runners::ReconcileExistingRunnerVersionsCronWorker - :feature_category: :runner_fleet + :feature_category: :fleet_visibility :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -284,7 +293,7 @@ :tags: [] - :name: cronjob:ci_runners_stale_machines_cleanup_cron :worker_name: Ci::Runners::StaleMachinesCleanupCronWorker - :feature_category: :runner_fleet + :feature_category: :fleet_visibility :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -1749,6 +1758,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: package_cleanup:packages_nuget_cleanup_stale_symbols + :worker_name: Packages::Nuget::CleanupStaleSymbolsWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_debian_generate_distribution :worker_name: Packages::Debian::GenerateDistributionWorker :feature_category: :package_registry @@ -2041,7 +2059,7 @@ :worker_name: PipelineMetricsWorker :feature_category: :continuous_integration :has_external_dependencies: false - :urgency: :high + :urgency: :low :resource_boundary: :unknown :weight: 3 :idempotent: false @@ -2298,6 +2316,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: abuse_trust_score + :worker_name: Abuse::TrustScoreWorker + :feature_category: :instance_resiliency + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: analytics_usage_trends_counter_job :worker_name: Analytics::UsageTrends::CounterJobWorker :feature_category: :devops_reports @@ -2559,6 +2586,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bitbucket_server_import_stage_import_users + :worker_name: Gitlab::BitbucketServerImport::Stage::ImportUsersWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bulk_import :worker_name: BulkImportWorker :feature_category: :importers @@ -2636,7 +2672,7 @@ :feature_category: :importers :has_external_dependencies: false :urgency: :low - :resource_boundary: :unknown + :resource_boundary: :memory :weight: 1 :idempotent: true :tags: [] @@ -2649,6 +2685,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: bulk_imports_transform_references + :worker_name: BulkImports::TransformReferencesWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: chat_notification :worker_name: ChatNotificationWorker :feature_category: :integrations @@ -2703,6 +2748,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: ci_low_urgency_cancel_redundant_pipelines + :worker_name: Ci::LowUrgencyCancelRedundantPipelinesWorker + :feature_category: :continuous_integration + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: ci_parse_secure_file_metadata :worker_name: Ci::ParseSecureFileMetadataWorker :feature_category: :mobile_devops @@ -2714,7 +2768,7 @@ :tags: [] - :name: ci_runners_process_runner_version_update :worker_name: Ci::Runners::ProcessRunnerVersionUpdateWorker - :feature_category: :runner_fleet + :feature_category: :fleet_visibility :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -3459,6 +3513,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: pages_deactivate_mr_deployments + :worker_name: Pages::DeactivateMrDeploymentsWorker + :feature_category: :pages + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pages_domain_ssl_renewal :worker_name: PagesDomainSslRenewalWorker :feature_category: :pages diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index e510a8c0d06..258ccea1f63 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -3,9 +3,10 @@ module BulkImports class EntityWorker include ApplicationWorker + include ExclusiveLeaseGuard idempotent! - deduplicate :until_executed, if_deduplicated: :reschedule_once + deduplicate :until_executing data_consistency :always feature_category :importers sidekiq_options retry: 3, dead: false @@ -27,7 +28,10 @@ module BulkImports if running_tracker.present? log_info(message: 'Stage running', entity_stage: running_tracker.stage) else - start_next_stage + # Use lease guard to prevent duplicated workers from starting multiple stages + try_obtain_lease do + start_next_stage + end end re_enqueue @@ -38,7 +42,9 @@ module BulkImports Gitlab::ErrorTracking.track_exception( exception, - log_params(message: "Request to export #{entity.source_type} failed") + { + message: "Request to export #{entity.source_type} failed" + }.merge(logger.default_attributes) ) entity.fail_op! @@ -49,7 +55,9 @@ module BulkImports attr_reader :entity def re_enqueue - BulkImports::EntityWorker.perform_in(PERFORM_DELAY, entity.id) + with_context(bulk_import_entity_id: entity.id) do + BulkImports::EntityWorker.perform_in(PERFORM_DELAY, entity.id) + end end def running_tracker @@ -66,43 +74,34 @@ module BulkImports next_pipeline_trackers.each_with_index do |pipeline_tracker, index| log_info(message: 'Stage starting', entity_stage: pipeline_tracker.stage) if index == 0 - BulkImports::PipelineWorker.perform_async( - pipeline_tracker.id, - pipeline_tracker.stage, - entity.id - ) + with_context(bulk_import_entity_id: entity.id) do + BulkImports::PipelineWorker.perform_async( + pipeline_tracker.id, + pipeline_tracker.stage, + entity.id + ) + end end end - def source_version - entity.bulk_import.source_version_info.to_s + def lease_timeout + PERFORM_DELAY end - def logger - @logger ||= Logger.build + def lease_key + "gitlab:bulk_imports:entity_worker:#{entity.id}" end - def log_exception(exception, payload) - Gitlab::ExceptionLogFormatter.format!(exception, payload) - - logger.error(structured_payload(payload)) + def log_lease_taken + log_info(message: lease_taken_message) end - def log_info(payload) - logger.info(structured_payload(log_params(payload))) + def logger + @logger ||= Logger.build.with_entity(entity) end - def log_params(extra) - defaults = { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - source_version: source_version, - importer: Logger::IMPORTER_NAME - } - - defaults.merge(extra) + def log_info(payload) + logger.info(structured_payload(payload)) end end end diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index f7456ddccb1..bfe561cca5c 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -20,7 +20,9 @@ module BulkImports set_source_xid request_export - BulkImports::EntityWorker.perform_async(entity_id) + with_context(bulk_import_entity_id: entity_id) do + BulkImports::EntityWorker.perform_async(entity_id) + end end def perform_failure(exception, entity_id) @@ -73,16 +75,7 @@ module BulkImports ::GlobalID.parse(response.dig(*entity_query.data_path, 'id')).model_id rescue StandardError => e - log_exception(e, - { - message: 'Failed to fetch source entity id', - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - source_version: entity.bulk_import.source_version_info.to_s - } - ) + log_exception(e, message: 'Failed to fetch source entity id') nil end @@ -96,7 +89,7 @@ module BulkImports end def logger - @logger ||= Logger.build + @logger ||= Logger.build.with_entity(entity) end def log_exception(exception, payload) @@ -106,16 +99,7 @@ module BulkImports end def log_and_fail(exception) - log_exception(exception, - { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - message: "Request to export #{entity.source_type} failed", - source_version: entity.bulk_import.source_version_info.to_s - } - ) + log_exception(exception, message: "Request to export #{entity.source_type} failed") BulkImports::Failure.create(failure_attributes(exception)) diff --git a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb index 40d26e14dc1..2670dc5438d 100644 --- a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb +++ b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb @@ -6,6 +6,7 @@ module BulkImports include ExceptionBacktrace REQUEUE_DELAY = 5.seconds + STALE_AFTER = 4.hours idempotent! deduplicate :until_executing @@ -18,46 +19,50 @@ module BulkImports @tracker = Tracker.find(pipeline_tracker_id) @context = ::BulkImports::Pipeline::Context.new(tracker) - return unless tracker.batched? - return unless tracker.started? + return unless tracker.batched? && tracker.started? + + @sorted_batches = tracker.batches.by_last_updated + return fail_stale_tracker_and_batches if most_recent_batch_stale? + return re_enqueue if import_in_progress? - if tracker.stale? - logger.error(log_attributes(message: 'Tracker stale. Failing batches and tracker')) - tracker.batches.map(&:fail_op!) - tracker.fail_op! - else - tracker.pipeline_class.new(@context).on_finish - logger.info(log_attributes(message: 'Tracker finished')) - tracker.finish! - end + tracker.pipeline_class.new(@context).on_finish + logger.info(log_attributes(message: 'Tracker finished')) + tracker.finish! end private - attr_reader :tracker + attr_reader :tracker, :sorted_batches def re_enqueue - self.class.perform_in(REQUEUE_DELAY, tracker.id) + with_context(bulk_import_entity_id: tracker.entity.id) do + self.class.perform_in(REQUEUE_DELAY, tracker.id) + end end def import_in_progress? - tracker.batches.any? { |b| b.started? || b.created? } + sorted_batches.in_progress.any? + end + + def most_recent_batch_stale? + return false unless sorted_batches.any? + + sorted_batches.first.updated_at < STALE_AFTER.ago + end + + def fail_stale_tracker_and_batches + logger.error(log_attributes(message: 'Batch stale. Failing batches and tracker')) + sorted_batches.map(&:fail_op!) + tracker.fail_op! end def logger - @logger ||= Logger.build + @logger ||= Logger.build.with_tracker(tracker) end def log_attributes(extra = {}) - structured_payload( - { - tracker_id: tracker.id, - bulk_import_id: tracker.entity.id, - bulk_import_entity_id: tracker.entity.bulk_import_id, - pipeline_class: tracker.pipeline_name - }.merge(extra) - ) + structured_payload(extra) end end end diff --git a/app/workers/bulk_imports/finish_project_import_worker.rb b/app/workers/bulk_imports/finish_project_import_worker.rb index 815101c89f3..18b8c016493 100644 --- a/app/workers/bulk_imports/finish_project_import_worker.rb +++ b/app/workers/bulk_imports/finish_project_import_worker.rb @@ -5,7 +5,7 @@ module BulkImports include ApplicationWorker feature_category :importers - sidekiq_options retry: 5 + sidekiq_options retry: 3 data_consistency :sticky idempotent! diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb index 1485275e616..c24cc64e5c0 100644 --- a/app/workers/bulk_imports/pipeline_batch_worker.rb +++ b/app/workers/bulk_imports/pipeline_batch_worker.rb @@ -9,7 +9,7 @@ module BulkImports data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency feature_category :importers - sidekiq_options dead: false, retry: 3 + sidekiq_options dead: false, retry: 6 worker_has_external_dependencies! worker_resource_boundary :memory idempotent! @@ -42,6 +42,7 @@ module BulkImports @batch = ::BulkImports::BatchTracker.find(batch_id) @tracker = @batch.tracker + @entity = @tracker.entity @pending_retry = false return unless process_batch? @@ -50,7 +51,11 @@ module BulkImports try_obtain_lease { run } ensure - ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) unless pending_retry + unless pending_retry + with_context(bulk_import_entity_id: entity.id) do + ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) + end + end end def perform_failure(batch_id, exception) @@ -62,7 +67,7 @@ module BulkImports private - attr_reader :batch, :tracker, :pending_retry + attr_reader :batch, :tracker, :pending_retry, :entity def run return batch.skip! if tracker.failed? || tracker.finished? @@ -83,7 +88,7 @@ module BulkImports Gitlab::ErrorTracking.track_exception(exception, log_attributes(message: 'Batch tracker failed')) BulkImports::Failure.create( - bulk_import_entity_id: batch.tracker.entity.id, + bulk_import_entity_id: tracker.entity.id, pipeline_class: tracker.pipeline_name, pipeline_step: 'pipeline_batch_worker_run', exception_class: exception.class.to_s, @@ -91,7 +96,9 @@ module BulkImports correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id ) - ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) + with_context(bulk_import_entity_id: tracker.entity.id) do + ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) + end end def context @@ -115,7 +122,9 @@ module BulkImports def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY) log_extra_metadata_on_done(:re_enqueue, true) - self.class.perform_in(delay, batch.id) + with_context(bulk_import_entity_id: entity.id) do + self.class.perform_in(delay, batch.id) + end end def process_batch? diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 2c1d28b33c5..0bb9464c6de 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -4,14 +4,17 @@ module BulkImports class PipelineWorker include ApplicationWorker include ExclusiveLeaseGuard + include Gitlab::Utils::StrongMemoize FILE_EXTRACTION_PIPELINE_PERFORM_DELAY = 10.seconds + LimitedBatches = Struct.new(:numbers, :final?, keyword_init: true).freeze + DEFER_ON_HEALTH_DELAY = 5.minutes data_consistency :always feature_category :importers - sidekiq_options dead: false, retry: 3 + sidekiq_options dead: false, retry: 6 worker_has_external_dependencies! deduplicate :until_executing worker_resource_boundary :memory @@ -52,7 +55,6 @@ module BulkImports try_obtain_lease do if pipeline_tracker.enqueued? || pipeline_tracker.started? logger.info(log_attributes(message: 'Pipeline starting')) - run end end @@ -62,7 +64,7 @@ module BulkImports @entity = ::BulkImports::Entity.find(entity_id) @pipeline_tracker = ::BulkImports::Tracker.find(pipeline_tracker_id) - fail_tracker(exception) + fail_pipeline(exception) end private @@ -84,7 +86,8 @@ module BulkImports return pipeline_tracker.finish! if export_status.batches_count < 1 - enqueue_batches + enqueue_limited_batches + re_enqueue unless all_batches_enqueued? else log_extra_metadata_on_done(:batched, false) @@ -96,13 +99,11 @@ module BulkImports retry_tracker(e) end - def source_version - entity.bulk_import.source_version_info.to_s - end - - def fail_tracker(exception) + def fail_pipeline(exception) pipeline_tracker.update!(status_event: 'fail_op', jid: jid) + entity.fail_op! if pipeline_tracker.abort_on_failure? + log_exception(exception, log_attributes(message: 'Pipeline failed')) Gitlab::ErrorTracking.track_exception(exception, log_attributes) @@ -118,18 +119,20 @@ module BulkImports end def logger - @logger ||= Logger.build + @logger ||= Logger.build.with_tracker(pipeline_tracker) end def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY) log_extra_metadata_on_done(:re_enqueue, true) - self.class.perform_in( - delay, - pipeline_tracker.id, - pipeline_tracker.stage, - entity.id - ) + with_context(bulk_import_entity_id: entity.id) do + self.class.perform_in( + delay, + pipeline_tracker.id, + pipeline_tracker.stage, + entity.id + ) + end end def context @@ -181,19 +184,7 @@ module BulkImports end def log_attributes(extra = {}) - structured_payload( - { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_tracker_id: pipeline_tracker.id, - pipeline_class: pipeline_tracker.pipeline_name, - pipeline_tracker_state: pipeline_tracker.human_status_name, - source_version: source_version, - importer: Logger::IMPORTER_NAME - }.merge(extra) - ) + logger.default_attributes.merge(extra) end def log_exception(exception, payload) @@ -206,20 +197,60 @@ module BulkImports Time.zone.now - (pipeline_tracker.created_at || entity.created_at) end - def lease_timeout - 30 + def enqueue_limited_batches + next_batch.numbers.each do |batch_number| + batch = pipeline_tracker.batches.create!(batch_number: batch_number) + + with_context(bulk_import_entity_id: entity.id) do + ::BulkImports::PipelineBatchWorker.perform_async(batch.id) + end + end + + log_extra_metadata_on_done(:tracker_batch_numbers_enqueued, next_batch.numbers) + log_extra_metadata_on_done(:tracker_final_batch_was_enqueued, next_batch.final?) end - def lease_key - "gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}" + def all_batches_enqueued? + next_batch.final? end - def enqueue_batches - 1.upto(export_status.batches_count) do |batch_number| - batch = pipeline_tracker.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord + def next_batch + all_batch_numbers = (1..export_status.batches_count).to_a + + created_batch_numbers = pipeline_tracker.batches.pluck_batch_numbers - ::BulkImports::PipelineBatchWorker.perform_async(batch.id) + remaining_batch_numbers = all_batch_numbers - created_batch_numbers + + if Feature.disabled?(:bulk_import_limit_concurrent_batches, context.portable) + return LimitedBatches.new(numbers: remaining_batch_numbers, final?: true) end + + limit = next_batch_count + + LimitedBatches.new( + numbers: remaining_batch_numbers.first(limit), + final?: remaining_batch_numbers.count <= limit + ) + end + strong_memoize_attr :next_batch + + # Calculate the number of batches, up to `batch_limit`, to process in the + # next round. + def next_batch_count + limit = batch_limit - pipeline_tracker.batches.in_progress.limit(batch_limit).count + [limit, 0].max + end + + def batch_limit + ::Gitlab::CurrentSettings.bulk_import_concurrent_pipeline_batch_limit + end + + def lease_timeout + 30 + end + + def lease_key + "gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}" end end end diff --git a/app/workers/bulk_imports/relation_batch_export_worker.rb b/app/workers/bulk_imports/relation_batch_export_worker.rb index 87ceb775075..08c5fb81460 100644 --- a/app/workers/bulk_imports/relation_batch_export_worker.rb +++ b/app/workers/bulk_imports/relation_batch_export_worker.rb @@ -7,7 +7,8 @@ module BulkImports idempotent! data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency feature_category :importers - sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 3 + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 6 + worker_resource_boundary :memory sidekiq_retries_exhausted do |job, exception| batch = BulkImports::ExportBatch.find(job['args'][1]) diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb index 168626fee85..90941e7583b 100644 --- a/app/workers/bulk_imports/relation_export_worker.rb +++ b/app/workers/bulk_imports/relation_export_worker.rb @@ -10,7 +10,7 @@ module BulkImports loggable_arguments 2, 3 data_consistency :always feature_category :importers - sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 3 + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 6 worker_resource_boundary :memory sidekiq_retries_exhausted do |job, exception| diff --git a/app/workers/bulk_imports/stuck_import_worker.rb b/app/workers/bulk_imports/stuck_import_worker.rb index 6c8569b0aa0..cb4e10a29b2 100644 --- a/app/workers/bulk_imports/stuck_import_worker.rb +++ b/app/workers/bulk_imports/stuck_import_worker.rb @@ -12,24 +12,27 @@ module BulkImports feature_category :importers + # Using Keyset pagination for scopes that involve timestamp indexes def perform - BulkImport.stale.find_each do |import| - logger.error(message: 'BulkImport stale', bulk_import_id: import.id) - import.cleanup_stale + Gitlab::Pagination::Keyset::Iterator.new(scope: bulk_import_scope).each_batch do |imports| + imports.each do |import| + logger.error(message: 'BulkImport stale', bulk_import_id: import.id) + import.cleanup_stale + end end - BulkImports::Entity.includes(:trackers).stale.find_each do |entity| # rubocop: disable CodeReuse/ActiveRecord - ApplicationRecord.transaction do - logger.error( - message: 'BulkImports::Entity stale', - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_id: entity.id - ) + Gitlab::Pagination::Keyset::Iterator.new(scope: entity_scope).each_batch do |entities| + entities.each do |entity| + ApplicationRecord.transaction do + logger.with_entity(entity).error( + message: 'BulkImports::Entity stale' + ) - entity.cleanup_stale + entity.cleanup_stale - entity.trackers.find_each do |tracker| - tracker.cleanup_stale + entity.trackers.find_each do |tracker| + tracker.cleanup_stale + end end end end @@ -38,5 +41,13 @@ module BulkImports def logger @logger ||= Logger.build end + + def bulk_import_scope + BulkImport.stale.order_by_updated_at_and_id(:asc) + end + + def entity_scope + BulkImports::Entity.with_trackers.stale.order_by_updated_at_and_id(:asc) + end end end diff --git a/app/workers/bulk_imports/transform_references_worker.rb b/app/workers/bulk_imports/transform_references_worker.rb new file mode 100644 index 00000000000..383ad2fd733 --- /dev/null +++ b/app/workers/bulk_imports/transform_references_worker.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +module BulkImports + class TransformReferencesWorker + include ApplicationWorker + + idempotent! + data_consistency :delayed + sidekiq_options retry: 3, dead: false + feature_category :importers + + # rubocop: disable CodeReuse/ActiveRecord + def perform(object_ids, klass, tracker_id) + @tracker = BulkImports::Tracker.find_by_id(tracker_id) + + return unless tracker + + project = tracker.entity.project + + klass.constantize.where(id: object_ids, project: project).find_each do |object| + transform_and_save(object) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + attr_reader :tracker + + private + + def transform_and_save(object) + body = object_body(object).dup + + return if body.blank? + + object.refresh_markdown_cache! + + body.gsub!(username_regex(mapped_usernames), mapped_usernames) + + if object_has_reference?(body) + matching_urls(object).each do |old_url, new_url| + body.gsub!(old_url, new_url) if body.include?(old_url) + end + end + + object.assign_attributes(body_field(object) => body) + object.save!(touch: false) if object_body_changed?(object) + + object + rescue StandardError => e + log_and_fail(e) + end + + def object_body(object) + call_object_method(object) + end + + def object_body_changed?(object) + call_object_method(object, suffix: '_changed?') + end + + def call_object_method(object, suffix: nil) + method = body_field(object) + method = "#{method}#{suffix}" if suffix.present? + + object.public_send(method) # rubocop:disable GitlabSecurity/PublicSend -- the method being called is dependent on several factors + end + + def body_field(object) + object.is_a?(Note) ? 'note' : 'description' + end + + def mapped_usernames + @mapped_usernames ||= ::BulkImports::UsersMapper.new(context: context) + .map_usernames.transform_keys { |key| "@#{key}" } + .transform_values { |value| "@#{value}" } + end + + def username_regex(mapped_usernames) + @username_regex ||= Regexp.new(mapped_usernames.keys.sort_by(&:length) + .reverse.map { |x| Regexp.escape(x) }.join('|')) + end + + def matching_urls(object) + URI.extract(object_body(object), %w[http https]).each_with_object([]) do |url, array| + parsed_url = URI.parse(url) + + next unless source_host == parsed_url.host + next unless parsed_url.path&.start_with?("/#{source_full_path}") + + array << [url, new_url(object, parsed_url)] + end + end + + def new_url(object, parsed_old_url) + parsed_old_url.host = ::Gitlab.config.gitlab.host + parsed_old_url.port = ::Gitlab.config.gitlab.port + parsed_old_url.scheme = ::Gitlab.config.gitlab.https ? 'https' : 'http' + parsed_old_url.to_s.gsub!(source_full_path, full_path(object)) + end + + def source_host + @source_host ||= URI.parse(context.configuration.url).host + end + + def source_full_path + @source_full_path ||= context.entity.source_full_path + end + + def full_path(object) + object.project.full_path + end + + def object_has_reference?(body) + body.include?(source_full_path) + end + + def log_and_fail(exception) + Gitlab::ErrorTracking.track_exception(exception, log_params) + BulkImports::Failure.create(failure_attributes(exception)) + end + + def log_params + { + message: 'Failed to update references', + bulk_import_id: context.bulk_import_id, + bulk_import_entity_id: tracker.bulk_import_entity_id, + source_full_path: context.entity.source_full_path, + source_version: context.bulk_import.source_version, + importer: 'gitlab_migration' + } + end + + def failure_attributes(exception) + { + bulk_import_entity_id: context.entity.id, + pipeline_class: 'ReferencesPipeline', + exception_class: exception.class.to_s, + exception_message: exception.message.truncate(255), + correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id + } + end + + def context + @context ||= BulkImports::Pipeline::Context.new(tracker) + end + end +end diff --git a/app/workers/ci/catalog/resources/process_sync_events_worker.rb b/app/workers/ci/catalog/resources/process_sync_events_worker.rb new file mode 100644 index 00000000000..15e06393aff --- /dev/null +++ b/app/workers/ci/catalog/resources/process_sync_events_worker.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + # This worker can be called multiple times simultaneously but only one can process events + # at a time. This is ensured by `try_obtain_lease` in `Ci::ProcessSyncEventsService`. + # + # This worker is enqueued in 3 ways: + # 1. By Project model callback after updating one of the columns referenced in + # `Ci::Catalog::Resource#sync_with_project`. + # 2. Every minute by cron job. This ensures we process SyncEvents from direct/bulk + # database updates that do not use the Project AR model. + # 3. By `Ci::ProcessSyncEventsService` if there are any remaining pending + # SyncEvents after processing. + # + class ProcessSyncEventsWorker + include ApplicationWorker + include CronjobQueue # rubocop: disable Scalability/CronWorkerContext -- Periodic processing is required + + feature_category :pipeline_composition + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency -- We should not sync stale data + urgency :high + + idempotent! + deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute + + def perform + results = ::Ci::ProcessSyncEventsService.new( + ::Ci::Catalog::Resources::SyncEvent, ::Ci::Catalog::Resource + ).execute + + results.each do |key, value| + log_extra_metadata_on_done(key, value) + end + end + end + end + end +end diff --git a/app/workers/ci/low_urgency_cancel_redundant_pipelines_worker.rb b/app/workers/ci/low_urgency_cancel_redundant_pipelines_worker.rb new file mode 100644 index 00000000000..4eb55a9ecd4 --- /dev/null +++ b/app/workers/ci/low_urgency_cancel_redundant_pipelines_worker.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Ci + # Scheduled pipelines rarely cancel other pipelines and we don't need to + # use high urgency + class LowUrgencyCancelRedundantPipelinesWorker < CancelRedundantPipelinesWorker + urgency :low + idempotent! + end +end diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb index 53bed0fa9da..3184fee2071 100644 --- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb @@ -13,6 +13,7 @@ module Ci feature_category :code_testing idempotent! + deduplicate :until_executed def perform(pipeline_id) pipeline = Ci::Pipeline.find_by_id(pipeline_id) diff --git a/app/workers/ci/runners/process_runner_version_update_worker.rb b/app/workers/ci/runners/process_runner_version_update_worker.rb index f1ad0c8563e..acb1aac78a4 100644 --- a/app/workers/ci/runners/process_runner_version_update_worker.rb +++ b/app/workers/ci/runners/process_runner_version_update_worker.rb @@ -7,7 +7,7 @@ module Ci data_consistency :always - feature_category :runner_fleet + feature_category :fleet_visibility urgency :low idempotent! diff --git a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb index 722c513a4bb..7bcfed1580f 100644 --- a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb +++ b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb @@ -9,7 +9,7 @@ module Ci include CronjobQueue # rubocop:disable Scalability/CronWorkerContext data_consistency :sticky - feature_category :runner_fleet + feature_category :fleet_visibility urgency :low deduplicate :until_executed diff --git a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb index 9407e7c0e0a..9831e3e98b7 100644 --- a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb +++ b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb @@ -9,7 +9,7 @@ module Ci include CronjobQueue # rubocop:disable Scalability/CronWorkerContext data_consistency :sticky - feature_category :runner_fleet + feature_category :fleet_visibility urgency :low idempotent! diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb index e884a43b1e3..21c10566a67 100644 --- a/app/workers/click_house/events_sync_worker.rb +++ b/app/workers/click_house/events_sync_worker.rb @@ -3,7 +3,9 @@ module ClickHouse class EventsSyncWorker include ApplicationWorker + include ClickHouseWorker include Gitlab::ExclusiveLeaseHelpers + include Gitlab::Utils::StrongMemoize idempotent! queue_namespace :cronjob @@ -91,8 +93,13 @@ module ClickHouse ) end + def last_event_id_in_postgresql + Event.maximum(:id) + end + strong_memoize_attr :last_event_id_in_postgresql + def enabled? - ClickHouse::Client.configuration.databases[:main].present? && Feature.enabled?(:event_sync_worker_for_click_house) + ClickHouse::Client.database_configured?(:main) && Feature.enabled?(:event_sync_worker_for_click_house) end def next_batch @@ -110,24 +117,34 @@ module ClickHouse def process_batch(context) Enumerator.new do |yielder| - has_data = false - # rubocop: disable CodeReuse/ActiveRecord - Event.where(Event.arel_table[:id].gt(context.last_record_id)).each_batch(of: BATCH_SIZE) do |relation| - has_data = true - - relation.select(*EVENT_PROJECTIONS).each do |row| + has_more_data = false + batching_scope.each_batch(of: BATCH_SIZE) do |relation| + records = relation.select(*EVENT_PROJECTIONS).to_a + has_more_data = records.size == BATCH_SIZE + records.each do |row| yielder << row context.last_processed_id = row.id break if context.record_limit_reached? end - break if context.over_time? || context.record_limit_reached? + break if context.over_time? || context.record_limit_reached? || !has_more_data end - context.no_more_records! if has_data == false - # rubocop: enable CodeReuse/ActiveRecord + context.no_more_records! unless has_more_data end end + + # rubocop: disable CodeReuse/ActiveRecord + def batching_scope + return Event.none unless last_event_id_in_postgresql + + table = Event.arel_table + + Event + .where(table[:id].gt(context.last_record_id)) + .where(table[:id].lteq(last_event_id_in_postgresql)) + end + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/concerns/click_house_worker.rb b/app/workers/concerns/click_house_worker.rb new file mode 100644 index 00000000000..6399796f6df --- /dev/null +++ b/app/workers/concerns/click_house_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ClickHouseWorker + extend ActiveSupport::Concern + + class_methods do + def register_click_house_worker? + click_house_worker_attrs.present? + end + + def click_house_worker_attrs + get_class_attribute(:click_house_worker_attrs) + end + + def click_house_migration_lock(ttl) + raise ArgumentError unless ttl.is_a?(ActiveSupport::Duration) + + set_class_attribute( + :click_house_worker_attrs, + (click_house_worker_attrs || {}).merge(migration_lock_ttl: ttl) + ) + end + end + + included do + click_house_migration_lock(ClickHouse::MigrationSupport::ExclusiveLock::DEFAULT_CLICKHOUSE_WORKER_TTL) + + pause_control :click_house_migration + end +end diff --git a/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb b/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb index 1090d82c922..fbcb5d81c8a 100644 --- a/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb +++ b/app/workers/concerns/gitlab/bitbucket_server_import/object_importer.rb @@ -7,6 +7,8 @@ module Gitlab module ObjectImporter extend ActiveSupport::Concern + FAILED_IMPORT_STATES = %w[canceled failed].freeze + included do include ApplicationWorker @@ -33,8 +35,10 @@ module Gitlab return unless project - if project.import_state&.canceled? - info(project.id, message: 'project import canceled') + import_state = project.import_status + + if FAILED_IMPORT_STATES.include?(import_state) + info(project.id, message: "project import #{import_state}") return end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index fcc7a96fa2b..15156e1deef 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -16,6 +16,7 @@ module Gitlab feature_category :importers worker_has_external_dependencies! + sidekiq_options retry: 5 sidekiq_retries_exhausted do |msg| args = msg['args'] jid = msg['jid'] @@ -57,12 +58,7 @@ module Gitlab end info(project.id, message: 'importer finished') - rescue NoMethodError => e - # This exception will be more useful in development when a new - # Representation is created but the developer forgot to add a - # `#github_identifiers` method. - track_and_raise_exception(project, e, fail_import: true) - rescue ActiveRecord::RecordInvalid, NotRetriableError => e + rescue ActiveRecord::RecordInvalid, NotRetriableError, NoMethodError => e # We do not raise exception to prevent job retry track_exception(project, e) rescue StandardError => e diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index 7cc23dd7c0b..5aabc74a3d5 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -14,7 +14,7 @@ module Gitlab # the dead queue. This does mean some resources may not be imported, but # this is better than a project being stuck in the "import" state # forever. - sidekiq_options dead: false, retry: 5 + sidekiq_options dead: false end end end diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index 316d30d94da..e2808f45821 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -8,6 +8,8 @@ module Gitlab extend ActiveSupport::Concern include JobDelayCalculator + attr_reader :project + ENQUEUED_JOB_COUNT = 'github-importer/enqueued_job_count/%{project}/%{collection}' included do @@ -17,8 +19,10 @@ module Gitlab # project_id - The ID of the GitLab project to import the note into. # hash - A Hash containing the details of the GitHub object to import. # notify_key - The Redis key to notify upon completion, if any. + def perform(project_id, hash, notify_key = nil) - project = Project.find_by_id(project_id) + @project = Project.find_by_id(project_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables -- GitHub Import + # uses modules everywhere. Too big to refactor. return notify_waiter(notify_key) unless project diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 5c63c667a03..5f6812ab84f 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -9,6 +9,11 @@ module Gitlab included do include ApplicationWorker + include GithubImport::Queue + + sidekiq_options retry: 6 + + sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION sidekiq_retries_exhausted do |msg, e| Gitlab::Import::ImportFailureService.track( @@ -37,8 +42,6 @@ module Gitlab # - Continue their loop from where it left off: # https://gitlab.com/gitlab-org/gitlab/-/blob/024235ec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer.rb#L15 def resumes_work_when_interrupted! - return unless Feature.enabled?(:github_importer_raise_max_interruptions) - sidekiq_options max_retries_after_interruption: MAX_RETRIES_AFTER_INTERRUPTION end end @@ -79,7 +82,7 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def try_import(client, project) - project.import_state.refresh_jid_expiration + RefreshImportJidWorker.perform_in_the_future(project.id, jid) import(client, project) rescue RateLimitError diff --git a/app/workers/concerns/update_repository_storage_worker.rb b/app/workers/concerns/update_repository_storage_worker.rb index 01744d1e57d..fd437ebc158 100644 --- a/app/workers/concerns/update_repository_storage_worker.rb +++ b/app/workers/concerns/update_repository_storage_worker.rb @@ -11,7 +11,19 @@ module UpdateRepositoryStorageWorker urgency :throttled end - def perform(container_id, new_repository_storage_key, repository_storage_move_id = nil) + LEASE_TIMEOUT = 30.minutes.to_i + + # `container_id` and `new_repository_storage_key` arguments have been deprecated. + # `repository_storage_move_id` is now a mandatory argument. + # We are using *args for backwards compatability. Previously defined as: + # perform(container_id, new_repository_storage_key, repository_storage_move_id = nil) + def perform(*args) + if args.length == 1 + repository_storage_move_id = args[0] + else + container_id, new_repository_storage_key, repository_storage_move_id = *args + end + repository_storage_move = if repository_storage_move_id find_repository_storage_move(repository_storage_move_id) @@ -24,7 +36,35 @@ module UpdateRepositoryStorageWorker ) end - update_repository_storage(repository_storage_move) + container_id ||= repository_storage_move.container_id + + # Use exclusive lock to prevent multiple storage migrations at the same time + # + # Note: instead of using a randomly generated `uuid`, we provide a worker jid value. + # That will allow to track a worker that requested a lease. + lease_key = [self.class.name.underscore, container_id].join(':') + exclusive_lease = Gitlab::ExclusiveLease.new(lease_key, uuid: jid, timeout: LEASE_TIMEOUT) + lease = exclusive_lease.try_obtain + + if lease + begin + update_repository_storage(repository_storage_move) + ensure + exclusive_lease.cancel + end + else + # If there is an ungoing storage migration, then the current one should be marked as failed + repository_storage_move.do_fail! + + # A special case + # Sidekiq can receive an interrupt signal during the processing. + # It kills existing workers and reschedules their jobs using the same jid. + # But it can cause a situation when the migration is only half complete (see https://gitlab.com/gitlab-org/gitlab/-/issues/429049#note_1635650597) + # + # Here we detect this case and release the lock. + uuid = Gitlab::ExclusiveLease.get_uuid(lease_key) + exclusive_lease.cancel if uuid == jid + end end private diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb index 9ec02dd613e..cd61c5ebcb4 100644 --- a/app/workers/container_registry/cleanup_worker.rb +++ b/app/workers/container_registry/cleanup_worker.rb @@ -38,7 +38,7 @@ module ContainerRegistry # Deleting stale ongoing repair details would put the project back to the analysis pool ContainerRegistry::DataRepairDetail .ongoing_since(STALE_REPAIR_DETAIL_THRESHOLD.ago) - .each_batch(of: BATCH_SIZE) do |batch| # rubocop:disable Style/SymbolProc + .each_batch(of: BATCH_SIZE) do |batch| batch.delete_all end end diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index 6a375a0cdd4..4634ea8ff4f 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -14,7 +14,7 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker delete_user = User.find_by_id(delete_user_id) return unless delete_user.present? - return if delete_user.banned? && ::Feature.enabled?(:delay_delete_own_user) + return if skip_own_account_deletion?(delete_user) current_user = User.find_by_id(current_user_id) return unless current_user.present? @@ -23,4 +23,34 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker rescue Gitlab::Access::AccessDeniedError => e Gitlab::AppLogger.warn("User could not be destroyed: #{e}") end + + private + + def skip_own_account_deletion?(user) + return false unless ::Feature.enabled?(:delay_delete_own_user) + + skip = + if user.banned? + true + else + # User is blocked when they delete their own account. Skip record deletion + # when user has been unblocked (e.g. when the user's account is reinstated + # by Trust & Safety) + user.deleted_own_account? && !user.blocked? + end + + if skip + user.custom_attributes.by_key(UserCustomAttribute::DELETED_OWN_ACCOUNT_AT).first&.destroy + UserCustomAttribute.set_skipped_account_deletion_at(user) + + Gitlab::AppLogger.info( + message: 'Skipped own account deletion.', + reason: "User has been #{user.banned? ? 'banned' : 'unblocked'}.", + user_id: user.id, + username: user.username + ) + end + + skip + end end diff --git a/app/workers/gitlab/bitbucket_server_import/stage/import_repository_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/import_repository_worker.rb index b378d07d59c..573c73cd7df 100644 --- a/app/workers/gitlab/bitbucket_server_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/bitbucket_server_import/stage/import_repository_worker.rb @@ -14,7 +14,11 @@ module Gitlab importer.execute - ImportPullRequestsWorker.perform_async(project.id) + if Feature.enabled?(:bitbucket_server_convert_mentions_to_users, project.creator) + ImportUsersWorker.perform_async(project.id) + else + ImportPullRequestsWorker.perform_async(project.id) + end end def importer_class diff --git a/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb new file mode 100644 index 00000000000..dd18139fc9e --- /dev/null +++ b/app/workers/gitlab/bitbucket_server_import/stage/import_users_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketServerImport + module Stage + class ImportUsersWorker # rubocop:disable Scalability/IdempotentWorker -- ImportPullRequestsWorker is not idempotent + include StageMethods + + private + + def import(project) + importer = importer_class.new(project) + + importer.execute + + ImportPullRequestsWorker.perform_async(project.id) + end + + def importer_class + Importers::UsersImporter + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index a012241e90c..417b8598547 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -11,12 +11,15 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include ::Gitlab::Import::AdvanceStage - sidekiq_options dead: false - feature_category :importers loggable_arguments 1, 2 + sidekiq_options retry: 6 + + # TODO: Allow this class to include GithubImport::Queue and remove + # the following two lines https://gitlab.com/gitlab-org/gitlab/-/issues/435622 + feature_category :importers + sidekiq_options dead: false # The known importer stages and their corresponding Sidekiq workers. STAGES = { diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb index 3de4bef053f..dfc581f201b 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -9,8 +9,10 @@ module Gitlab include GithubImport::Queue + sidekiq_options retry: 5 + # The interval to schedule new instances of this job at. - INTERVAL = 1.minute.to_i + INTERVAL = 5.minutes.to_i def self.perform_in_the_future(*args) perform_in(INTERVAL, *args) @@ -23,9 +25,11 @@ module Gitlab return unless import_state if SidekiqStatus.running?(check_job_id) - # As long as the repository is being cloned we want to keep refreshing - # the import JID status. - import_state.refresh_jid_expiration + # As long as the worker is running we want to keep refreshing + # the worker's JID as well as the import's JID. + Gitlab::SidekiqStatus.expire(check_job_id, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION) + Gitlab::SidekiqStatus.set(import_state.jid, Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION) + self.class.perform_in_the_future(project_id, check_job_id) end diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index 90445a6d46c..8d5a98136af 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # project - An instance of Project. diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb index a5d085a82c0..bbf762133e1 100644 --- a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 5bbe14b6528..d965c1ae847 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # These importers are fast enough that we can just run them in the same diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb index 037b529b866..b5b1601e3ed 100644 --- a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # client - An instance of Gitlab::GithubImport::Client. diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index 35779d7bfc5..27d14a1a108 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 58e1f637b6a..595f0ca44d4 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index 8d7bd98f303..34c31fea726 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # Importer::LfsObjectsImporter can resume work when interrupted as diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index 0459545d8e1..8aea27a94d4 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb index e281e965f94..65b9d85f453 100644 --- a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # client - An instance of Gitlab::GithubImport::Client. diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb index 2f543951bf3..20b2e5ed6af 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb index db76545ae87..1262fc23c6c 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb index 31b7c57a524..bb4699889da 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index c68b95b5111..bcc39b169af 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods resumes_work_when_interrupted! diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index 2a62930b5ea..44481b8a75c 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -8,18 +8,11 @@ module Gitlab data_consistency :always - include GithubImport::Queue include StageMethods # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) - # In extreme cases it's possible for a clone to take more than the - # import job expiration time. To work around this we schedule a - # separate job that will periodically run and refresh the import - # expiration time. - RefreshImportJidWorker.perform_in_the_future(project.id, jid) - info(project.id, message: "starting importer", importer: 'Importer::RepositoryImporter') # If a user creates an issue while the import is in progress, this can lead to an import failure. diff --git a/app/workers/gitlab/import/advance_stage.rb b/app/workers/gitlab/import/advance_stage.rb index 782439894c0..709957556d3 100644 --- a/app/workers/gitlab/import/advance_stage.rb +++ b/app/workers/gitlab/import/advance_stage.rb @@ -37,6 +37,8 @@ module Gitlab if new_job_count != previous_job_count timeout_timer = Time.zone.now previous_job_count = new_job_count + + import_state_jid.refresh_jid_expiration end if new_waiters.empty? diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index db1a1e96997..36979e843ef 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -19,7 +19,7 @@ class MergeRequestCleanupRefsWorker def perform_work unless merge_request - logger.error('No existing merge request to be cleaned up.') + logger.info('No existing merge request to be cleaned up.') return end diff --git a/app/workers/packages/cleanup_package_registry_worker.rb b/app/workers/packages/cleanup_package_registry_worker.rb index 5b2d8bacd62..50036923e94 100644 --- a/app/workers/packages/cleanup_package_registry_worker.rb +++ b/app/workers/packages/cleanup_package_registry_worker.rb @@ -14,6 +14,7 @@ module Packages enqueue_package_file_cleanup_job if Packages::PackageFile.pending_destruction.exists? enqueue_cleanup_policy_jobs if Packages::Cleanup::Policy.runnable.exists? enqueue_cleanup_stale_npm_metadata_cache_job if Packages::Npm::MetadataCache.pending_destruction.exists? + enqueue_cleanup_stale_nuget_symbols_job if Packages::Nuget::Symbol.pending_destruction.exists? log_counts end @@ -32,6 +33,10 @@ module Packages Packages::Npm::CleanupStaleMetadataCacheWorker.perform_with_capacity end + def enqueue_cleanup_stale_nuget_symbols_job + Packages::Nuget::CleanupStaleSymbolsWorker.perform_with_capacity + end + def log_counts use_replica_if_available do pending_destruction_package_files_count = Packages::PackageFile.pending_destruction.count diff --git a/app/workers/packages/npm/create_metadata_cache_worker.rb b/app/workers/packages/npm/create_metadata_cache_worker.rb index 0b6e34b13eb..cff7871dab7 100644 --- a/app/workers/packages/npm/create_metadata_cache_worker.rb +++ b/app/workers/packages/npm/create_metadata_cache_worker.rb @@ -16,7 +16,7 @@ module Packages def perform(project_id, package_name) project = Project.find_by_id(project_id) - return unless project && Feature.enabled?(:npm_metadata_cache, project) + return unless project ::Packages::Npm::CreateMetadataCacheService .new(project, package_name) diff --git a/app/workers/packages/nuget/cleanup_stale_symbols_worker.rb b/app/workers/packages/nuget/cleanup_stale_symbols_worker.rb new file mode 100644 index 00000000000..be90b86604c --- /dev/null +++ b/app/workers/packages/nuget/cleanup_stale_symbols_worker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class CleanupStaleSymbolsWorker + include ApplicationWorker + include ::Packages::CleanupArtifactWorker + + MAX_CAPACITY = 2 + + data_consistency :sticky + + queue_namespace :package_cleanup + feature_category :package_registry + + deduplicate :until_executed + idempotent! + + def max_running_jobs + MAX_CAPACITY + end + + private + + def model + Packages::Nuget::Symbol + end + + def next_item + model.next_pending_destruction(order_by: nil) + end + + def log_metadata(nuget_symbol) + log_extra_metadata_on_done(:nuget_symbol_id, nuget_symbol.id) + end + + def log_cleanup_item(nuget_symbol) + logger.info( + structured_payload( + nuget_symbol_id: nuget_symbol.id + ) + ) + end + end + end +end diff --git a/app/workers/pages/deactivate_mr_deployments_worker.rb b/app/workers/pages/deactivate_mr_deployments_worker.rb new file mode 100644 index 00000000000..910cae72d12 --- /dev/null +++ b/app/workers/pages/deactivate_mr_deployments_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Pages + class DeactivateMrDeploymentsWorker + include ApplicationWorker + + idempotent! + data_consistency :always # rubocop: disable SidekiqLoadBalancing/WorkerDataConsistency -- performing writes only + urgency :low + + feature_category :pages + + def perform(merge_request_id) + build_ids = Ci::Build.ids_in_merge_request(merge_request_id) + deactivate_deployments_with_build_ids(build_ids) + end + + private + + def deactivate_deployments_with_build_ids(build_ids) + PagesDeployment + .versioned + .ci_build_id_in(build_ids) + .each_batch do |batch| + batch.deactivate + end + end + end +end diff --git a/app/workers/pages/deactivated_deployments_delete_cron_worker.rb b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb index 75905759761..eeafed446c8 100644 --- a/app/workers/pages/deactivated_deployments_delete_cron_worker.rb +++ b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb @@ -11,7 +11,7 @@ module Pages feature_category :pages def perform - PagesDeployment.deactivated.each_batch do |deployments| # rubocop: disable Style/SymbolProc + PagesDeployment.deactivated.each_batch do |deployments| deployments.each { |deployment| deployment.file.remove! } deployments.delete_all end diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 4e98c7268ac..b45a1c33d5c 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -8,7 +8,7 @@ class PipelineMetricsWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 include PipelineQueue - urgency :high + urgency :low def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index ca589acf26c..6237f64fa86 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -10,6 +10,8 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker LOCK_RETRY = 3 LOCK_TTL = 5.minutes + DELAY = 7.seconds + BATCH_SIZE = 500 feature_category :continuous_integration worker_resource_boundary :cpu @@ -20,12 +22,8 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker .select(:id, :owner_id, :project_id) # Minimize the selected columns .runnable_schedules .preloaded - .find_in_batches do |schedules| - RunPipelineScheduleWorker.bulk_perform_async_with_contexts( - schedules, - arguments_proc: ->(schedule) { [schedule.id, schedule.owner_id, { scheduling: true }] }, - context_proc: ->(schedule) { { project: schedule.project, user: schedule.owner } } - ) + .find_in_batches(batch_size: BATCH_SIZE).with_index do |schedules, index| # rubocop: disable CodeReuse/ActiveRecord -- activates because of batch_size + enqueue_run_pipeline_schedule_worker(schedules, index) end end end @@ -42,4 +40,21 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker retries: LOCK_RETRY } end + + def enqueue_run_pipeline_schedule_worker(schedules, index) + if ::Feature.enabled?(:run_pipeline_schedule_worker_with_delay) + RunPipelineScheduleWorker.bulk_perform_in_with_contexts( + [1, index * DELAY].max, + schedules, + arguments_proc: ->(schedule) { [schedule.id, schedule.owner_id, { scheduling: true }] }, + context_proc: ->(schedule) { { project: schedule.project, user: schedule.owner } } + ) + else + RunPipelineScheduleWorker.bulk_perform_async_with_contexts( + schedules, + arguments_proc: ->(schedule) { [schedule.id, schedule.owner_id, { scheduling: true }] }, + context_proc: ->(schedule) { { project: schedule.project, user: schedule.owner } } + ) + end + end end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index cc72704d8c9..30e394a95cf 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -42,6 +42,8 @@ class ProcessCommitWorker update_issue_metrics(commit, author) end + private + def process_commit_message(project, commit, user, author, default = false) # Ignore closing references from GitLab-generated commit messages. find_closing_issues = default && !commit.merged_merge_request?(user) diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 61ef7494d38..52d825e5421 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -10,7 +10,7 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_creation feature_category :continuous_integration - deduplicate :until_executed + deduplicate :until_executed, including_scheduled: true idempotent! def perform(schedule_id, user_id, options = {}) |