diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 13:49:51 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 13:49:51 +0000 |
commit | 71786ddc8e28fbd3cb3fcc4b3ff15e5962a1c82e (patch) | |
tree | 6a2d93ef3fb2d353bb7739e4b57e6541f51cdd71 /app | |
parent | a7253423e3403b8c08f8a161e5937e1488f5f407 (diff) | |
download | gitlab-ce-71786ddc8e28fbd3cb3fcc4b3ff15e5962a1c82e.tar.gz |
Add latest changes from gitlab-org/gitlab@15-9-stable-eev15.9.0-rc42
Diffstat (limited to 'app')
1552 files changed, 20695 insertions, 12085 deletions
diff --git a/app/assets/images/auth_buttons/authentiq_64.png b/app/assets/images/auth_buttons/authentiq_64.png Binary files differdeleted file mode 100644 index 81767bbcc54..00000000000 --- a/app/assets/images/auth_buttons/authentiq_64.png +++ /dev/null diff --git a/app/assets/images/auth_buttons/shibboleth_64.png b/app/assets/images/auth_buttons/shibboleth_64.png Binary files differdeleted file mode 100644 index d4c752f9400..00000000000 --- a/app/assets/images/auth_buttons/shibboleth_64.png +++ /dev/null diff --git a/app/assets/images/file_icons.svg b/app/assets/images/file_icons.svg deleted file mode 100644 index 8b19f411c7b..00000000000 --- a/app/assets/images/file_icons.svg +++ /dev/null @@ -1 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol viewBox="0 0 24 24" id="actionscript" xmlns="http://www.w3.org/2000/svg"><text style="line-height:113.99999857%" x="5.605" y="15.892" transform="scale(.91325 1.095)" font-weight="400" font-size="42.822" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/><path style="line-height:125%" d="M4.744 2.031c-1.157 0-1.994.31-2.51.93-.515.612-.771 1.678-.771 3.197v2.467c0 1.408-.402 2.111-1.201 2.111v2.035c.8 0 1.2.679 1.2 2.036v2.654c0 1.512.26 2.562.78 3.152.52.59 1.355.885 2.502.885V19.43c-.447 0-.77-.151-.97-.453-.195-.303-.292-.815-.292-1.538v-2.267c0-1.807-.404-2.937-1.214-3.395v-.045c.81-.464 1.214-1.581 1.214-3.351V6.025c0-1.283.42-1.925 1.262-1.925V2.03zm14.66 0V4.1c.842 0 1.262.642 1.262 1.925v2.268c0 1.843.402 2.996 1.207 3.46v.046c-.805.442-1.207 1.544-1.207 3.306v2.356c0 .715-.099 1.22-.299 1.516-.2.302-.52.453-.963.453v2.068c1.152 0 1.984-.295 2.494-.885.516-.59.772-1.663.772-3.218V14.84c0-1.379.404-2.069 1.209-2.069v-2.035c-.805 0-1.21-.696-1.21-2.09V6.113c0-1.49-.255-2.54-.77-3.152-.516-.62-1.348-.93-2.495-.93zm-3.054 4.46c-.455 0-.886.057-1.293.173a3.056 3.056 0 0 0-1.078.527c-.308.241-.551.549-.731.924-.18.37-.27.817-.27 1.336 0 .663.165 1.227.493 1.695.33.468.831.864 1.502 1.188.263.125.509.249.736.37.227.12.422.244.586.374.168.13.299.271.394.424a.963.963 0 0 1 .145.521c0 .144-.03.28-.09.405a.9.9 0 0 1-.275.318c-.12.088-.272.158-.455.21a2.34 2.34 0 0 1-.635.075c-.415 0-.825-.083-1.233-.25a3.644 3.644 0 0 1-1.13-.763v2.222a3.68 3.68 0 0 0 1.101.418c.427.093.875.139 1.346.139.459 0 .894-.05 1.305-.152a3.002 3.002 0 0 0 1.09-.5c.31-.237.556-.543.736-.918.183-.38.275-.849.275-1.405 0-.403-.052-.755-.156-1.056a2.542 2.542 0 0 0-.45-.813 3.295 3.295 0 0 0-.704-.633 6.754 6.754 0 0 0-.922-.535 12.4 12.4 0 0 1-.676-.348c-.2-.115-.37-.231-.51-.347a1.502 1.502 0 0 1-.322-.375.91.91 0 0 1-.115-.453c0-.153.033-.288.101-.408a.948.948 0 0 1 .29-.32c.123-.089.275-.156.454-.202a2.18 2.18 0 0 1 .598-.078c.16 0 .326.015.502.043.18.028.36.07.539.13.18.056.354.13.522.218.171.088.329.188.472.304V6.871a4.039 4.039 0 0 0-.957-.285 6.448 6.448 0 0 0-1.185-.096zm-8.774.165l-3.123 9.967h2.094l.605-2.217h3.053l.61 2.217h2.107L9.869 6.656H7.576zm1.072 1.78h.047c.028.347.077.646.145.896l.922 3.35H7.564l.934-3.377c.08-.288.13-.578.15-.87z" font-weight="400" font-size="51.019" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="android" xmlns="http://www.w3.org/2000/svg"><path d="M15 5h-1V4h1m-5 1H9V4h1m5.53-1.84L16.84.85c.19-.19.19-.51 0-.71a.513.513 0 0 0-.71 0l-1.48 1.48C13.85 1.23 12.95 1 12 1c-.96 0-1.86.23-2.66.63L7.85.14a.501.501 0 0 0-.7 0c-.2.2-.2.52 0 .71l1.31 1.31C6.97 3.26 6 5 6 7h12c0-2-1-3.75-2.47-4.84M20.5 8A1.5 1.5 0 0 0 19 9.5v7a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 20.5 8m-17 0A1.5 1.5 0 0 0 2 9.5v7A1.5 1.5 0 0 0 3.5 18 1.5 1.5 0 0 0 5 16.5v-7A1.5 1.5 0 0 0 3.5 8M6 18a1 1 0 0 0 1 1h1v3.5A1.5 1.5 0 0 0 9.5 24a1.5 1.5 0 0 0 1.5-1.5V19h2v3.5a1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5V19h1a1 1 0 0 0 1-1V8H6v10z" fill="#c0ca33"/></symbol><symbol viewBox="0 0 24 24" id="angular" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="angular-component" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#0288d1"/></symbol><symbol viewBox="0 0 24 24" id="angular-directive" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ab47bc"/></symbol><symbol viewBox="0 0 24 24" id="angular-guard" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-pipe" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#00897b"/></symbol><symbol viewBox="0 0 24 24" id="angular-resolver" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-routing" xmlns="http://www.w3.org/2000/svg"><path d="M11 10H5L3 8l2-2h6V3l1-1 1 1v1h6l2 2-2 2h-6v2h6l2 2-2 2h-6v6a2 2 0 0 1 2 2H9a2 2 0 0 1 2-2V10z" fill="#43a047"/></symbol><symbol viewBox="0 0 24 24" id="angular-service" xmlns="http://www.w3.org/2000/svg"><path d="M12.102 2.625l8.84 3.15-1.34 11.7-7.5 4.15-7.5-4.15-1.34-11.7 8.84-3.15m0 2.1l-5.53 12.4h2.06l1.11-2.78h4.7l1.11 2.78h2.05l-5.5-12.4m1.62 7.9h-3.23l1.61-3.87z" fill="#ffca28"/></symbol><symbol viewBox="0 0 100 100" id="apiblueprint" xmlns="http://www.w3.org/2000/svg"><title>api-blueprint</title><path d="M50.133 7.521A16.998 16.998 0 0 0 33.135 24.52a16.998 16.998 0 0 0 4.945 11.974L24.861 57.398a16.998 16.998 0 0 0-3.175-.308A16.998 16.998 0 0 0 4.688 74.088a16.998 16.998 0 0 0 16.998 16.998 16.998 16.998 0 0 0 16.998-16.998 16.998 16.998 0 0 0-7.063-13.773l12.576-19.89a16.998 16.998 0 0 0 5.936 1.093 16.998 16.998 0 0 0 6.154-1.155l12.537 19.83a16.998 16.998 0 0 0-7.244 13.895 16.998 16.998 0 0 0 16.998 17 16.998 16.998 0 0 0 16.998-17A16.998 16.998 0 0 0 78.578 57.09a16.998 16.998 0 0 0-2.95.262L62.337 36.327A16.998 16.998 0 0 0 67.13 24.52 16.998 16.998 0 0 0 50.132 7.522z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="applescript" xmlns="http://www.w3.org/2000/svg"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" fill="#78909c"/></symbol><symbol viewBox="0 0 24 24" id="appveyor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c-.084 0-.165.008-.248.01a10 10 0 0 0-.266.01 9.952 9.952 0 0 0-.754.066 10 10 0 0 0-.148.018 9.855 9.855 0 0 0-.93.177 10 10 0 0 0-.07.02c-.196.049-.392.1-.584.16v.012a10 10 0 0 0-2 .875V3.34c-.02.012-.038.027-.059.039a10 10 0 0 0-.953.635c-.09.067-.172.142-.26.213a10 10 0 0 0-.628.546c-.109.104-.211.211-.315.319a10 10 0 0 0-.476.539c-.1.12-.201.237-.295.361a10 10 0 0 0-.52.766c-.088.143-.17.288-.252.435a10 10 0 0 0-.363.723c-.072.161-.136.327-.2.492a10 10 0 0 0-.269.778c-.02.067-.044.131-.062.199a10 10 0 0 0-.008.027c-.098.364-.166.728-.22 1.09-.012.077-.024.153-.034.23a9.85 9.85 0 0 0-.08 1.182c0 .03-.006.057-.006.086a10 10 0 0 0 .008.148c.001.094-.002.188.002.282l.011.004a10 10 0 0 0 .333 2.158l-.012-.004c.012.047.033.091.047.139a10 10 0 0 0 .322.955c.02.052.037.106.059.158a10 10 0 0 0 .503 1.035c.065.116.14.226.21.34a10 10 0 0 0 .423.64c.092.128.187.252.285.375a10 10 0 0 0 .448.52c.112.123.222.248.341.365a10 10 0 0 0 .803.719 10 10 0 0 0 .01.006c.099.078.207.146.309.22a10 10 0 0 0 .648.442c.138.085.28.163.424.242a10 10 0 0 0 .715.358c.114.051.226.106.343.154a10 10 0 0 0 1.133.389c.016.004.031.01.047.015a10 10 0 0 0 .461.098 10 10 0 0 0 .482.103 10 10 0 0 0 .418.051 10 10 0 0 0 .575.065 10 10 0 0 0 .144.005A10 10 0 0 0 12 22a10 10 0 0 0 .197-.01 10 10 0 0 0 .496-.025 10 10 0 0 0 .49-.043 10 10 0 0 0 .489-.074 10 10 0 0 0 .51-.098 10 10 0 0 0 .47-.12 10 10 0 0 0 .477-.14 10 10 0 0 0 .47-.172 10 10 0 0 0 .481-.197 10 10 0 0 0 .414-.201 10 10 0 0 0 .475-.252 10 10 0 0 0 .39-.238 10 10 0 0 0 .452-.301 10 10 0 0 0 .38-.291 10 10 0 0 0 .385-.315 10 10 0 0 0 .375-.347 10 10 0 0 0 .36-.363 10 10 0 0 0 .293-.334 10 10 0 0 0 .353-.434 10 10 0 0 0 .28-.393 10 10 0 0 0 .263-.4 10 10 0 0 0 .264-.461 10 10 0 0 0 .228-.436 10 10 0 0 0 .195-.437 10 10 0 0 0 .196-.48 10 10 0 0 0 .228-.69 10 10 0 0 0 .028-.094 10 10 0 0 0 .021-.066 10 10 0 0 0 .098-.461 10 10 0 0 0 .103-.482 10 10 0 0 0 .051-.418 10 10 0 0 0 .065-.575 10 10 0 0 0 .005-.144A10 10 0 0 0 22 12a10 10 0 0 0-.01-.197 10 10 0 0 0-.025-.496 10 10 0 0 0-.043-.49 10 10 0 0 0-.074-.489 10 10 0 0 0-.098-.51 10 10 0 0 0-.12-.47 10 10 0 0 0-.14-.477 10 10 0 0 0-.172-.47 10 10 0 0 0-.197-.481 10 10 0 0 0-.201-.414 10 10 0 0 0-.252-.475 10 10 0 0 0-.238-.39 10 10 0 0 0-.301-.452 10 10 0 0 0-.291-.38 10 10 0 0 0-.315-.385 10 10 0 0 0-.347-.375 10 10 0 0 0-.363-.36 10 10 0 0 0-.334-.293 10 10 0 0 0-.434-.353 10 10 0 0 0-.393-.28 10 10 0 0 0-.4-.263 10 10 0 0 0-.461-.264 10 10 0 0 0-.436-.228 10 10 0 0 0-.437-.196 10 10 0 0 0-.48-.195 10 10 0 0 0-.69-.228 10 10 0 0 0-.094-.028 10 10 0 0 0-.066-.021 10 10 0 0 0-.461-.098 10 10 0 0 0-.482-.103 10 10 0 0 0-.418-.051 10 10 0 0 0-.575-.065 10 10 0 0 0-.144-.005A10 10 0 0 0 12 2zm-.016 5.002a5 5 0 0 1 .262.01 5 5 0 0 1 .227.011 5 5 0 0 1 .341.05 5 5 0 0 1 .135.019 5 5 0 0 1 .014.004 5 5 0 0 1 .115.025 5 5 0 0 1 .303.076 5 5 0 0 1 .265.086 5 5 0 0 1 .2.074 5 5 0 0 1 .242.106 5 5 0 0 1 .228.11 5 5 0 0 1 .196.109 5 5 0 0 1 .244.15 5 5 0 0 1 .17.12 5 5 0 0 1 .224.171 5 5 0 0 1 .186.16 5 5 0 0 1 .176.164 5 5 0 0 1 .172.18 5 5 0 0 1 .177.203 5 5 0 0 1 .133.172 5 5 0 0 1 .16.223 5 5 0 0 1 .133.214 5 5 0 0 1 .12.21 5 5 0 0 1 .107.216 5 5 0 0 1 .109.24 5 5 0 0 1 .084.223 5 5 0 0 1 .08.242 5 5 0 0 1 .07.264 5 5 0 0 1 .047.207 5 5 0 0 1 .045.277 5 5 0 0 1 .028.227 5 5 0 0 1 .02.351 5 5 0 0 1 .003.079 5 5 0 0 1-.012.271 5 5 0 0 1-.011.227 5 5 0 0 1-.05.341 5 5 0 0 1-.019.135 5 5 0 0 1-.004.014 5 5 0 0 1-.025.115 5 5 0 0 1-.076.303 5 5 0 0 1-.086.265 5 5 0 0 1-.074.2 5 5 0 0 1-.106.242 5 5 0 0 1-.11.228 5 5 0 0 1-.109.196 5 5 0 0 1-.15.244 5 5 0 0 1-.12.17 5 5 0 0 1-.171.224 5 5 0 0 1-.16.186 5 5 0 0 1-.164.176 5 5 0 0 1-.18.172 5 5 0 0 1-.203.177l-.002.002c-.018.019-.028.035-.047.053l-3.959 5.09-3.05-.979a141.684 141.684 0 0 0 3.177-3.084 5 5 0 0 1-.103-.015 5 5 0 0 1-.149-.024 5 5 0 0 1-.115-.025 5 5 0 0 1-3.57-3.04 5.072 5.072 0 0 1-.206-.661 5 5 0 0 1-.033-.147c-.025-.118-.036-.24-.054-.36-.987.993-1.964 1.993-2.954 3.05l-.98-3.053 5.092-3.957c.043-.044.082-.07.125-.11a5 5 0 0 1 .71-.634c.18-.13.367-.25.561-.356a5 5 0 0 1 .16-.08 4.94 4.94 0 0 1 .516-.222 5 5 0 0 1 .147-.057c.211-.07.43-.123.654-.164a5 5 0 0 1 .172-.027c.236-.035.476-.058.722-.059zM12 9a3 3 0 0 0-.053.002 3 3 0 0 0-.166.01 3 3 0 0 0-.133.011 3 3 0 0 0-.17.026 3 3 0 0 0-.113.021 3 3 0 0 0-.19.05 3 3 0 0 0-.103.03 3 3 0 0 0-.16.057 3 3 0 0 0-.129.053 3 3 0 0 0-.146.072 3 3 0 0 0-.12.063 3 3 0 0 0-.132.082 3 3 0 0 0-.123.08 3 3 0 0 0-.116.088 3 3 0 0 0-.126.105 3 3 0 0 0-.1.094 3 3 0 0 0-.111.111 3 3 0 0 0-.096.107 3 3 0 0 0-.094.116 3 3 0 0 0-.098.136 3 3 0 0 0-.072.11 3 3 0 0 0-.076.133 3 3 0 0 0-.07.132 3 3 0 0 0-.063.14 3 3 0 0 0-.054.14 3 3 0 0 0-.077.228 3 3 0 0 0-.007.026 3 3 0 0 0-.03.138 3 3 0 0 0-.031.149 3 3 0 0 0-.014.11 3 3 0 0 0-.02.183 3 3 0 0 0-.001.052A3 3 0 0 0 9 12a3 3 0 0 0 .002.053 3 3 0 0 0 .01.166 3 3 0 0 0 .011.133 3 3 0 0 0 .026.17 3 3 0 0 0 .021.113 3 3 0 0 0 .05.19 3 3 0 0 0 .03.103 3 3 0 0 0 .057.16 3 3 0 0 0 .053.129 3 3 0 0 0 .072.146 3 3 0 0 0 .063.12 3 3 0 0 0 .082.132 3 3 0 0 0 .08.123 3 3 0 0 0 .088.116 3 3 0 0 0 .105.126 3 3 0 0 0 .094.1 3 3 0 0 0 .111.111 3 3 0 0 0 .107.096 3 3 0 0 0 .116.094 3 3 0 0 0 .136.098 3 3 0 0 0 .11.072 3 3 0 0 0 .133.076 3 3 0 0 0 .132.07 3 3 0 0 0 .135.06 3 3 0 0 0 .153.061 3 3 0 0 0 .216.07 3 3 0 0 0 .004.003 3 3 0 0 0 .026.007 3 3 0 0 0 .138.03 3 3 0 0 0 .149.031 3 3 0 0 0 .11.014 3 3 0 0 0 .183.02 3 3 0 0 0 .011.001 3 3 0 0 0 .041 0A3 3 0 0 0 12 15a3 3 0 0 0 .053-.002 3 3 0 0 0 .166-.01 3 3 0 0 0 .133-.011 3 3 0 0 0 .17-.026 3 3 0 0 0 .113-.021 3 3 0 0 0 .19-.05 3 3 0 0 0 .103-.03 3 3 0 0 0 .16-.057 3 3 0 0 0 .129-.053 3 3 0 0 0 .146-.072 3 3 0 0 0 .12-.063 3 3 0 0 0 .132-.082 3 3 0 0 0 .123-.08 3 3 0 0 0 .116-.088 3 3 0 0 0 .126-.105 3 3 0 0 0 .1-.094 3 3 0 0 0 .111-.111 3 3 0 0 0 .096-.107 3 3 0 0 0 .094-.116 3 3 0 0 0 .098-.136 3 3 0 0 0 .072-.11 3 3 0 0 0 .076-.133 3 3 0 0 0 .07-.132 3 3 0 0 0 .06-.135 3 3 0 0 0 .061-.153 3 3 0 0 0 .07-.216 3 3 0 0 0 .003-.004 3 3 0 0 0 .007-.026 3 3 0 0 0 .03-.138 3 3 0 0 0 .031-.149 3 3 0 0 0 .002-.008 3 3 0 0 0 .012-.101 3 3 0 0 0 .02-.184 3 3 0 0 0 .001-.011 3 3 0 0 0 0-.041A3 3 0 0 0 15 12a3 3 0 0 0-.002-.053 3 3 0 0 0-.01-.166 3 3 0 0 0-.011-.133 3 3 0 0 0-.026-.17 3 3 0 0 0-.021-.113 3 3 0 0 0-.05-.19 3 3 0 0 0-.03-.103 3 3 0 0 0-.057-.16 3 3 0 0 0-.053-.129 3 3 0 0 0-.072-.146 3 3 0 0 0-.063-.12 3 3 0 0 0-.082-.132 3 3 0 0 0-.08-.123 3 3 0 0 0-.088-.116 3 3 0 0 0-.105-.126 3 3 0 0 0-.094-.1 3 3 0 0 0-.111-.111 3 3 0 0 0-.107-.096 3 3 0 0 0-.116-.094 3 3 0 0 0-.136-.098 3 3 0 0 0-.11-.072 3 3 0 0 0-.133-.076 3 3 0 0 0-.132-.07 3 3 0 0 0-.14-.063 3 3 0 0 0-.14-.054 3 3 0 0 0-.228-.077 3 3 0 0 0-.026-.007 3 3 0 0 0-.138-.03 3 3 0 0 0-.149-.031 3 3 0 0 0-.008-.002 3 3 0 0 0-.101-.012 3 3 0 0 0-.184-.02 3 3 0 0 0-.011-.001 3 3 0 0 0-.041 0A3 3 0 0 0 12 9z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 720 720" id="arduino" xmlns="http://www.w3.org/2000/svg"><defs><symbol id="ana" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke-opacity="100%" stroke-width="60" stroke="#00979c" d="M174 30a10.5 10.1 0 0 0 0 280C364 320 344 30 544 30a10.5 10.1 0 0 1 0 280C354 320 374 30 174 30"/><path d="M528 205v-32.8h-32.5v-13.7H528V126h13.9v32.5h32.5v13.7h-32.5V205H528z" text-anchor="middle" fill="#00979c" stroke-width="20" stroke="#00979c" font-family="sans-serif" font-size="167"/><path fill="#00979c" stroke="#00979c" stroke-width="23.6" transform="matrix(1.56 0 0 .64 -366 .528)" d="M321 266v-17.4h53.3V266H321z"/></symbol></defs><title>Layer 1</title><use x="20.063" y="360.85" transform="matrix(.997 0 0 .997 -18.596 -159.19)" xlink:href="#ana"/></symbol><symbol viewBox="0 0 24 24" id="assembly" xmlns="http://www.w3.org/2000/svg"><path d="M1.746 1.566v20.905H5.13v-2.088H3.438V3.656h1.69v-2.09H1.747zm17.219 0v2.09h1.693v16.727h-1.693v2.09h3.383V1.566h-3.383zM15.196 3.988c-.5 0-.93.076-1.29.225-.359.15-.652.372-.877.671-.226.302-.39.673-.494 1.108a6.715 6.715 0 0 0-.155 1.54c0 .573.049 1.083.15 1.528.1.442.264.811.49 1.11.222.298.512.524.872.676.36.153.795.23 1.304.23.518 0 .954-.075 1.308-.224.353-.153.643-.376.869-.671.219-.29.38-.661.484-1.112.104-.454.156-.967.156-1.54 0-.573-.052-1.079-.152-1.515a2.92 2.92 0 0 0-.485-1.106 2.09 2.09 0 0 0-.868-.686c-.354-.155-.79-.234-1.312-.234zm-6.814.12a.941.941 0 0 1-.138.458.849.849 0 0 1-.356.296A1.71 1.71 0 0 1 7.385 5a5.244 5.244 0 0 1-.631.037v1.11H8.19v3.6H6.754v1.188h4.545V9.745H9.894V4.11H8.382zm6.814 1.138c.375 0 .643.176.805.527.161.348.241.933.241 1.756 0 .814-.082 1.399-.247 1.756-.164.356-.43.534-.799.534-.369 0-.636-.178-.8-.534-.165-.357-.248-.941-.248-1.749 0-.829.082-1.415.243-1.763.162-.35.43-.527.805-.527zm-6.33 7.64c-.5 0-.93.073-1.29.223-.359.15-.651.374-.877.673-.225.302-.39.67-.494 1.106a6.715 6.715 0 0 0-.155 1.54c0 .573.05 1.082.15 1.527.1.442.264.814.49 1.112.222.3.514.525.874.677.36.152.793.229 1.302.229.519 0 .954-.076 1.308-.225.354-.153.643-.376.869-.672.22-.29.38-.66.484-1.111.104-.455.156-.967.156-1.54 0-.573-.05-1.079-.15-1.515a2.923 2.923 0 0 0-.487-1.106 2.084 2.084 0 0 0-.867-.686c-.353-.156-.791-.232-1.313-.232zm5.846.119a.941.941 0 0 1-.138.457.85.85 0 0 1-.356.296 1.71 1.71 0 0 1-.503.137 5.245 5.245 0 0 1-.631.037v1.112h1.435v3.597h-1.435v1.189h4.545v-1.189h-1.405v-5.636h-1.512zm-5.846 1.137c.375 0 .643.176.805.527.162.347.241.933.241 1.756 0 .813-.08 1.399-.245 1.755-.164.357-.432.534-.8.534-.37 0-.637-.177-.802-.534-.164-.356-.245-.939-.245-1.746 0-.83.08-1.418.242-1.765.161-.35.43-.527.804-.527z" fill="#ff6e40"/></symbol><symbol viewBox="0 0 24 24" id="aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="api" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apa"/><linearGradient id="apa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#apb"/><linearGradient id="apb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#apc"/><linearGradient id="apc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#apd"/><linearGradient id="apd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#ape"/><linearGradient id="ape" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="apn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#apf"/><linearGradient id="apf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="apo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#apg"/><linearGradient id="apg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="app" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#aph"/><linearGradient id="aph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><g transform="rotate(11.282 -1.694 21.569) scale(.47102)" clip-rule="evenodd" fill="none" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#api)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#apj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#apk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#apl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#apm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#apn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#apo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#app)"/></g></symbol><symbol viewBox="0 0 24 24" id="autohotkey" xmlns="http://www.w3.org/2000/svg"><path d="M5 3c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5zm3.668 3.447a.9.9 0 0 1 .652.256.84.84 0 0 1 .262.625c0 .34-.014.852-.041 1.537-.022.68-.033 1.19-.033 1.53 0 .111-.016.326-.047.644a6.149 6.149 0 0 0-.033.68l2.578-.485c1.007-.179 1.874-.281 2.603-.308.018-.3.048-1.105.088-2.416.01-.345.115-.742.317-1.19.25-.55.533-.826.851-.826.237 0 .448.08.631.236.197.17.295.382.295.637a.775.775 0 0 1-.025.201c-.09.327-.135.612-.135.854 0 .125-.014.32-.041.584-.023.26-.033.453-.033.578 0 .425-.022 1.056-.067 1.893a38.963 38.963 0 0 0-.068 1.892c0 .327.025.816.074 1.465.05.649.074 1.136.074 1.463a.84.84 0 0 1-.261.625.893.893 0 0 1-.65.254 1 1 0 0 1-.686-.254.777.777 0 0 1-.29-.611c0-.327-.015-.818-.046-1.471a39.552 39.552 0 0 1-.041-1.47c0-.256.004-.482.013-.679-.702.032-1.57.142-2.603.33-.86.157-1.719.316-2.578.477-.01.304-.042.812-.096 1.523a22.354 22.354 0 0 0-.066 1.538.84.84 0 0 1-.262.625.893.893 0 0 1-.65.253.898.898 0 0 1-.653-.253.84.84 0 0 1-.262-.625c0-.452.038-1.128.114-2.028.08-.9.12-1.575.12-2.027 0-.573.015-1.436.042-2.586.027-1.155.04-2.017.04-2.59a.84.84 0 0 1 .263-.625.895.895 0 0 1 .65-.256z" fill="#4caf50"/></symbol><symbol viewBox="0 0 24 24" id="autoit" xmlns="http://www.w3.org/2000/svg"><defs id="ardefs8"><style id="arstyle4482">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style><style id="arstyle4510">.cls-1{fill:#5d83ac}.cls-2{fill:#f0f0f0;fill-rule:evenodd}</style></defs><g id="arg4522" transform="translate(-59.538 -26.404) scale(.0555)"><path d="M12.8 2.133A10.666 10.666 0 0 0 2.136 12.799 10.666 10.666 0 0 0 12.8 23.465a10.666 10.666 0 0 0 10.668-10.666A10.666 10.666 0 0 0 12.8 2.133zm.15 4.713c.456 0 .836.105 1.142.314.306.21.565.469.78.78l6.089 8.812H9.627l1.82-2.506h3.36c.315 0 .589.01.822.03a11.93 11.93 0 0 1-.473-.663 39.13 39.13 0 0 0-.517-.75l-1.748-2.578-4.577 6.467H4.746l6.25-8.813c.204-.281.46-.534.772-.757.31-.224.705-.336 1.181-.336z" transform="matrix(16.89188 0 0 16.89188 1072.761 475.745)" id="arcircle4514" fill="#1976d2" stroke-width=".026"/></g></symbol><symbol viewBox="0 0 213.33333 213.33333" id="babel" xmlns="http://www.w3.org/2000/svg"><path d="M50.22 199.659c-.875-.406-1.261-1.6-.857-2.652.404-1.053.12-1.914-.63-1.914s-1.615.748-1.92 1.663c-.328.983-1.27.302-2.304-1.667-.962-1.831-3.718-5.533-6.126-8.226-9.418-10.535-7.71-27.444 5.432-53.77 12.459-24.96 23.117-39.033 45.966-60.696 30.229-28.66 52.679-46.223 70.587-55.22 10.98-5.518 13.025-5.059 2.778.624-11.004 6.102-11.378 6.359-10.512 7.226.33.33 7.306-2.67 15.504-6.667 15.87-7.737 16.34-7.912 16.34-6.082 0 .652-4.95 3.738-11 6.858-13.062 6.736-12.722 6.48-10.472 7.872 1.117.69 5.428-.582 11.54-3.406 5.367-2.48 10.397-4.508 11.179-4.508 2.755 0-3.928 5.302-11.541 9.157-20.437 10.35-68.937 46.043-68.07 50.097.166.777-5.792 7.639-13.241 15.248-15.257 15.587-26.14 30.002-33.748 44.706-6.379 12.326-7.457 17.734-5.385 26.996 3.482 15.56 11.592 18.366 31.482 10.895 28.228-10.603 45.758-28.704 47.022-48.556.602-9.442-1.317-13.479-8.52-17.93-4.01-2.48-5.268-2.621-12.065-1.365-4.173.771-10.153 2.906-13.289 4.744s-6.455 3.34-7.377 3.34c-.922 0-3.216 1.336-5.096 2.968-1.88 1.633.48-1.13 5.247-6.14 6.82-7.167 7.956-8.9 5.333-8.132-5.208 1.525-10.194 4.33-15.649 8.803-2.76 2.264-.923.175 4.08-4.641 11.565-11.131 21.183-15.97 33.088-16.641 17.097-.966 27.254 5.805 31.964 21.31 2.435 8.017 2.609 10.24 1.353 17.37-1.65 9.361-7.034 21.553-15.593 35.307-4.398 7.067-8.434 11.427-15.588 16.844-9.166 6.94-15.654 11.02-15.654 9.845 0-.295 2.455-2.161 5.455-4.147 8.818-5.835 5.075-5.377-8.326 1.02-6.854 3.27-15.199 6.593-18.542 7.38-7.106 1.675-30.527 3.164-32.846 2.089zm-8.408-19.899c0-1.1-.6-2-1.333-2-.734 0-1.334.9-1.334 2s.6 2 1.333 2c.734 0 1.334-.9 1.334-2zm89.255-8.204c1.53-1.945 2.473-3.845 2.097-4.222-.377-.377-.836-.435-1.02-.13-.182.306-1.787 2.206-3.565 4.223-1.778 2.016-2.571 3.666-1.763 3.666s2.72-1.591 4.25-3.536zm-77.644-1.745c-.82-2.172-1.74-3.7-2.045-3.396-.951.952 1.088 7.345 2.343 7.345.656 0 .522-1.777-.298-3.95zm82.303-27.915c-.837-.837-3.217 2.55-3.184 4.53.012.734.896.178 1.965-1.235 1.07-1.413 1.618-2.896 1.219-3.295zm-66.238-36.904c-1.312-1.312-3.676.702-3.676 3.133 0 2.035.175 2.031 2.254-.047 1.24-1.24 1.88-2.628 1.422-3.086zm39.657.768c4.403-2.196 6.8-3.986 5.333-3.982-2.838.01-16.667 6.028-16.667 7.254 0 1.6 3.717.527 11.333-3.272zm16.667-5.333c0-.733-.9-1.333-2-1.333s-2 .6-2 1.333.9 1.333 2 1.333 2-.6 2-1.333zm-3.334-3.923l5.334-1.104-7.334-.133c-4.033-.073-8.233.45-9.333 1.16-2.539 1.64 3.572 1.682 11.333.077zm35.738-63.976c2.788-1.69 4.765-3.376 4.393-3.748-.947-.947-11.942 5.654-14.237 8.548-1.792 2.258-1.714 2.276 1.44.329a1452.76 1452.76 0 0 1 8.403-5.13z" fill="#ffca28" stroke-width="1.333"/></symbol><symbol viewBox="0 0 400 400" fill-opacity=".05" id="bithound" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.88 0 0 .88 24.121 2.895)" fill="#e53935" fill-opacity="1"><path d="M370.5 207c-1.5-14.8-4.8-29.9-9.5-44-13.5-40.3-38.6-81.6-70.3-110.1-1.4-1.2-6.7-4.4-8.7-3.3-5.2 2.9 4.6 22.8 5.8 26.4 7.4 22 12.1 45.3 6.8 68.3-7.1 30.4-30.4 51.7-61.5 54.3-17.1 1.4-34.3-.5-51.4 1.5-25.6 3-51.7 11.8-68 32.8-1.9 2.4-3.6 5.1-5.2 7.9h-.4c-6.3.7-12.6-2-15.7-3.7-.8-.5-1.6-.9-2.2-1.2-19-10.5-33-34-41.6-53.4-3.9-9-7.2-18.4-9.3-27.9-1-4.3-1.1-8.8-1.3-13.2-.1-2.7.3-6.5-1.2-8.9-3.3-5.2-7.5-.2-8.2 4-1.1 6.9-2.1 13.7-1.8 20.7.5 11.8 3.8 23.5 8 34.5 6.2 16.2 14.9 31.1 26.2 44.4 4.7 5.5 9.7 10.6 15.1 15.3 4.8 4.3 10.9 7.7 14.5 13.2 4.2 6.3 4.9 14.1 4.5 21.4-1 19.3-1.6 37.4 3.9 56.2 4.8 16.7 10.8 33.8 20.8 48.1 5 7.1 11.2 14.6 18 19.9 4.6 3.6 13.3 4 8.3-9.2-11.1-29.3-12.1-59.7 5.2-87.1 14.5-22.8 40.1-43.1 69-39.5 42.5 5.3 72.1 44.3 70 86-.6 11.7-1 21.7-4.7 32.7-1.5 4.4-2.6 10-1.5 14.6 1.8 7.8 10.5 4.9 14.3-.2 10.3-14 21.1-27.6 30.8-42 31.6-47.2 47-101.8 41.3-158.5z"/><path d="M132.4 92.1c.7 2.3 1.4 4.8 1.9 7.5.1 1.1.4 2.3 1 3.4 2.6 6.8 8.9 10.5 14.8 14 3.6 2.2 10.1 4.3 14.1 5.9 5.2 2.1 16.4-.6 21.7-1 12.2-1 23.5-5.3 34.7 1.2-57.4 67.3-3.2 82.3 38.8 49.9 48-37 2.8-124.3 2.8-124.3s-1-6.8-19.2-10.8c-1.7-.9-3.4-1.7-5.1-2.4-18-8.3-34.2 5.3-47.2 16.4-3.8 3.2-7.5 6.4-11.5 9.4-5.4 4-11.2 7.3-17.3 10.2-6.4 3-14 6.4-21.1 6.7-1 0-2.9.2-4.9.6-3.1.3-4.7 1.1-5.4 2.5-1.2 1-2 2.4-1.8 4.2.2 2.5 1.4 4.6 2.7 6.2.4.1.7.3 1 .4z"/></g></symbol><symbol viewBox="0 0 400.00001 399.99999" id="bower" xmlns="http://www.w3.org/2000/svg"><g transform="translate(12.061 33.203) scale(.81733)"><path d="M447.61 200.08c-23.139-22.234-138.85-36.114-175.36-40.154a107.137 107.137 0 0 0 4.517-12.944 146.107 146.107 0 0 1 15.905-5.901c.677 1.997 3.865 9.648 5.682 13.279 73.415 2.025 77.184-54.557 80.17-70.058 2.92-15.157 2.771-29.802 27.953-56.575-37.516-10.933-91.467 16.945-109.54 58.437-6.79-2.545-13.597-4.424-20.328-5.586-4.824-19.46-29.944-73.672-95.863-73.672-83.46 0-174.43 68.853-174.43 185.41 0 97.976 66.891 183.84 104.68 183.84 16.505 0 30.703-12.36 34.036-23.44 2.795 7.597 11.368 31.213 14.184 37.225 4.162 8.89 23.41 16.583 31.833 7.357 10.83 6.017 30.703 9.641 41.534-6.405 20.86 4.412 39.3-8.026 39.702-22.868 10.235-.546 15.256-14.918 13.021-26.363-1.647-8.426-19.248-38.66-26.113-49.098 13.59 11.054 48.013 14.183 52.194.007 21.911 17.198 56.057 8.171 58.765-5.815 26.624 6.917 57.16-8.276 52.146-26.676 42.771-2.958 37.296-48.464 25.296-59.996z" fill="#543729" stroke-width=".973"/><path d="M328.514 103.025c9.212-18.277 20.788-38.234 35.409-50.58-16.093 6.485-31.981 25.873-41.375 46.595a144.914 144.914 0 0 0-14.552-8.132c13.105-27.972 43.555-51.332 77.112-53.157-22.477 20.385-14.498 62.754-32.979 85.183-5.288-5.311-17.43-15.562-23.615-19.909zm-14.53 29.762c.01-.7.272-6.094.763-8.557-1.288-.304-9.3-1.87-13.476-1.772-.304 5.245 2.204 14.17 4.684 19.541 17.075-.358 29.408-5.471 36.667-10.172-6.18-2.88-16.726-5.442-24.745-6.974-.894 1.851-3.097 6.568-3.892 7.934z" fill="#00acee"/><g stroke-width=".973"><path d="M250.54 277.39c.004.024.015.057.018.082-2.165-4.657-4.463-10.314-7.208-17.708 10.688 15.557 44.184 7.533 42.427-6.407 16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455 28 5.4 54.832 10.783 63.256 12.938-5.595 9.123-18.339 15.566-37.549 11.089 10.38 14.14-9.773 31.105-37.844 21.76 6.18 13.883-18.814 26.38-47.22 11.91.361 13.889-35.24 15.488-49.315.143zm55.543-70.194c32.497 2.495 86.238 7.34 119.51 11.997-2.102-10.828-7.844-13.921-25.905-18.772-19.425 2.072-68.706 6.913-93.604 6.776z" fill="#2baf2b"/><path d="M285.78 253.36c16.395 12.336 50.143-2.055 42.471-19.353 16.423 7.653 35.168-7.745 30.964-14.455-33.103-6.383-67.84-12.788-75.719-13.908 4.78.254 12.702.797 22.59 1.556 24.899.137 74.18-4.704 93.604-6.775-31.452-7.975-95.666-19.613-140.01-22.48-2.055 3.003-5.833 8.097-12.413 13.51-19.403 41.053-54.557 68.34-93.454 68.34-11.335 0-24.018-1.912-38.233-6.456-8.865 9.497-46.661 16.694-77.329 1.641 24.326 56.961 80.74 94.984 143.19 94.984 52.591 0 75.912-53.704 70.808-67.914-1.238-3.45-6.145-14.889-8.891-22.283 10.689 15.556 44.185 7.532 42.429-6.408z" fill="#ffcc2f"/><path d="M253.91 145.27c4.644-2.526 20.69-12.253 35.981-15.908a67.843 67.843 0 0 1-.536-5.12c-10.032 2.403-28.945 10.51-39.784-.661 22.866 6.9 34.283-6.149 51.09-6.149 10.014 0 24.305 2.798 35.57 7.22-9.061-8.37-38.772-33.63-75.558-33.717-8.213 9.957-17.09 31.526-6.764 54.334z" fill="#cecece"/><path d="M115.58 253.33c14.215 4.544 26.898 6.457 38.233 6.457 38.896 0 74.05-27.29 93.454-68.341-14.351 11.978-39.291 22.228-78.241 22.228 34.694-7.866 64.56-25.156 79.753-50.427-10.68-16.998-22.263-54.603 7.07-84.33-4.512-14.497-26.475-52.766-75.095-52.766-84.85 0-155.17 71.001-155.17 166.15 0 22.525 4.547 43.65 12.67 62.664 30.666 15.054 68.462 7.858 77.327-1.64z" fill="#ef5734"/><path d="M141.03 108.45c0 21.644 17.546 39.191 39.19 39.191s39.192-17.548 39.192-39.191c0-21.644-17.548-39.191-39.192-39.191-21.644 0-39.19 17.547-39.19 39.191z" fill="#ffcc2f"/><path d="M156.76 108.45c0 12.958 10.507 23.463 23.463 23.463 12.96 0 23.464-10.506 23.464-23.463 0-12.959-10.504-23.464-23.464-23.464-12.957 0-23.463 10.506-23.463 23.464z" fill="#543729"/><ellipse cx="180.22" cy="98.044" rx="13.673" ry="8.501" fill="#fff"/></g></g></symbol><symbol viewBox="0 0 140 140" id="browserlist" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><path d="M70.314 10.066a59.828 59.828 0 0 0-59.828 59.828 59.828 59.828 0 0 0 59.828 59.828 59.828 59.828 0 0 0 59.828-59.828 59.828 59.828 0 0 0-59.828-59.828zm-4.836 8.785c.496 4.043 1.352 7.322 2.572 10.223 4.779-4.287 10.265-7.546 16.041-9.02-.981 3.938-1.357 7.295-1.261 10.43 6.026-2.314 12.349-3.404 18.3-2.706-3.182 2.413-5.482 4.717-7.128 7.015-2.201 12.074 6.858 20.43 14.779 24.551a5.128 5.128 0 0 1 5.183-3.888 5.128 5.128 0 0 1 3.7 8.435v.002c-.487 1.055-2.002 2.343-3.497 3.219-4.075 2.39-11.172 5.736-20.914 7.39.045 1.214.077 2.453.077 3.747 0 4.817-.485 8.291-1.385 10.699-3.3 13.313-12.648 26.76-24.695 31.95.357-4.083.197-7.485-.402-10.591-5.582 3.218-11.646 5.278-17.623 5.52h-.002c1.785-3.662 2.855-6.878 3.412-9.976-6.347.996-12.727.742-18.377-1.17 2.93-2.732 5.054-5.314 6.673-7.96-6.292-1.344-12.169-3.87-16.766-7.686 3.822-1.544 6.795-3.239 9.3-5.197-5.426-3.517-10.034-7.998-12.972-13.23 4.012-.07 7.321-.568 10.3-1.453-3.786-5.215-6.468-11.032-7.333-16.951 3.861 1.405 7.196 2.133 10.36 2.355-1.662-6.22-2.081-12.605-.768-18.436 3.03 2.634 5.824 4.48 8.63 5.815.678-6.406 2.576-12.52 5.893-17.496 1.926 3.622 3.914 6.391 6.111 8.672 2.93-5.754 6.9-10.798 11.791-14.262zm26.465 19.557c-2.395 5.514-1.665 11.297-.555 18.732a2.138 2.138 0 0 0 .28-4.178 3.419 3.419 0 1 1 .092 6.704c.574 3.882 1.157 8.18 1.421 13.125a67.143 67.143 0 0 0 3.25-.649c6.616-1.487 12.258-3.801 16.871-6.506.45-.264.884-.563 1.276-.867.366-.557.333-.957.035-1.285-4.831-1.245-10.891-4.53-15.258-8.795-4.764-4.653-7.428-10.164-7.412-16.281z" fill="#ffca28" stroke-width=".855"/></symbol><symbol viewBox="0 0 140 140" id="browserlist_light" xmlns="http://www.w3.org/2000/svg"><title>Browserslist logo</title><g transform="translate(10.823 10.1)" stroke-width=".855"><circle cx="59.492" cy="59.795" r="59.828" fill="#ffca28"/><path d="M54.656 8.752c-4.89 3.464-8.862 8.508-11.791 14.262-2.198-2.28-4.185-5.05-6.111-8.672-3.318 4.976-5.216 11.09-5.893 17.496-2.807-1.335-5.6-3.18-8.63-5.814-1.314 5.83-.895 12.216.767 18.436-3.164-.223-6.498-.95-10.36-2.356.865 5.92 3.548 11.737 7.333 16.951-2.978.885-6.287 1.383-10.3 1.453 2.939 5.233 7.547 9.714 12.972 13.23-2.505 1.959-5.478 3.654-9.299 5.198 4.596 3.815 10.474 6.341 16.766 7.685-1.62 2.647-3.743 5.228-6.674 7.96 5.65 1.912 12.03 2.166 18.377 1.17-.556 3.098-1.626 6.314-3.412 9.975h.002c5.977-.24 12.042-2.3 17.623-5.52.6 3.108.76 6.51.402 10.593 12.047-5.19 21.395-18.638 24.695-31.951.9-2.408 1.385-5.881 1.385-10.7 0-1.293-.031-2.531-.076-3.745 9.742-1.655 16.839-5.001 20.914-7.39 1.494-.877 3.01-2.165 3.496-3.22v-.002a5.128 5.128 0 0 0-3.7-8.435 5.128 5.128 0 0 0-5.183 3.889c-7.92-4.122-16.98-12.477-14.779-24.551 1.646-2.299 3.947-4.603 7.13-7.016-5.952-.698-12.276.392-18.302 2.707-.095-3.135.28-6.492 1.262-10.43-5.776 1.473-11.262 4.733-16.041 9.02-1.22-2.902-2.076-6.18-2.572-10.223zm26.465 19.557c-.015 6.117 2.648 11.628 7.412 16.281 4.366 4.265 10.426 7.55 15.258 8.795.298.328.331.728-.035 1.285-.392.304-.825.603-1.275.867-4.613 2.704-10.256 5.019-16.871 6.506-1.071.24-2.154.458-3.25.649-.265-4.945-.848-9.243-1.422-13.125a3.419 3.419 0 1 0-.092-6.703 2.138 2.138 0 0 1-.28 4.177c-1.11-7.435-1.84-13.218.555-18.732z" fill="#37474f"/></g></symbol><symbol viewBox="0 0 24 24" id="bucklescript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm14.1 8.858a5.5 5.5 0 0 1 1.26.145c.417.093.778.213 1.082.357v1.723h-.18a3.281 3.281 0 0 0-.959-.603 2.867 2.867 0 0 0-1.155-.247c-.14 0-.277.011-.416.035a1.4 1.4 0 0 0-.395.12.756.756 0 0 0-.291.231.54.54 0 0 0-.123.348c0 .198.065.35.196.456.13.104.376.2.738.288.237.057.466.11.683.164.22.054.455.128.706.222.496.188.86.444 1.095.77.238.32.357.738.357 1.253 0 .737-.271 1.336-.813 1.798-.538.46-1.27.689-2.197.689a5.447 5.447 0 0 1-1.402-.161 6.725 6.725 0 0 1-1.117-.416v-1.794h.183c.344.318.73.563 1.155.734.429.17.839.256 1.233.256.1 0 .235-.01.4-.03.166-.02.3-.055.403-.102a.97.97 0 0 0 .313-.225c.084-.09.127-.223.127-.4a.568.568 0 0 0-.183-.424c-.119-.12-.294-.213-.526-.276-.243-.067-.5-.128-.773-.185a5.523 5.523 0 0 1-.76-.227c-.544-.204-.936-.48-1.177-.828-.237-.351-.357-.786-.357-1.305 0-.697.27-1.265.81-1.703.54-.442 1.235-.663 2.083-.663zm-8.981.135h2.51c.521 0 .903.02 1.143.06.243.041.484.13.721.266.246.144.43.338.548.583.121.24.181.518.181.83 0 .36-.082.68-.247.959a1.697 1.697 0 0 1-.7.642v.04c.423.098.758.298 1.004.603.249.305.373.706.373 1.205 0 .361-.063.686-.19.97-.125.285-.296.52-.516.707a2.31 2.31 0 0 1-.845.472c-.304.094-.69.141-1.159.141H8.12v-7.478zm1.659 1.372v1.582h.262c.263 0 .486-.007.672-.017.185-.01.332-.043.44-.1.15-.077.248-.175.294-.295.046-.124.07-.266.07-.427a.91.91 0 0 0-.083-.371.518.518 0 0 0-.282-.277 1.187 1.187 0 0 0-.456-.086c-.18-.007-.433-.01-.76-.01h-.157zm0 2.873V18.1H9.9c.469 0 .804-.002 1.007-.006.202-.003.39-.046.56-.13a.712.712 0 0 0 .357-.33c.067-.142.099-.302.099-.483 0-.237-.04-.42-.121-.547-.078-.13-.214-.228-.405-.291a1.842 1.842 0 0 0-.538-.072 49.47 49.47 0 0 0-.716-.003h-.366z" fill="#26a69a" stroke-width="1.067"/></symbol><symbol viewBox="0 0 24 24" id="c" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 15.97l.42 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96-1.14-1.27-1.68-2.88-1.68-4.83C6 9.9 6.68 8.13 8 6.89 9.28 5.64 10.92 5 12.9 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.6 2.49-1.04-.34c-.4-.1-.87-.15-1.4-.15-1.15-.01-2.11.36-2.86 1.1-.76.73-1.14 1.85-1.18 3.34.01 1.36.37 2.42 1.08 3.2.71.77 1.7 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.09-.32z" fill="#0277bd"/></symbol><symbol viewBox="0 0 300 300" id="cabal" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -822.52)" fill-rule="evenodd" color="#000"><rect transform="matrix(-.98339 .18149 .60192 .79856 0 0)" x="405.55" y="967.22" width="107.25" height="156.59" rx="12.306" ry="12.31" fill="#2d9bbd"/><rect transform="matrix(-.98528 .17093 -.59175 .80612 0 0)" x="-1156.5" y="1461.9" width="108.34" height="123.15" rx="10.69" ry="12.31" fill="#4a4bcd"/><path d="M52.112 965.158c-1.343 3.515-26.292 23.248-25.744 27.277.548 4.03 29.812 16.023 32.04 19.027s10.545 41.668 13.603 42.5 18.828-31.274 21.548-32.932c2.72-1.658 32.808 2.503 34.15-1.01 1.343-3.515-18.174-35.352-18.721-39.381-.548-4.03 9.732-40.12 7.502-43.125-2.229-3.005-30.06 9.427-33.118 8.594-3.059-.833-26.793-27.3-29.514-25.643-2.72 1.657-.405 41.177-1.747 44.693z" fill="#2e5bc1"/></g></symbol><symbol viewBox="0 0 24 24" id="cake" xmlns="http://www.w3.org/2000/svg"><path d="M12.254 6.621a1.807 1.807 0 0 0 1.808-1.807c0-.344-.09-.66-.262-.932l-1.546-2.684-1.546 2.684a1.72 1.72 0 0 0-.262.932 1.808 1.808 0 0 0 1.808 1.807m4.158 9.04l-.967-.976-.976.976c-1.175 1.166-3.236 1.175-4.42 0l-.959-.976-.994.976a3.134 3.134 0 0 1-3.977.353v4.167a.904.904 0 0 0 .904.904h14.463a.904.904 0 0 0 .904-.904v-4.167a3.134 3.134 0 0 1-3.977-.353m1.265-6.328h-4.52V7.525H11.35v1.808H6.83a2.712 2.712 0 0 0-2.711 2.712v1.392c0 .977.795 1.772 1.771 1.772.489 0 .94-.18 1.248-.515l1.952-1.926 1.908 1.926c.669.669 1.835.669 2.504 0l1.916-1.926 1.944 1.926c.316.334.768.515 1.247.515.976 0 1.78-.795 1.78-1.772v-1.392a2.712 2.712 0 0 0-2.711-2.712z" fill="#ff7043" stroke-width=".904"/></symbol><symbol viewBox="0 0 24 24" id="certificate" xmlns="http://www.w3.org/2000/svg"><path d="M4 3c-1.11 0-2 .89-2 2v10a2 2 0 0 0 2 2h8v5l3-3 3 3v-5h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4m8 2l3 2 3-2v3.5l3 1.5-3 1.5V15l-3-2-3 2v-3.5L9 10l3-1.5V5M4 5h5v2H4V5m0 4h3v2H4V9m0 4h5v2H4v-2z" fill="#ff5722"/></symbol><symbol viewBox="0 0 24 24" id="changelog" xmlns="http://www.w3.org/2000/svg"><path d="M11 7v5.11l4.71 2.79.79-1.28-4-2.37V7m0-5C8.97 2 5.91 3.92 4.27 6.77L2 4.5V11h6.5L5.75 8.25C6.96 5.73 9.5 4 12.5 4a7.5 7.5 0 0 1 7.5 7.5 7.5 7.5 0 0 1-7.5 7.5c-3.27 0-6.03-2.09-7.06-5h-2.1c1.1 4.03 4.77 7 9.16 7 5.24 0 9.5-4.25 9.5-9.5A9.5 9.5 0 0 0 12.5 2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="clojure" xmlns="http://www.w3.org/2000/svg"><path d="M3.355 1.78c-.845 0-1.525.68-1.525 1.525v17.441c0 .845.68 1.525 1.525 1.525h17.442c.845 0 1.525-.68 1.525-1.525V3.305c0-.845-.68-1.526-1.525-1.526H3.355zm6.168 2.572h1.963l6.368 14.931H15.93l-3.38-8.086-3.349 8.086H7.21l4.346-10.38-2.032-4.551z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="cmake" xmlns="http://www.w3.org/2000/svg"><path d="M11.99 2.965L2.977 20.999l9.874-8.47-.863-9.564z" fill="#1e88e5"/><path d="M12.007 2.963l.002.29 1.312 14.498-.001.006.023.26 7.362 2.979h.416l-.158-.311-.114-.228h-.002l-8.84-17.494z" fill="#e53935"/><path d="M8.607 16.11L2.98 20.995h17.743v-.016L8.607 16.11z" fill="#7cb342"/></symbol><symbol class="bfmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate" xmlns="http://www.w3.org/2000/svg"><path class="bfsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#eee" stroke-width="2.849"/></symbol><symbol class="bgmain_logo__svg" viewBox="0 0 300 300.00001" id="code-climate_light" xmlns="http://www.w3.org/2000/svg"><path class="bgsymbol" d="M196.19 75.562l-51.846 51.561 30.766 30.766 21.08-21.08 59.252 59.537 30.481-30.766zm-61.246 60.961l-30.481-30.481-78.053 78.053-11.964 11.964 30.766 30.766 11.964-12.249 39.596-39.312 7.691-7.691 30.481 30.48 28.772 28.773 30.766-30.766-28.772-28.772z" fill="#455a64" stroke-width="2.849"/></symbol><symbol viewBox="0 0 24 24" id="coffee" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="coldfusion" xmlns="http://www.w3.org/2000/svg"><rect transform="rotate(90)" x="2.283" y="-21.86" width="19.487" height="19.487" ry="0" fill="#0d3858" stroke="#4dd0e1" stroke-width=".7"/><text x="6.653" y="16.426" fill="#4dd0e1" font-family="Calibri" font-size="29.001" font-weight="bold" letter-spacing="0" stroke-width=".725" word-spacing="0" style="line-height:1.25"><tspan x="6.653" y="16.426" font-family="'Segoe UI'" font-size="10.634" font-weight="normal">C<tspan font-size="11.844">f</tspan></tspan></text></symbol><symbol viewBox="0 0 24 24" id="conduct" xmlns="http://www.w3.org/2000/svg"><path d="M10 17l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9m-6-6a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#cddc39"/></symbol><symbol viewBox="0 0 24 24" id="console" xmlns="http://www.w3.org/2000/svg"><path d="M20 19V7H4v12h16m0-16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16m-7 14v-2h5v2h-5m-3.42-4L5.57 9H8.4l3.3 3.3c.39.39.39 1.03 0 1.42L8.42 17H5.59z" fill="#ff7043"/></symbol><symbol viewBox="0 0 24 24" id="contributing" xmlns="http://www.w3.org/2000/svg"><path d="M17 9H7V7h10m0 6H7v-2h10m-3 6H7v-2h7M12 3a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m7 0h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="cpp" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C1.56 15.77 1 14.16 1 12.21c.05-2.31.72-4.08 2-5.32C4.32 5.64 5.96 5 7.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M11 11h2V9h2v2h2v2h-2v2h-2v-2h-2v-2m7 0h2V9h2v2h2v2h-2v2h-2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="credits" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v2H3V3m4 4h10v2H7V7m-4 4h18v2H3v-2m4 4h10v2H7v-2m-4 4h18v2H3v-2z" fill="#9ccc65"/></symbol><symbol viewBox="0 0 200 200" id="crystal" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" stroke-width="1.153" fill="#cfd8dc"/></symbol><symbol viewBox="0 0 200 200" id="crystal_light" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:none}</style><path d="M179.363 121.67l-57.623 57.507c-.23.23-.576.346-.806.23l-78.713-21.09c-.346-.115-.577-.345-.577-.576L20.44 79.144c-.115-.345 0-.576.23-.806L78.294 20.83c.23-.23.576-.346.807-.23l78.713 21.09c.345.114.576.345.576.575l21.09 78.597c.23.346.115.577-.115.807zM102.148 59.09l-77.33 20.63c-.115 0-.23.23-.115.345l56.586 56.47c.115.115.346.115.346-.115l20.744-77.215c.115 0-.115-.23-.23-.116z" fill="#37474f" stroke-width="1.153"/></symbol><symbol viewBox="0 0 24 24" id="csharp" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 15.97l.41 2.44c-.26.14-.68.27-1.24.39-.57.13-1.24.2-2.01.2-2.21-.04-3.87-.7-4.98-1.96C2.56 15.77 2 14.16 2 12.21c.05-2.31.72-4.08 2-5.32C5.32 5.64 6.96 5 8.94 5c.75 0 1.4.07 1.94.19s.94.25 1.2.4l-.58 2.49-1.06-.34c-.4-.1-.86-.15-1.39-.15-1.16-.01-2.12.36-2.87 1.1-.76.73-1.15 1.85-1.18 3.34 0 1.36.37 2.42 1.08 3.2.71.77 1.71 1.17 2.99 1.18l1.33-.12c.43-.08.79-.19 1.1-.32M13.89 19l.61-4H13l.34-2h1.5l.32-2h-1.5L14 9h1.5l.61-4h2l-.61 4h1l.61-4h2l-.61 4H22l-.34 2h-1.5l-.32 2h1.5L21 15h-1.5l-.61 4h-2l.61-4h-1l-.61 4h-2m2.95-6h1l.32-2h-1l-.32 2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="css" xmlns="http://www.w3.org/2000/svg"><path d="M5 3l-.65 3.34h13.59L17.5 8.5H3.92l-.66 3.33h13.59l-.76 3.81-5.48 1.81-4.75-1.81.33-1.64H2.85l-.79 4 7.85 3 9.05-3 1.2-6.03.24-1.21L21.94 3H5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="css-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#42a5f5"/><path d="M4.676 3l-.488 2.51h10.211l-.33 1.623H3.864l-.496 2.502H13.58l-.57 2.863-4.119 1.36-3.569-1.36.248-1.232H3.06l-.593 3.005 5.898 2.254 6.8-2.254.902-4.53.18-.91L17.406 3H4.675z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 33 33" id="cucumber" xmlns="http://www.w3.org/2000/svg"><title>cucumber-mark-transparent-pips</title><g transform="translate(0 -5)" fill="none" fill-rule="evenodd"><path d="M-4-1h40v40H-4z"/><path d="M16.641 7.092c-7.028 0-12.714 5.686-12.714 12.714 0 6.187 4.435 11.327 10.288 12.471v3.64C21.824 34.77 28.561 28.73 29.063 20.8c.303-4.772-2.076-9.644-6.09-12.01a10.575 10.575 0 0 0-1.455-.728l-.243-.097c-.223-.082-.448-.175-.68-.242a12.614 12.614 0 0 0-3.954-.632zm2.62 4.707a1.387 1.387 0 0 0-1.213.485c-.233.31-.379.611-.534.923-.466 1.087-.31 2.251.388 3.105 1.087-.233 2.01-.927 2.475-2.014a2.45 2.45 0 0 0 .243-1.02c.048-.824-.634-1.404-1.359-1.479zm-5.654.073c-.708.068-1.382.63-1.382 1.407 0 .31.087.709.243 1.02.466 1.086 1.46 1.78 2.546 2.013.621-.854.782-2.018.316-3.105-.155-.311-.3-.617-.534-.85a1.364 1.364 0 0 0-1.188-.485zm-3.809 3.735c-1.224.063-1.77 1.602-.752 2.402.31.233.612.403.922.559 1.087.466 2.344.306 3.275-.316-.233-1.009-1.023-1.936-2.11-2.402-.388-.155-.703-.243-1.092-.243-.087-.009-.161-.004-.243 0zm11.961 4.708a3.551 3.551 0 0 0-2.013.582c.233 1.01 1.023 1.936 2.11 2.401.389.156.705.244 1.093.244 1.397.077 2.08-1.65.994-2.427-.31-.233-.611-.379-.922-.534a3.354 3.354 0 0 0-1.262-.266zm-10.603.072a3.376 3.376 0 0 0-1.261.267c-.389.155-.69.325-.923.558-1.009.854-.33 2.48 1.068 2.402.388 0 .782-.087 1.092-.243 1.087-.465 1.859-1.392 2.014-2.401a3.474 3.474 0 0 0-1.99-.582zm3.931 2.378c-1.087.233-2.009.927-2.475 2.014-.155.31-.243.684-.243.995-.077 1.32 1.724 2.028 2.5 1.02.233-.312.378-.613.534-.923.466-1.01.306-2.174-.316-3.106zm2.887.073c-.621.854-.781 2.019-.315 3.106.155.31.3.615.534.848.854.932 2.65.243 2.572-.921 0-.31-.088-.71-.243-1.02-.466-1.087-1.46-1.78-2.547-2.013z" fill="#4caf50" stroke-width=".776"/></g></symbol><symbol id="cuda" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style>.bust0{fill:#76b900}</style><title>NVIDIA-Logo</title><path id="buEye_Mark" class="bust0" d="M76.362 75.199V64.116c1.095-.068 2.19-.137 3.284-.137 30.377-.958 50.286 26.135 50.286 26.135s-21.483 29.83-44.539 29.83c-3.079 0-6.089-.48-8.962-1.438v-33.66c11.836 1.436 14.23 6.636 21.277 18.471l15.804-13.273s-11.562-15.12-30.992-15.12c-2.053-.068-4.105.069-6.158.274m0-36.67v16.556l3.284-.205c42.213-1.437 69.784 34.618 69.784 34.618s-31.608 38.45-64.516 38.45c-2.873 0-5.678-.274-8.483-.753v10.262c2.326.274 4.72.48 7.046.48 30.65 0 52.817-15.668 74.3-34.14 3.558 2.874 18.13 9.784 21.14 12.794-20.388 17.104-67.937 30.856-94.893 30.856-2.6 0-5.062-.137-7.525-.41v14.436h116.44V38.532zm0 79.977v8.757C48.038 122.2 40.17 92.712 40.17 92.712s13.615-15.05 36.192-17.514v9.579h-.068c-11.836-1.437-21.14 9.646-21.14 9.646s5.268 18.678 21.209 24.082M26.077 91.481S42.839 66.714 76.43 64.115v-9.03C39.213 58.094 7.057 89.565 7.057 89.565s18.199 52.68 69.305 57.47v-9.579c-37.492-4.652-50.286-45.975-50.286-45.975z" fill="#8bc34a" stroke-width=".684"/></symbol><symbol viewBox="0 0 24 24" id="dart" xmlns="http://www.w3.org/2000/svg"><title>Dart</title><path d="M12.486 1.385a.978.978 0 0 0-.682.281l-.01.007-6.387 3.692 6.371 6.372v.004l7.659 7.659 1.46-2.63-5.265-12.64-2.456-2.457a.972.972 0 0 0-.69-.288z" fill="#00ca94"/><path d="M5.422 5.35L1.73 11.733l-.007.01a.967.967 0 0 0 .006 1.371l3.059 3.061 11.963 4.706 2.704-1.502-.073-.073-.018.002-7.5-7.512h-.01L5.423 5.35z" fill="#1565c0"/><path d="M5.405 5.353l6.518 6.525h.01l7.502 7.51 2.855-.544.005-8.449-3.016-2.955c-.66-.647-1.675-1.064-2.695-1.202l.002-.032-11.181-.853z" fill="#1565c0"/><path d="M5.414 5.361l6.521 6.522v.009l7.506 7.506-.546 2.855h-8.448l-2.954-3.017c-.647-.66-1.064-1.676-1.2-2.696l-.033.003L5.414 5.36z" fill="#00ee94"/></symbol><symbol viewBox="0 0 24 24" id="database" xmlns="http://www.w3.org/2000/svg"><path d="M12 3C7.58 3 4 4.79 4 7s3.58 4 8 4 8-1.79 8-4-3.58-4-8-4M4 9v3c0 2.21 3.58 4 8 4s8-1.79 8-4V9c0 2.21-3.58 4-8 4s-8-1.79-8-4m0 5v3c0 2.21 3.58 4 8 4s8-1.79 8-4v-3c0 2.21-3.58 4-8 4s-8-1.79-8-4z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="diff" xmlns="http://www.w3.org/2000/svg"><path d="M3 1c-1.11 0-2 .89-2 2v11c0 1.11.89 2 2 2h2v-2H3V3h11v2h2V3c0-1.11-.89-2-2-2H3m6 6c-1.11 0-2 .89-2 2v2h2V9h2V7H9m4 0v2h1v1h2V7h-3m5 0v2h2v11H9v-2H7v2c0 1.11.89 2 2 2h11c1.11 0 2-.89 2-2V9c0-1.11-.89-2-2-2h-2m-4 5v2h-2v2h2c1.11 0 2-.89 2-2v-2h-2m-7 1v3h3v-2H9v-1H7z" fill="#42a5f5"/></symbol><symbol id="docker" viewBox="0 0 41 34.5" xmlns="http://www.w3.org/2000/svg"><style id="bystyle2">.byst0{fill:#fff}.byst1{clip-path:url(#bySVGID_4_)}</style><g id="byg34" transform="translate(.292 1.9)" fill="#0087c9"><g id="byg32"><g id="byg30"><g id="byg28"><g id="byg26"><g id="byg9"><path id="bySVGID_1_" class="byst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2c1.2 0 2.1.9 2.1 2s-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g></g></g></g></g></g></symbol><symbol viewBox="0 0 24 24" id="document" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m9 16v-2H6v2h9m3-4v-2H6v2h12z" fill="#42a5f5"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone" xmlns="http://www.w3.org/2000/svg"><g fill="#e0e0e0" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 200 200" id="drone_light" xmlns="http://www.w3.org/2000/svg"><g fill="#424242" transform="translate(9.063 22.346) scale(.71044)"><path d="M128.22.723C32.095.723.39 84.566.39 115.222h77.928S89.36 75.275 128.22 75.275s49.906 39.947 49.906 39.947h77.476c0-30.66-31.257-114.5-127.38-114.5m98.82 134.45h-48.914s-8.55 39.946-49.906 39.946c-41.355 0-49.902-39.948-49.902-39.948H30.255c0 10.25 37.727 82.708 98.443 82.708 60.714 0 98.344-59.604 98.344-82.708"/><circle cx="128" cy="126.08" r="32.768"/></g></symbol><symbol viewBox="0 0 3473 3473" id="editorconfig" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><defs id="ccdefs4"><style id="ccstyle2">.ccfil2{fill:#020202}.ccfil0{fill:#e3e3f8}.ccfil5{fill:#efefef}.ccfil6{fill:#faf1f1}.ccfil3{fill:#fdf2f2}.ccfil1{fill:#fdfdfd}.ccfil4{fill:#fef3f3}</style></defs><g id="ccLayer_x0020_1" transform="matrix(.8945 0 0 .8945 138.649 275.985)"><g id="cc_631799120"><g id="ccg11"><path class="ccfil0" d="M967 1895c46-30 84-105 61-158-63 27-60 89-61 158z" id="ccpath7" fill="#e3e3f8"/><path class="ccfil0" d="M1679 2067c50-16 98-72 71-130-39 27-64 64-71 130z" id="ccpath9" fill="#e3e3f8"/></g><g id="ccg21"><path class="ccfil1" d="M280 2895c0 63 16 131 60 155 162 91 730 20 923-23 101-23 183-98 278-139 214-93 369-168 540-293 124-91 321-347 342-500l-169-38c-4 172-43 211-196 251-103 28-304 34-409 16-139-23-202-96-265-179-122-162 27-275-166-286-203 249-561 70-718 45-67 97-224 727-222 871 97-33 158 3 245 37 308 119 39 224-84 193-84-20-110-75-159-110z" id="ccpath13" fill="#fdfdfd"/><path class="ccfil1" d="M683 1458c125 24 236 76 342 129 173 86 204 74 220 194 2 22-2 34 61 54 106 33-61-26 223-25 169 1 556 69 681 148 52 33 42 75 218 70-2-207-57-516-138-706-99-230-230-265-497-351-156-50-614-105-756-17-133 83-158 182-282 356-36 51-49 90-72 148z" id="ccpath15" fill="#fdfdfd"/><path class="ccfil1" d="M1784 1883c100 41-5 306-144 242-45-127 62-199 91-256-60-9-231-36-282-17-66 25-81 166-47 232 160 314 867 247 792 3-30-99-58-115-159-149-81-27-162-55-251-55z" id="ccpath17" fill="#fdfdfd"/><path class="ccfil1" d="M527 1848c80 77 261 89 378 95 15-155 28-271 152-262 61 83 29 181-35 244 109-1 172-83 156-202-92-66-371-198-511-217-39 42-135 272-140 342z" id="ccpath19" fill="#fdfdfd"/></g><path class="ccfil2" d="M339 2838c66-6 238 44 252 100-107 13-243 3-252-100zm-59 57c49 35 75 90 159 110 123 31 392-74 84-193-87-34-148-70-245-37-2-144 155-774 222-871 157 25 515 204 718-45 193 11 44 124 166 286 63 83 126 156 265 179 105 18 306 12 409-16 153-40 192-79 196-251l169 38c-21 153-218 409-342 500-171 125-326 200-540 293-95 41-177 116-278 139-193 43-761 114-923 23-44-24-60-92-60-155zm1399-828c7-66 32-103 71-130 27 58-21 114-71 130zm105-184c89 0 170 28 251 55 101 34 129 50 159 149 75 244-632 311-792-3-34-66-19-207 47-232 51-19 222 8 282 17-29 57-136 129-91 256 139 64 244-201 144-242zm-817 12c1-69-2-131 61-158 23 53-15 128-61 158zm-440-47c5-70 101-300 140-342 140 19 419 151 511 217 16 119-47 201-156 202 64-63 96-161 35-244-124-9-137 107-152 262-117-6-298-18-378-95zm-100-80c-37-102-37-261 120-274l-80 223c-21 48-21 37-40 51zm256-310c23-58 36-97 72-148 124-174 149-273 282-356 142-88 600-33 756 17 267 86 398 121 497 351 81 190 136 499 138 706-176 5-166-37-218-70-125-79-512-147-681-148-284-1-117 58-223 25-63-20-59-32-61-54-16-120-47-108-220-194-106-53-217-105-342-129zm1770-49c-19-63 16-59 77-102 35-25 63-51 106-75 161-90 461-105 589 2 52 43 137 127 124 237-27 219-177 339-300 439-125 102-333 207-548 137-18-44-4-323-25-426-19-92-9-102 44-157 156-162 494-280 686-141 81 60 58 83 100 129 52-56-45-244-403-232-243 8-348 198-450 189zM997 840c5-139 133-427 261-527 155-120 317-233 555-98 59 33 56 50 62 132 5 79-2 108-22 172-158 510-290 217-796 338 19-166 163-314 243-391 137-133 236-219 442-191 57 95 63 155-6 266-92 148-115 139-101 240 72-18 94-88 127-158 201-420-91-471-270-394-120 51-334 287-404 429-14 28-29 64-42 95zm792 21c21-125 145-156 145-541 0-166-204-315-471-204-229 94-264 166-386 350-115 174-111 365-210 526-29 46-55 62-87 108-23 34-40 77-63 117-47 77-95 133-133 225-120 3-221 5-233 129-16 170 64 212 64 276-1 69-281 765-203 1180 22 114 97 115 217 129 289 35 664 23 923-81l470-225c119-67 319-194 408-287 63-65 96-120 150-197 74-108 76-106 92-253 98 18 281 61 342 114-7 69-41 36-41 98 39 1 104-48 120-102-41-60-84-50-143-98 47-37 132-54 197-81 140-58 379-234 438-394 47-129 12-344-64-428-80-88-266-133-418-133-181 0-368 130-514 186-56-49-60-105-101-159-47-64-353-224-499-255z" id="ccpath23" fill="#020202"/><path class="ccfil3" d="M2453 1409c102 9 207-181 450-189 358-12 455 176 403 232-42-46-19-69-100-129-192-139-530-21-686 141-53 55-63 65-44 157 21 103 7 382 25 426 215 70 423-35 548-137 123-100 273-220 300-439 13-110-72-194-124-237-128-107-428-92-589-2-43 24-71 50-106 75-61 43-96 39-77 102z" id="ccpath25" fill="#fdf2f2"/><path class="ccfil4" d="M997 840l49-87c13-31 28-67 42-95 70-142 284-378 404-429 179-77 471-26 270 394-33 70-55 140-127 158-14-101 9-92 101-240 69-111 63-171 6-266-206-28-305 58-442 191-80 77-224 225-243 391 506-121 638 172 796-338 20-64 27-93 22-172-6-82-3-99-62-132-238-135-400-22-555 98-128 100-256 388-261 527z" id="ccpath27" fill="#fef3f3"/><path class="ccfil5" d="M427 1768c19-14 19-3 40-51l80-223c-157 13-157 172-120 274z" id="ccpath29" fill="#efefef"/><path class="ccfil6" d="M591 2938c-14-56-186-106-252-100 9 103 145 113 252 100z" id="ccpath31" fill="#faf1f1"/></g></g></symbol><symbol viewBox="0 0 24 24" id="elixir" xmlns="http://www.w3.org/2000/svg"><path d="M12.431 22.383c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.774-8.172 4.916-10.91 1.014-1.295 2.931-2.321 2.931-2.321s-.982 5.238 1.683 7.318c2.365 1.847 4.105 4.25 4.105 6.363 0 4.232-2.784 7.68-6.645 7.68z" fill="#9575cd" stroke-width="1.256"/></symbol><symbol viewBox="0 0 323.00001 322.99999" id="elm" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.8053 0 0 .8053 30.106 31.524)"><path fill="#f0ad00" d="M160.8 153.865l68.028-68.03H92.77z"/><path fill="#7fd13b" d="M160.983 5.098H12.033l68.524 68.525H229.51z"/><path fill="#7fd13b" stroke-width=".974" d="M243.906 88.021l74.136 74.137-74.474 74.475-74.137-74.137z"/><path fill="#60b5cc" d="M318.2 145.045V5.098H178.252z"/><path fill="#5a6378" d="M152.164 162.499L3.4 13.733v297.533z"/><path fill="#f0ad00" d="M252.205 245.27l65.995 65.996v-131.99z"/><path fill="#60b5cc" d="M160.8 171.134L12.034 319.899h297.53z"/></g></symbol><symbol viewBox="0 0 24 24" id="email" xmlns="http://www.w3.org/2000/svg"><path d="M20 8l-8 5-8-5V6l8 5 8-5m0-2H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 30 30" id="erlang" xmlns="http://www.w3.org/2000/svg"><path style="line-height:1.25;-inkscape-font-specification:'Wide Latin'" d="M5.217 4.367c-.048.052-.097.1-.144.153C2.697 7.182 1.51 10.798 1.51 15.366c0 4.418 1.156 7.862 3.46 10.34h19.414c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52-1.454 1.381c-.866.773-.845.931-2.314 1.78-1.496.674-3.04.966-4.634.966-2.516 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.097-6.68l17.458.067-.182-1.472s-.847-7.129-2.542-9.372zm8.76.846c1.565 0 3.22.535 3.96 1.471.742.937.932 1.667.974 3.524H9.12c.111-1.955.436-2.81 1.372-3.697.937-.888 2.03-1.298 3.484-1.298z" font-weight="400" font-size="48" font-family="Wide Latin" letter-spacing="0" word-spacing="0" fill="#f44336" stroke-width=".97"/></symbol><symbol viewBox="0 0 299.99999 300.00001" id="eslint" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-2.88 18.438) scale(1.0344)"><path d="M97.021 99.016l48.432-27.962c1.212-.7 2.706-.7 3.918 0l48.433 27.962a3.92 3.92 0 0 1 1.959 3.393v55.924a3.924 3.924 0 0 1-1.959 3.394l-48.433 27.962c-1.212.7-2.706.7-3.918 0l-48.432-27.962a3.92 3.92 0 0 1-1.959-3.394v-55.924a3.922 3.922 0 0 1 1.959-3.393" fill="#7986cb"/><path d="M273.34 124.49L215.473 23.82c-2.102-3.64-5.985-6.325-10.188-6.325H89.545c-4.204 0-8.088 2.685-10.19 6.325L21.488 124.27c-2.102 3.641-2.102 8.236 0 11.877l57.867 99.847c2.102 3.64 5.986 5.501 10.19 5.501h115.74c4.203 0 8.087-1.805 10.188-5.446l57.867-100.01c2.104-3.639 2.104-7.907.001-11.547m-47.917 48.41c0 1.48-.891 2.849-2.174 3.59l-73.71 42.527a4.194 4.194 0 0 1-4.17 0l-73.767-42.527c-1.282-.741-2.179-2.109-2.179-3.59V87.847c0-1.481.884-2.849 2.167-3.59l73.707-42.527a4.185 4.185 0 0 1 4.168 0l73.772 42.527c1.283.741 2.186 2.109 2.186 3.59z" fill="#3f51b5"/></g></symbol><symbol viewBox="0 0 24 24" id="exe" xmlns="http://www.w3.org/2000/svg"><path d="M19 4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14m0 14V8H5v10h14z" fill="#e64a19"/></symbol><symbol viewBox="0 0 24 24" id="favicon" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2 9.19 8.62 2 9.24l5.45 4.73L5.82 21 12 17.27z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="file" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m5 2H6v16h12v-9h-7V4z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 400 400" id="firebase" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 103)"><path d="M72.55 208.77l44.456-292.29 56.209 90.445L195.49-37.57 330.6 209.28z" fill="#ffa712"/><path d="M195.7 276.73l134.9-67.45-46.5-224.83L72.55 208.77z" fill="#fcca3f"/><path d="M173.22 6.932L72.56 208.772l136.35-144.58z" fill="#f6820c"/></g></symbol><symbol viewBox="0 0 24 24" id="flash" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cma"><stop offset="0" stop-color="#d92f3c"/><stop offset="1" stop-color="#791223"/></linearGradient><linearGradient xlink:href="#cma" id="cmb" x1="2.373" y1="12.027" x2="21.86" y2="12.027" gradientUnits="userSpaceOnUse" gradientTransform="translate(-.09 -24.144)"/></defs><rect width="19.487" height="19.487" x="2.283" y="-21.86" transform="rotate(90)" ry="0" fill="url(#cmb)"/><path style="line-height:125%" d="M16.802 5.768l-.013.002a6.43 6.43 0 0 0-1.182.192 5.062 5.062 0 0 0-1.494.718c-.428.323-.817.72-1.17 1.191-.34.48-.682 1.032-1.022 1.66-.12.228-.233.424-.35.636v.002h-.004l-1.34 2.394-.005-.002c-.238.443-.461.847-.665 1.198a4.358 4.358 0 0 1-.716.94 2.79 2.79 0 0 1-.907.594c-.072.027-.161.042-.242.063h-.989v2.414h.989v-.002a6.427 6.427 0 0 0 1.185-.192 5.062 5.062 0 0 0 1.494-.718 5.94 5.94 0 0 0 1.171-1.191c.34-.48.681-1.033 1.021-1.66.12-.228.235-.425.353-.637l.006.002.003-.005.037-.066h2.53v.002h1.124v-2.408h-.33v-.001h-1.98c.22-.407.432-.789.621-1.115.214-.37.452-.682.717-.94a2.79 2.79 0 0 1 .906-.594c.07-.027.16-.041.239-.061h.992V8.18h-.002V5.77h-.977v-.002z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol class="cnflow-logo" viewBox="0 0 299.99999 300" id="flow" xmlns="http://www.w3.org/2000/svg"><title>Flow logo</title><path d="M38.75 33.427l77.461 77.47H54.436l61.145 61.16H38.437l93.462 93.478v-77.158l.01-.01v-77.47h-.01V66.982h46.691l20.394 20.393H153.57v76.531h22.05l24.474 24.473h-15.806l-.01-.01v.01h-31.665l-.01-.01v.01h-.313l.313.313v77.148h109.149l-39.2-39.2v-15.806l8.465 8.466v-77.37h-15.682l.017-38.191 30.09 30.086V56.362h-64.874l-22.94-22.934H113.71z" fill="#fbc02d" fill-opacity=".976" stroke-width=".955" class="cnflow-logo-mark"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="coa" x1="-388.15%" x2="237.68%" y1="-144.18%" y2="430.41%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cob" x1="72.945%" x2="-97.052%" y1="84.424%" y2="-147.7%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="coc" x1="-283.88%" x2="287.54%" y1="-693.6%" y2="101.71%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cod" x1="-821.19%" x2="101.99%" y1="-469.05%" y2="288.24%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coe" x1="-140.36%" x2="419.01%" y1="-230.93%" y2="261.98%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cof" x1="191.08%" x2="20.358%" y1="253.95%" y2="20.403%"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cog" x1="-388.09%" x2="237.67%" y1="-173.85%" y2="518.99%"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="coi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#coa"/><linearGradient id="coj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cob"/><linearGradient id="cok" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#coc"/><linearGradient id="col" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cod"/><linearGradient id="com" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#coe"/><linearGradient id="con" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cof"/><linearGradient id="coo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cog"/><linearGradient id="cop" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#coh"/><linearGradient id="coh" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#coi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#coj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cok)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#col)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#com)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#con)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#coo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cop)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-aurelia-open" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="cpi" x1="-31.824" x2="19.682" y1="-11.741" y2="35.548" gradientTransform="scale(.95818 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpa"/><linearGradient id="cpa" x1="-3.881" x2="2.377" y1="-1.442" y2="4.304"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpj" x1="12.022" x2="-15.716" y1="13.922" y2="-23.952" gradientTransform="scale(.96226 1.0392)" gradientUnits="userSpaceOnUse" xlink:href="#cpb"/><linearGradient id="cpb" x1=".729" x2="-.971" y1=".844" y2="-1.477"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpk" x1="-23.39" x2="23.931" y1="-57.289" y2="8.573" gradientTransform="scale(1.0429 .95884)" gradientUnits="userSpaceOnUse" xlink:href="#cpc"/><linearGradient id="cpc" x1="-2.839" x2="2.875" y1="-6.936" y2="1.017"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpl" x1="-53.331" x2="6.771" y1="-30.517" y2="18.785" gradientTransform="scale(.99898 1.001)" gradientUnits="userSpaceOnUse" xlink:href="#cpd"/><linearGradient id="cpd" x1="-8.212" x2="1.02" y1="-4.691" y2="2.882"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpm" x1="-14.029" x2="41.998" y1="-23.111" y2="26.259" gradientTransform="scale(1.0003 .99965)" gradientUnits="userSpaceOnUse" xlink:href="#cpe"/><linearGradient id="cpe" x1="-1.404" x2="4.19" y1="-2.309" y2="2.62"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpn" x1="31.177" x2="3.37" y1="41.442" y2="3.402" gradientTransform="scale(.96254 1.0389)" gradientUnits="userSpaceOnUse" xlink:href="#cpf"/><linearGradient id="cpf" x1="1.911" x2=".204" y1="2.539" y2=".204"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".29"/><stop stop-color="#CD0F7E" offset=".84"/><stop stop-color="#ED2C89" offset="1"/></linearGradient><linearGradient id="cpo" x1="-31.905" x2="19.599" y1="-14.258" y2="42.767" gradientTransform="scale(.95823 1.0436)" gradientUnits="userSpaceOnUse" xlink:href="#cpg"/><linearGradient id="cpg" x1="-3.881" x2="2.377" y1="-1.738" y2="5.19"><stop stop-color="#C06FBB" offset="0"/><stop stop-color="#6E4D9B" offset="1"/></linearGradient><linearGradient id="cpp" x1="4.301" x2="34.534" y1="34.41" y2="4.514" gradientTransform="scale(1.002 .99796)" gradientUnits="userSpaceOnUse" xlink:href="#cph"/><linearGradient id="cph" x1=".112" x2=".901" y1=".897" y2=".116"><stop stop-color="#6E4D9B" offset="0"/><stop stop-color="#77327A" offset=".14"/><stop stop-color="#B31777" offset=".53"/><stop stop-color="#CD0F7E" offset=".79"/><stop stop-color="#ED2C89" offset="1"/></linearGradient></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f06292" fill-rule="nonzero"/><g transform="matrix(.31022 .0619 -.0619 .31022 11.807 7.546)" fill="none"><path d="M8.002 6.127L4.117 8.719.116 2.723 4 .13z" transform="rotate(-11.284 17.839 -78.732)" fill="url(#cpi)"/><path d="M9.179 1.887l6.637 9.946-7.906 5.276-6.637-9.946L.115 5.43 8.02.153z" transform="rotate(-11.284 129.49 -99.884)" fill="url(#cpj)"/><path d="M7.3 1.88l1.462 2.189-6.018 4.015L.124 4.16l1.315-.877L6.143.144z" transform="rotate(-11.284 167.2 -62.32)" fill="url(#cpk)"/><path d="M2.328 1.146L4.016.02l2.619 3.925L2.75 6.537 1.29 4.347l2.197-1.466zm-1.04 3.201L.132 2.612l2.197-1.466 1.158 1.735z" transform="rotate(-11.284 104.37 -149.22)" fill="url(#cpl)"/><path d="M5.346 9.155l-1.315.877L.03 4.035 6.047.019l2.805 4.204L4.15 7.36l4.703-3.138 1.197 1.793z" transform="rotate(-11.284 81.819 7.645)" fill="url(#cpm)"/><path d="M14.533 9.934l1.197 1.793-7.907 5.276-1.196-1.793L.052 5.358 7.958.082z" transform="rotate(-11.284 17.141 -7.825)" fill="url(#cpn)"/><path d="M6.235 7.177L4.038 8.643 2.84 6.849.036 2.646 3.92.053 7.923 6.05z" transform="rotate(-11.284 18.188 -79.174)" fill="url(#cpo)"/><path d="M18.955 35.925L17.48 34.45l3.998-3.998 1.475 1.475z" fill="#714896"/><path d="M33.33 21.55l-1.475-1.474 1.867-1.868 1.475 1.475z" fill="#6f4795"/><path d="M7.12 24.09l-1.525-1.525 3.998-3.998 1.525 1.525z" fill="#88519f"/><path d="M21.495 9.714L19.97 8.19l1.868-1.868 1.524 1.525z" fill="#85509e"/><path d="M31.418 23.462l-6.72 6.72-1.475-1.474 6.72-6.721z" fill="#8d166a"/><path d="M18.058 10.101l1.525 1.525-6.721 6.72-1.525-1.524z" fill="#a70d6f"/><path d="M2.375 11.769l1.9 1.9-1.9 1.901-1.901-1.9z" fill="#9e61ad"/><path d="M15.523 36.482l1.9 1.9-1.9 1.901-1.9-1.9z" fill="#8053a3"/><path d="M8.372 38.294L.017 29.876 29.749.08l8.636 8.201z" transform="translate(1.823 1.548)" fill="url(#cpp)"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#cddc39" fill-rule="nonzero"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#cddc39"/><path d="M11.185 9.613h5.346v2.9l3.782-3.775 3.775 3.775-3.775 3.782h2.9v5.346h-5.346v-5.346h2.446l-3.782-3.782v2.446h-5.346V9.613m0 6.682h5.346v5.346h-5.346z" fill="#f0f4c3" stroke-width=".668"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00acc1" fill-rule="nonzero"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-config-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00acc1"/><path d="M17.293 17.786a2.308 2.308 0 0 1-2.308-2.308 2.308 2.308 0 0 1 2.308-2.307 2.308 2.308 0 0 1 2.308 2.307 2.308 2.308 0 0 1-2.308 2.308m4.899-1.668c.026-.211.046-.422.046-.64 0-.217-.02-.435-.046-.659l1.391-1.075a.333.333 0 0 0 .08-.422l-1.32-2.28c-.079-.146-.257-.205-.402-.146l-1.641.66a4.779 4.779 0 0 0-1.115-.647l-.244-1.747a.333.333 0 0 0-.33-.277h-2.637a.333.333 0 0 0-.33.277l-.243 1.747a4.78 4.78 0 0 0-1.114.646l-1.642-.659a.324.324 0 0 0-.402.145l-1.319 2.281a.325.325 0 0 0 .08.422l1.39 1.075c-.026.224-.046.442-.046.66s.02.428.046.639l-1.39 1.094a.325.325 0 0 0-.08.422l1.319 2.282c.079.145.257.197.402.145l1.642-.666c.342.264.698.488 1.114.653l.244 1.747a.333.333 0 0 0 .33.277h2.637a.333.333 0 0 0 .33-.277l.243-1.747a4.802 4.802 0 0 0 1.115-.653l1.641.666c.145.052.323 0 .403-.145l1.318-2.282a.333.333 0 0 0-.079-.422z" fill="#80deea" stroke-width=".659"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-css-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M12.488 9.415l-.44 2.259h9.188l-.298 1.46h-9.18l-.447 2.251H20.5l-.514 2.576-3.705 1.224-3.211-1.224.223-1.109h-2.258l-.534 2.704 5.307 2.029 6.118-2.029.812-4.076.162-.818 1.041-5.247H12.488z" fill-rule="nonzero" fill="#bbdefb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#e57373" fill-rule="nonzero"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" stroke-width=".644"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-dist-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#e57373"/><path d="M18.575 11.113h-2.576V9.825h2.576m3.864 1.288h-2.576V9.825l-1.288-1.288h-2.576L14.71 9.825v1.288h-2.577c-.715 0-1.288.573-1.288 1.288v7.085a1.288 1.288 0 0 0 1.288 1.288H22.44a1.288 1.288 0 0 0 1.288-1.288V12.4c0-.715-.58-1.288-1.288-1.288z" fill="#ffcdd2" fill-rule="evenodd" stroke-width=".644"/></symbol><symbol id="folder-docker" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs id="cydefs10"><path id="cySVGID_2_" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></defs><path id="cypath2" d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><style id="cystyle2">.cyst0{fill:#fff}.cyst1{clip-path:url(#cySVGID_4_)}</style><g id="cyg34" transform="translate(8.319 9.626) scale(.39491)" fill="#b3e5fc"><g id="cyg32"><g id="cyg30"><title id="cytitle4">Group 3</title><g id="cyg28"><g id="cyg26"><g id="cyg9"><path id="cySVGID_1_" class="cyst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/></g><g id="cyg24"><clipPath id="cySVGID_4_"><use id="cyuse14" width="100%" height="100%" xlink:href="#cySVGID_2_"/></clipPath><g id="cyg22" class="cyst1" clip-path="url(#cySVGID_4_)"><g id="cyg20"><g id="cyg18"><path id="cySVGID_3_" class="cyst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></g></g></g></g></g></g></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docker-open" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="cza"><use width="100%" height="100%" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#SVGID_2_"/></clipPath></defs><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><g transform="matrix(.3949 0 0 .39489 8.319 9.626)" fill="#b3e5fc"><title>Group 3</title><path class="czst0" d="M8.7 24c-1.1 0-2.1-.9-2.1-2s.9-2 2.1-2 2.1.9 2.1 2-1 2-2.1 2zm25.8-10.9c-.2-1.6-1.2-2.9-2.5-3.9l-.5-.4-.4.5c-.8.9-1.1 2.5-1 3.7.1.9.4 1.8.9 2.5-.4.2-.9.4-1.3.6-.9.3-1.8.4-2.7.4H1.1l-.1.6c-.2 1.9.1 3.9.9 5.7l.4.7v.1c2.4 4 6.7 5.8 11.4 5.8 9 0 16.4-3.9 19.9-12.3 2.3.1 4.6-.5 5.7-2.7l.3-.5-.5-.3c-1.3-.8-3.1-.9-4.6-.5zm-12.9-1.6h-3.9v3.9h3.9zm0-4.9h-3.9v3.9h3.9zm0-5h-3.9v3.9h3.9zm4.8 9.9h-3.9v3.9h3.9zm-14.5 0H8v3.9h3.9zm4.9 0h-3.9v3.9h3.9zm-9.7 0H3.2v3.9h3.9zm9.7-4.9h-3.9v3.9h3.9zm-4.9 0H8v3.9h3.9z"/><g class="czst1" clip-path="url(#cza)"><path class="czst0" d="M-48.8-21H1226v151.4H-48.8z"/></g></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-docs-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#0277bd" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m6.075 10.8v-1.35H13.85v1.35h6.075m2.025-2.7v-1.35h-8.1v1.35h8.1z" fill-rule="nonzero" fill="#b3e5fc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#01579b" fill-rule="nonzero"/><style>.dcst0{fill:#1173b6}.st1{fill:#585d67}</style><path class="dcst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-expo-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#01579b"/><path class="ddst0" d="M18.575 9.82c-.489-.745-.605-.844-1.6-.844h-.024c-.996 0-1.106.099-1.601.844-.46.699-5.024 9.058-5.024 9.291 0 .338.087.658.402 1.112.32.46.873.716 1.275.309.273-.274 3.201-5.321 4.616-7.23a.425.425 0 0 1 .693 0c1.414 1.909 4.343 6.956 4.616 7.23.402.407.955.15 1.275-.309.314-.454.402-.774.402-1.112-.006-.233-4.57-8.598-5.03-9.291z" fill="#1173b6" stroke-width=".058" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-font-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef9a9a" fill-rule="nonzero"/><path d="M14.62 17.403l2.38-6.33 2.37 6.33m-3.37-9l-5.5 14h2.25l1.12-3h6.25l1.13 3h2.25l-5.5-14h-2z" fill="#f44336" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-git-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M10.43 14.14l4.044-4.052 1.183 1.19a1.387 1.387 0 0 0 .65 1.56v3.877c-.42.238-.699.693-.699 1.21 0 .768.632 1.4 1.4 1.4.767 0 1.4-.632 1.4-1.4 0-.517-.28-.972-.7-1.21v-3.4l1.448 1.462c-.05.105-.05.224-.05.35 0 .767.633 1.399 1.4 1.399.768 0 1.4-.632 1.4-1.4 0-.767-.632-1.4-1.4-1.4-.126 0-.245 0-.35.05l-1.798-1.799a1.385 1.385 0 0 0-.805-1.637c-.3-.112-.615-.14-.895-.063l-1.19-1.183.553-.545a1.381 1.381 0 0 1 1.973 0l5.591 5.59a1.381 1.381 0 0 1 0 1.974l-5.59 5.591a1.381 1.381 0 0 1-1.974 0l-5.591-5.59a1.381 1.381 0 0 1 0-1.974z" fill="#e64a19" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M21.132 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.217 1.217m.608-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-global-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M21.133 18.585a1.22 1.22 0 0 0-1.156-.846h-.609v-1.825a.608.608 0 0 0-.608-.609h-3.65v-1.217h1.216a.608.608 0 0 0 .609-.608v-1.217h1.217a1.217 1.217 0 0 0 1.216-1.217v-.25a4.858 4.858 0 0 1 1.765 7.79m-4.198 1.545a4.86 4.86 0 0 1-4.26-4.826c0-.377.049-.742.128-1.089l2.915 2.915v.608a1.217 1.217 0 0 0 1.216 1.217m.609-9.735a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.085-6.085 6.085 6.085 0 0 0-6.085-6.084z" fill="#c5cae9" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#5c6bc0" fill-rule="nonzero"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-i18n-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#5c6bc0"/><path d="M17.293 17.786l-1.53-1.512.018-.018a10.555 10.555 0 0 0 2.235-3.934h1.765v-1.205h-4.217V9.912h-1.205v1.205h-4.217v1.205h6.73a9.5 9.5 0 0 1-1.91 3.223 9.424 9.424 0 0 1-1.392-2.018h-1.205c.44.982 1.042 1.91 1.795 2.747l-3.067 3.024.856.856 3.012-3.013 1.874 1.874.458-1.229m3.392-3.054H19.48l-2.711 7.23h1.205l.674-1.808h2.862l.68 1.807h1.206l-2.711-7.23m-1.579 4.218l.976-2.609.976 2.609z" fill="#c5cae9" stroke-width=".602"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-images-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688" fill-rule="nonzero"/><path d="M18.575 12.859h3.713l-3.713-3.713v3.713M13.85 8.134h5.4l4.05 4.05v8.1c0 .74-.61 1.35-1.35 1.35h-8.1a1.35 1.35 0 0 1-1.35-1.35v-10.8c0-.75.6-1.35 1.35-1.35m0 12.15h8.1v-5.4l-2.7 2.7-1.35-1.35-4.05 4.05m1.35-7.425c-.74 0-1.35.61-1.35 1.35s.61 1.35 1.35 1.35 1.35-.61 1.35-1.35-.61-1.35-1.35-1.35z" fill-rule="nonzero" fill="#b2dfdb"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-include-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.788 15.981h-2.434v2.434h-1.217V15.98h-2.434v-1.217h2.434V12.33h1.217v2.434h2.434m-3.042-5.476a6.085 6.085 0 0 0-6.085 6.084 6.085 6.085 0 0 0 6.085 6.085 6.085 6.085 0 0 0 6.084-6.085 6.085 6.085 0 0 0-6.084-6.084z" fill="#b3e5fc" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ffca28" fill-rule="nonzero"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.411 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill-rule="nonzero" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-javascript-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ffca28"/><path d="M17.935 18.374a2.18 2.18 0 0 0 1.972 1.213c.829 0 1.354-.415 1.354-.987 0-.682-.542-.927-1.452-1.324l-.502-.216c-1.435-.613-2.404-1.378-2.404-3.005 0-1.5 1.167-2.638 2.917-2.638a2.957 2.957 0 0 1 2.842 1.599l-1.552.999a1.362 1.362 0 0 0-1.29-.858.873.873 0 0 0-.957.858c0 .583.374.84 1.226 1.213l.502.216c1.697.733 2.654 1.47 2.654 3.139 0 1.798-1.412 2.783-3.308 2.783a3.839 3.839 0 0 1-3.618-2.046zm-7.048.175c.315.583.583 1.027 1.283 1.027s1.066-.256 1.066-1.255v-6.774h1.998v6.804c0 2.064-1.214 3.01-2.982 3.01a3.104 3.104 0 0 1-2.993-1.826z" fill="#ffecb3"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#c0ca33" fill-rule="nonzero"/><path d="M17.39 12.544a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-lib-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#c0ca33"/><path d="M17.391 12.543a2.05 2.05 0 0 0 2.05-2.05 2.05 2.05 0 0 0-2.05-2.052 2.05 2.05 0 0 0-2.05 2.051 2.05 2.05 0 0 0 2.05 2.051m0 2.42a8.992 8.992 0 0 0-6.152-2.42v7.52c2.392 0 4.539.923 6.152 2.42a8.992 8.992 0 0 1 6.152-2.42v-7.52c-2.392 0-4.539.923-6.152 2.42z" fill="#f0f4c3" stroke-width=".684"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#e1bee7" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-effects-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#b2ebf2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#ffcdd2" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.655 8.39l-6.152 2.193.933 8.142 5.219 2.888 5.219-2.888.932-8.142zm-1.278 2.067c.234-.004.487.07.768.223.124.066.498.16.83.21 1.183.17 2.586 1.073 3.03 1.95.306.602.243.927-.225 1.169-.404.209-1.23.108-2.43-.297l-1.012-.342-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.518 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.004-.363-.046 0-.04.164-.243.363-.451.748-.781 1.004-1.365 1.083-2.474l.055-.767.176.365c.194.401.23.98.091 1.478-.115.416-.038.462.173.104.261-.443.345-.373.299.251-.05.678-.283 1.187-.808 1.762-.429.468-.377.552.141.233.5-.308.567-.26.31.224-.487.914-1.516 1.69-2.585 1.948-.647.158-1.106.187-1.7.11-1.55-.204-3.018-1.249-3.718-2.648a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.141.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.108.282-.199.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-ngrx-state-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><path d="M17.655 8.39l-6.152 2.192.933 8.143 5.219 2.888 5.219-2.888.932-8.143zm-1.278 2.067c.234-.004.487.07.768.222.124.067.498.162.83.21 1.183.171 2.586 1.074 3.03 1.95.306.603.243.928-.225 1.17-.404.208-1.23.107-2.43-.298l-1.012-.341-.36.137c-.522.2-1.044.694-1.258 1.19-.154.359-.177.527-.149 1.116.028.59.071.761.28 1.132.239.422.786.96.88.866.026-.026-.03-.197-.124-.38-.093-.183-.148-.368-.122-.41.026-.042.273.114.548.347.611.517 1.326.848 1.981.917.538.056.661-.044.258-.211a1.238 1.238 0 0 1-.374-.25c-.157-.173-.166-.168.504-.318.417-.094 1.24-.531 1.29-.685.016-.05-.118-.07-.338-.053-.2.016-.363-.005-.363-.046 0-.04.164-.243.363-.452.748-.78 1.004-1.364 1.083-2.474l.055-.766.176.364c.194.402.23.981.091 1.479-.115.416-.038.462.173.103.261-.442.345-.372.299.252-.05.678-.283 1.186-.808 1.761-.429.47-.377.553.141.234.5-.308.567-.26.31.224-.487.914-1.516 1.689-2.585 1.948-.647.158-1.106.187-1.7.109-1.55-.203-3.018-1.248-3.718-2.647a8.736 8.736 0 0 0-.572-.989c-.275-.373-.298-.54-.113-.823.093-.142.114-.286.076-.502-.176-.999-.17-1.03.23-1.437.35-.353.371-.4.371-.813 0-.358.036-.475.198-.637.109-.109.282-.2.384-.2.296-.003.807-.277 1.11-.595.252-.265.52-.4.822-.404z" fill="#dcedc8" stroke-width=".696"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-node-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a" fill-rule="nonzero"/><path d="M17.25 8.403c-.188 0-.382.048-.542.139l-5.166 2.986a1.096 1.096 0 0 0-.542.944v5.959c0 .388.208.75.542.944l1.354.778c.66.32.882.326 1.187.326.973 0 1.535-.59 1.535-1.618V12.98a.154.154 0 0 0-.153-.153h-.646c-.09 0-.16.07-.16.153v5.882c0 .458-.472.91-1.228.528l-1.424-.813a.181.181 0 0 1-.076-.145v-5.959c0-.062.027-.118.076-.146l5.167-2.979a.15.15 0 0 1 .152 0l5.167 2.98a.164.164 0 0 1 .076.145v5.959a.181.181 0 0 1-.076.145l-5.167 2.98c-.041.027-.11.027-.16 0l-1.305-.792c-.055-.021-.111-.028-.146-.007-.368.208-.437.25-.778.354-.083.028-.215.076.05.222l1.721 1.021c.167.097.348.146.542.146s.375-.049.542-.146l5.166-2.979c.334-.194.542-.556.542-.944v-5.959c0-.389-.208-.75-.542-.944l-5.166-2.986a1.103 1.103 0 0 0-.542-.14m1.389 4.272c-1.472 0-2.354.618-2.354 1.66 0 1.117.875 1.444 2.291 1.583 1.688.166 1.82.416 1.82.75 0 .576-.465.82-1.549.82-1.375 0-1.666-.341-1.77-1.022a.157.157 0 0 0-.153-.125h-.667c-.083 0-.146.063-.146.153 0 .861.472 1.903 2.736 1.903 1.632 0 2.57-.646 2.57-1.771 0-1.118-.75-1.41-2.34-1.625-1.605-.208-1.765-.32-1.765-.694 0-.313.14-.73 1.327-.73 1.042 0 1.451.23 1.611.945.014.07.076.118.146.118h.673a.134.134 0 0 0 .105-.049c.027-.028.048-.07.034-.11-.097-1.237-.916-1.806-2.57-1.806z" fill-rule="nonzero" fill="#f1f8e9"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#039be5" fill-rule="nonzero"/><path d="M20.036 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.968 4.968 0 0 1-2.679 2.203m-.155-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.1-1.238h2.894c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399h-1.806a4.902 4.902 0 0 1 2.672-2.202c-.37.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.049.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.94 4.94 0 0 1 2.679 2.202m-4.281-3.712a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-public-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#039be5"/><path d="M20.037 16.746c.05-.408.087-.817.087-1.237s-.037-.83-.087-1.238h2.091c.099.396.16.81.16 1.238a5.1 5.1 0 0 1-.16 1.237m-3.186 3.44c.371-.687.656-1.43.854-2.203h1.825a4.967 4.967 0 0 1-2.68 2.203m-.154-3.44h-2.895c-.062-.408-.099-.817-.099-1.237s.037-.835.099-1.238h2.895c.056.403.1.817.1 1.238s-.044.829-.1 1.237m-1.447 3.687a8.39 8.39 0 0 1-1.182-2.45h2.363a8.39 8.39 0 0 1-1.181 2.45m-2.475-7.399H13.06a4.902 4.902 0 0 1 2.672-2.202c-.371.686-.65 1.429-.866 2.202m-1.806 4.95h1.806c.217.773.495 1.515.866 2.202a4.954 4.954 0 0 1-2.672-2.203m-.508-1.237a5.099 5.099 0 0 1-.16-1.237 5.1 5.1 0 0 1 .16-1.238h2.091c-.05.409-.086.817-.086 1.238s.037.829.086 1.237m2.698-6.168a8.425 8.425 0 0 1 1.181 2.456h-2.363a8.426 8.426 0 0 1 1.182-2.456m4.28 2.456h-1.824a9.682 9.682 0 0 0-.854-2.202 4.941 4.941 0 0 1 2.679 2.202M17.34 9.322a6.193 6.193 0 0 0-6.187 6.187 6.186 6.186 0 0 0 6.187 6.186 6.186 6.186 0 0 0 6.186-6.186 6.186 6.186 0 0 0-6.186-6.187z" fill="#b3e5fc" stroke-width=".619"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#00bcd4" fill-rule="nonzero"/><path d="M16.473 13.927c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a17.015 17.015 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359a5.558 5.558 0 0 0-.203.604c.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.569-1.053c-.21-.372-.435-.702-.639-1.032-.38-.022-.78-.022-1.2-.022-.422 0-.823 0-1.202.022-.204.33-.428.66-.639 1.032l-.569 1.053.57 1.054c.21.372.434.702.638 1.032.38.021.78.021 1.201.021.421 0 .822 0 1.201-.02.204-.331.428-.661.639-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.245-9.284c-.436-.267-1.405.14-2.522 1.194.366.414.724.864 1.06 1.334.577.057 1.146.14 1.686.253.359-1.503.225-2.535-.224-2.78m-.492 4.03l.204.358c.077-.203.154-.407.203-.604-.19-.042-.4-.077-.618-.112l.211.358m1.018-4.95c1.033.589 1.145 2.141.71 3.953 1.784.527 3.069 1.398 3.069 2.584 0 1.187-1.285 2.058-3.07 2.585.436 1.812.324 3.364-.709 3.954-1.025.59-2.423-.085-3.77-1.37-1.35 1.285-2.747 1.96-3.78 1.37-1.025-.59-1.137-2.142-.702-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.203 6.538c.239.527.45 1.054.625 1.588 1.475-.443 2.303-1.075 2.303-1.588 0-.512-.828-1.144-2.303-1.587a15.81 15.81 0 0 1-.625 1.587m-7.136 0a15.806 15.806 0 0 1-.625-1.587c-1.474.443-2.303 1.075-2.303 1.587 0 .513.829 1.145 2.303 1.588.176-.534.387-1.06.625-1.588m6.321 1.588l-.21.358c.217-.035.428-.07.617-.113-.049-.196-.126-.4-.203-.604l-.204.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.522 1.194.45-.246.583-1.278.224-2.781-.54.112-1.11.196-1.685.253-.337.47-.695.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.113.049.196.126.4.203.604l.204-.359m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-react-components-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#00bcd4"/><path d="M16.473 13.928c.723 0 1.313.59 1.313 1.327 0 .703-.59 1.3-1.313 1.3a1.318 1.318 0 0 1-1.313-1.3c0-.737.59-1.327 1.313-1.327m-3.252 6.946c.443.267 1.412-.14 2.529-1.194a16.997 16.997 0 0 1-1.06-1.335 15.945 15.945 0 0 1-1.686-.252c-.358 1.502-.225 2.535.217 2.78m.499-4.03l-.204-.359c-.077.204-.154.408-.203.604.19.042.4.078.618.113l-.211-.359m4.593-.533l.569-1.054-.57-1.053c-.21-.372-.434-.702-.638-1.032-.38-.022-.78-.022-1.201-.022-.421 0-.822 0-1.2.022-.205.33-.43.66-.64 1.032l-.569 1.053.569 1.054c.21.372.435.702.64 1.032.378.021.779.021 1.2.021.421 0 .822 0 1.2-.02.205-.33.43-.661.64-1.033m-1.84-4.72c-.133.155-.274.316-.414.506h.828c-.14-.19-.28-.351-.414-.506m0 7.332c.133-.154.274-.316.414-.505h-.828c.14.19.28.35.414.505m3.244-9.284c-.435-.267-1.404.14-2.52 1.194.364.414.723.864 1.06 1.334.575.057 1.144.14 1.685.253.358-1.503.225-2.535-.225-2.78m-.491 4.03l.203.358c.078-.203.155-.407.204-.604-.19-.042-.4-.077-.618-.112l.21.358m1.02-4.95c1.032.589 1.144 2.141.708 3.953 1.784.527 3.07 1.398 3.07 2.584 0 1.187-1.286 2.058-3.07 2.585.436 1.812.323 3.364-.709 3.954-1.025.59-2.423-.085-3.771-1.37-1.348 1.285-2.746 1.96-3.778 1.37-1.026-.59-1.138-2.142-.703-3.954-1.783-.527-3.069-1.398-3.069-2.585s1.286-2.057 3.07-2.584c-.436-1.812-.324-3.364.702-3.954 1.032-.59 2.43.084 3.778 1.37 1.348-1.286 2.746-1.96 3.771-1.37m-.204 6.538c.24.527.45 1.054.625 1.588 1.475-.443 2.304-1.075 2.304-1.588 0-.512-.829-1.144-2.304-1.587a15.81 15.81 0 0 1-.625 1.587m-7.135 0a15.808 15.808 0 0 1-.625-1.587c-1.475.443-2.303 1.075-2.303 1.587 0 .513.828 1.145 2.303 1.588.176-.534.386-1.06.625-1.588m6.32 1.588l-.21.358c.218-.035.428-.07.618-.113a5.56 5.56 0 0 0-.204-.604l-.203.359m-2.03 2.837c1.117 1.053 2.086 1.46 2.521 1.194.45-.246.583-1.278.225-2.781-.54.112-1.11.196-1.685.253-.338.47-.696.92-1.06 1.334m-3.477-6.012l.21-.358c-.217.035-.428.07-.617.112.049.197.126.4.203.604l.204-.358m2.03-2.837c-1.117-1.053-2.086-1.46-2.529-1.194-.442.246-.576 1.278-.217 2.781.54-.112 1.11-.196 1.685-.253.337-.47.695-.92 1.06-1.334z" fill="#b2ebf2" stroke-width=".702"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ab47bc" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-actions-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ab47bc"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#e1bee7" stroke="#e1bee7" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ef5350" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-reducer-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ef5350"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#ffcdd2" stroke="#ffcdd2" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#8bc34a" fill-rule="nonzero"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-redux-store-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#8bc34a"/><g transform="translate(8.378 6.436) scale(.17228)" fill="#dcedc8" stroke="#dcedc8" stroke-miterlimit="4" stroke-width="1.702"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8S68 54.2 65 54.2h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#fbc02d" fill-rule="nonzero"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.434h-1.217v8.518a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-resource-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#fbc02d"/><path d="M21.598 12.059h-6.085v-1.217h6.085m-2.434 6.085h-3.65V15.71h3.65m2.434-1.217h-6.085v-1.217h6.085m.608-4.26h-7.301a1.217 1.217 0 0 0-1.217 1.218v7.301a1.217 1.217 0 0 0 1.217 1.217h7.301a1.217 1.217 0 0 0 1.217-1.217v-7.301a1.217 1.217 0 0 0-1.217-1.217m-9.735 2.433h-1.217v8.519a1.217 1.217 0 0 0 1.217 1.217h8.519v-1.217H12.47z" fill="#fff9c4" stroke-width=".608"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" id="folder-sass-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#f8bbd0" fill-rule="nonzero"/><path d="M23.36 10.506c-.39-1.527-2.922-2.03-5.319-1.178-1.426.507-2.97 1.302-4.08 2.34-1.32 1.235-1.53 2.31-1.444 2.759.306 1.584 2.477 2.62 3.37 3.388v.005c-.264.13-2.19 1.104-2.64 2.1-.476 1.052.075 1.806.44 1.908 1.131.315 2.292-.251 2.916-1.182.602-.897.551-2.056.29-2.633.36-.095.781-.138 1.316-.076 1.508.177 1.804 1.118 1.748 1.513-.057.394-.373.61-.48.676-.105.065-.137.088-.129.137.013.07.062.068.152.053.125-.021.792-.321.821-1.048.037-.924-.849-1.958-2.416-1.93-.646.01-1.052.072-1.345.181-.022-.024-.044-.05-.067-.073-.969-1.034-2.76-1.765-2.684-3.156.027-.505.203-1.835 3.442-3.45 2.653-1.322 4.777-.957 5.145-.151.524 1.152-1.136 3.293-3.891 3.601-1.05.118-1.603-.289-1.74-.44-.145-.16-.166-.167-.22-.137-.088.049-.033.19 0 .274.082.214.42.594.995.782.506.166 1.739.258 3.23-.319 1.669-.646 2.972-2.443 2.59-3.944zm-7.103 7.783a2.2 2.2 0 0 1-.065 1.413 2.405 2.405 0 0 1-.453.704c-.5.546-1.198.752-1.497.579-.323-.188-.161-.956.418-1.568.623-.66 1.52-1.083 1.52-1.083l-.002-.002.079-.043z" fill="#ec407a" fill-rule="nonzero" stroke="#ec407a" stroke-width=".5199012000000001"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-scripts-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#546e7a" fill-rule="nonzero"/><path d="M18.466 20.241c.69 0 1.259-.568 1.259-1.258v-8.18H15.32a.632.632 0 0 0-.63.63v6.292h-1.887v-6.922c0-1.036.852-1.888 1.888-1.888h6.921c1.036 0 1.888.852 1.888 1.888v.63h-2.517v8.18a1.896 1.896 0 0 1-1.888 1.887h-6.292a1.896 1.896 0 0 1-1.888-1.888v-.629h6.293c0 .69.568 1.258 1.258 1.258z" fill-rule="nonzero" fill="#cfd8dc"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#4caf50" fill-rule="nonzero"/><g fill="#c8e6c9" transform="translate(2.065 -.225) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-src-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#4caf50"/><g fill="#c8e6c9" fill-rule="evenodd" transform="translate(2.064 -.224) scale(.70678)"><path d="M19.146 30.989a.902.902 0 0 1-.207-.025 1.045 1.045 0 0 1-.726-1.213l2.709-14.431c.049-.279.209-.525.444-.683a.891.891 0 0 1 .7-.122c.519.152.837.684.727 1.213L20.077 30.16a1.032 1.032 0 0 1-.442.681.895.895 0 0 1-.489.148zM24.578 28.944h-.068a.932.932 0 0 1-.668-.377 1.104 1.104 0 0 1 .1-1.419l4.658-4.553-4.638-4.239a1.105 1.105 0 0 1-.141-1.416.938.938 0 0 1 .661-.4.9.9 0 0 1 .709.237l5.47 5c.386.372.448.974.144 1.416a1.05 1.05 0 0 1-.142.163l-5.447 5.324a.913.913 0 0 1-.638.264zM16.423 28.947a.917.917 0 0 1-.639-.267l-5.452-5.327a.874.874 0 0 1-.132-.153 1.097 1.097 0 0 1 .141-1.414l5.471-5a.882.882 0 0 1 .7-.238.939.939 0 0 1 .665.4 1.104 1.104 0 0 1-.14 1.417L12.4 22.6l4.659 4.551c.377.382.42.988.1 1.419a.928.928 0 0 1-.669.377z" fill-rule="nonzero"/></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-test-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1de9b6" fill-rule="nonzero"/><path d="M14 8.097v1.39h.695v9.732A2.794 2.794 0 0 0 17.475 22a2.794 2.794 0 0 0 2.781-2.78V9.486h.695v-1.39H14m2.78 9.732c-.417 0-.695-.278-.695-.695 0-.417.278-.695.696-.695.417 0 .695.278.695.695 0 .417-.278.695-.695.695m1.39-2.78c-.417 0-.695-.278-.695-.696 0-.417.278-.695.695-.695.417 0 .695.278.695.695 0 .418-.278.696-.695.696m.695-3.476h-2.78V9.487h2.78v2.086z" fill="#00897b" fill-rule="nonzero"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#1e88e5" fill-rule="nonzero"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-tools-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#1e88e5"/><path d="M21.043 15.266a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141-2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-3.569 0a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m-2.141 2.855a1.07 1.07 0 0 1-1.07-1.07 1.07 1.07 0 0 1 1.07-1.071 1.07 1.07 0 0 1 1.07 1.07 1.07 1.07 0 0 1-1.07 1.071m3.925-6.424a6.424 6.424 0 0 0-6.423 6.424 6.424 6.424 0 0 0 6.423 6.424 1.07 1.07 0 0 0 1.071-1.07c0-.28-.107-.53-.278-.715a1.105 1.105 0 0 1-.271-.713 1.07 1.07 0 0 1 1.07-1.071h1.263a3.569 3.569 0 0 0 3.57-3.569c0-3.154-2.877-5.71-6.425-5.71z" fill="#bbdefb" stroke-width=".714"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="folder-views-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#ff8a65" fill-rule="nonzero"/><path d="M12.487 21.868L11.384 9.5H23.5l-1.104 12.366-4.961 1.375-4.948-1.373zm4.464-3.2l-3.926-2.36v-.855l3.926-2.361v1.323l-2.504 1.465 2.504 1.465v1.323zm.982-.001v-1.323l2.522-1.464-2.522-1.464v-1.323l3.926 2.35v.874l-3.926 2.35z" fill="#e44d26"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.811 8.52l-5.988 5.506-3.346-2.522-1.383.805 3.298 3.03-3.298 3.032 1.383.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.622v6.396l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vscode-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#42a5f5" fill-rule="nonzero"/><path d="M20.81 8.52l-5.988 5.506-3.346-2.522-1.384.805 3.3 3.03-3.3 3.032 1.384.807 3.346-2.522 5.988 5.503 2.921-1.419V9.94zm0 3.621v6.397l-4.245-3.198z" fill="#bbdefb" stroke-width=".974"/></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#009688" fill-rule="nonzero"/><g transform="translate(8.459 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-vue-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#009688"/><g transform="translate(8.458 6.362) scale(.69572)"><path d="M1.821 4.15l10.21 17.618L22.239 4.235v-.084h-7.692l-2.434 4.178-2.422-4.178z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179z" fill="#35495e"/></g></symbol><symbol clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack" xmlns="http://www.w3.org/2000/svg"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.097.903 2 2 2h16c1.097 0 2-.903 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" fill="#03a9f4" fill-rule="nonzero"/><g transform="translate(9.192 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" viewBox="0 0 24 24" id="folder-webpack-open" xmlns="http://www.w3.org/2000/svg"><path d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7c1.097 0 2 .903 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5z" fill="#03a9f4"/><g transform="translate(9.193 7.48) scale(.66328)"><path d="M19.376 15.988l-7.708 4.45-7.709-4.45v-8.9l7.709-4.451 7.708 4.45z" fill="#fff" fill-opacity=".785"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18s.41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.94v2.103h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83zm-5 5.08v3.58l4 2.309v-3.58zm10 0l-4 2.308v3.58l4-2.308z" fill="#1c78c0"/></g></symbol><symbol viewBox="0 0 24 24" id="font" xmlns="http://www.w3.org/2000/svg"><path d="M9.62 12L12 5.67 14.37 12M11 3L5.5 17h2.25l1.12-3h6.25l1.13 3h2.25L13 3h-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 500 500" id="fsharp" xmlns="http://www.w3.org/2000/svg"><path d="M235.906 36.66L21.963 250.601l213.943 213.943v-84.36L106.209 250.487l129.697-129.696z" fill="#378bba" stroke-width="14.706"/><path d="M235.906 156.614l-93.622 93.62 93.622 93.622z" fill="#378bba" stroke-width="15.006"/><path d="M263.417 36.64L477.36 250.583 263.417 464.526v-84.36l129.696-129.697-129.696-129.696z" fill="#30b9db" stroke-width="14.706"/></symbol><symbol viewBox="0 0 152.99 160.01" id="fusebox" xmlns="http://www.w3.org/2000/svg"><defs id="fkdefs4"><style id="fkstyle2">.fkcls-1{fill:#fff}.fkcls-2{fill:#515151}.fkcls-3{fill:#1d79bf}.fkcls-4{fill:#383838}</style></defs><title id="fktitle6">Asset 3</title><g id="fkLayer_2" data-name="Layer 2" transform="matrix(.87285 0 0 .87285 10.17 10.175)"><g id="fkFuse_Box" data-name="Fuse Box"><g id="fkLOGO"><path class="fkcls-1" id="fkpolygon8" fill="#fff" d="M76.56 2.19l74.22 24.93-7.7 87.77-65.41 42.66-69.79-43.93-5.7-86.13z"/><path class="fkcls-2" d="M77.69 160L5.87 114.81 0 26 76.55 0 153 25.67l-7.94 90.4zM9.88 112.43l67.77 42.66 63.45-41.39 7.47-85.13-72-24.18L4.36 28.95z" id="fkpath10" fill="#515151"/><path class="fkcls-3" id="fkpolygon12" fill="#1d79bf" d="M76.4 148.8V61.68l66.93-29.82-5.99 78.77z"/><path id="fkF" class="fkcls-4" fill="#383838" d="M76.4 148.8l-60.35-37.39L9.63 31.8 76.4 61.68z"/><path class="fkcls-1" d="M25.58 52.73l.54 15.93 37.35 18.18.12 14.69-37-18.21 1.64 37.1-14.56-9-5.05-80.55 67.79 30.82v15.46z" id="fkpath15" fill="#fff"/><path class="fkcls-1" d="M135.91 90.77c-.08 13.12-6.33 26.59-16.77 33.12l-42.8 27.93V61.71l42.27-18.84c5.16-2.41 9.51-1.43 12.4 3.11 1.9 3 2.89 7.23 2.86 12.21A35.69 35.69 0 0 1 129.34 76c4.29 2 6.66 6.55 6.57 14.77zM123 63.76c0-4.64-2-6.93-4.92-5.45l-29 14.48L89 90l29.44-15.59c2.5-1.32 4.56-5.91 4.56-10.65zM125.15 96c0-5.71-2.42-8.24-6.55-5.93L89 106.64v19.58l29.34-17.46c4.43-2.64 6.79-7.27 6.81-12.76z" id="fkpath17" fill="#fff"/><path id="fkTOP" class="fkcls-4" fill="#383838" d="M76.4 8.82L9.71 31.77l109.77 2.38-84.02 9.21L76.4 61.68l20.76-9.25-27.73-1.37 49.78-8.46 24.12-10.74z"/></g></g></g></symbol><symbol viewBox="0 0 24 24" id="git" xmlns="http://www.w3.org/2000/svg"><path d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2 2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82z" fill="#e64a19"/></symbol><symbol viewBox="0 0 164 158" id="gitlab" xmlns="http://www.w3.org/2000/svg"><path d="M161.26 62.6545L161.031 62.0689L138.845 4.16909C138.394 3.03428 137.595 2.07162 136.562 1.41923C135.529 0.777924 134.324 0.46905 133.11 0.534313C131.896 0.599576 130.731 1.03583 129.773 1.78418C128.825 2.55402 128.137 3.59717 127.804 4.77168L112.824 50.6027H52.1654L37.1855 4.77168C36.8606 3.59077 36.1716 2.54239 35.2164 1.77569C34.2581 1.02734 33.0931 0.591087 31.8789 0.525824C30.6647 0.460561 29.4597 0.769435 28.4267 1.41074C27.3965 2.06577 26.598 3.02759 26.1436 4.1606L3.91558 62.035L3.69492 62.6206C0.501198 70.9653 0.106986 80.1221 2.57171 88.7104C5.03644 97.2986 10.2265 104.853 17.3593 110.234L17.4357 110.293L17.6394 110.438L51.4355 135.747L68.1553 148.401L78.34 156.09C79.5313 156.995 80.986 157.485 82.4818 157.485C83.9775 157.485 85.4322 156.995 86.6235 156.09L96.8082 148.401L113.528 135.747L147.528 110.285L147.613 110.217C154.73 104.835 159.907 97.2884 162.368 88.7117C164.829 80.135 164.44 70.9913 161.26 62.6545V62.6545Z" fill="#e24329"/><path d="M161.26 62.6545L161.031 62.0688C150.221 64.2878 140.034 68.8667 131.198 75.4787L82.4733 112.322C99.0658 124.874 113.511 135.78 113.511 135.78L147.511 110.319L147.596 110.251C154.723 104.869 159.909 97.3176 162.373 88.7336C164.837 80.1496 164.447 70.9973 161.26 62.6545Z" fill="#fc6d26"/><path d="M51.4355 135.78L68.1553 148.435L78.34 156.124C79.5313 157.029 80.9859 157.518 82.4817 157.518C83.9775 157.518 85.4322 157.029 86.6235 156.124L96.8081 148.435L113.528 135.78C113.528 135.78 99.0657 124.84 82.4732 112.322C65.8807 124.84 51.4355 135.78 51.4355 135.78Z" fill="#fca326"/><path d="M33.7397 75.4787C24.9112 68.8531 14.7266 64.2622 3.91558 62.0349L3.69492 62.6205C0.501198 70.9652 0.106986 80.122 2.57171 88.7103C5.03644 97.2985 10.2265 104.853 17.3593 110.234L17.4357 110.293L17.6394 110.437L51.4355 135.746C51.4355 135.746 65.8638 124.84 82.4733 112.288L33.7397 75.4787Z" fill="#fc6d26"/></symbol><symbol viewBox="0 0 24 24" id="go" xmlns="http://www.w3.org/2000/svg"><path d="M10.575 1.695c-2.634 0-4.756 2.453-4.756 5.502v4.6l-.027-.003v4.71c0 3.05 2.123 5.502 4.757 5.502h2.286c2.634 0 4.757-2.453 4.757-5.502v-4.6a5.1 5.1 0 0 0 .026.003v-4.71c0-3.049-2.122-5.502-4.756-5.502h-2.287z" fill="#73cddc"/><rect width="2.289" height="3.335" x="-1.178" y="6.092" ry="1.125" transform="matrix(.4849 -.87457 .85979 .51065 0 0)" fill="#73cddc"/><rect width="2.297" height="3.39" x="10.261" y="-15.076" ry="1.143" transform="matrix(.44646 .8948 -.89204 .45195 0 0)" fill="#73cddc"/><circle cx="9.267" cy="5.13" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><circle cx="14.214" cy="5.116" r="2.054" fill="#fff" stroke="#5e5d5b" stroke-width=".1"/><ellipse cx="8.039" cy="5.051" rx=".792" ry=".901" fill="#030d18"/><path d="M11.792 9.556l.763.138a.403.689 0 0 1 .008.138.403.689 0 0 1-.402.69.403.689 0 0 1-.404-.69.403.689 0 0 1 .035-.276z" fill="#fff" stroke="#fff" stroke-width=".155"/><ellipse cx="8.51" cy="5.365" rx=".138" ry=".166" fill="#fff"/><ellipse cx="12.945" cy="5.189" rx=".792" ry=".901" fill="#030d18"/><ellipse cx="13.414" cy="5.446" rx=".138" ry=".166" fill="#fff"/><ellipse cx="-12.982" cy="-3.409" rx=".708" ry="1.026" transform="rotate(-129.403)" fill="#f6d2a1" stroke-width=".4"/><path d="M11.772 9.553l-.757.135a.4.672 0 0 0-.008.135.4.672 0 0 0 .4.672.4.672 0 0 0 .4-.672.4.672 0 0 0-.035-.27z" fill="#fff" stroke="#fff" stroke-width=".153"/><ellipse cx="1.841" cy="-21.563" rx=".707" ry="1.026" transform="scale(1 -1) rotate(50.597)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="-17.281" cy="-21.784" rx=".864" ry="1.27" transform="matrix(.3054 -.95222 -.97065 -.2405 0 0)" fill="#f6d2a1" stroke-width=".4"/><ellipse cx="22.885" cy="2.587" rx=".864" ry="1.27" transform="matrix(.22652 .974 .95652 -.29167 0 0)" fill="#f6d2a1" stroke-width=".4"/><path d="M10.708 8.392a.594.594 0 0 0-.594.597v.115c0 .331.264.598.594.598h.386a.973.772 0 0 1 .697-.235.973.772 0 0 1 .698.235h.334c.33 0 .594-.267.594-.598V8.99a.595.595 0 0 0-.594-.597h-2.115z" fill="#f6d2a1" stroke="#657075" stroke-width=".1"/><ellipse cx="11.734" cy="8.203" rx="1.208" ry=".68" fill="#030d18" stroke="#fff" stroke-width=".162"/></symbol><symbol viewBox="0 0 24 24" id="gradle" xmlns="http://www.w3.org/2000/svg"><path d="M21.718 5.503c-.731-1.315-2.04-1.708-2.963-1.727-1.133-.023-2.065.605-1.888 1.017.037.088.25.55.38.741.19.275.527.064.646 0 .353-.187.73-.248 1.16-.198.409.048.954.3 1.319 1.001.859 1.652-1.794 5.05-5.114 2.697-3.32-2.353-6.548-1.574-8.01-1.1-1.462.475-2.135.952-1.556 2.055.785 1.498.524 1.038 1.285 2.28 1.21 1.97 3.856-.908 3.856-.908-1.972 2.906-3.662 2.204-4.31 1.188a15.864 15.864 0 0 1-1.038-1.97c-4.993 1.76-3.642 9.534-3.642 9.534h2.48c.632-2.862 2.892-2.757 3.28 0h1.892c1.673-5.59 5.914 0 5.914 0h2.466c-.69-3.812 1.388-5.01 2.697-7.246 1.31-2.235 2.551-4.969 1.146-7.364zm-6.362 7.362c-1.304-.426-.837-1.723-.837-1.723s1.139.368 2.68.87c-.09.403-.856 1.175-1.843.853z" fill="#0097a7" stroke-width=".47"/></symbol><symbol preserveAspectRatio="xMidYMid" viewBox="0 0 300 300" id="graphcool" xmlns="http://www.w3.org/2000/svg"><path d="M246.886 107.727c-12.237-6.892-27.616 2.1-30.081 3.646l-52.834 29.965c-7.8-6.196-18.914-5.933-26.412.625-7.499 6.558-9.24 17.537-4.14 26.094 5.102 8.556 15.588 12.246 24.923 8.768 9.335-3.478 14.852-13.129 13.111-22.937l52.688-29.9.321-.196c3.464-2.188 11.5-5.462 15.256-3.34 2.706 1.524 4.252 6.629 4.376 14.148h-.066v66.092a17.313 17.313 0 0 1-8.635 14.95l-75.739 43.755a17.312 17.312 0 0 1-17.261 0l-75.74-43.756a17.312 17.312 0 0 1-8.634-14.95V113.22c.01-6.165 3.3-11.86 8.634-14.95l68.549-39.562c6.522 7.482 17.451 9.25 26 4.206s12.283-15.468 8.886-24.794c-3.397-9.327-12.962-14.904-22.751-13.27-9.79 1.636-17.022 10.02-17.204 19.944L59.397 85.632a31.932 31.932 0 0 0-15.978 27.588v87.454a31.933 31.933 0 0 0 15.927 27.602l75.74 43.755a31.934 31.934 0 0 0 31.846 0l75.74-43.755a31.933 31.933 0 0 0 15.927-27.58V137.12h.05c.373-14.913-3.616-24.794-11.762-29.389z" fill="#27ae60" stroke="#27ae60" stroke-width="7.883622079999999"/></symbol><symbol viewBox="0 0 400 400" id="graphql" xmlns="http://www.w3.org/2000/svg"><path d="M67.008 293.022l-13.143-7.588L200.282 31.839l13.143 7.588z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M50.855 265.174H343.69v15.177H50.855z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M203.122 358.269L56.649 273.7l7.589-13.143 146.472 84.568zm127.24-220.407L183.889 53.293l7.589-13.143 146.472 84.568z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M64.278 137.803l-7.588-13.142 146.472-84.568 7.588 13.143z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M327.661 293.025L181.244 39.43l13.143-7.589 146.417 253.596zM62.466 114.597h15.176v169.136H62.466zm254.528 0h15.176v169.136h-15.176z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M200.538 351.845l-6.628-11.481L321.3 266.812l6.629 11.48z" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/><path d="M352.284 288.67c-8.777 15.268-28.342 20.48-43.61 11.703-15.268-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.36 8.869 20.57 28.342 11.703 43.61M97.574 141.567c-8.778 15.268-28.343 20.48-43.61 11.703-15.269-8.777-20.48-28.342-11.703-43.61 8.777-15.268 28.342-20.48 43.61-11.703 15.268 8.869 20.479 28.342 11.702 43.61M42.353 288.67c-8.777-15.268-3.566-34.741 11.702-43.61 15.268-8.776 34.741-3.565 43.61 11.703 8.776 15.268 3.565 34.741-11.703 43.61-15.36 8.776-34.833 3.565-43.61-11.703m254.71-147.103c-8.776-15.268-3.565-34.741 11.703-43.61 15.268-8.776 34.742-3.565 43.61 11.703 8.777 15.268 3.566 34.741-11.702 43.61-15.268 8.776-34.833 3.565-43.61-11.703m-99.745 236.608c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907c0 17.554-14.262 31.907-31.907 31.907m0-294.206c-17.645 0-31.907-14.262-31.907-31.907s14.262-31.907 31.907-31.907 31.907 14.262 31.907 31.907-14.262 31.907-31.907 31.907" fill="#ec407a" stroke-width="6.803" stroke="#ec407a"/></symbol><symbol viewBox="0 0 24 24" id="groovy" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.982a10.119 10.119 0 0 0-10.12 10.12A10.119 10.119 0 0 0 12 22.22 10.119 10.119 0 0 0 22.12 12.1 10.119 10.119 0 0 0 12 1.983zm1.254 2.422c.91 0 1.647.261 2.213.78.571.518.857 1.188.857 2.013 0 .889-.319 1.673-.959 2.35-.64.677-1.376 1.015-2.207 1.015-.486 0-.89-.119-1.213-.357-.317-.238-.476-.532-.476-.88 0-.212.06-.4.181-.563.127-.164.274-.246.438-.246.159 0 .238.092.238.277 0 .164.06.29.182.38.121.09.261.136.42.136.423 0 .828-.29 1.215-.866.391-.582.587-1.202.587-1.863 0-.465-.151-.844-.453-1.135-.301-.296-.69-.445-1.166-.445-.714 0-1.406.318-2.078.953-.666.635-1.211 1.47-1.635 2.506-.417 1.031-.627 2.014-.627 2.945 0 .857.185 1.54.555 2.047.37.503.863.754 1.477.754 1.037 0 2.027-.734 2.974-2.2l1.493-.212c.185-.026.277.018.277.135 0 .053-.072.28-.215.681-.143.402-.337 1.074-.586 2.016.82-.476 1.455-1.003 1.904-1.58v.914c-.36.418-1.046.888-2.062 1.412-.212 1.407-.682 2.493-1.406 3.26-.725.772-1.54 1.16-2.444 1.16-.433 0-.775-.102-1.023-.303-.243-.2-.365-.477-.365-.832 0-.984.955-1.94 2.865-2.865.2-.714.395-1.356.586-1.928-.333.482-.817.907-1.451 1.278-.635.37-1.225.554-1.77.554-.889 0-1.628-.383-2.22-1.15-.588-.772-.881-1.748-.881-2.928 0-1.243.333-2.42 1-3.531a7.747 7.747 0 0 1 2.625-2.674c1.084-.672 2.134-1.008 3.15-1.008zM12.03 16.592c-1.375.687-2.062 1.365-2.062 2.031 0 .354.169.533.508.533.666 0 1.184-.856 1.554-2.564z" fill="#26c6da"/></symbol><symbol viewBox="0 0 24 24" id="gulp" xmlns="http://www.w3.org/2000/svg"><path d="M8.37 15.94a596.238 596.238 0 0 1-.482-4.982c.002-.042-.225-.077-.505-.077h-.508V8.95h3.966V5.198l1.871-1.124c1.14-.685 1.978-1.125 2.144-1.125.4 0 .866.506.866.939 0 .19-.057.422-.127.517-.07.095-.722.53-1.45.966l-1.321.792-.029 1.393-.028 1.393h3.972v1.932h-.98l-.495 4.983-.495 4.983H8.854l-.485-4.906z" fill="#e53935"/></symbol><symbol viewBox="0 0 24 24" id="h" xmlns="http://www.w3.org/2000/svg"><path d="M16.745 19.818h-3.007v-5.882q0-2.381-1.736-2.381-.869 0-1.438.663-.56.662-.56 1.718v5.882H6.988V4.533h3.016v6.508h.037q1.186-1.802 3.193-1.802 3.511 0 3.511 4.239z" stroke-width=".478" fill="#0277bd"/></symbol><symbol viewBox="0 0 253.6 253.6" id="hack" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-29.243 -29.515) scale(1.2301)"><path fill="#607d8b" d="M69.496 159.551v52.576l51.77-52.576zM123.507 41.523l-54.01 52.755v55.084l54.01-54.009z"/><path fill="#eceff1" d="M130.023 95.663v51.501l52.128-51.5z"/><path fill="#607d8b" d="M185.465 101.867l-55.442 55.174v55.083l55.442-55.262z"/><path fill="#ffa000" d="M73.068 154.283l50.427.09v-50.248z"/></g></symbol><symbol viewBox="0 0 300 300.00001" id="haml" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 165.6)"><path d="M78.42-132.307c-12.047-.302-26.924 5.998-26.924 5.998l49.195 99.791L74.605 85.005c23.81 20.134 50.07 10.504 50.07 10.504L136.76 9.212c1.526 1.446 3.146 2.77 4.777 3.995 5.244 3.714 10.925 6.553 16.606 8.738 5.68 2.185 11.583 3.933 17.482 5.244 3.933.874 7.645 1.53 11.578 1.967-1.748 3.933-2.84 8.083-2.621 12.672 0 .437.22.873.656 1.092h.217c4.152 2.185 8.521 3.934 13.328 5.027 4.589.874 9.615 1.312 14.422.656 5.026-.655 10.051-2.623 13.984-5.9 3.933-3.278 6.774-7.648 8.522-12.237l.219-.218v-.217l.656-5.899v-.22c2.185-1.311 4.37-2.621 6.555-4.37 2.622-2.184 5.025-4.589 6.773-7.648 1.748-3.059 2.84-6.774 2.621-10.488-.218-3.496-1.53-6.99-3.06-10.049-1.53-3.059-3.495-5.901-5.68-8.523-4.37-5.026-9.614-9.176-15.295-12.454-5.462-3.496-11.581-6.338-17.7-8.304l-2.404-.656-1.962-.655c-1.311-.437-2.406-1.092-3.498-1.53-2.185-1.31-3.717-2.622-4.809-4.37-2.185-3.278-2.403-8.301-1.31-13.545.218-1.311.656-2.623 1.093-3.934a96.064 96.064 0 0 0 1.31-4.152c.314-1.412.51-2.829.598-4.402l29.203-25.553c-2.275-8.404-27.488-17.158-27.488-17.158l-74.931 63.726-43.243-81.584c-1.553-.35-3.218-.527-4.94-.57zm107.682 73.14c-.449 2.336-.647 4.795-.647 7.258.219 3.715 1.311 7.87 3.715 11.366 2.403 3.496 5.68 6.117 8.957 7.646a29.663 29.663 0 0 0 5.027 1.967l2.623.654 2.184.438c5.68 1.53 11.142 3.714 16.168 6.554 5.025 2.84 9.833 6.337 13.766 10.27s6.992 8.959 7.43 13.984c.218 3.496-.22 6.118-1.313 8.303-1.093 2.404-2.84 4.588-4.807 6.555-.874.874-1.966 1.747-2.84 2.402a27.11 27.11 0 0 0-.654-5.898c-.219-1.093-.438-1.966-.875-3.059-.437-.874-.872-1.966-1.965-2.621-.218 0-.44-.001-.44.217-1.31 3.277-3.494 6.12-5.898 8.086-2.403 1.966-5.462 2.84-8.521 3.058-3.06.219-6.338-.436-9.616-1.31-3.277-.874-6.552-1.968-9.83-3.06l-.439-.22c-.656-.218-1.526.002-1.963.44-1.748 2.185-3.06 4.149-4.59 6.334a58.435 58.435 0 0 0-2.84 5.027c-3.933-1.53-7.649-2.841-11.582-4.37-5.462-2.186-10.925-4.37-15.95-6.991-5.245-2.404-10.268-5.246-14.638-8.524-3.15-2.363-6.062-4.845-8.185-7.681l2.404-17.172z" fill="#f4511e" stroke-width="0" stroke-linejoin="round"/></g></symbol><symbol viewBox="0 0 24 24" id="handlebars" xmlns="http://www.w3.org/2000/svg"><path d="M8.55 10.32c-2.753 0-4.202 3.48-5.793 3.48-.98 0-1.126-.677-1.126-.915 0-.332.236-.706.564-.706.59 0 .414.77.414.77s.798-.555.272-1.298c-.42-.595-1.31-.623-1.92-.17-.617.458-1.057 1.146-.853 2.287.1.551.468 1.35 1.233 1.805.764.455 1.925.566 2.335.566 2.194 0 4.342-1.633 6.639-2.322a5.513 5.513 0 0 1 1.497-.222 6.19 6.19 0 0 1 1.92.226c2.296.689 4.444 2.323 6.638 2.323.41 0 1.57-.11 2.335-.566.765-.455 1.132-1.256 1.231-1.807.204-1.14-.235-1.829-.853-2.287-.61-.453-1.497-.423-1.918.172-.526.743.27 1.297.27 1.297s-.176-.77.414-.77c.329 0 .565.373.565.705 0 .238-.147.914-1.126.914-1.592 0-3.04-3.478-5.794-3.478-2.565 0-3.076 1.177-3.462 1.718-.004.005-.005.011-.008.016-.005-.006-.007-.013-.012-.02-.386-.54-.896-1.717-3.461-1.717z" fill="#ff7043" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 300.00001 300" id="haskell" xmlns="http://www.w3.org/2000/svg"><g stroke-width="2.422"><path d="M23.928 240.5l59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z" fill="#ef5350"/><path d="M83.869 240.5l59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z" fill="#ffa726"/><path d="M228.72 188.08l-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924l-19.98-29.953h99.901v29.953z" fill="#ffee58"/></g></symbol><symbol viewBox="0 0 210 210" id="haxe" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -87)"><path fill="#f68712" stroke-width=".221" d="M42.78 191.545l63.431-63.43 63.431 63.43-63.431 63.431z"/><path d="M42.8 191.592L31.193 148.28 19.59 104.97 62.9 116.575l43.311 11.605-31.706 31.706z" fill="#fab20b" stroke-width=".266"/><path d="M105.956 128.111l-43.19-11.544-43.177-11.597 22.927.185 23.228.294 20.264 11.36z" fill="#fbc707" stroke-width=".265"/><path d="M19.59 104.97l11.596 43.176 11.545 43.19-11.303-19.948-11.36-20.263-.294-23.228z" fill="#fff200" stroke-width=".265"/><path d="M106.23 128.133l43.312-11.605 43.311-11.605-11.605 43.31-11.605 43.312-31.706-31.706z" fill="#f47216" stroke-width=".266"/><path d="M169.711 191.289l11.545-43.19 11.597-43.176-.185 22.927-.294 23.228-11.36 20.263z" fill="#f1471d" stroke-width=".265"/><path d="M192.853 104.923l-43.176 11.597-43.19 11.544 19.947-11.303 20.264-11.36 23.228-.293z" fill="#fbc707" stroke-width=".265"/><path d="M169.643 191.545l11.605 43.31 11.605 43.312-43.311-11.605-43.311-11.606 31.706-31.705z" fill="#f25c19" stroke-width=".266"/><path d="M106.487 255.025l43.19 11.544 43.176 11.598-22.927-.185-23.228-.294-20.264-11.36z" fill="#f68712" stroke-width=".265"/><path d="M192.853 278.167l-11.597-43.176-11.545-43.19 11.303 19.947 11.36 20.264.294 23.228z" fill="#f1471d" stroke-width=".265"/><path d="M106.211 254.976l-43.31 11.605-43.312 11.605 11.605-43.31L42.8 191.563l31.706 31.706z" fill="#f89c0e" stroke-width=".266"/><path d="M42.731 191.82l-11.545 43.19-11.597 43.176.185-22.927.294-23.228 11.36-20.263z" fill="#fff200" stroke-width=".265"/><path d="M19.59 278.186l43.175-11.597 43.19-11.544-19.947 11.303-20.264 11.36-23.228.293z" fill="#f25c19" stroke-width=".265"/></g></symbol><symbol viewBox="0 0 144 152" id="heroku" xmlns="http://www.w3.org/2000/svg"><path d="M118.68 13.279H26.865c-6.337 0-11.476 5.139-11.476 11.476V129.32c0 6.338 5.139 11.477 11.476 11.477h91.813c6.338 0 11.477-5.14 11.477-11.477V24.755c0-6.337-5.139-11.476-11.477-11.476zM44.08 121.669V96.165l14.346 12.752zm44.632 0v-38.08c-.063-2.976-1.496-6.551-7.97-6.551-12.966 0-27.51 6.52-27.654 6.586l-9.008 4.08V32.407h12.752v36.201c6.366-2.072 15.266-4.321 23.91-4.321 7.882 0 12.6 3.099 15.17 5.698 5.484 5.547 5.56 12.613 5.551 13.43v38.255zm3.188-68.54H79.149c5.011-6.576 8.158-13.496 9.564-20.723h12.751c-.86 7.243-3.796 14.187-9.563 20.722z" fill="#6963b9"/></symbol><symbol viewBox="0 0 24 24" id="hpp" xmlns="http://www.w3.org/2000/svg"><path d="M9.757 19.818H6.751v-5.882q0-2.381-1.737-2.381-.868 0-1.438.663-.56.662-.56 1.718v5.882H0V4.533h3.016v6.508h.037Q4.24 9.239 6.247 9.239q3.51 0 3.51 4.239z" stroke-width=".478" fill="#0277bd"/><path d="M13.073 11.448v2h-2v2h2v2h2v-2h2v-2h-2v-2zm7 0v2h-2v2h2v2h2v-2h2v-2h-2v-2z" fill="#0277bd"/></symbol><symbol viewBox="0 0 24 24" id="html" xmlns="http://www.w3.org/2000/svg"><path d="M12 17.56l4.07-1.13.55-6.1H9.38L9.2 8.3h7.6l.2-1.99H7l.56 6.01h6.89l-.23 2.58-2.22.6-2.22-.6-.14-1.66h-2l.29 3.19L12 17.56M4.07 3h15.86L18.5 19.2 12 21l-6.5-1.8L4.07 3z" fill="#e44d26"/></symbol><symbol viewBox="0 0 24 24" id="http" xmlns="http://www.w3.org/2000/svg"><path d="M16.046 13.784c.074-.613.13-1.225.13-1.856s-.056-1.244-.13-1.856h3.137c.148.594.241 1.215.241 1.856a7.65 7.65 0 0 1-.241 1.856m-4.78 5.16c.557-1.03.984-2.144 1.281-3.304h2.738a7.452 7.452 0 0 1-4.019 3.304m-.232-5.16H9.828a12.314 12.314 0 0 1-.149-1.856c0-.631.056-1.253.149-1.856h4.343c.084.603.149 1.225.149 1.856 0 .63-.065 1.243-.149 1.856M12 19.315c-.77-1.113-1.393-2.348-1.773-3.675h3.545c-.38 1.327-1.002 2.562-1.773 3.675m-3.712-11.1h-2.71a7.353 7.353 0 0 1 4.01-3.304c-.557 1.03-.975 2.144-1.3 3.304m-2.71 7.425h2.71c.325 1.16.743 2.274 1.3 3.304a7.433 7.433 0 0 1-4.01-3.304m-.761-1.856a7.65 7.65 0 0 1-.241-1.856c0-.64.093-1.262.241-1.856h3.137c-.074.612-.13 1.225-.13 1.856 0 .63.056 1.243.13 1.856m4.046-9.253c.77 1.114 1.393 2.357 1.773 3.684h-3.545c.38-1.327 1.002-2.57 1.773-3.684m6.422 3.684h-2.738a14.523 14.523 0 0 0-1.28-3.304 7.412 7.412 0 0 1 4.018 3.304m-6.423-5.568c-5.132 0-9.28 4.176-9.28 9.28a9.28 9.28 0 0 0 9.28 9.282 9.28 9.28 0 0 0 9.281-9.281A9.28 9.28 0 0 0 12 2.647z" fill="#e53935" stroke-width=".928"/></symbol><symbol viewBox="0 0 24 24" id="image" xmlns="http://www.w3.org/2000/svg"><path d="M13.009 9.202h5.368l-5.368-5.368v5.368M6.177 2.37h7.808l5.856 5.856v11.711a1.952 1.952 0 0 1-1.952 1.952H6.178a1.951 1.951 0 0 1-1.952-1.952V4.322c0-1.083.868-1.952 1.952-1.952m0 17.567h11.71V12.13l-3.903 3.903-1.952-1.951-5.856 5.855M8.13 9.202a1.952 1.952 0 0 0-1.952 1.952 1.952 1.952 0 0 0 1.952 1.952 1.952 1.952 0 0 0 1.952-1.952A1.952 1.952 0 0 0 8.13 9.202z" fill="#26a69a" stroke-width=".976"/></symbol><symbol viewBox="0 0 512 512" id="ionic" xmlns="http://www.w3.org/2000/svg"><g fill="#4f8ff7"><path d="M423.592 132.804A31.855 31.855 0 0 0 429 115c0-17.675-14.33-32-32-32a31.853 31.853 0 0 0-17.805 5.409C344.709 63.015 302.11 48 256 48 141.125 48 48 141.125 48 256c0 114.877 93.125 208 208 208 114.873 0 208-93.123 208-208 0-46.111-15.016-88.71-40.408-123.196zM391.83 391.832c-17.646 17.646-38.191 31.499-61.064 41.174-23.672 10.012-48.826 15.089-74.766 15.089-25.94 0-51.095-5.077-74.767-15.089-22.873-9.675-43.417-23.527-61.064-41.174s-31.5-38.191-41.174-61.064C68.982 307.096 63.905 281.94 63.905 256c0-25.94 5.077-51.095 15.089-74.767 9.674-22.873 23.527-43.417 41.174-61.064s38.191-31.5 61.064-41.174c23.673-10.013 48.828-15.09 74.768-15.09 25.939 0 51.094 5.077 74.766 15.089a191.221 191.221 0 0 1 37.802 21.327A31.853 31.853 0 0 0 365 115c0 17.675 14.327 32 32 32 5.293 0 10.28-1.293 14.678-3.568a191.085 191.085 0 0 1 21.327 37.801c10.013 23.672 15.09 48.827 15.09 74.767 0 25.939-5.077 51.096-15.09 74.768-9.675 22.873-23.527 43.418-41.175 61.064z"/><circle cx="256.003" cy="256" r="96"/></g></symbol><symbol viewBox="0 0 24 24" id="java" xmlns="http://www.w3.org/2000/svg"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="javascript" xmlns="http://www.w3.org/2000/svg"><path d="M3 3h18v18H3V3m4.73 15.04c.4.85 1.19 1.55 2.54 1.55 1.5 0 2.53-.8 2.53-2.55v-5.78h-1.7V17c0 .86-.35 1.08-.9 1.08-.58 0-.82-.4-1.09-.87l-1.38.83m5.98-.18c.5.98 1.51 1.73 3.09 1.73 1.6 0 2.8-.83 2.8-2.36 0-1.41-.81-2.04-2.25-2.66l-.42-.18c-.73-.31-1.04-.52-1.04-1.02 0-.41.31-.73.81-.73.48 0 .8.21 1.09.73l1.31-.87c-.55-.96-1.33-1.33-2.4-1.33-1.51 0-2.48.96-2.48 2.23 0 1.38.81 2.03 2.03 2.55l.42.18c.78.34 1.24.55 1.24 1.13 0 .48-.45.83-1.15.83-.83 0-1.31-.43-1.67-1.03l-1.38.8z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="javascript-map" xmlns="http://www.w3.org/2000/svg"><path d="M18 8v2h2v10H10v-2H8v4h14V8h-4z" fill="#ffca28"/><path d="M2.444 2.506h14.135v14.136H2.444V2.506m3.714 11.811c.315.668.935 1.218 1.995 1.218 1.178 0 1.987-.629 1.987-2.003V8.993H8.805v4.508c0 .675-.275.848-.707.848-.455 0-.644-.314-.856-.683l-1.084.651m4.697-.14c.392.769 1.185 1.358 2.426 1.358 1.257 0 2.199-.652 2.199-1.854 0-1.107-.636-1.602-1.767-2.089l-.33-.141c-.573-.243-.816-.408-.816-.801 0-.322.243-.573.636-.573.377 0 .628.165.856.573l1.028-.683c-.432-.754-1.044-1.045-1.884-1.045-1.186 0-1.948.754-1.948 1.752 0 1.083.636 1.594 1.594 2.002l.33.141c.613.267.974.432.974.888 0 .377-.354.652-.903.652-.652 0-1.029-.338-1.312-.81l-1.083.63z" fill="#ffca28"/></symbol><symbol viewBox="0 0 180 180" id="jenkins" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="gia"><path transform="scale(1 -1)" fill="#37474f" d="M.899-144.42h144.42V0H.899z"/></clipPath></defs><g transform="matrix(1.0691 0 0 -1.0691 9.4 166.143)" clip-path="url(#gia)"><g fill-rule="evenodd"><path d="M107.96 30.661l-12.506-1.876-16.883-1.876-10.943-.312-10.629.312-8.13 2.502-7.19 7.815-5.628 15.945-1.25 3.44-7.504 2.5-4.377 7.191-3.126 10.317 3.44 9.067 8.128 2.814 6.565-3.127 3.127-6.878 3.752.626 1.25 1.563-1.25 7.19-.313 9.068 1.876 12.505-.074 7.143 5.701 9.114 10.005 7.19 17.508 7.504 19.383-2.814 16.883-12.193 7.817-12.505 5.002-9.067 1.25-22.51-3.752-19.384-6.877-17.195-6.566-9.066" fill="#f0d6b7"/><path d="M97.334-23.425l-44.709-1.876v-7.503l3.752-26.262-1.876-2.19-31.264 10.63-2.19 3.752-3.126 35.328-7.19 21.26-1.563 5.002 25.01 17.195 7.818 3.127 6.877-8.441 5.94-5.315 6.88-2.188 3.125-.938L68.57 1.899l2.814-3.44 7.19 2.502-5.002-9.693 27.2-12.818-3.439-1.876" fill="#335061"/><path d="M23.238 85.687l8.128 2.814 6.566-3.127 3.127-6.878 3.751.626.938 3.751-1.876 7.19 1.876 17.197-1.563 9.379 5.627 6.565 12.193 9.692-3.44 4.69-17.194-8.442-7.191-5.627-4.064-8.754-6.253-8.442-1.876-10.005 1.251-10.63" fill="#6d6b6d"/><path d="M36.055 115.07s4.69 11.567 23.448 17.195c18.759 5.628.938 4.065.938 4.065l-20.321-7.817-7.817-7.816-3.438-6.253 7.19.626M26.676 87.875s-6.566 21.886 18.446 25.012l-.938 3.752-17.195-4.065-5.003-16.257 1.251-10.63 3.439 2.188" fill="#dcd9d8"/></g><g fill="#f7e4cd"><path d="M36.681 58.799l4.094 3.966s1.847-.214 2.16-2.402c.312-2.19 1.25-21.886 14.693-32.516 1.227-.97-10.004 1.564-10.004 1.564L37.62 45.042M94.209 64.739s.729 9.477 3.28 8.748c2.553-.729 2.553-3.28 2.553-3.28s-6.198-4.01-5.833-5.468" fill-rule="evenodd"/><path d="M120.16 99.442s-5.153-1.088-5.628-5.628c-.474-4.54 5.628-.938 6.566-.625M82.327 99.129s-6.879-.938-6.879-5.314c0-4.378 7.817-4.065 10.005-2.19"/><g fill-rule="evenodd"><path d="M39.807 78.808s-11.881 7.191-13.131.312c-1.25-6.877-4.065-11.88 1.876-19.07l-4.064 1.25-3.752 9.691-1.25 9.38 7.19 7.504 8.129-.626 4.69-3.751.312-4.69M45.435 98.504s5.315 27.512 32.203 32.827c22.136 4.375 33.765-.938 38.142-5.94 0 0-19.696 23.447-38.455 16.257-18.759-7.191-32.514-20.322-32.202-28.762.532-14.377.313-14.382.313-14.382M117.97 122.27s-9.066.312-9.38-7.817c0 0 0-1.25.625-2.5 0 0 7.192 8.129 11.568 3.751"/><path d="M78.268 111.1s-1.56 12.477-12.199 5.223c-6.878-4.69-6.252-11.255-5.002-12.505s.91-3.77 1.862-2.04c.952 1.728.638 7.356 4.078 8.918 3.439 1.564 9.077 3.31 11.26.404"/></g></g><g fill="#49728b" fill-rule="evenodd"><path d="M48.874 26.597L19.486 13.466s12.193-48.46 5.94-63.467l-4.377 1.563-.313 18.446-8.128 35.015-3.44 9.692 30.639 20.633 9.067-8.753M51.896-.206l4.17-5.087v-18.76h-5.003s-.625 13.132-.625 14.696c0 1.563.624 7.19.624 7.19M52-26.866l-14.069-.625 4.065-2.813L52-31.868"/></g><g fill-rule="evenodd"><path d="M100.15-23.739l11.567.313 2.814-28.764-11.881-1.563-2.5 30.014" fill="#335061"/><path d="M103.27-23.739l17.508.938s7.19 18.133 7.19 19.07c0 .939 6.253 26.263 6.253 26.263l-14.069 14.694-2.813 2.501-7.504-7.503V3.148l-6.565-26.887" fill="#335061"/><path d="M111.09-21.55l-10.942-2.188 1.563-8.755c4.064-1.876 10.943 3.127 10.943 3.127M111.4 33.162l21.885-16.257.626 7.503-16.57 15.32-5.94-6.566" fill="#49728b"/><path d="M62.85-85.332l-6.473 26.266-3.22 19.38-.531 14.385 29.296 1.56 18.226.003-1.658-32.83 2.814-25.324-.312-4.69-23.76-1.876-14.382 3.126" fill="#fff"/><path d="M96.083-23.426s-1.563-32.515 3.127-55.65c0 0-9.38-5.94-23.136-7.503l26.262.938 3.126 1.875-3.752 51.273-.938 10.944" fill="#dcd9d8"/><path d="M115.06-49.691l12.193 3.44 23.135 1.25 3.44 10.629-6.254 18.446-7.19.938-10.005-3.127-9.599-4.686-5.095.935-3.972-1.56" fill="#fff"/><path d="M114.84-43.435s8.128 3.751 9.38 3.438L120.78-22.8l4.065 1.563s2.814-16.257 2.814-18.133c0 0 17.507-.938 19.07-.938 0 0 3.752 7.191 2.814 14.694l3.44-10.005.312-5.628-5.002-7.503-5.627-1.25-9.38.312-3.126 4.064-10.943-1.563-3.44-1.25" fill="#dcd9d8"/></g><path d="M102.56-21.241L95.682-3.733l-7.19 10.317s1.562 4.377 3.75 4.377h7.192l6.878-2.501-.625-11.568-3.127-18.134" fill="#fff"/><path d="M103.9-15.297S95.145 1.585 95.145 4.086c0 0 1.563 3.752 3.752 2.814 2.19-.938 6.879-3.439 6.879-3.439v5.94l-10.63 2.19-7.19-.939 12.193-28.763 2.5-.313" fill="#dcd9d8" fill-rule="evenodd"/><path d="M65.664 25.968l-8.661.942-8.13 2.501v-2.814l3.972-4.38 12.506-5.627" fill="#fff"/><path d="M51.689 25.031s9.693-4.065 12.819-3.127l.311-3.748-8.752 1.872-5.316 3.752.938 1.251" fill="#dcd9d8" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43" fill="#d33833" fill-rule="evenodd"/><path d="M115.03 9.897c-5.305.156-10.098.786-14.294 1.97.285 1.72-.249 3.408.18 4.647 1.17.843 3.13.83 4.898 1.027-1.529.752-3.677 1.049-5.44.615-.042 1.194-.578 1.934-.902 2.868 2.982 1.064 10.024 8.044 13.984 5.732 1.887-1.099 2.689-7.377 2.835-10.43.122-2.533-.23-5.088-1.261-6.43z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669" fill="#d33833" fill-rule="evenodd"/><path d="M89.66 18.569c-.014-.401-.03-.806-.047-1.21-1.656-1.089-4.33-1.076-6.148-1.99 2.68-.117 4.79-.763 6.614-1.672l-.118-3.033c-3.036-2.078-5.81-5.173-9.384-7.122-1.69-.922-7.622-3.294-9.42-2.875-1.017.236-1.109 1.499-1.516 2.689-.866 2.548-2.861 6.605-3.035 10.44-.222 4.846-.71 12.967 4.51 11.969 4.213-.804 9.113-2.745 12.375-4.527 1.993-1.09 3.146-2.436 6.17-2.669z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695" fill="#d33833" fill-rule="evenodd"/><path d="M92.675 12.788c-.463 2.64-.999 3.393-.792 5.695 7.04 4.693 8.361-8.061.792-5.695z" fill="none" stroke="#d33833" stroke-width="2"/><path d="M102.87 10.649s-2.19 3.127-.626 4.065c1.564.938 3.127 0 4.065 1.563s0 2.501.313 4.377 1.877 2.189 3.44 2.501c1.562.313 5.94.938 6.565-.625l-1.876 5.627-3.752 1.25-11.88-6.877-.626-3.44v-6.877M70.041.331c-.376 4.88-.773 9.752-1.215 14.626-.662 7.279 1.748 6.009 8.057 6.009.964 0 5.933-1.15 6.289-1.876 1.705-3.483-2.851-2.709 1.964-5.335 4.065-2.216 11.246 1.346 9.603 6.273-.919 1.095-4.789.341-6.176 1.06l-7.327 3.8c-3.108 1.612-10.29 3.962-13.603 1.709-8.395-5.71.53-19.974 3.524-25.93" fill="#ef3d3a" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M78.268 111.1c-8.521 1.985-12.755-3.566-15.338-9.323-2.306.559-1.389 3.695-.806 5.294 1.525 4.194 7.672 9.778 12.694 9.02 2.161-.325 5.086-2.301 3.45-4.99M119.79 101.4l.404-.016c1.926-4 3.593-8.238 6.022-11.769-1.628-3.79-12.322-7.144-12.157-.338 2.313 1.01 6.305.206 8.356 1.497-1.186 3.254-2.897 6.024-2.625 10.626M82.63 101.29c1.827-3.35 2.422-6.868 5.019-9.4 1.17-1.14 3.444-2.529 2.316-5.698-.263-.747-2.189-2.414-3.3-2.741-4.06-1.2-13.521-.248-10.317 4.814 3.358-.157 7.871-2.18 10.38.257-1.927 3.081-5.363 9.177-4.098 12.768M118.26 67.253c-6.113-3.927-12.93-8.197-22.947-7.207-2.14 1.86-2.956 6.002-.877 8.737 1.082-1.861.402-5.284 3.419-5.799 5.684-.972 12.299 3.477 16.387 5.032 2.535 4.275-.219 5.847-2.503 8.597-4.675 5.636-10.947 12.622-10.72 21.06 1.89 1.37 2.053-2.092 2.325-2.722 2.44-5.714 8.585-13.021 13.07-17.912 1.1-1.205 2.914-2.36 3.115-3.157.582-2.315-1.513-5.09-1.27-6.63M37.668 71.387c-1.916 1.094-2.372 5.91-4.622 6.048-3.215.195-2.629-6.25-2.616-10.018-2.213 2.009-2.602 8.194-.976 11.37-1.853.91-2.68-1.003-3.708-1.677 1.32 9.595 14.036 4.45 11.922-5.723M122.15 63.257c-2.846-5.417-6.871-11.382-15.222-11.555-.17 1.75-.3 4.411.009 5.464 6.384.614 10.325 3.863 15.212 6.091M82.149 59.745c5.326-2.8 15.114-3.102 22.353-2.89.388-1.586.379-3.545.394-5.48-9.305-.463-20.307 1.84-22.747 8.37M81.136 54.523c3.683-9.247 16.341-8.182 27.016-7.927-.47-1.2-1.489-2.62-2.755-3.132-3.42-1.392-12.855-2.448-17.604.074-3.011 1.601-4.946 5.219-6.596 7.34-.797 1.024-4.765 3.64-.06 3.645"/></g><path d="M117.82 3.516c-4.322-7.402-8.457-15.005-13.585-21.534 2.15 6.32 3.07 16.9 3.394 24.965 4.498 2.105 8.349-.474 10.191-3.43" fill="#81b0c4" fill-rule="evenodd"/><g fill="#231f20" fill-rule="evenodd"><path d="M141.07-23.089c-4.839-.969-8.239-5.671-12.959-5.37 2.594 3.658 7.14 5.2 12.959 5.37M143.21-30.661c-3.944-.417-8.576-1.055-12.577-.726 1.894 2.892 9.19 1.894 12.577.726M144.58-37.19c-4.433-.096-9.942-.008-14.155.346 2.492 2.677 11.28.993 14.155-.346"/></g><g fill-rule="evenodd"><path d="M109.48-55.057c.636-5.567 2.843-11.207 2.566-17.304-2.45-.827-3.858-1.55-7.142-1.545-.232 5.181-.925 13.102-.718 18.041 1.615-.107 3.997 1.154 5.294.808" fill="#dcd9d8"/><path d="M102.33 26.985c-2.226-1.453-4.121-3.267-6.259-4.818-4.74-.235-7.327.328-10.81 3.05.057.219.407.121.42.39 5.075-2.262 11.524.92 16.648 1.378" fill="#f0d6b7"/><path d="M75.694-7.603c1.394 6.04 6.857 9.17 11.817 12.497 5.12-6.498 8.234-14.855 11.663-22.92-8.102 2.443-16.38 6.406-23.481 10.423" fill="#81b0c4"/><path d="M104.18-55.865c-.207-4.94.486-12.86.718-18.041 3.283-.004 4.691.718 7.142 1.545.276 6.096-1.93 11.737-2.566 17.304-1.298.346-3.679-.914-5.294-.808zm-51.13 28.09c2.165-19.906 5.301-36.639 11.054-54.266 12.766-3.876 28.157-4.214 39.441-.716-2.072 9.948-1.167 22.06-2.378 32.677-.912 7.98-.447 16.009-1.698 24.15-13.673 2.844-33 .665-46.418-1.845zm49.651 1.72c-.115-8.549.383-16.982 1.036-25.542 3.282.493 5.51.822 8.56 1.49-.99 8.241-.869 17.514-2.886 24.804-2.332-.023-4.385.027-6.71-.752zm16.653 1.378c-1.558.357-3.372.014-4.86-.015.7-6.969 2.397-14.659 2.995-21.974 2.342-.073 3.593 1.032 5.52 1.403.102 6.421-.562 15.268-3.655 20.586zm25.215-23.038c4.882 1.186 7.952 7.165 6.586 13.305-.916 4.127-2.548 11.898-4.295 14.538-1.29 1.953-4.79 4.51-7.584 2.72-4.545-2.91-12.552-3.755-15.867-7.278 1.662-5.534 2.178-13.135 2.864-20.146 5.678-.354 12.665 1.562 17.387-.471-3.297-1.068-7.575-1.077-10.423-2.633 2.328-1.125 7.778-.897 11.332-.035zM99.17-18.025c-3.43 8.063-6.543 16.42-11.663 22.918-4.96-3.327-10.423-6.456-11.817-12.497 7.1-4.017 15.379-7.98 23.481-10.422zm8.453 24.971c-.325-8.065-1.245-18.644-3.395-24.965 5.128 6.53 9.263 14.132 13.585 21.534-1.842 2.957-5.693 5.536-10.19 3.431zm-9.582 3.405c-1.943.21-3.592-2.233-6.117-1.177-.58-.64-1.105-1.333-1.695-1.958 5.579-6.723 8.114-16.262 12.423-24.163 2.312 7.59 2.045 15.904 2.555 24.188-3.177-.201-4.94 2.873-7.166 3.11zm-6.161 8.132c-.208-2.303.328-3.056.791-5.695 7.57-2.367 6.248 10.388-.791 5.695zm-8.394 2.755c-3.261 1.782-8.161 3.723-12.374 4.527-5.222.999-4.732-7.123-4.51-11.968.173-3.836 2.168-7.893 3.035-10.441.406-1.19.498-2.453 1.515-2.69 1.798-.418 7.73 1.954 9.42 2.875 3.575 1.95 6.348 5.045 9.384 7.123.04 1.011.078 2.021.119 3.032-1.826.91-3.935 1.555-6.615 1.673 1.818.914 4.492.901 6.148 1.989.016.405.033.81.047 1.21-3.024.234-4.176 1.58-6.17 2.67zm-31.152 5.659c-2.707-2.748 7.592-6.494 10.871-6.696-.018 1.739.991 3.378.788 4.626-3.895.684-9.013.232-11.66 2.07zm33.345-1.29c-.013-.27-.363-.172-.42-.39 3.482-2.722 6.07-3.285 10.81-3.05 2.137 1.551 4.033 3.365 6.259 4.818-5.124-.458-11.574-3.64-16.648-1.379zm30.606-9.282c-.146 3.053-.948 9.332-2.835 10.431-3.961 2.312-11.002-4.668-13.984-5.732.324-.934.86-1.674.901-2.868 1.764.434 3.912.137 5.44-.615-1.767-.198-3.727-.185-4.897-1.027-.429-1.239.105-2.927-.18-4.647 4.196-1.184 8.989-1.814 14.294-1.97 1.032 1.341 1.383 3.896 1.261 6.429zM47.777 24.24c-.85.606-6.6 8.087-7.388 7.777-10.405-4.103-20.134-11.199-28.828-17.91 8.29-17.787 11.635-39.579 12.227-60.582 9.496-4.441 17.836-10.844 30.722-11.512-1.491 10.55-2.852 19.962-3.699 29.895-3.237 1.365-7.882-.062-10.913.423-.025 3.651 4.628 1.6 5.015 4.054.292 1.858-2.56 1.998-1.631 4.923 2.368-.861 3.612-2.763 6.138-3.477 2.309 5.05-.032 13.985.3 18.205.064.792.397 4.39 2.172 3.759 1.57-.559-.09-9.569.082-13.563.157-3.68-.444-7.242 1.046-9.552a355.817 355.817 0 0 0 38.576 3.16c-2.964 1.272-6.485 2.475-10.345 4.651-2.093 1.18-8.69 3.635-9.293 5.622-.964 3.167 2.528 4.855 3.125 7.57-6.285-3.428-7.511 3.286-8.998 8.042-1.347 4.308-2.114 7.526-2.445 10.01-5.414 2.581-11.203 5.195-15.863 8.505zm63.009 6.872c8.67 4.204 10.232-15.711 6.834-22.127.525-1.914 2.331-2.646 3.069-4.366-4.838-8.667-10.211-16.756-15.148-25.32 3.672 2.286 8.917.409 13.238 2.12 1.58.624 2.722 4.24 3.918 7.133 3.29 7.958 6.743 17.99 8.28 25.586.346 1.73 1.292 5.5 1.08 7.04-.378 2.758-4.12 4.803-6.022 6.508-3.506 3.15-5.714 5.921-9.371 8.866-1.483-2.189-4.666-3.66-5.878-5.44zM27.95 107.99c-4.13-4.545-3.266-13.062-2.766-19.121 7.467 4.697 17.377-.372 17.284-8.36 3.565.094 1.332 4.452.687 7.259-2.107 9.169 3.55 19.13.256 27.516-6.395-.485-11.649-3.097-15.46-7.294zm29.558 26.38c-9.352-2.65-21.337-9.446-25.18-17.847 2.976.432 5.041 1.933 7.977 2.119 1.11.072 2.563-.466 3.838-.148 2.54.63 4.685 6.327 6.602 8.447 1.868 2.07 4.114 2.954 5.651 4.841.988.477 2.448.444 2.504 1.927-.428.457-.879.806-1.392.66zm48.681-2.493c-9.707 5.477-26.136 9.596-36.462 4.449-8.331-4.155-19.593-11.027-23.433-19.737 3.587-8.405-1.062-16.106-1.36-24.64-.157-4.54 2.139-8.504 2.315-13.446-1.228-2.025-4.978-2.275-7.574-2.136-.873 4.372-2.403 9.287-6.906 9.78-6.371.697-11.03-4.576-11.319-10.085-.342-6.48 4.978-17.22 12.517-16.475 2.913.287 3.629 3.207 6.802 3.177 1.72-3.432-2.653-4.51-3.103-6.964-.117-.634.363-3.112.642-4.274 1.37-5.658 4.422-12.982 7.427-17.29 3.814-5.464 11.307-6.288 19.37-6.823 1.44 3.101 6.743 2.846 10.2 2.035-4.143 1.64-7.993 5.617-11.185 9.137-3.665 4.039-7.378 8.371-7.566 13.65 6.927-9.61 12.65-18.003 25.246-22.23 9.53-3.196 20.662 1.465 27.986 6.608 3.039 2.137 4.853 5.529 7.013 8.634 8.082 11.626 11.854 28.219 11.024 44.303-.342 6.633-.327 13.244-2.552 17.706-2.326 4.666-10.193 8.84-14.8 4.62-.853 4.537 3.83 7.344 9.331 5.71-3.922 5.063-8.039 11.145-13.614 14.29zm18.084-149.66c7.585 3.77 21.757 10.149 26.512-.014 1.755-3.746 3.814-10.079 4.723-13.946 1.284-5.456-1.392-16.923-7-18.754-4.953-1.617-10.733-1.518-16.7-.32-.702.585-1.484 1.603-2.03 2.665-4.261.165-8.25-.229-11.615-1.98.319-3.15-1.812-3.656-3.81-4.305-1.48-5.872 2.963-13.541 1.9-18.896-.76-3.815-5.453-4.405-8.902-5.118-.113-2.12.15-3.89.386-5.683-.789-2.907-4.327-4.561-7.679-4.967-11.029-1.326-27.775-1.922-38.384 1.893-2.96 7.261-5.292 16.093-7.758 24.384-10.346-1.105-18.715 4.464-26.603 8.113-2.731 1.266-6.51 1.964-7.53 4.138-.99 2.105-.584 6.14-.83 9.95-.625 9.733-1.16 19.12-3.73 29.086-1.154 4.472-3.165 8.418-4.568 12.727C9.358 5.184 7.092 10.12 6.5 14.1c-.877 5.903 4.681 6.232 8.235 8.79 5.494 3.954 9.806 6.142 15.756 9.711 1.762 1.057 7.077 3.733 7.681 4.966 1.202 2.443-2.062 5.888-2.935 7.803-1.38 3.03-2.1 5.602-2.298 8.59-4.992.789-8.775 3.76-11.06 7.109-3.781 5.543-6.403 15.798-3.132 23.599.257.614 1.536 1.822 1.725 2.765.372 1.858-.7 4.329-.768 6.305-.343 10.14 1.716 18.875 8.541 21.932 2.771 11.038 12.688 14.71 22.032 20.195 3.493 2.05 7.343 3.36 11.32 4.824 14.263 5.25 36.15 4.261 47.987-4.692 5.02-3.797 13.044-11.813 15.914-17.617 7.58-15.323 7.042-40.931 1.74-59.571-.712-2.503-1.746-6.181-3.19-9.187-1.006-2.1-4.134-6.3-3.754-8.153.391-1.916 7.132-7.034 8.577-8.428 2.603-2.51 7.548-5.843 7.948-9.012.43-3.372-1.485-7.984-2.456-11.238-3.245-10.858-6.412-20.895-10.091-30.576" fill="#231f20"/><path d="M73.674 57.38c.411.548 2.674 1.38 5.84-.144 0 0-3.752-.626-3.44-6.881l-1.564.313s-1.615 5.672-.836 6.712" fill="#f7e4cd"/><path d="M101.09 3.617a1.72 1.72 0 1 0-3.44.001 1.72 1.72 0 0 0 3.44-.001M102.81-4.355a1.72 1.72 0 1 0-3.44 0 1.72 1.72 0 0 0 3.44 0" fill="#1d1919"/></g><g><rect transform="matrix(.8 0 0 -.8 0 144)" x="16.854" y="177.38" width="70.412" height="4.12" rx=".983" ry=".983"/><rect transform="scale(1 -1)" x="78.502" y="-2.097" width="50.037" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="13.483" y="-3.697" width="54.831" height="3.296" rx=".786" ry=".786"/><rect transform="scale(1 -1)" x="83.296" y="-3.697" width="45.243" height="3.296" rx=".786" ry=".786"/></g></g></symbol><symbol viewBox="0 0 24 24" id="json" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#fbc02d"/></symbol><symbol viewBox="0 0 50 50" id="julia" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" stroke-width="5.673"><circle cx="13.497" cy="281.632" r="9.555" fill="#bc342d"/><circle cx="36.081" cy="281.632" r="9.555" fill="#864e9f"/><circle cx="24.722" cy="262.389" r="9.555" fill="#328a22"/></g></symbol><symbol viewBox="0 0 64 64" id="karma" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -233)"><path d="M38.556 288.413l-20.29-26.687 9.532-7.246 20.29 26.686h-.001.002l5.527 7.247z" fill="#359b8b" stroke-width=".173"/><path d="M35.681 241.172L24.92 255.327v-14.13H12.947v13.817l7.84 33.235h4.132v-13.147l.003.003 20.29-26.686-.008-.006 5.504-7.24H35.84v.12z" fill="#3cbeae" stroke-width=".206"/></g></symbol><symbol viewBox="0 0 24 24" id="key" xmlns="http://www.w3.org/2000/svg"><path d="M7 14a2 2 0 0 1-2-2 2 2 0 0 1 2-2 2 2 0 0 1 2 2 2 2 0 0 1-2 2m5.65-4A5.99 5.99 0 0 0 7 6a6 6 0 0 0-6 6 6 6 0 0 0 6 6 5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65z" fill="#26a69a"/></symbol><symbol viewBox="0 0 24 24" id="kivy" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.89 0 0 1.89 -12.157 -11.429)" fill="#90a4ae"><path d="M7.026 8.63v4.474l1.928-1.928a.437.437 0 0 0 0-.619zM9.38 16.072v-4.474l-1.927 1.927a.437.437 0 0 0 0 .62zM18.576 10.412l-5.346.564-.017.018 2.39 2.39zM9.922 8.502s.023 3.304-.003 4.452c-.02.856.371 1.114.746 1.507.538.564 1.599 1.57 1.599 1.57a.53.53 0 0 0 .75 0l1.843-1.844a.53.53 0 0 0 0-.75z"/></g></symbol><symbol viewBox="0 0 24 24" id="kl" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:#3aaae1}.b{fill:#fdfeff}</style></defs><title>kl</title><path d="M12.033 1.737c-.25-.003-.5.11-.729.337C8.225 5.15 5.15 8.227 2.078 11.31c-.144.144-.229.346-.341.521v.41c.16.223.294.474.485.666a3259.51 3259.51 0 0 0 8.936 8.937c.193.192.443.325.666.486h.41c.205-.142.436-.256.609-.428 3.046-3.041 6.09-6.083 9.133-9.127.47-.47.472-1.005.006-1.472l-9.218-9.217c-.23-.23-.48-.347-.731-.35zm-1.062 4.545l1.386.832c.702.422 1.403.846 2.109 1.262a.544.544 0 0 1 .04.026l.016.013.017.013c.061.056.089.123.088.224a510.281 510.281 0 0 0 0 3.794.463.463 0 0 1-.007.094c-.015.069-.054.103-.142.109a.464.464 0 0 1-.044.002c-.045-.002-.09-.002-.136-.003-.323-.006-.648-.001-.998-.001v-.527-1.34-.671-.003l.004-.668c0-.147-.039-.231-.17-.308-.893-.528-1.78-1.066-2.67-1.6-.051-.03-.101-.065-.173-.111l.001-.003h-.001zm.362 3.39c.068-.003.119.043.173.138.085.148.174.293.264.44l.015.025c.096.154.194.31.292.47l-1.915 1.176c-.337.207-.673.417-1.014.617-.113.067-.154.143-.154.277.01.977.01 1.955.014 2.932V16H7.7V16h-.002c-.004-.053-.014-.112-.014-.17-.005-1.25-.006-2.501-.015-3.751 0-.142.045-.222.164-.294a467.13 467.13 0 0 0 3.353-2.054l.016-.01a.606.606 0 0 1 .032-.017l.016-.008a.308.308 0 0 1 .033-.013l.012-.004a.157.157 0 0 1 .028-.005l.01-.001zm5.677 3.126l.314.54.346.594v.001c-.158.094-.298.178-.438.259l-3.097 1.798c-.106.062-.189.071-.3.01l-.893-.496-1.524-.843-.895-.493c-.035-.02-.068-.044-.129-.085h.001l.137-.25.495-.902 1.446.795c.442.243.886.483 1.323.734.121.07.212.072.334 0 .894-.525 1.792-1.043 2.689-1.563.057-.034.118-.061.191-.1z" fill="#29b6f6" stroke-width=".041"/></symbol><symbol viewBox="0 0 500 500" id="kotlin" xmlns="http://www.w3.org/2000/svg"><path d="M500 500H0V0h500L250 250z" fill="#7F52FF"></path></symbol><symbol viewBox="0 0 240 240" id="laravel" xmlns="http://www.w3.org/2000/svg"><path d="M216.05 119.036c-1.433.343-24.945 6.673-24.945 6.673l-19.227-28.622c-.537-.828-.99-1.656.359-1.849 1.345-.196 23.195-4.477 24.182-4.723.99-.245 1.837-.536 3.053 1.267 1.21 1.8 17.836 24.626 18.464 25.506.627.877-.447 1.41-1.883 1.748m-4.101 49.326c.588 1.003 1.176 1.64-.67 2.367-1.843.73-62.243 22.847-63.418 23.39-1.173.546-2.092.73-3.607-1.637-1.51-2.362-21.16-39.264-21.16-39.264l64.03-18.075c1.876-.644 2.317-.405 3.103.822 1.074 1.68 21.143 31.403 21.726 32.4m-103.7-21.087c-.78.202-37.566 9.733-39.525 10.22-1.965.485-1.965.246-2.188-.49-.226-.727-43.728-98.053-44.333-99.271-.605-1.214-.574-2.177 0-2.177.571 0 34.734-3.313 35.944-3.383 1.207-.07 1.08.205 1.526 1.033l49.025 91.818c.84 1.58 1.239 1.81-.452 2.248m94.588-59.77c-3.5-4.58-5.2-3.751-7.357-3.41-2.154.336-27.277 4.915-30.194 5.449-2.918.536-4.758 1.803-2.963 4.53 1.597 2.422 18.113 27.824 21.751 33.42l-65.663 17.066L66.18 49.832c-2.075-3.342-2.507-4.514-7.236-4.28-4.735.23-40.969 3.495-43.55 3.731-2.58.233-5.416 1.479-2.835 8.09 2.583 6.612 43.734 102.82 44.88 105.62 1.149 2.803 4.128 7.345 11.11 5.527 7.157-1.871 31.969-8.894 45.52-12.742 7.163 14.07 21.77 42.619 24.473 46.707 3.607 5.459 6.089 4.56 11.626 2.738 4.325-1.42 67.65-26.129 70.502-27.4 2.855-1.273 4.613-2.184 2.685-5.275-1.419-2.28-18.124-26.558-26.876-39.26 5.993-1.733 27.305-7.888 29.575-8.557 2.646-.779 3.008-2.19 1.572-3.94-1.436-1.755-21.293-28.72-24.79-33.296z" fill="#ff5722" stroke="#ff5722" stroke-width="8.852" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="less" xmlns="http://www.w3.org/2000/svg"><path d="M13.696 2.999V5h2.002v5a2 2 0 0 0 1.999 2 2 2 0 0 0-2 2v5h-2v2h2a2 2 0 0 0 2-2v-4a2 2 0 0 1 2-2h1V11h-1a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2.001zm-.03 12.766v.47a1 1 0 0 0 .03-.236 1 1 0 0 0-.03-.234zM10.566 21v-2.001H8.565v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2.001v-2H8.565a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-.999V13h1a2 2 0 0 1 2 2v3.999A2 2 0 0 0 8.564 21zm.03-12.766v-.47a1 1 0 0 0-.03.236 1 1 0 0 0 .03.234z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="lib" xmlns="http://www.w3.org/2000/svg"><path d="M19 7H9V5h10m-4 10H9v-2h6m4-2H9V9h10m1-7H8a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2M4 6H2v14a2 2 0 0 0 2 2h14v-2H4V6z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 40 40" id="livescript" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -257)" fill="#317eac"><path stroke-width="3.299" d="M5.419 260.18h3.685v34.207H5.419z"/><path stroke-width="3.299" d="M37.074 288.197v3.685H2.867v-3.685z"/><path stroke-width="2.894" d="M29.612 265.658l2.004 2.005L7.428 291.85l-2.004-2.005z"/><path stroke-width="2.325" d="M10.73 262.471h2.835v22.08H10.73z"/><path stroke-width="2.063" d="M15.36 262.519h2.835v17.382H15.36z"/><path stroke-width="1.77" d="M19.99 262.471h2.835v12.802H19.99z"/><path stroke-width="1.422" d="M24.526 262.491h2.835v8.254h-2.835z"/><path stroke-width="1.128" d="M28.783 262.463h2.835v5.197h-2.835z"/><path stroke-width="2.325" d="M34.801 286.545v-2.835h-22.08v2.835z"/><path stroke-width="2.063" d="M34.753 281.914v-2.835H17.371v2.835z"/><path stroke-width="1.77" d="M34.801 277.284v-2.835H21.999v2.835z"/><path stroke-width="1.422" d="M34.781 272.749v-2.835h-8.254v2.835z"/><path stroke-width="1.128" d="M34.809 268.492v-2.835h-5.197v2.835z"/></g></symbol><symbol viewBox="0 0 24 24" id="lock" xmlns="http://www.w3.org/2000/svg"><path d="M12 17a2 2 0 0 0 2-2 2 2 0 0 0-2-2 2 2 0 0 0-2 2 2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5 5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3z" fill="#ffd54f"/></symbol><symbol viewBox="0 0 24 24" id="lua" xmlns="http://www.w3.org/2000/svg"><circle cx="12.203" cy="12.102" r="10.322" fill="none" stroke="#42a5f5"/><path d="M12.33 5.746a6.483 6.381 0 0 0-6.482 6.381 6.483 6.381 0 0 0 6.482 6.38 6.483 6.381 0 0 0 6.484-6.38 6.483 6.381 0 0 0-6.484-6.38zm1.86 1.916a2.329 2.292 0 0 1 2.33 2.293 2.329 2.292 0 0 1-2.33 2.291 2.329 2.292 0 0 1-2.329-2.29 2.329 2.292 0 0 1 2.328-2.294z" fill="#42a5f5" fill-rule="evenodd"/><ellipse cy="4.615" cx="19.631" rx="2.329" ry="2.292" fill="#42a5f5" fill-rule="evenodd"/></symbol><symbol viewBox="0 0 24 24" id="markdown" xmlns="http://www.w3.org/2000/svg"><path d="M2 16V8h2l3 3 3-3h2v8h-2v-5.17l-3 3-3-3V16H2m14-8h3v4h2.5l-4 4.5-4-4.5H16V8z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" preserveAspectRatio="xMidYMid" id="markojs" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -120.96)" stroke-width=".984"><path d="M4.002 126.482c-.655 1.07-1.32 2.14-1.976 3.21-.655 1.06-1.308 2.142-1.963 3.212l.002.002-.002.002c.655 1.07 1.308 2.15 1.963 3.211.655 1.07 1.32 2.141 1.976 3.211h3.33c-.664-1.07-1.318-2.14-1.974-3.21-.653-1.069-1.307-2.145-1.961-3.214.654-1.068 1.308-2.146 1.961-3.215a601.93 601.93 0 0 1 1.974-3.209z" fill="#2196f3"/><path d="M3.999 126.482l-.002.002c.655 1.07 1.31 2.15 1.964 3.212.655 1.07 1.32 2.14 1.974 3.21h3.331c-.664-1.07-1.319-2.14-1.974-3.21-.653-1.068-1.306-2.146-1.96-3.214z" fill="#26a69a"/><path d="M15.203 126.482l.002.002c-.655 1.07-1.31 2.15-1.965 3.212-.655 1.07-1.319 2.14-1.974 3.21h-3.33c.664-1.07 1.318-2.14 1.973-3.21.654-1.069 1.307-2.146 1.961-3.214z" fill="#8bc34a"/><path d="M11.874 126.484c.664 1.07 1.318 2.14 1.974 3.21.653 1.068 1.307 2.146 1.961 3.214-.654 1.069-1.308 2.145-1.961 3.213-.656 1.07-1.31 2.14-1.974 3.21h3.33c.655-1.07 1.319-2.14 1.974-3.21.655-1.06 1.31-2.14 1.966-3.21l-.002-.003.002-.002c-.656-1.07-1.311-2.152-1.966-3.213-.655-1.07-1.319-2.138-1.974-3.209z" fill="#ffc107"/><path d="M16.74 126.482c.665 1.07 1.319 2.14 1.974 3.21.654 1.068 1.306 2.146 1.96 3.214-.654 1.069-1.306 2.145-1.96 3.213-.655 1.07-1.31 2.141-1.974 3.211h3.33c.656-1.07 1.32-2.14 1.974-3.21.655-1.062 1.31-2.141 1.966-3.212l-.002-.002.002-.002c-.655-1.07-1.31-2.152-1.966-3.213-.655-1.07-1.318-2.138-1.973-3.209z" fill="#f44336"/></g></symbol><symbol viewBox="0 0 23 24" id="mathematica" xmlns="http://www.w3.org/2000/svg"><path d="M11.512 1.523l-.073.025-.46.794-.454.763-1.217 2.09H9.29L5.435 3.5l-.1-.047h-.018v.092l.025.163v.086l.132 1.226v.082l.032.252v.082l.22 2.137v.075l.018.082v.06l-2.348.507-.04.015-.457.1-.025.01h-.042l-1.096.244-.04.007-.17.036v.082l.018.01 1.859 2.086.053.052.114.132.804.909v.005l-.053.05-.22.257-2.564 2.875-.01.007v.082l.071.006.295.075 1.697.366v.006l2.139.472h.015v.047l-.036.252v.08l-.046.412v.082l-.036.244v.082l-.045.412v.08l-.05.41v.08l-.036.244v.082l-.046.412v.082l-.05.407v.082l-.032.248V20l-.05.407v.104h.037l3.642-1.6.294-.134h.018l.177.312.539.911.015.032.854 1.465.16.262.404.695.007.022h.092l.005-.022.017-.025.56-.947.014-.042.6-1.033.316-.539.644-1.091.05.013 3.906 1.721h.035v-.085l-.138-1.32v-.082l-.032-.244v-.082l-.035-.245v-.085l-.033-.244v-.081l-.032-.245v-.082l-.032-.244v-.085l-.035-.245v-.082l-.032-.245v-.082l-.033-.244v-.085l-.025-.17v-.053l1.632-.354.043-.008.458-.107h.028v-.01l.23-.05.03-.01h.042l.382-.09.025-.01h.043l.194-.05h.033l1.015-.23.07-.007v-.064l-.015-.013-1.19-1.342-.028-.028-.197-.22-1.428-1.604v-.006l.295-.323.4-.457 2.148-2.408.015-.01v-.065l-.035-.008-1.288-.28-.372-.084-.047-.01-2.481-.544v-.045l.432-4.265v-.02h-.042l-.302.135-.01.014h-.025l-3.307 1.45-.297.135h-.015l-2.028-3.483-.099-.145-.014-.045zm-.001 1.114l1.365 2.323.34.592-.008.025-1.18 1.511-.517.66-.012-.01-.258-.335-.04-.05-1.397-1.787.03-.063 1.378-2.365.287-.491zm4.908 2.039l-.007.025-.168.225-.538.066zm-9.817.004l.053.02.677.3h-.499l-.224-.3zM16.947 5l-.123 1.248-.113-.928.226-.307zm-9.26.156l.053.024.705.309-.757-.175zm7.388.116l.02.168-1.318.403.003-.003.16-.071 1.015-.444zM9.669 6.388l.944 1.204v.01L9.483 7.2zm3.55.172l.21.682-.234.084-.089.022-.702.255.008-.022.776-.982zm-5 .836l.986.356.898.312.048.02 1.054.373.011 3.086-.362-.117-.67-.224-.081-.038-.735-.245-.77-.256-.29-.1-.011-.255-.032-1.195-.01-.287-.015-.894-.013-.297zm6.583 0l-.011.227-.028.9-.008.303-.032 1.475-.01.262-.337.117-.734.245-.77.256-.712.245-.355.117.01-3.086 1.632-.578zm.585.437l.09.735.79-.097-.915 1.302-.018.006.01-.183.018-.877zm-9.451.536l.152.22 1.447 2.049-2.607.968-.05.015-1.972-2.214-.28-.312.003-.01.115-.018.424-.1.14-.021.337-.078.042-.01zm11.146.003l3.284.713.029.01-.022.025-1.954 2.192-.277.312-.092-.036-2.564-.95.475-.681.152-.216zM6.787 8.52h.86l.036 1.258-.013-.006-.763-1.078zm1.358 2.625l.152.06.77.252.712.245.746.247.49.167-.065.092-1.723 2.334-1.015-.302-.082-.017-.035-.015-1.902-.56.938-1.22.981-1.277zm6.73 0l.033.006 1.787 2.327.132.17-.128.036-.032.014-2.196.642-.105.032-.564.17-.018-.003-1.053-1.44-.174-.239-.547-.726-.007-.018.469-.16.769-.254.713-.245.77-.252zm-7.766.305l-.007.02-.405.523-.291-.291.657-.245zm8.802 0l.043.007.578.212.714.27-.661.394-.375-.479-.03-.042-.262-.342zm-10.843.75l-.67.668.355-.397.207-.23zm12.911.016l.068.025.045.042.554.627.042.043.204.228-.255.135zm-6.473.265l.022.015 1.38 1.872.032.05.343.465.008.031-.088.117-.422.629-.047.074-.245.343-.97 1.43-.013.007-1.18-1.72-.096-.16-.493-.708-.008-.037 1.618-2.191.007-.01zm7.827 1.194l.565.633.063.082-.272-.093-.037-.013zm-15.785.148l.297.299-.637.218-.152.05.038-.058zm13.224.47l-.855.448.346.66-.185-.058-.27-.088-1.092-.348.012-.01zm-9.687.255l1.222.356-.006.007-.458.145-.443.135-.032.01-.49.157zm-2.765.048l.318.32 2.007.517-.567.18-.055.004-2.103-.469-.744-.156.007-.006zm14.966.205l.548.188v.003l-.457.1-.043.014-1.069.23zm-10.23.507l.007.227.01.347.025 1.363.025.691-.007.255-.24.107-2.863 1.255.032-.372.033-.255.017-.227.031-.256.037-.407.045-.42.018-.23.032-.251.032-.412.05-.414.013-.14 1.455-.457.003-.014.301-.098zm4.908 0l1.245.39v.014l.312.1 1.146.362.022.23.03.255.043.408.04.42.017.23.033.251.032.412.042.325.078.848-.078-.04-3.025-1.322-.004-.305.06-2.368zm-4.295.617l.015.007.067.107.6.875-.64.531-.034-1.438zm3.671 0h.008l-.005.06-.02.678-.005.214-.479-.223zm-2.888 3.605l.763.915.001.37-.017-.006-.025-.05-.464-.791-.012-.018zm1.53.61l.184.083-.343.586-.018.007.002-.532z" fill="#f44336" fill-rule="evenodd" stroke="#f44336" stroke-width=".7747499999999999" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 720 720" id="matlab" xmlns="http://www.w3.org/2000/svg"><title>Layer 1</title><path d="M209.247 329.98L52.368 387.638l121.325 85.822 96.752-95.804-61.198-47.674z" fill="#4db6ac" fill-rule="evenodd" stroke-width=".3"/><path d="M480.193 71.446c-13.123 1.784-9.565 1.013-28.4 16.09-18.008 14.418-69.925 100.347-97.673 129.256-24.688 25.722-34.46 12.199-60.102 33.661-25.68 21.494-65.273 64.464-65.273 64.464l63.978 47.32L394.15 222.754c23.948-32.932 23.694-37.266 36.744-71.82 6.384-16.907 17.76-29.9 27.756-45.809 12.488-19.874 30.186-34.855 21.543-33.68z" fill="#00897b" fill-rule="evenodd" stroke-width=".3"/><path d="M478.206 69.796c-31.268-.189-62.068 137.245-115.56 242.691-54.543 107.519-162.235 176.82-162.235 176.82 18.156 8.243 34.681 4.91 54.236 23.394 13.375 16.164 52.09 95.976 75.174 146.117 0 0 18.964-10.297 42.994-27.695 24.03-17.397 53.124-41.896 73.384-70.3 26.883-37.692 47.897-61.043 65.703-75.271 17.806-14.23 32.404-19.336 46.458-20.54 50.238-4.305 124.582 85.792 124.582 85.792S527.267 70.09 478.206 69.796z" fill="#ffb74d" fill-rule="evenodd" stroke-width=".3"/></symbol><symbol viewBox="0 0 24 24" id="merlin" xmlns="http://www.w3.org/2000/svg"><text style="line-height:1.25;-inkscape-font-specification:'Century Gothic Bold'" x="1.953" y="21.178" transform="scale(.99582 1.0042)" font-weight="700" font-size="30.255" font-family="Century Gothic" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-width=".756"><tspan x="1.953" y="21.178" style="-inkscape-font-specification:'Century Gothic Bold'" font-size="22.745">M</tspan></text></symbol><symbol viewBox="0 0 192 191.99999" id="mocha" xmlns="http://www.w3.org/2000/svg"><title>Mocha Logo</title><g transform="translate(-354.75 -262.42) scale(4.835)" fill="#a1887f"><path d="M103.6 69.6c0-.5-.4-1-1-1H83.8c-.5 0-1 .4-1 1 0 3.4.5 15.1 5.5 20.8.2.2.4.3.7.3h8.4c.3 0 .5-.1.7-.3 5-5.6 5.5-17.3 5.5-20.8zm-7.4 18.2h-5.9c-.3 0-.5-.1-.7-.3-3.4-4-3.8-12-3.9-14.8 0-.5.4-1 1-1h13.2c.5 0 1 .4 1 1 0 2.8-.5 10.7-3.9 14.8-.3.2-.5.3-.8.3zM95.1 66.6s3.6-2.1 1.4-5.9c-1.3-2-1.9-3.7-1.4-4.4-1.3 1.6-3.5 3.3-1.1 6.9.8.9 1.2 2.8 1.1 3.4zM91.1 66.9s2.4-1.4.9-4c-.9-1.3-1.3-2.5-.9-2.9-.9 1.1-2.3 2.2-.7 4.7.5.5.7 1.8.7 2.2z"/><path d="M99.3 78.5c-.4 2.7-1.2 5.8-2.9 7.8-.2.2-.4.3-.6.3h-5c-.2 0-.5-.1-.6-.3-1.2-1.5-2-3.5-2.5-5.6 0 0 5.8.8 9.1-.4 2.4-.9 2.5-1.8 2.5-1.8z"/></g></symbol><symbol viewBox="0 0 24 24" id="movie" xmlns="http://www.w3.org/2000/svg"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V4h-4z" fill="#ff9800"/></symbol><symbol viewBox="0 0 24 24" id="music" xmlns="http://www.w3.org/2000/svg"><path d="M16 9V7h-4v5.5c-.42-.31-.93-.5-1.5-.5A2.5 2.5 0 0 0 8 14.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5V9h3m-4-7a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2z" fill="#ef5350"/></symbol><symbol viewBox="0 0 24 24" id="mxml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#ffa726"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-actions" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#ab47bc" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-effects" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#26c6da" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-reducer" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#e53935" stroke-width="12.914"/></symbol><symbol viewBox="0 0 300 300" id="ngrx-state" xmlns="http://www.w3.org/2000/svg"><path d="M150 27.324L35.85 68.006l17.303 151.09 96.843 53.586 96.843-53.586 17.303-151.09zm-23.719 38.349c4.346-.075 9.04 1.316 14.265 4.131 2.3 1.24 9.235 2.994 15.407 3.889 21.936 3.18 47.975 19.934 56.21 36.186 5.667 11.183 4.508 17.209-4.18 21.702-7.492 3.874-22.822 2-45.08-5.517l-18.785-6.343-6.683 2.552c-9.683 3.698-19.366 12.877-23.33 22.09-2.858 6.645-3.293 9.768-2.77 20.705.523 10.955 1.315 14.12 5.2 20.997 4.423 7.829 14.576 17.818 16.331 16.064.473-.473-.574-3.648-2.308-7.048-1.735-3.4-2.744-6.825-2.26-7.606.482-.781 5.054 2.123 10.157 6.44 11.35 9.6 24.608 15.74 36.77 17.01 9.985 1.045 12.266-.814 4.787-3.912-2.41-.998-5.544-3.088-6.95-4.641-2.907-3.212-3.072-3.12 9.356-5.906 7.736-1.733 23.026-9.849 23.937-12.71.29-.91-2.195-1.296-6.27-.972-3.706.295-6.732-.087-6.732-.85 0-.76 3.032-4.523 6.732-8.385 13.883-14.489 18.62-25.32 20.098-45.906l1.02-14.217 3.257 6.756c3.601 7.452 4.265 18.202 1.701 27.437-2.141 7.711-.712 8.564 3.208 1.92 4.845-8.212 6.39-6.905 5.54 4.666-.924 12.587-5.243 22.017-14.993 32.686-7.95 8.699-7.001 10.254 2.624 4.326 9.273-5.711 10.511-4.815 5.736 4.155-9.031 16.964-28.122 31.35-47.948 36.161-12.016 2.917-20.537 3.461-31.544 2.018-28.78-3.775-56.001-23.157-68.993-49.114-3.378-6.748-8.154-14.994-10.62-18.348-5.092-6.924-5.529-10.038-2.09-15.286 1.715-2.618 2.116-5.307 1.41-9.308-3.273-18.531-3.167-19.11 4.276-26.659 6.468-6.56 6.878-7.44 6.878-15.092 0-6.637.671-8.813 3.67-11.811 2.02-2.02 5.23-3.7 7.12-3.718 5.49-.05 14.97-5.135 20.584-11.033 4.687-4.927 9.674-7.417 15.262-7.51z" fill="#9ccc65" stroke-width="12.914"/></symbol><symbol viewBox="0 0 24 24" id="nim" xmlns="http://www.w3.org/2000/svg"><path d="M4.464 15.75L2.288 3.78l5.985 7.617L12.08 3.78l3.809 7.617 5.985-7.617-2.177 11.97H4.464m15.234 3.264a1.088 1.088 0 0 1-1.088 1.088H5.553a1.088 1.088 0 0 1-1.089-1.088v-1.089h15.234z" stroke-width="1.088" fill="#ffca28"/></symbol><symbol viewBox="0 0 500 500" id="nix" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-1.965 36.302)" stroke-width=".395"><path d="M135.59 415.7c0-.295-2.752-5.283-6.116-11.084-3.364-5.801-6.116-10.776-6.116-11.055s9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694 0 .14-11.836.21-26.302.154-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.365.96h-13.229c-10.896 0-13.229-.095-13.229-.538zm167.58-125.61c-.134-.216 1.188-2.863 2.938-5.882 6.924-11.944 84.291-145.75 96.491-166.88 7.143-12.371 13.142-22.465 13.333-22.433.363.062 25.861 43.105 25.861 43.655 0 .174-6.761 11.952-15.026 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.564.564 30.213.564h29.996l.958 1.48c.526.814 3.296 5.547 6.155 10.518 2.859 4.971 5.45 9.29 5.756 9.597.706.705.704.724-.16 1.572-.395.388-3.36 5.323-6.587 10.965-3.228 5.643-6.056 10.387-6.285 10.543-.23.156-19.695.171-43.256.034l-42.84-.249-.804 1.15c-.441.632-7.504 12.736-15.696 26.897l-14.892 25.747H339.03c-8.517 0-20.015.116-25.55.259-6.55.168-10.15.121-10.309-.135zM169.42 132.23c-56.373-.055-102.5-.182-102.5-.282 0-.1 5.617-10.132 12.481-22.294l12.481-22.112h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427-8.139-14.199-14.797-25.917-14.797-26.04 0-.123 2.682-4.853 5.96-10.51s6.003-10.578 6.055-10.934c.086-.586 1.376-.648 13.572-.648 7.413 0 13.463.143 13.446.317-.017.174.222.707.531 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.118.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z" fill="#5075c1"/><path d="M25.289 203.14c-6.098 10.563-6.69 11.711-6.225 12.078.283.224 3.18 5.044 6.44 10.712 3.261 5.668 6.017 10.355 6.124 10.417.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.472.273-1.08 3.094-14.637 26.574L62.06 289.793l12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956.086.05 23.257-39.831 51.492-88.624 11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.097-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.024.037c-.011.003-1.62-.001-1.825 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.568.03-1.147-.03-1.29-.132-.142-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.199 21.131 89.393 155.03 96.276 167 1.502 2.613 2.92 4.803 3.443 5.348.9-1.249 3.531-5.63 7.954-13.219a1342.88 1342.88 0 0 1 10.049-17.76l6.606-11.443c.692-1.403.754-1.818.653-2.117-.162-.48-6.904-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.991-26.32a.73.73 0 0 1-.009-.366l-.426-.913L359.42 72.5c3.69-6.307 6.425-11.042 9.47-16.29 9.159-15.948 12.037-21.189 11.896-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.145.035-.42.112-5.454.195-11.189.185-5.734-.01-11.22.024-12.188.073l-1.76.089-14.997 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.272.05-.255-.148-7.143-11.902-15.306-26.119l-14.36-25.016c-.115-.186-.444-.744-.457-.752-.477-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.042.48.329 1.05 25.338 43.735 25.772 43.985.207.119 14.178.239 31.05.266 26.651.044 30.75.152 31.234.832.308.43 9.988 17.214 21.513 37.296s21.152 36.627 21.394 36.767c.242.14 5.927.243 12.633.23 6.706-.013 12.401.099 12.657.246.132.076.382-.141.852-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.441-9.774-.195-.712-.065-.822 1.156-.98 1.956-.252 57.397-.057 58.07.205.238.092.79-.569 2.594-3.497 1.866-3.067 5.03-8.524 11-18.866 7.22-12.505 13.044-22.784 12.942-22.843-.102-.059-.771-.051-1.489.016l-.046.001c-4.452.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.121.01-2.095.076-2.66.172a.25.25 0 0 0-.062.083z" fill="#7db7e1"/></g></symbol><symbol viewBox="0 0 24 24" id="nodejs" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.85c-.27 0-.55.07-.78.2l-7.44 4.3c-.48.28-.78.8-.78 1.36v8.58c0 .56.3 1.08.78 1.36l1.95 1.12c.95.46 1.27.47 1.71.47 1.4 0 2.21-.85 2.21-2.33V8.44c0-.12-.1-.22-.22-.22H8.5c-.13 0-.23.1-.23.22v8.47c0 .66-.68 1.31-1.77.76L4.45 16.5a.26.26 0 0 1-.11-.21V7.71c0-.09.04-.17.11-.21l7.44-4.29c.06-.04.16-.04.22 0l7.44 4.29c.07.04.11.12.11.21v8.58c0 .08-.04.16-.11.21l-7.44 4.29c-.06.04-.16.04-.23 0L10 19.65c-.08-.03-.16-.04-.21-.01-.53.3-.63.36-1.12.51-.12.04-.31.11.07.32l2.48 1.47c.24.14.5.21.78.21s.54-.07.78-.21l7.44-4.29c.48-.28.78-.8.78-1.36V7.71c0-.56-.3-1.08-.78-1.36l-7.44-4.3c-.23-.13-.5-.2-.78-.2M14 8c-2.12 0-3.39.89-3.39 2.39 0 1.61 1.26 2.08 3.3 2.28 2.43.24 2.62.6 2.62 1.08 0 .83-.67 1.18-2.23 1.18-1.98 0-2.4-.49-2.55-1.47a.226.226 0 0 0-.22-.18h-.96c-.12 0-.21.09-.21.22 0 1.24.68 2.74 3.94 2.74 2.35 0 3.7-.93 3.7-2.55 0-1.61-1.08-2.03-3.37-2.34-2.31-.3-2.54-.46-2.54-1 0-.45.2-1.05 1.91-1.05 1.5 0 2.09.33 2.32 1.36.02.1.11.17.21.17h.97c.05 0 .11-.02.15-.07.04-.04.07-.1.05-.16C17.56 8.82 16.38 8 14 8z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 300 300" id="nodemon" xmlns="http://www.w3.org/2000/svg"><title>nodemon</title><path d="M149.868 20.62c-2.124 0-4.25.55-6.154 1.648L41.899 81.083a12.306 12.306 0 0 0-6.15 10.652v117.633a12.29 12.29 0 0 0 6.152 10.646l101.815 58.766h.001a12.282 12.282 0 0 0 12.291 0l101.84-58.766a12.29 12.29 0 0 0 6.153-10.652V91.738a12.31 12.31 0 0 0-6.146-10.652L156.015 22.27a12.302 12.302 0 0 0-6.153-1.648zM83.303 70.93s11.789 33.031 35.477 31.934l27.74-15.961a7.348 7.348 0 0 1 3.414-.99h.641a7.233 7.233 0 0 1 3.404.99l27.738 15.961c23.69 1.094 35.475-31.934 35.475-31.934 5.233 23.154 1.06 38.641-5.924 48.942l4.541 2.614h.002c2.321 1.327 3.734 3.795 3.737 6.49l-.12 95.811a3.724 3.724 0 0 1-1.855 3.227 3.624 3.624 0 0 1-3.735 0L177.1 206.971c-2.311-1.363-3.742-3.818-3.742-6.48v-44.763a7.44 7.44 0 0 0-3.737-6.465l-15.642-9.01a7.28 7.28 0 0 0-3.715-1.01 7.378 7.378 0 0 0-3.742 1.01l-15.648 9.01c-2.316 1.323-3.729 3.798-3.729 6.467v44.762c0 2.663-1.413 5.1-3.738 6.48l-36.748 21.041a3.571 3.571 0 0 1-3.71 0c-1.173-.65-1.864-1.887-1.864-3.224l-.137-95.812a7.483 7.483 0 0 1 3.74-6.49l4.541-2.615c-6.982-10.302-11.16-25.79-5.925-48.942z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 990 990" id="npm" xmlns="http://www.w3.org/2000/svg"><defs><style>.hncls-1{fill:#cb3837}.cls-2{fill:#fff}</style></defs><title>n</title><path class="hncls-1" d="M113.26 876.74V113.27h763.47v763.47zm143.59-620.4v476.18h240.61V355.63h140.21v376.96h95.457V256.34z" fill="#e53935" stroke-width=".771"/></symbol><symbol id="nunjucks" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.host0{fill:#388e3c}</style><path class="host0" d="M11.2 21.1H8.1l-2.3-7.9v7.9H2.7V2.9h3.1l2.3 7.4V2.9h3.1zM21.3 19.2c0 1-.8 1.9-1.9 1.9h-4.8c-1 0-1.9-.8-1.9-1.9v-3.8l3.2-.7V18h2.3V7.2h3.1v12z"/></symbol><symbol viewBox="0 0 150 150.00001" id="ocaml" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.76136 0 0 .76136 11.616 19.98)"><path d="M83.02 101.645l.023-.062c-.035-.159-.047-.195-.024.062z" fill="none" stroke-width="1.028"/><linearGradient id="hpa" gradientUnits="userSpaceOnUse" x1="-696.735" y1="97.7" x2="-696.735" y2="142.997" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M82.313 138.79c-.471-1.004-1.904-3.621-2.624-4.46-1.562-1.828-1.927-1.966-2.386-4.275-.799-4.02-2.913-11.31-5.405-16.341-1.286-2.596-3.426-4.777-5.385-6.66-1.71-1.652-5.565-4.431-6.237-4.294-6.296 1.257-8.249 7.432-11.21 12.323-1.638 2.705-3.374 5.007-4.665 7.885-1.192 2.646-1.087 5.577-3.128 7.849-2.093 2.333-3.454 4.814-4.48 7.829-.194.574-.747 6.596-1.348 8.015l9.357-.659c8.719.594 6.2 3.936 19.81 3.208l21.487-.665c-.666-1.97-1.584-4.25-1.938-4.991-.599-1.248-1.352-3.69-1.848-4.763z" fill="url(#hpa)" stroke-width="1.028"/><linearGradient id="hpb" gradientUnits="userSpaceOnUse" x1="-666.972" y1="142.12" x2="-666.972" y2="142.12" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><linearGradient id="hpc" gradientUnits="userSpaceOnUse" x1="-675.228" y1="-1.28" x2="-675.228" y2="142.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M109.553 94.296c-1.652 1.193-4.88 4.06-11.902 5.145-3.152.487-6.1.527-9.335.365-1.584-.076-3.077-.157-4.665-.177-.936-.008-4.074-.107-3.919.193l-.349.871c.054.287.169 1.004.2 1.177.129.704.165 1.265.192 1.912.048 1.331-.11 2.719-.043 4.062.141 2.787 1.175 5.326 1.306 8.137.143 3.13 1.69 6.442 3.188 8.998.569.973 1.434 1.084 1.811 2.283.442 1.373.024 2.83.239 4.293.842 5.675 2.477 11.606 5.032 16.728.018.043.038.09.06.128 3.156-.53 6.318-1.665 10.418-2.271 7.517-1.115 17.972-.54 24.688-1.17 16.993-1.597 26.216 6.97 41.478 3.459V22.459c0-11.84-9.594-21.438-21.435-21.438H19.239C7.4 1.021-2.197 10.62-2.197 22.458v46.774c3.067-1.11 7.479-7.635 8.861-9.222 2.419-2.775 2.858-6.315 4.062-8.544 2.743-5.078 3.215-8.57 9.451-8.57 2.907 0 4.061.67 6.027 3.31 1.368 1.834 3.731 5.224 4.837 7.49 1.277 2.615 3.357 6.153 4.272 6.867.677.53 1.35.928 1.976 1.163 1.012.38 1.848-.316 2.525-.855.863-.687 1.235-2.088 2.035-3.957 1.152-2.696 2.408-5.926 3.122-7.054 1.237-1.949 1.658-4.261 2.993-5.381 1.97-1.652 4.54-1.768 5.246-1.908 3.957-.781 5.755 1.906 7.704 3.645 1.276 1.138 3.019 3.432 4.256 6.507.967 2.4 2.199 4.622 2.714 6.008.497 1.339 1.725 3.484 2.453 6.055.661 2.336 2.43 4.125 3.102 5.235 0 0 1.029 2.882 7.285 5.516 1.357.572 4.1 1.501 5.736 2.096 2.718.988 5.351.86 8.704.458 2.391 0 3.686-3.462 4.772-6.234.643-1.639 1.259-6.334 1.678-7.667.406-1.297-.544-2.3.265-3.437.946-1.327 1.508-1.399 2.054-3.129 1.172-3.704 7.95-3.89 11.761-3.89 3.176 0 2.772 3.083 8.16 2.028 3.086-.605 6.059.398 9.335 1.265 2.758.732 5.352 1.566 6.906 3.385 1.005 1.178 3.5 7.08.958 7.331.244.3.423.84.88 1.135-.566 2.226-3.03.64-4.4.355-1.845-.383-3.147.057-4.952.856-3.085 1.374-7.598 1.214-10.286 3.452-2.281 1.898-2.277 6.133-3.34 8.507-.002-.001-2.955 7.6-9.402 12.248z" fill="url(#hpc)" stroke-width="1.028"/><linearGradient id="hpd" gradientUnits="userSpaceOnUse" x1="-735.137" y1="90.833" x2="-735.137" y2="141.967" gradientTransform="matrix(1.02783 0 0 1.02783 776.895 2.337)"><stop offset="0" stop-color="#f29100"/><stop offset="1" stop-color="#ec670f"/></linearGradient><path d="M38.247 105.09c-1.467-.15-2.83-.317-4.256-.605-2.662-.536-5.57-1.06-8.193-1.688-1.592-.385-6.895-2.263-8.048-2.792-2.702-1.246-4.496-4.63-6.609-4.282-1.348.22-2.662.682-3.5 2.042-.685 1.11-.917 3.016-1.391 4.294-.55 1.485-1.5 2.87-2.331 4.284-1.53 2.595-4.282 4.941-5.468 7.469-.239.52-.45 1.101-.649 1.708V144.415a48.57 48.57 0 0 1 4.45.96c11.955 3.19 14.872 3.46 26.598 2.119l1.1-.146c.897-1.867 1.59-8.227 2.171-10.195.454-1.51 1.077-2.712 1.313-4.253.223-1.463-.02-2.858-.146-4.188-.329-3.332 2.427-4.522 3.742-7.384 1.186-2.589 1.871-5.535 2.853-8.181.941-2.54 2.41-6.13 4.918-7.408-.305-.355-5.237-.518-6.554-.65z" fill="url(#hpd)" stroke-width="1.028"/></g></symbol><symbol viewBox="0 0 24 24" id="pdf" xmlns="http://www.w3.org/2000/svg"><path d="M14 9h5.5L14 3.5V9M7 2h8l6 6v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m4.93 10.44c.41.9.93 1.64 1.53 2.15l.41.32c-.87.16-2.07.44-3.34.93l-.11.04.5-1.04c.45-.87.78-1.66 1.01-2.4m6.48 3.81c.18-.18.27-.41.28-.66.03-.2-.02-.39-.12-.55-.29-.47-1.04-.69-2.28-.69l-1.29.07-.87-.58c-.63-.52-1.2-1.43-1.6-2.56l.04-.14c.33-1.33.64-2.94-.02-3.6a.853.853 0 0 0-.61-.24h-.24c-.37 0-.7.39-.79.77-.37 1.33-.15 2.06.22 3.27v.01c-.25.88-.57 1.9-1.08 2.93l-.96 1.8-.89.49c-1.2.75-1.77 1.59-1.88 2.12-.04.19-.02.36.05.54l.03.05.48.31.44.11c.81 0 1.73-.95 2.97-3.07l.18-.07c1.03-.33 2.31-.56 4.03-.75 1.03.51 2.24.74 3 .74.44 0 .74-.11.91-.3m-.41-.71l.09.11c-.01.1-.04.11-.09.13h-.04l-.19.02c-.46 0-1.17-.19-1.9-.51.09-.1.13-.1.23-.1 1.4 0 1.8.25 1.9.35M8.83 17c-.65 1.19-1.24 1.85-1.69 2 .05-.38.5-1.04 1.21-1.69l.48-.31m3.02-6.91c-.23-.9-.24-1.63-.07-2.05l.07-.12.15.05c.17.24.19.56.09 1.1l-.03.16-.16.82-.05.04z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="perl" xmlns="http://www.w3.org/2000/svg"><path d="M12 14c-1 0-3 1-3 2 0 2 3 2 3 2v-1a1 1 0 0 1-1-1 1 1 0 0 1 1-1v-1m0 5s-4-.5-4-2.5c0-3 3-3.75 4-3.75V11.5c-1 0-5 1.5-5 4.5 0 4 5 4 5 4v-1M10.07 7.03l1.19.53c.43-2.44 1.58-4.06 1.58-4.06-.43 1.03-.71 1.88-.89 2.55C13.16 3.55 15.61 2 15.61 2a15.916 15.916 0 0 0-2.64 3.53c1.58-1.68 3.77-2.78 3.77-2.78-2.69 1.72-3.9 4.45-4.2 5.21l.55.08c0 .52 0 1 .25 1.38C14.1 11.31 18 11.47 18 16s-4.03 6-6.17 6C9.69 22 5 21.03 5 16s4.95-5.07 5.83-7.08c.12-.38-.76-1.89-.76-1.89z" fill="#9575cd"/></symbol><symbol viewBox="0 0 24 24" id="php" xmlns="http://www.w3.org/2000/svg"><path d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09-.28.22-.71.33-1.29.33h-.87l.53-2.76h.99m-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59l-1.28 6.58m7.25-8.33l-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18.11.12.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35h-1.44M18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76h1m-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75l-1.29 6.58z" fill="#1E88E5"/></symbol><symbol viewBox="0 0 79 78" id="postcss" xmlns="http://www.w3.org/2000/svg"><title>postcss-logo-symbol</title><g transform="translate(5.48 5.52) scale(.85425)" fill="#e53935" fill-rule="evenodd" stroke="#e53935" stroke-width="1.519"><path d="M15.447 32.623c.106.08.29.132.106.29-.132.184-.29.342-.395.553-.105.185-.184.237-.342.106.21-.343.42-.66.63-.95zM68.342 60.24c0 .078.026.13.026.21.053-.105.053-.158.08-.21zm0 .236v-.026zm-5.368 10.277l-4.58-25.402c-.078-.025-.183-.077-.368-.13.053.105.08.184.106.263.13-.026.184-.026.236-.052 0-.026 0-.052.027-.08l4.58 25.404zm-4.737-31.12c-.026.078-.026.158-.026.237 0-.08 0-.16.028-.238zm.026.526c-.026 0-.026 0-.052-.028v.026c.028.026.028.026.054 0zm-.052.21v-.185c-.077.026-.156.026-.262.053.132.05.264.078.264.13z"/><path d="M78.71 33.967c-.052-1.028-.078-2.056-.184-3.083-.184-1.397-.368-2.82-.684-4.19-.237-1.133-.63-2.214-1.026-3.294-.5-1.265-1-2.556-1.632-3.768-1.026-1.95-2.368-3.69-3.605-5.508-.818-1.16-1.87-2.108-2.66-3.294-.447-.685-1.105-1.264-1.763-1.79-1.053-.845-2.158-1.61-3.263-2.347a32.525 32.525 0 0 0-2.58-1.634c-.71-.397-1.473-.713-2.21-1.056-.842-.395-1.658-.87-2.605-1.054-.238-.05-.448-.13-.685-.21-.605-.21-1.184-.447-1.79-.632-.92-.29-1.815-.632-2.763-.87C50.342 1 49.394.843 48.446.71 47.394.555 46.316.5 45.262.397a26.83 26.83 0 0 0-2.026-.184C42.236.16 41.21.16 40.21.134c-.5-.027-1.026-.08-1.526-.053-.763.026-1.526.105-2.29.21-.736.08-1.473.21-2.183.317-.867.105-1.735.158-2.604.264-.816.106-1.658.264-2.473.396-.29.053-.58.158-.87.21-.63.132-1.288.185-1.92.396-1.13.344-2.263.74-3.368 1.16-1.027.422-2.027.87-3 1.397-1 .552-1.948 1.21-2.895 1.844a45.325 45.325 0 0 0-2.66 1.923c-.84.66-1.63 1.397-2.394 2.135-.42.42-.763.922-1.158 1.396-.657.765-1.315 1.502-1.947 2.293-.524.66-1 1.344-1.5 2.03-.893 1.21-1.656 2.502-2.366 3.794-.29.527-.553 1.054-.816 1.58-.395.79-.816 1.555-1.184 2.372-.264.554-.474 1.16-.632 1.766-.367 1.292-.736 2.61-1.078 3.9-.316 1.16-.395 2.372-.42 3.558-.027 1.054.078 2.082.183 3.136.027.264-.13.58.184.79-.105.29-.026.45.13.5-.182.29.08.476-.024.74-.027.052.08.157.13.236 0 .08-.025.185 0 .264.028.237.133.474.133.738 0 .184.157.395.21.58.026.078 0 .21-.053.263-.158.184-.132.342.105.448.133.342.08.5.054.66.052.236-.027.315 0 .368.21.422.29.896.315 1.37 0 .106.053.212.106.343.026 0 0 .5 0 .5.13-.078.237-.104.368-.157.08.342.158.66.263.95.132.21.132.314.08.34.105.474.157.922.34 1.37 0-.5-.05-1-.13-1.475.368.132.684.263.895.263.027-.08.053-.184.08-.237-.158-.157-.29-.394-.448-.552.053.21 0 .29 0 .37-.105-.054-.237-.107-.368-.16.105-.13.21-.263.368-.42 0-.238-.13-.45-.5-.423.158-.052.316-.13.5-.184.29-.157-.026-.447-.026-.816.026-.447-.237-.895-.316-1.37-.132-.737-.105-1.844-.184-2.582-.158-.132-.29.21-.316.237.08.632.158 1.264.21 1.897-.157-.527-.263-1.107-.394-1.74-.027.185-.053.264-.053.37-.13.13-.026.29.053.474-.184-.08-.395-.052-.395-.052v.738c-.262-.264-.34-.474-.473-.66-.052-.21-.08-.42-.13-.63.05-.133 0-.212 0-.29a15.968 15.968 0 0 1-.08-.634c.026-.026-.026-.42-.026-.42.21.025.343.05.474.05-.263-.34-.08-.552.027-.763.053-.106.237-.13.29-.238.21-.395.553-.71.553-1.212 0-.237.08-.5.105-.738.053-.448.105-.896.13-1.344.054-.58 0-1.16.133-1.713.212-.92.475-1.843.764-2.766.21-.66.448-1.29.71-1.95.395-1.028.764-2.056 1.264-3.03.71-1.424 1.526-2.794 2.316-4.19.5-.87 1.026-1.687 1.58-2.53.525-.817 1.05-1.66 1.657-2.425a21.452 21.452 0 0 1 2.79-2.978c1.053-.948 2.053-1.923 3.184-2.793a32.218 32.218 0 0 1 4.685-3.005c1.343-.71 2.737-1.266 4.132-1.793.895-.342 1.868-.5 2.79-.79 1.052-.343 2.105-.5 3.21-.527.71-.027 1.395-.106 2.105-.185.632-.05 1.263-.104 1.948-.183-.08.105-.106.158-.132.21-.288.422-.604.844-.894 1.265-.237.343-.5.712-.737 1.054-.422.555-.87 1.108-1.264 1.688-.605.87-1.158 1.766-1.79 2.635-.63.843-1.315 1.634-1.973 2.45-.868 1.134-1.684 2.293-2.552 3.426-.79 1.08-1.63 2.11-2.394 3.19-.684.947-1.29 1.95-1.948 2.923-.973 1.45-1.947 2.872-2.92 4.322a271.93 271.93 0 0 1-2.316 3.294c-.053.08-.132.104-.21.157-.21.342-.21.527-.29.685-.21.395-.42.79-.658 1.16-.132.21-.316.394-.474.605-.026-.316.42-.474.21-.87-.13.212-.263.396-.394.607l-.316.63c.105.08.29.133.105.29-.08.133-.158.29-.237.423a.954.954 0 0 0 .29-.264c0 .29-.158.526-.29.763-.105.21-.368.37-.552.527.026.027.21.106.237.132.237-.08.316-.21.343-.132.08-.105.158-.184.184-.263.104-.264.262-.474.525-.58.106-.053.184-.132.263-.21.79-.818 1.606-1.608 2.316-2.478 1.106-1.345 2.106-2.74 3.16-4.11.446-.58.973-1.16 1.446-1.714.078.606.026 1.185 0 1.74-.08.974-.132 1.95-.21 2.95-.027.395 0 .79-.027 1.186 0 .105-.08.184-.08.29 0 .263.08.553.08.817-.08.975-.186 1.923-.265 2.898-.027.21.078.422.13.607-.13 1.422.16 2.925-.078 4.427.184-.29.237-.474.237-.658.025-.158 0-.316 0-.5v-.264c.025-.475.13-.975.078-1.45-.053-.527-.053-1.027.053-1.528.053-.21-.026-.474.106-.738v.395c-.026 1.5.027 3.003-.183 4.505-.027.132.08.37-.21.343-.238.474.052.817-.21 1.08-.054.053.05.29.077.448-.106.317-.106.317.052.343.026.58.08 1.106.105 1.66.42-1 .21-2.03.396-3.058.026.422.053.844.026 1.29 0 .687-.026 1.345-.052 2.03 0 .132-.027.264-.053.396-.08.37-.105.738-.237 1.08-.105.264-.052.66-.052.975v1.003c.105.448-.027.685.052.948-.08.265-.105.344-.08.423l.08.395c.527-.053.29.343.5.553-.158.212-.105.29-.105.397 0 .237-.025.448-.052.685 0 .606-.026 1.212-.026 1.792 0 .08.026.157.026.236 0 .054-.026.74-.026.74.053.078 0 .157-.08.236-.025 0-.104-3.347-.104-3.347h-.395c-.052 1.58.08 3.003-.21 4.48-.316.025-.42.078-.764.078-.816 0-1.632 0-2.448.026-.974 0-1.92.026-2.895.026-.472 0-.972.054-1.446.054-.632 0-1.29-.08-1.92-.08-.975 0-1.922.08-2.896.106-.71.026-1.42.026-2.13.053-.475.025-.95.05-1.422.104-.21.026-.395.105-.658.184-.08 0-.263-.026-.42 0-.265.053-.5.21-.765.264-.395.08-.5.184-.448.58v.263c-.026.052.58-.08.58-.08-.054 0-.08.158-.16.29.212-.08.343-.132.475-.184.395.185.737.08 1.052.16 1.026.262 2.078.37 3.13.473.685.053 1.343.08 2.027.105.973.053 1.947.106 2.92.106.816 0 1.606-.08 2.42-.08 1.13 0 2.264.052 3.395.08.237 0 .5-.028.763-.028h1.92c1.712-.052 3.422-.08 5.133-.13.975-.028 1.975-.08 2.948-.107l3-.08c1.158-.026 2.316-.026 3.448-.05.868 0 1.71-.03 2.58-.055.972-.026 1.972-.105 2.946-.157.527-.027 1.054-.08 1.58-.132.632-.052 1.29-.13 1.92-.157.948-.054 1.922-.08 2.87-.133 1.184-.078 2.368-.183 3.578-.21 1.106-.052 2.237-.026 3.343-.052.974-.027 1.948-.08 2.948-.106l1.66-.08s1.104-.026 1.657-.08c.947-.052 1.894-.157 2.842-.183.604-.027 1.21 0 1.815-.027.973-.026 1.973-.08 2.947-.08.367 0 .762.054 1.236.08-.21.185-.342.29-.5.422.105.026.21.08.316.132a.71.71 0 0 1-.42.13c-.054.133-.107.186-.16.45h.474c-.184 0-.342.237-.526.395-.21-.054-.395 0-.5.29.184.104.158.183.132.29-.316.104-.553.21-.42.552-.107.052-.238.105-.37.184-.13.21-.368.263-.316.553.106.025.21.08.29.104-.132.053-.263.132-.395.184-.473.29-.262.422-.157.554-.08.053-.158.105-.237.132.052.237.13.29.157.29a9.3 9.3 0 0 0-.395.316c-.08.237-.185.342-.29.5s-.158.37-.29.527c-.552.607-.947 1.32-1.657 1.793-.264.185-.5.422-.737.66-.474.447-.895.948-1.395 1.37a29.595 29.595 0 0 1-2.052 1.554 151.56 151.56 0 0 1-2.604 1.792c-.474.315-1 .552-1.5.842s-.974.554-1.474.843c-.316.21-.606.5-.948.66-.868.37-1.79.685-2.684 1.028-.87.37-1.5.685-2.158.922-.605.21-1.237.37-1.868.5-.21.054-.448 0-.685.027-.448.08-.895.186-1.343.238-1.158.158-2.316.264-3.473.422-.685.08-1.343.21-2.027.29-.473.026-.973-.026-1.447-.026-.342 0-.71.08-1.053.027-.552-.08-1.105-.21-1.658-.316-.13-.026-.316-.08-.42-.026-.21.106-.396-.052-.607 0-.13.027-.262-.08-.394-.08-.106-.025-.238.028-.37 0-.29-.078-.552-.183-.87-.157-.313.026-.63-.132-.97-.21-.475-.106-.92-.21-1.396-.317a2.38 2.38 0 0 1-.525-.237c-.685 0-1.133-.026-1.554-.185-.368-.13-.71-.315-1.105-.262-.104.026-.183-.026-.29-.026-.08-.106-.157-.317-.235-.317-.526.027-.842-.42-1.29-.553-.236-.08-.42-.343-.657-.422-.58-.237-1.052-.737-1.71-.816-.21-.027-.42-.132-.658-.21.08.104.13.183.21.262-.763-.37-1.473-.79-2.184-1.186-.104-.026-.183-.13-.262-.184l-.71-.474c-.395.08-.553-.08-.66-.132-.71-.5-1.525-.817-2.21-1.37-.29-.238-.63-.396-.84-.686-.37-.448-.817-.764-1.317-1.027-.394-.21-.762-.448-1.13-.685-.185-.132-.37-.29-.37-.58 0-.185-.078-.37-.315-.264-.105-.158-.21-.342-.342-.395-.316-.13-.526-.37-.763-.58s-.42-.5-.71-.605c-.527-.21-.843-.658-1.158-1.027-.738-.87-1.396-1.82-2.08-2.74-.053-.08-.158-.133-.237-.212.105.29.237.527.368.79-.262-.105-.446-.29-.604-.474-.027.027 1.815 3.057 1.815 3.057.16.237.29.475.448.712a.813.813 0 0 1-.79-.422c-.236-.42-.5-.684-1.026-.63a4.588 4.588 0 0 1-.13-.58c-.107 0-.185 0-.37-.027.37.58.685 1.08 1.027 1.66-.133-.08-.21-.132-.265-.158.473.5.815 1.133 1.42 1.45.132.605.816.895.974 1.475-.13-.027-.238-.053-.37-.08-.21-.263-.447-.526-.683-.816.052.184.13.342.236.474.316.395.606.79.974 1.133.132.134.316.187.316.424.21.105.29.13.368.13.054.16-.025.397.29.344.21.395.42.395.71.264.343.343.528.37.764.16 0 .13.026.262.026.368.105-.053.08-.132.08-.264.13.105.21.158.262.21.263.37.5.712.868 1.002.5.422.948.87 1.42 1.265.922.765 1.95 1.398 2.975 1.977 1.264.712 2.475 1.476 3.764 2.16 1.552.818 3.21 1.372 4.92 1.767.632.132 1.237.263 1.87.42.55.16 1.104.397 1.657.528.842.185 1.71.343 2.552.5.183.027.37.054.58.08.235.053.524-.053.577.027.132.21.237.104.395.078.184-.053.395-.053.605-.053.737.026 1.447.184 2.184.132.16 0 .396-.133.528.13.236-.105.368-.105.473-.13.028.236 0 .236-.05.262-.054.026-.133.053-.238.132.947.184 1.842.21 2.63 0 1.37.105 2.554-.053 3.686-.448.105.132.184.316.342.053.052-.08.184-.107.29-.133.236-.053.526-.158.736-.08.238.08.317-.13.5-.13.317 0 .606-.027.896-.08.158-.026.316-.105.5-.158a1.285 1.285 0 0 0-.58-.133c.317-.158.606-.29.896-.42-.053.078-.106.183-.21.183h.367c-.08 0-.185.237-.316.395.946-.237 1.814-.448 2.657-.66-.29-.552.315-.367.526-.684-.263.08-.526.158-.79.21.895-.447 1.816-.842 2.71-1.237-.13.158-.29.237-.525.37.158.025.263.025.342.05.42.133.316-.262.447-.5.5 0 .71-.078.947-.158.263-.08.526-.158.79-.263.42-.184.815-.42 1.236-.63.08-.028.21 0 .316 0 .29-.186.394-.344.473-.318.37.053.63-.08.736-.42.184-.133.316-.238.447-.318.578-.316 1.13-.632 1.71-.948.21 0 .316 0 .368-.027.344-.16.66-.342.975-.527a2.258 2.258 0 0 1-.263-.13c.262-.054.34-.08.5-.133.63-.74 1.5-1.24 2.157-1.82.29-.026.29-.105.29-.157.104-.132.21-.29.34-.396.58-.527 1.21-.975 1.737-1.528a37.16 37.16 0 0 0 2.184-2.374c.63-.738 1.264-1.475 1.79-2.292.737-1.133 1.368-2.293 2.026-3.48.474-.842.895-1.685 1.37-2.528.05-.08.157-.185.236-.185.71-.08 1.422-.13 2.106-.21.158-.026.342-.13.5-.21-.08-.132-.132-.29-.21-.422-.106-.16-.264-.29-.37-.45-.104-.13-.183-.29-.262-.447-.08-.13-.158-.236-.237-.37a9.7 9.7 0 0 1-.45-.894c-.026-.08-.08-.21-.052-.29.474-1.027.658-2.134 1.105-3.162.447-1.054.58-2.24.79-3.373.184-1.08.29-2.16.42-3.24.08-.764.185-1.502.21-2.266.16-1.212.106-2.346.08-3.48-.026-1-.08-2.028-.13-3.03zM12.685 66.405c-.184-.21-.342-.448-.526-.658l.08-.08c.287.317.577.633.866.976-.158-.08-.342-.132-.42-.238zm.42.238c.08-.027.16-.027.238-.053.08.132.132.29.21.448-.368-.027-.552-.185-.447-.395zm27.37 10.883v-.08c.5-.052.973-.105 1.473-.157v.077c-.5.08-.973.13-1.473.158zm6.63-.685c-.367.08-.762.133-1.13.186-.132.026-.29.158-.342-.08-.053.027-.106.027-.158.054.13.394.447.078.71.236-.58.08-1.13.132-1.684.21v-.052c.16-.026.343-.053.5-.08v-.078a7.743 7.743 0 0 0-.79-.053c-.077 0-.183.106-.262.132-.105.026-.21.053-.342.053-.447.026-.894.026-1.316.052-.027 0-.08-.026-.106-.026v-.08c1.763-.236 3.5-.473 5.263-.71.027.052.027.105.053.157-.158 0-.263.055-.395.08zm.396-.262c.606-.08 1.16-.132 1.738-.21-1.21.342-1.605.394-1.737.21zM24.58 23.374c.84-1.16 1.71-2.32 2.552-3.505.263-.345.473-.714.736-1.056.08-.106.185-.158.316-.264l-.026-.05c.105-.133.21-.24.263-.344.134-.21.213-.448.318-.685a.385.385 0 0 1 .105-.103c.37.184.37-.21.5-.343.237-.264.474-.553.684-.817.158-.21.316-.395.448-.632.026-.08-.053-.21-.08-.317h-.078c.08-.052.158-.13.237-.184.026 0 .026 0 .052-.026.158-.238.316-.475.474-.686.315-.42.657-.842 1.025-1.21-.052.13-.105.263-.158.368.027 0 .027.027.053.027.316-.422.658-.817.974-1.24-.027-.025-.053-.052-.08-.052-.13.132-.236.264-.368.396-.026-.027-.052-.053-.08-.053.265-.343.528-.685.79-1.08.053.08.106.184.21.395.107-.263.212-.447.29-.632-.078.08-.183.158-.262.238l-.08-.08.474-.71c.5-.712 1-1.45 1.5-2.162.185-.263.42-.474.58-.738.5-1 1.29-1.792 1.894-2.714.132-.184.316-.342.474-.5.13-.16.237-.106.342.026.71.896 1.42 1.818 2.13 2.714.528.66 1.054 1.29 1.554 1.976.605.844 1.184 1.687 1.79 2.53.684.975 1.368 1.95 2.026 2.95 1 1.477 1.947 2.953 2.947 4.428.737 1.08 1.474 2.135 2.184 3.215h-1.344c-1.236-.025-2.5-.13-3.736-.078-1.684.08-3.394.264-5.078.396-2.132.185-4.29.21-6.42.21-.765 0-1.528.107-2.29.16-.922.052-1.817.105-2.738.13-1.08.054-2.13.08-3.21.107-.606.026-1.237 0-1.895 0zm30.183 12.12v.238c-.026 0-.052.027-.105.027-.105-.37-.21-.766-.342-1.135-.263-.765-.553-1.53-1.027-2.214-.528-.737-1-1.5-1.528-2.265-.13-.185-.316-.343-.474-.5-.553-.607-1.106-1.24-1.816-1.687a21.485 21.485 0 0 0-3.29-1.688 7.374 7.374 0 0 1-.92-.474h.63l4.5-.08c.974-.025 1.922-.025 2.895-.078.236 0 .368.08.5.29.236.395.473.79.736 1.186.027.052.08.13.08.21 0 .58 0 1.186.026 1.766.025.606.08 1.186.104 1.792 0 .606-.053 1.238-.026 1.87.027.897.053 1.82.053 2.74zM26.447 26.67c1.237-.053 2.42-.132 3.632-.185.945-.053 1.92-.08 2.866-.132.395-.025.764-.05 1.158 0-.42.212-.842.423-1.21.686-.474.316-.92.737-1.395 1.08-.475.342-.896.764-1.29 1.212-.5.605-1.053 1.132-1.58 1.712-.37.422-.79.817-1.105 1.265-.447.58-.842 1.21-1.263 1.87.132-2.504.29-4.98.184-7.51zm17.185 25.35c-.843.21-1.71.448-2.58.553-.736.106-1.5.08-2.263.08a25.42 25.42 0 0 1-2.028-.08c-.763-.078-1.526-.157-2.263-.5-.633-.29-1.29-.553-1.92-.87-.634-.316-1.265-.684-1.74-1.264-.34-.423-.815-.765-1.236-1.134.08.316.263.58.553.764-.132.158-.316.08-.58-.343-.078.053-.157.08-.21.106.08-.185.158-.37.237-.527-.105-.21-.237-.448-.342-.66-.21-.342-.42-.71-.605-1.053-.053-.08-.053-.158-.105-.237a5.893 5.893 0 0 1-.37-.475c-.21-.315-.394-.657-.657-.974 0 .08.027.158.027.264-.027 0-.053.026-.053.026l-.554-1.344c-.026 0-.026 0-.052.026l.473 1.74c-.026 0-.052.025-.08.025-.077-.104-.156-.21-.21-.34-.052-.212-.21-.212-.34-.133-.08.053-.133.237-.106.316.185.448.395.896.606 1.344.052.158.105.29.184.448.027.053.106.105.106.184.106.21.185.42.316.606.237.316.5.632.737.948.235.316.445.66.656.975.026.053.105.053.13.08.133.395.58.684.896.526.08.606.737.817 1 1.397a11.957 11.957 0 0 1-.763-.343c-.027.026-.027.052-.054.105.316.158.632.316.92.5.265.16.528.317.765.5.316.29.685.45 1.13.554a.282.282 0 0 0-.05-.107c.736.343 1.5.712 2.078 1-2.737.054-5.658.107-8.685.16 0-.5-.026-.975-.026-1.476 0-.21.052-.395.025-.606-.08-1.21-.08-2.424-.237-3.61-.157-1.264-.157-2.503-.13-3.77.025-.683-.027-1.394-.054-2.08 0-.922 0-1.82.028-2.74 0-.132.053-.237.106-.37h.08c.025.054 0 .133.05.16.08.08.212.21.265.184.157-.106.394-.21.447-.37.13-.315.184-.658.184-.974 0-.236.106-.394.21-.553.054-.08.08-.158.133-.263-.105-.08-.21-.132-.342-.237.106-.29.08-.633.475-.79.052-.027.052-.16.08-.238.025-.213.05-.45.078-.66.052.08.08.105.13.157a.42.42 0 0 1 .054-.08c0-.104-.026-.315 0-.315.316-.053.184-.395.342-.553.025-.028-.027-.107-.027-.16 0-.052 0-.13.026-.13.367-.08.315-.475.552-.66.08-.053.105-.13.21-.263.21.368-.158.553-.184.816.446-.263.578-.895.315-1.08.105-.08.21-.184.29-.29.29-.316.604-.606.868-.922.185-.236.29-.526.474-.763.106-.132.316-.237.474-.317.474-.262.92-.552 1.21-1 .053-.053.132-.105.21-.158.08-.053.238-.053.264-.132.027-.052-.052-.184-.105-.263.104-.053.21-.158.42-.264-.08.158-.105.264-.158.37l.13.13c.238-.184.606-.394.843-.552 0-.025-.132-.13-.132-.13-.157.08-.394.21-.63.316.05-.08.05-.132.08-.158.367-.237.735-.474 1.13-.66.92-.42 1.842-.842 2.763-1.237.158-.08.37-.026.553-.026.078 0 .13 0 .21-.026.42-.132.842-.264 1.263-.37.183-.052.393-.078.58-.078.787.025 1.577.025 2.366.078.342.026.658.105.974.21a9.88 9.88 0 0 1 1.184.5c.447.24.868.502 1.29.792.763.5 1.473 1.054 2.236 1.502.737.448 1.316 1.054 1.79 1.74.58.816 1.237 1.554 1.5 2.555l.394 1.74c.08.316.264.632.185 1-.133.66-.238 1.345-.343 2.004-.052.265-.105.53-.078.79.05.82-.265 1.53-.58 2.268-.106.237-.264.475-.395.738a.798.798 0 0 0 .21.106l.237-.474c.027 0 .027 0 .053.027-.132.368-.237.764-.37 1.133-.314.817-.63 1.66-1.025 2.45-.21.448-.58.817-.842 1.24-.262.368-.473.763-.736 1.106-.237.29-.473.58-.79.79-.71.527-1.447 1.054-2.21 1.476-.473.29-1.026.448-1.552.58zm-14.027-1.4l-.026.027c-.055-.026-.134-.052-.186-.105l-.632-.95c-.052-.078-.08-.157-.052-.262.29.448.58.87.895 1.29zm16.37 3.61c1.183-.5 2.157-1.21 3.05-2.028.133-.132.264-.263.422-.37 1.106-.684 1.92-1.633 2.658-2.687.842-1.212 1.395-2.582 2.08-3.873a2.73 2.73 0 0 1 .157-.29c-.053 3.004.29 5.955.684 8.933-2.973.105-6 .21-9.052.316zm26.683-.79c-.026.053-.08.106-.105.16-.027-.054-.027-.133-.053-.24-.158.423-.5.212-.737.212-1.42.027-2.868.027-4.29.027-1.368 0-2.762 0-4.13.024-.448 0-.922.105-1.37.132-1.078.052-2.157.08-3.236.105-.08 0-.158-.13-.29-.236a1.81 1.81 0 0 1-.158.237c-.028-.052-.08-.104-.133-.183-.026.08-.053.158-.08.21H58c-.053-.368-.158-.71-.158-1.08 0-.79.08-1.58.105-2.372.027-.368 0-.71 0-1.054.106.08.185.133.29.21.052-.103.105-.182.158-.26 0 0-.053-.028-.106-.08.05-.027.104-.08.104-.106.026-.08.08-.158.08-.21 0-.185-.054-.343-.08-.5.026 0 .052 0 .08-.028l.157.79h.08c-.106-.183.236-.342-.053-.552-.026-.027.026-.185.026-.264-.08-.157-.13-.315-.21-.526.026-.026.105-.053.184-.08-.105-.052-.184-.104-.263-.13.263-.238.263-.37.026-.633.054-.025.106-.025.106-.05 0-.238 0-.475-.052-.71-.053-.266.08-.58-.316-.74a.79.79 0 0 0 .105.21s-.08.027-.158.08c-.342-.317-.13-.74-.21-1.213.184.053.316.106.447.16-.053-.186-.184-.397-.263-.634h-.107v-1.74c0 .027.184.027.29.054 0-.027.025-.053.025-.08-.08-.105-.185-.21-.29-.342l.053-.053c-.21-.262-.105-.63-.105-.71V39.4c.264.264-.13.606.264.764v-.263h-.027c-.026-.395-.026-.79-.052-1.186h-.052c-.027.054-.027.08-.054.133h-.052l.158-6.298c.263.342.552.66.736 1 .606 1.108 1.395 2.057 2.132 3.058.632.87 1.21 1.818 1.79 2.714.71 1.08 1.394 2.16 2.105 3.24a81.41 81.41 0 0 0 1.63 2.426c.5.71 1.028 1.396 1.554 2.082.446.606.92 1.212 1.367 1.818.527.738 1.053 1.475 1.58 2.187.262.368.552.737.84 1.106.16.21.396.37.554.5-.025 0-.052 0-.104-.026.08.105.13.184.184.237.29.158.316.316.158.554zM74 46.854v-.185c0 .052.026.13 0 .184zm.895-11.62c-.027 0-.184-.16-.21-.186-.027.08 0 .158-.053.264-.027-.078-.21-.052-.21-.13-.027.368.157.737.13 1.106.08-.053.395-.08.474-.158.027.026.08.052.106.052-.527.396-.395.79-.158 1.24.052.104.21.315.052.526-.052.053.027.21.053.343h.077v.05l-.237.08c-.052-.08-.367-.236-.367-.37v1.346c.263.08.263.448.368.633a.768.768 0 0 0 .107-.21l.027.024c-.027.158-.053.316-.106.475-.052.236-.105.447-.13.684 0 .026.05.08.05.105-.288.66-.13 1.396-.235 2.08-.08.5 0 1.03-.053 1.556-.054.448-.16.922-.264 1.37-.027.08-.08.105-.21.158.052-.316.026-.527-.027-.817-.028 0-.37-.184-.397-.184 0 .37.21.87.29 1.29-.08-.026-.395-.21-.42-.21-.054.316-.054.738-.08 1.08-.027.264-.263.5-.29.79 0 .16.184.264.158.528h.21c0-.526.238-1 .238-1.554h.078c.027.053.106.106.08.132-.053.29-.16.606-.132.896 0 .158.13.316.08.5-.054.16-.08.317-.107.554-.027-.132-.053-.184-.053-.263-.026 0-.263-.027-.29-.027-.026.158.185.316.158.448-.026.026-.052.026-.105.053l-.868-1.266c-.686-1-1.37-2.003-2.054-3.03a6.312 6.312 0 0 1-.475-.79 37.09 37.09 0 0 0-2.71-4.033c-.762-.974-1.37-2.03-2.08-3.055-.656-.975-1.314-1.924-1.972-2.9-.237-.315-.526-.605-.737-.948-.683-1.08-1.29-2.187-1.972-3.267-.58-.897-1.21-1.767-1.816-2.636-.21-.29-.42-.607-.632-.923a.37.37 0 0 1-.052-.182c-.053-.58-.106-1.16-.132-1.713 0-.527.053-1.054.053-1.608v-.474c0-.132.025-.237.025-.37.025-.025.052-.078.078-.104-.763 0-1.553-.028-2.316 0-.5.025-.763-.186-1.105-.555-1-1.133-1.737-2.424-2.605-3.636a162.42 162.42 0 0 0-2.5-3.427c-.685-.922-1.37-1.818-2.053-2.74-.764-1.054-1.5-2.108-2.29-3.162a381.983 381.983 0 0 0-2.895-3.794c-.45-.58-.95-1.133-1.45-1.74.343.054.66.106.975.133l1.264.08c.947.077 1.894.13 2.84.26.79.107 1.58.265 2.396.396 1.738.29 3.448.765 5.106 1.318.974.316 1.92.738 2.87 1.133 2.13.87 4.157 1.924 6.157 3.03.63.343 1 .896 1.472 1.397.685.712 1.37 1.423 2.027 2.16.762.87 1.472 1.766 2.21 2.662.657.79 1.34 1.58 2 2.372.21.237.37.527.552.79.42.633.895 1.24 1.263 1.924.262.502.42 1.082.604 1.635.262.817.526 1.607.79 2.424.183.606.34 1.24.472 1.87.106.423.08.87.21 1.29.16.556 0 1.16.16 1.715.025.053.05.132.078.185.105.104.184.21.026.368-.025.026-.025.13 0 .21.054-.052.08-.105.133-.184 0 .053.025.08.025.105 0 .104-.027.21 0 .315 0 .052.052.13.078.184.053-.054.105-.08.21-.16.237.897.264 1.793.264 2.715 0 .87.157 1.74-.21 2.583.078-.29-.106-.555-.027-.818z"/><path d="M58.08 45.482c.025 0 .052.027.052.027l-.027-.03c0-.025 0-.025-.026 0zm4.157 26.036c-.29.21-.58.395-.948.474-.028-.026-.028-.053-.054-.08.29-.184.605-.368.895-.553.027.05.08.104.106.157zM12.895 35.81c.29-.367.58-.736.894-1.105.025.026.235.08.262.105-.29.37-.685.87-.974 1.265-.054-.053-.133-.237-.185-.264zM5.42 48.725c-.21-.448-.42-.923-.63-1.37a.91.91 0 0 1 .236-.106c.29.42.42.92.632 1.37 0 0-.21.105-.237.105zm6.712-12.65c-.158.238-.316.502-.474.74-.026-.028-.316.104-.342.078.158-.237.552-.66.71-.896.027.026.053.053.106.08zM59.422 72.6c.025 0 .025-.026.052-.026.184.026.394.052.605.052-.344.237-.555.21-.66-.026zm-47.24-35.418c.028-.08.08-.158.133-.237.052 0 .13-.027.13-.027.107-.184.107-.316.212-.474-.026-.026-.053-.026-.08-.053-.157.108-.315.24-.473.345.053.052.053.08.053.132-.21-.027-.29.08-.395.368-.026.08-.158.106-.29.21-.026.054-.052.186-.105.317l.027.028c-.053.053-.132.08-.132.08-.158.157-.342.29-.5.447-.026.08-.052.158-.052.237.185-.184.5-.527.737-.738l.027.027c.105-.158.184-.316.29-.474.025.026.025.052.052.08-.08.21-.158.446-.237.657-.055.026-.134.08-.134.053-.105.08-.184.184-.29.263l-.473.316c-.263.237-.526.447-.816.685-.184.29-.368.553-.58.896.317-.08.396.053.37.317.368.052.395-.237.5-.448.026-.054.053-.16.105-.186.237-.21.5-.394.763-.605.053-.053.053-.16.053-.238 0-.026-.133-.026-.212-.053.237-.264.58-.71.816-1 .132-.08.263-.186.263-.265-.026-.29.158-.368.37-.474-.106-.08-.133-.157-.133-.183z"/><path d="M12.71 36.892c-.105.184-.21.342-.315.527l-.158-.08c-.105.605-.474 1.132-.842 1.237.105.053.21.106.29.08.078-.027.13-.16.183-.238l.71-1.028.238-.396-.105-.105zM3.948 48.46c.132 0 .264.026.42.026 0-.105.133-.08.133-.184h.08c0 .132.026.237.026.37h-.552c-.027-.027-.132-.186-.106-.212zm-.21-1.212c-.08-.08-.21-.158-.21-.237-.027-.104.052-.235.13-.367.054.184.08.342.132.527-.027.025-.053.052-.053.078zm.658-1.687c.105.266.21.556.316.82a.798.798 0 0 0-.21.105c-.105-.264-.237-.554-.342-.817a.652.652 0 0 1 .237-.106zm58.58 25.194c.13-.052.288-.08.5-.13-.238.183-.422.315-.58.473-.027-.026-.053-.053-.08-.053.053-.105.106-.184.16-.29zM30.63 15.074c.157-.106.29-.185.447-.29l.052.052c-.16.21-.29.42-.475.685-.026-.183-.026-.29-.053-.42-.026 0 0 0 .027-.026zm7.71 13.333c.237-.106.474-.21.763-.343-.026.158-.026.264-.026.37a.927.927 0 0 0-.264-.054c-.158.027-.448.238-.58.264-.025 0 .106-.21.106-.237zm19.74 22.346c.052.263.552.395.052.658.08.055.157.08.236.134a.2.2 0 0 1-.052.106c-.053.025-.158.078-.21.05-.027 0-.08-.104-.08-.157 0-.237.027-.474.053-.79z"/></g></symbol><symbol viewBox="0 0 24 24" id="powerpoint" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M8 11v2h1v6H8v1h4v-1h-1v-2h2a3 3 0 0 0 3-3 3 3 0 0 0-3-3H8m5 2a1 1 0 0 1 1 1 1 1 0 0 1-1 1h-2v-2h2z" fill="#d14524"/></symbol><symbol viewBox="0 0 67.47 70" id="powershell" xmlns="http://www.w3.org/2000/svg"><path d="M18.545 12.4c-3.014 0-6.08 2.34-6.873 5.248L1.91 53.438c-.793 2.908.996 5.248 4.01 5.248h42.887c3.014 0 6.08-2.34 6.873-5.248l9.761-35.79c.794-2.908-.993-5.248-4.007-5.248h-42.89zm4.848 6.243c.652.04 1.29.33 1.76.86l7.96 9.013-3.957 3.246 3.957-3.244 4.832 5.47c.037.042.06.088.094.131.026.034.057.06.082.096.02.028.032.057.05.086.057.087.105.176.15.267.028.06.055.117.08.178a2.546 2.546 0 0 1 .171.764c.005.073.01.146.008.219-.002.09-.01.178-.021.267a2.53 2.53 0 0 1-.036.217 2.56 2.56 0 0 1-.07.252c-.024.076-.048.15-.08.224a2.547 2.547 0 0 1-.111.22 2.503 2.503 0 0 1-.133.218 2.546 2.546 0 0 1-.147.187c-.058.07-.118.137-.185.202-.027.026-.048.057-.076.082-.037.032-.077.054-.116.084-.038.03-.07.065-.11.093L16.8 52.271a2.552 2.552 0 0 1-3.563-.626 2.553 2.553 0 0 1 .63-3.563l18.349-12.853-3.06-3.467-7.839-8.873a2.549 2.549 0 0 1 .225-3.608 2.546 2.546 0 0 1 1.85-.638zm22.441 28.214c1.377 0 2.255 1.083 1.969 2.43-.287 1.347-1.627 2.433-3.004 2.434l-9.957.006c-1.378 0-2.256-1.083-1.969-2.43.287-1.347 1.626-2.433 3.004-2.434l9.957-.006z" fill="#03a9f4" stroke-width="5.342" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 210 210" id="prettier" xmlns="http://www.w3.org/2000/svg"><title>prettier-icon-dark</title><g transform="matrix(.9 0 0 .9 10.5 10.5)" fill="none" fill-rule="evenodd"><rect fill="#56B3B4" x="165" y="40" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="200" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="135" y="120" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="75" y="120" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="120" width="50" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="160" width="60" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="80" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="65" y="20" width="110" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="20" width="40" height="10" rx="5"/><rect fill="#F7BA3E" x="55" y="180" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="55" y="60" width="20" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="180" width="30" height="10" rx="5"/><rect fill="#F7BA3E" x="15" y="60" width="30" height="10" rx="5"/><rect fill="#56B3B4" x="95" y="100" width="90" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="100" width="40" height="10" rx="5"/><rect fill="#EA5E5E" x="15" y="100" width="20" height="10" rx="5"/><rect fill="#BF85BF" x="105" y="40" width="50" height="10" rx="5"/><rect fill="#56B3B4" x="15" y="40" width="80" height="10" rx="5"/><rect fill="#F7BA3E" x="45" y="140" width="100" height="10" rx="5"/><rect fill="#BF85BF" x="15" y="140" width="20" height="10" rx="5"/><rect fill="#EA5E5E" x="135" y="60" width="60" height="10" rx="5"/><rect fill="#F7BA3E" x="135" y="80" width="60" height="10" rx="5"/><rect fill="#56B3B4" x="15" width="130" height="10" rx="5"/></g></symbol><symbol viewBox="0 0 80 80" id="protractor" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="hxa"><path transform="scale(1 -1)" fill="#564b55" stroke-width="27.224" d="M-2.983-69.251h69.412v67.108H-2.983z"/></clipPath></defs><g transform="matrix(1.13039 0 0 -1.13039 5.714 82.137)" clip-path="url(#hxa)"><g transform="scale(.1)"><path d="M1180.54 92.324c-5.53 0-9.93-1.797-13.23-5.39-3.29-3.614-5.22-8.594-5.81-14.97h36.02c0 6.583-1.47 11.622-4.4 15.126-2.93 3.496-7.12 5.234-12.58 5.234zm2.84-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.88 6.21-8.83 14.824-8.83 25.84 0 11.101 2.73 19.922 8.21 26.464 5.45 6.524 12.81 9.805 22.02 9.805 8.63 0 15.46-2.851 20.48-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.45c.21-8.086 2.26-14.22 6.12-18.418 3.89-4.18 9.34-6.29 16.38-6.29 7.42 0 14.76 1.563 22 4.669V34.14c-3.68-1.602-7.18-2.746-10.48-3.438-3.28-.684-7.24-1.035-11.89-1.035M1272.34 30.918v44.57c0 5.606-1.28 9.805-3.82 12.559-2.56 2.773-6.56 4.16-12.02 4.16-7.2 0-12.49-1.953-15.84-5.851-3.34-3.895-5.03-10.32-5.03-19.286V30.918h-10.42v68.887h8.47l1.71-9.422h.5c2.14 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.15 2.804 12.88 2.804 8.29 0 14.54-2.011 18.73-6.015 4.19-3.985 6.28-10.391 6.28-19.192V30.918h-10.43M1328.96 38.406c7.1 0 12.27 1.938 15.48 5.813 3.22 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.44 6.25-15.56 6.25-6.11 0-10.79-2.383-14.04-7.129-3.26-4.746-4.88-11.472-4.88-20.136 0-8.797 1.61-15.45 4.84-19.93 3.23-4.484 7.97-6.723 14.22-6.723zm20.85 1.762h-.56c-4.83-7.004-12.02-10.5-21.62-10.5-9.01 0-16.03 3.066-21.04 9.238-5 6.153-7.5 14.922-7.5 26.27 0 11.355 2.51 20.176 7.54 26.465 5.03 6.289 12.03 9.433 21 9.433 9.34 0 16.5-3.398 21.49-10.195h.81l-.43 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.38 9.25M1434.91 38.27c1.85 0 3.63.136 5.34.421 1.72.274 3.09.547 4.1.84v-7.976c-1.15-.559-2.81-.996-5.01-1.36-2.18-.351-4.17-.527-5.94-.527-13.32 0-19.97 7.012-19.97 21.055V91.71h-9.88v5.027l9.88 4.336 4.38 14.707h6.04V99.805h20V91.71h-20V51.16c0-4.15.98-7.333 2.96-9.56 1.97-2.206 4.67-3.331 8.1-3.331M1463.81 65.43c0-8.809 1.76-15.508 5.27-20.118 3.53-4.609 8.69-6.906 15.53-6.906s12.01 2.297 15.56 6.875c3.53 4.602 5.3 11.301 5.3 20.149 0 8.75-1.77 15.41-5.3 19.953-3.55 4.539-8.77 6.824-15.69 6.824-6.82 0-11.99-2.246-15.47-6.73-3.46-4.48-5.2-11.16-5.2-20.047zm52.47 0c0-11.23-2.83-20-8.48-26.309-5.66-6.309-13.47-9.453-23.44-9.453-6.17 0-11.64 1.445-16.42 4.336-4.78 2.89-8.46 7.031-11.06 12.45-2.59 5.401-3.88 11.73-3.88 18.976 0 11.23 2.8 19.968 8.41 26.242 5.61 6.258 13.4 9.402 23.38 9.402 9.64 0 17.3-3.222 22.97-9.62 5.69-6.415 8.52-15.087 8.52-26.024M1591.71 92.324c-5.54 0-9.94-1.797-13.23-5.39-3.3-3.614-5.24-8.594-5.81-14.97h36c0 6.583-1.46 11.622-4.39 15.126-2.93 3.496-7.13 5.234-12.57 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.11 9.297-5.89 6.21-8.83 14.824-8.83 25.84 0 11.101 2.74 19.922 8.2 26.464 5.46 6.524 12.81 9.805 22.04 9.805 8.62 0 15.45-2.851 20.48-8.523 5.03-5.676 7.54-13.157 7.54-22.461v-6.613h-47.45c.21-8.086 2.25-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.36-6.29 7.43 0 14.77 1.563 22.01 4.669V34.14c-3.69-1.602-7.17-2.746-10.46-3.438-3.3-.684-7.27-1.035-11.91-1.035M1683.5 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12.01 4.16-7.2 0-12.48-1.953-15.83-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M1740.11 38.406c7.12 0 12.28 1.938 15.49 5.813 3.21 3.879 4.81 10.129 4.81 18.758v2.199c0 9.765-1.62 16.726-4.87 20.898-3.25 4.18-8.43 6.25-15.56 6.25-6.12 0-10.8-2.383-14.05-7.129-3.24-4.746-4.88-11.472-4.88-20.136 0-8.797 1.64-15.45 4.85-19.93 3.22-4.484 7.96-6.723 14.21-6.723zm20.87 1.762h-.57c-4.82-7.004-12.03-10.5-21.62-10.5-9.01 0-16.02 3.066-21.03 9.238-5 6.153-7.52 14.922-7.52 26.27 0 11.355 2.52 20.176 7.55 26.465 5.02 6.289 12.02 9.433 21 9.433 9.34 0 16.5-3.398 21.48-10.195h.83l-.44 4.96-.25 4.845v28.039h10.43V30.918h-8.49l-1.37 9.25M1846.07 38.27c1.85 0 3.64.136 5.36.421 1.7.274 3.07.547 4.08.84v-7.976c-1.13-.559-2.8-.996-5-1.36-2.2-.351-4.18-.527-5.94-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.86v5.027l9.86 4.336 4.4 14.707h6.04V99.805H1855V91.71h-19.98V51.16c0-4.15.98-7.333 2.95-9.56 1.97-2.206 4.68-3.331 8.1-3.331M1894.26 92.324c-5.53 0-9.94-1.797-13.22-5.39-3.31-3.614-5.25-8.594-5.83-14.97h36.01c0 6.583-1.45 11.622-4.38 15.126-2.95 3.496-7.13 5.234-12.58 5.234zm2.83-62.656c-10.19 0-18.22 3.086-24.1 9.297-5.9 6.21-8.84 14.824-8.84 25.84 0 11.101 2.73 19.922 8.2 26.464 5.47 6.524 12.81 9.805 22.03 9.805 8.63 0 15.46-2.851 20.49-8.523 5.03-5.676 7.55-13.157 7.55-22.461v-6.613h-47.46c.22-8.086 2.26-14.22 6.13-18.418 3.87-4.18 9.33-6.29 16.37-6.29 7.42 0 14.75 1.563 22 4.669V34.14c-3.7-1.602-7.17-2.746-10.47-3.438-3.28-.684-7.25-1.035-11.9-1.035M1983.36 49.727c0-6.426-2.4-11.368-7.18-14.844-4.77-3.477-11.47-5.215-20.11-5.215-9.13 0-16.26 1.445-21.37 4.336v9.687a51.32 51.32 0 0 1 10.65-3.964c3.79-.977 7.45-1.457 10.97-1.457 5.46 0 9.64.87 12.57 2.609 2.95 1.738 4.41 4.394 4.41 7.95 0 2.694-1.17 4.98-3.5 6.894-2.32 1.914-6.85 4.152-13.6 6.757-6.41 2.383-10.97 4.473-13.67 6.25-2.71 1.778-4.72 3.81-6.04 6.067-1.31 2.254-1.98 4.96-1.98 8.113 0 5.606 2.29 10.04 6.86 13.281 4.57 3.25 10.84 4.883 18.79 4.883 7.42 0 14.66-1.515 21.74-4.531l-3.71-8.496c-6.9 2.851-13.17 4.277-18.79 4.277-4.94 0-8.67-.77-11.18-2.324-2.52-1.543-3.78-3.691-3.78-6.406 0-1.844.48-3.418 1.42-4.707.95-1.309 2.46-2.54 4.56-3.711 2.09-1.184 6.11-2.871 12.07-5.086 8.16-2.98 13.69-5.98 16.55-8.996 2.87-3.02 4.32-6.809 4.32-11.367M2021.28 38.27c1.85 0 3.64.136 5.35.421 1.71.274 3.09.547 4.09.84v-7.976c-1.14-.559-2.81-.996-5.01-1.36-2.18-.351-4.18-.527-5.93-.527-13.33 0-19.99 7.012-19.99 21.055V91.71h-9.87v5.027l9.87 4.336 4.4 14.707h6.02V99.805h20V91.71h-20V51.16c0-4.15 1-7.333 2.97-9.56 1.98-2.206 4.67-3.331 8.1-3.331M2053.61 30.918h-10.42v68.887h10.42zm-11.31 87.559c0 2.39.59 4.14 1.76 5.253 1.18 1.106 2.65 1.661 4.42 1.661 1.67 0 3.1-.567 4.32-1.7 1.22-1.132 1.82-2.871 1.82-5.214 0-2.344-.6-4.09-1.82-5.247-1.22-1.16-2.65-1.726-4.32-1.726-1.77 0-3.24.566-4.42 1.726-1.17 1.157-1.76 2.903-1.76 5.247M2121.59 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.49l1.69-9.422h.5c2.15 3.387 5.14 6.023 8.99 7.887 3.85 1.867 8.16 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2159.29 77.742c0-4.812 1.35-8.465 4.08-10.926 2.72-2.48 6.51-3.71 11.37-3.71 10.19 0 15.28 4.953 15.28 14.831 0 10.344-5.16 15.532-15.47 15.532-4.9 0-8.67-1.32-11.31-3.965-2.63-2.649-3.95-6.555-3.95-11.762zm-5.67-58.387c0-3.73 1.58-6.55 4.72-8.488 3.14-1.922 7.65-2.879 13.52-2.879 8.75 0 15.24 1.309 19.45 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.15 6.32-3.45 7.754-2.31 1.457-6.65 2.168-13.01 2.168h-12.51c-4.74 0-8.43-1.12-11.06-3.386-2.65-2.266-3.97-5.508-3.97-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.23-3.39 3.15-5.754.91-2.371 1.37-5.039 1.37-8.02 0-6.746-2.29-12.128-6.91-16.152-4.61-4.012-10.93-6.023-18.98-6.023-2.05 0-3.98.156-5.78.5-4.45-2.356-6.67-5.305-6.67-8.871 0-1.883.77-3.282 2.34-4.176 1.54-.902 4.21-1.36 7.97-1.36h12.2c7.46 0 13.19-1.574 17.19-4.707 4-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2192.38 2.004 2183.46 0 2171.72 0c-9 0-15.95 1.68-20.82 5.027-4.88 3.352-7.34 8.079-7.34 14.211 0 4.18 1.35 7.813 4.03 10.88 2.68 3.046 6.45 5.116 11.32 6.21-1.77.8-3.24 2.031-4.44 3.711-1.19 1.68-1.78 3.633-1.78 5.84 0 2.52.66 4.707 2.01 6.602 1.34 1.882 3.44 3.71 6.34 5.468-3.56 1.465-6.46 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.26 13.37 6.79 17.452 4.52 4.082 10.93 6.133 19.22 6.133 3.6 0 6.86-.429 9.75-1.27h23.82M2284.61 91.71h-17.54V30.919h-10.43v60.793h-12.31v4.707l12.31 3.766v3.839c0 16.922 7.4 25.391 22.19 25.391 3.65 0 7.93-.73 12.82-2.195l-2.7-8.364c-4.03 1.301-7.46 1.946-10.31 1.946-3.93 0-6.85-1.309-8.73-3.926-1.89-2.617-2.84-6.816-2.84-12.598v-4.472h17.54V91.71M2302.87 65.43c0-8.809 1.76-15.508 5.28-20.118 3.52-4.609 8.7-6.906 15.52-6.906 6.84 0 12.02 2.297 15.57 6.875 3.54 4.602 5.3 11.301 5.3 20.149 0 8.75-1.76 15.41-5.3 19.953-3.55 4.539-8.78 6.824-15.69 6.824-6.83 0-11.99-2.246-15.46-6.73-3.48-4.48-5.22-11.16-5.22-20.047zm52.48 0c0-11.23-2.82-20-8.47-26.309-5.67-6.309-13.48-9.453-23.46-9.453-6.15 0-11.62 1.445-16.4 4.336-4.77 2.89-8.47 7.031-11.06 12.45-2.59 5.401-3.9 11.73-3.9 18.976 0 11.23 2.81 19.968 8.43 26.242 5.6 6.258 13.4 9.402 23.38 9.402 9.63 0 17.28-3.222 22.97-9.62 5.68-6.415 8.51-15.087 8.51-26.024M2403.79 101.074c3.07 0 5.8-.254 8.22-.761l-1.43-9.676c-2.86.633-5.37.933-7.55.933-5.58 0-10.33-2.261-14.3-6.785-3.95-4.531-5.94-10.156-5.94-16.902V30.918h-10.43v68.887h8.62l1.19-12.754h.5c2.56 4.48 5.63 7.949 9.23 10.37 3.61 2.423 7.56 3.653 11.89 3.653M2500.33 69.766l-10.68 28.476c-1.39 3.594-2.81 8.028-4.28 13.262-.93-4.024-2.24-8.438-3.96-13.262l-10.81-28.476zm14.77-38.848l-11.44 29.227h-36.83l-11.32-29.227h-10.81l36.34 92.273h8.98l36.13-92.273h-11.05M2583.07 30.918v44.57c0 5.606-1.27 9.805-3.83 12.559-2.55 2.773-6.55 4.16-12 4.16-7.21 0-12.49-1.953-15.84-5.851-3.35-3.895-5.03-10.32-5.03-19.286V30.918h-10.43v68.887h8.48l1.69-9.422h.51c2.14 3.387 5.14 6.023 8.99 7.887 3.84 1.867 8.15 2.804 12.88 2.804 8.3 0 14.54-2.011 18.74-6.015 4.19-3.985 6.29-10.391 6.29-19.192V30.918h-10.45M2620.76 77.742c0-4.812 1.36-8.465 4.08-10.926 2.73-2.48 6.53-3.71 11.37-3.71 10.2 0 15.28 4.953 15.28 14.831 0 10.344-5.15 15.532-15.45 15.532-4.91 0-8.68-1.32-11.32-3.965-2.64-2.649-3.96-6.555-3.96-11.762zm-5.66-58.387c0-3.73 1.57-6.55 4.71-8.488 3.15-1.922 7.65-2.879 13.53-2.879 8.75 0 15.23 1.309 19.44 3.926 4.21 2.617 6.31 6.172 6.31 10.652 0 3.723-1.14 6.32-3.45 7.754-2.31 1.457-6.64 2.168-13 2.168h-12.51c-4.74 0-8.43-1.12-11.07-3.386-2.63-2.266-3.96-5.508-3.96-9.747zm54.94 80.45v-6.582l-12.76-1.512c1.18-1.477 2.22-3.39 3.14-5.754.92-2.371 1.38-5.039 1.38-8.02 0-6.746-2.3-12.128-6.92-16.152-4.61-4.012-10.92-6.023-18.97-6.023-2.05 0-3.99.156-5.78.5-4.46-2.356-6.67-5.305-6.67-8.871 0-1.883.78-3.282 2.33-4.176 1.55-.902 4.21-1.36 7.98-1.36h12.2c7.46 0 13.18-1.574 17.18-4.707 4.01-3.144 6-7.714 6-13.71 0-7.618-3.06-13.426-9.17-17.43C2653.87 2.004 2644.94 0 2633.2 0c-9 0-15.95 1.68-20.83 5.027-4.88 3.352-7.33 8.079-7.33 14.211 0 4.18 1.35 7.813 4.02 10.88 2.69 3.046 6.47 5.116 11.32 6.21-1.77.8-3.23 2.031-4.43 3.711-1.19 1.68-1.79 3.633-1.79 5.84 0 2.52.66 4.707 2.01 6.602 1.35 1.882 3.45 3.71 6.35 5.468-3.56 1.465-6.47 3.953-8.71 7.48-2.23 3.516-3.35 7.54-3.35 12.06 0 7.55 2.25 13.37 6.79 17.452 4.52 4.082 10.92 6.133 19.21 6.133 3.62 0 6.86-.429 9.75-1.27h23.83M2692.7 99.805V55.117c0-5.605 1.27-9.805 3.83-12.566 2.56-2.766 6.57-4.145 12.01-4.145 7.2 0 12.47 1.965 15.81 5.903 3.33 3.945 4.99 10.379 4.99 19.304v36.192h10.44V30.918h-8.62l-1.5 9.25h-.58c-2.13-3.41-5.1-5.988-8.88-7.793-3.8-1.809-8.13-2.707-12.99-2.707-8.37 0-14.65 1.992-18.81 5.977-4.18 3.964-6.26 10.351-6.26 19.101v45.059h10.56M2760.61 30.918h10.43v97.805h-10.43zM2810.67 38.27c6.5 0 11.6 1.789 15.31 5.343 3.71 3.575 5.56 8.555 5.56 14.961v6.23l-10.44-.448c-8.3-.286-14.27-1.583-17.94-3.868-3.66-2.273-5.5-5.82-5.5-10.644 0-3.781 1.14-6.64 3.42-8.613 2.29-1.973 5.48-2.961 9.59-2.961zm23.57-7.352l-2.07 9.805h-.51c-3.44-4.305-6.86-7.227-10.27-8.77-3.42-1.523-7.68-2.285-12.8-2.285-6.83 0-12.17 1.758-16.05 5.273-3.87 3.528-5.81 8.536-5.81 15.032 0 13.906 11.12 21.199 33.37 21.875l11.7.359v4.277c0 5.418-1.17 9.395-3.5 11.985-2.32 2.566-6.03 3.855-11.15 3.855-5.74 0-12.24-1.758-19.49-5.273l-3.21 7.988c3.4 1.836 7.11 3.281 11.16 4.324a47.81 47.81 0 0 0 12.16 1.575c8.23 0 14.3-1.817 18.27-5.461 3.96-3.66 5.93-9.5 5.93-17.54V30.919h-7.73M2893.6 101.074c3.07 0 5.8-.254 8.25-.761l-1.46-9.676c-2.84.633-5.35.933-7.54.933-5.56 0-10.33-2.261-14.3-6.785-3.96-4.531-5.93-10.156-5.93-16.902V30.918h-10.44v68.887h8.61l1.19-12.754h.5c2.57 4.48 5.65 7.949 9.25 10.37 3.6 2.423 7.56 3.653 11.87 3.653M2901.63 6.727c-3.94 0-7.04.558-9.31 1.691v9.121c2.97-.84 6.08-1.25 9.31-1.25 4.14 0 7.3 1.25 9.45 3.77 2.16 2.507 3.24 6.132 3.24 10.859v91.895h10.69V31.797c0-7.95-2.01-14.121-6.04-18.496-4.02-4.383-9.8-6.574-17.34-6.574M2999.96 55.371c0-8.086-2.93-14.394-8.8-18.918-5.87-4.52-13.83-6.785-23.88-6.785-10.9 0-19.27 1.406-25.14 4.219v10.3c3.77-1.59 7.88-2.847 12.31-3.765 4.45-.93 8.85-1.399 13.21-1.399 7.12 0 12.49 1.36 16.09 4.063 3.59 2.695 5.4 6.465 5.4 11.277 0 3.196-.63 5.805-1.91 7.832-1.29 2.024-3.42 3.907-6.42 5.625-2.99 1.711-7.56 3.664-13.67 5.84-8.55 3.059-14.66 6.692-18.32 10.871-3.66 4.2-5.51 9.668-5.51 16.407 0 7.089 2.68 12.714 7.99 16.914 5.32 4.191 12.36 6.289 21.12 6.289 9.13 0 17.54-1.68 25.2-5.032l-3.32-9.304c-7.59 3.183-14.96 4.785-22.13 4.785-5.66 0-10.07-1.223-13.26-3.652-3.19-2.43-4.78-5.809-4.78-10.118 0-3.191.59-5.8 1.76-7.832 1.17-2.031 3.14-3.886 5.95-5.597 2.78-1.688 7.04-3.563 12.79-5.625 9.63-3.426 16.26-7.118 19.89-11.063 3.62-3.937 5.43-9.043 5.43-15.332M741.648 375.406h30c28.965 0 50.227 5.039 63.774 15.117 13.531 10.079 20.32 25.821 20.32 47.247 0 19.832-6.074 34.628-18.191 44.402-12.141 9.758-31.028 14.641-56.692 14.641h-39.211zm172.192 64.246c0-36.062-11.809-63.691-35.434-82.898-23.621-19.219-57.234-28.82-100.847-28.82h-35.911V198.73h-56.445v345.329h99.438c43.14 0 75.457-8.829 96.961-26.465 21.496-17.637 32.238-43.614 32.238-77.942M1099.26 464.691c11.17 0 20.39-.789 27.63-2.371l-5.43-51.718c-7.88 1.894-16.07 2.832-24.57 2.832-22.2 0-40.19-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.514v261.227h43.464l7.32-46.055h2.83c8.66 15.594 19.96 27.95 33.9 37.09 13.93 9.141 28.93 13.699 45 13.699M1206.88 329.82c0-60.308 22.28-90.465 66.85-90.465 44.08 0 66.13 30.157 66.13 90.465 0 59.688-22.21 89.512-66.61 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.95-75.972-32.83-99.898-21.89-23.945-52.35-35.918-91.41-35.918-24.41 0-45.97 5.508-64.7 16.543-18.75 11.016-33.16 26.836-43.23 47.48-10.08 20.625-15.11 44.551-15.11 71.793 0 42.364 10.86 75.43 32.58 99.2 21.73 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.05-24.328 33.06-57.121 33.06-98.379M1558.11 238.887c13.54 0 27.07 2.129 40.62 6.386v-41.816c-6.13-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.59 0-78.88 27.715-78.88 83.144v140.778h-35.68v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.48-9.57 26.34-9.57M1783.44 464.691c11.17 0 20.38-.789 27.62-2.371l-5.43-51.718c-7.88 1.894-16.06 2.832-24.56 2.832-22.2 0-40.2-7.246-53.97-21.731-13.78-14.48-20.66-33.301-20.66-56.453V198.73h-55.52v261.227h43.46l7.34-46.055h2.82c8.66 15.594 19.95 27.95 33.9 37.09 13.92 9.141 28.93 13.699 45 13.699M1925.05 236.523c20.15 0 36.32 5.625 48.52 16.895 12.21 11.25 18.31 27.051 18.31 47.344v22.676l-33.54-1.407c-26.13-.937-45.16-5.312-57.04-13.105-11.89-7.793-17.82-19.727-17.82-35.781 0-11.661 3.45-20.665 10.39-27.051 6.91-6.387 17.32-9.571 31.18-9.571zm82.66-37.793l-11.11 36.387h-1.87c-12.62-15.918-25.29-26.738-38.04-32.48-12.74-5.742-29.13-8.633-49.13-8.633-25.67 0-45.7 6.934-60.1 20.801-14.41 13.847-21.62 33.457-21.62 58.808 0 26.934 10 47.246 30 60.934 19.99 13.691 50.45 21.172 91.41 22.441l45.09 1.414v13.938c0 16.699-3.88 29.16-11.68 37.441-7.79 8.262-19.88 12.383-36.25 12.383-13.39 0-26.23-1.953-38.5-5.891a294.638 294.638 0 0 1-35.44-13.933l-17.94 39.668c14.17 7.41 29.68 13.035 46.52 16.894 16.85 3.868 32.77 5.789 47.72 5.789 33.22 0 58.31-7.246 75.22-21.726 16.94-14.492 25.4-37.246 25.4-68.262V198.73h-39.68M2220.04 194.004c-39.52 0-69.55 11.543-90.1 34.609-20.55 23.067-30.82 56.172-30.82 99.321 0 43.925 10.74 77.707 32.23 101.339 21.5 23.614 52.56 35.418 93.18 35.418 27.56 0 52.35-5.117 74.41-15.359l-16.78-44.641c-23.46 9.133-42.82 13.704-58.1 13.704-45.19 0-67.79-29.993-67.79-89.981 0-29.293 5.63-51.305 16.89-66.031 11.26-14.707 27.76-22.09 49.48-22.09 24.72 0 48.11 6.152 70.15 18.437v-48.417c-9.92-5.84-20.5-10-31.76-12.52-11.26-2.52-24.93-3.789-40.99-3.789M2451.52 238.887c13.54 0 27.08 2.129 40.63 6.386v-41.816c-6.15-2.676-14.05-4.922-23.73-6.738-9.69-1.797-19.73-2.715-30.12-2.715-52.6 0-78.9 27.715-78.9 83.144v140.778h-35.66v24.558l38.26 20.325 18.9 55.261h34.26v-58.113h74.39v-42.031h-74.39v-139.84c0-13.379 3.34-23.242 10.03-29.629 6.69-6.387 15.47-9.57 26.33-9.57M2585.92 329.82c0-60.308 22.28-90.465 66.84-90.465 44.09 0 66.15 30.157 66.15 90.465 0 59.688-22.22 89.512-66.62 89.512-23.31 0-40.2-7.707-50.67-23.144-10.47-15.43-15.7-37.54-15.7-66.368zm190.13 0c0-42.672-10.94-75.972-32.83-99.898-21.89-23.945-52.36-35.918-91.4-35.918-24.42 0-45.98 5.508-64.72 16.543-18.74 11.016-33.14 26.836-43.22 47.48-10.07 20.625-15.12 44.551-15.12 71.793 0 42.364 10.87 75.43 32.59 99.2 21.74 23.777 52.36 35.671 91.89 35.671 37.79 0 67.7-12.156 89.75-36.492 22.04-24.328 33.06-57.121 33.06-98.379M2972.33 464.691c11.18 0 20.38-.789 27.63-2.371l-5.43-51.718c-7.87 1.894-16.05 2.832-24.57 2.832-22.2 0-40.19-7.246-53.96-21.731-13.78-14.48-20.67-33.301-20.67-56.453V198.73h-55.51v261.227h43.46l7.33-46.055h2.83c8.66 15.594 19.96 27.95 33.89 37.09 13.94 9.141 28.94 13.699 45 13.699" fill="#100f0d"/><path d="M610.11 372.83c0-170.584-138.257-308.862-308.846-308.862-170.602 0-308.846 138.278-308.846 308.863 0 170.576 138.244 308.846 308.846 308.846 170.59 0 308.846-138.27 308.846-308.846" fill="#e53935" stroke-width="1.029"/><path d="M460.694 521.792l-105.04.958-61.415 61.415-72.096-47.883 12.445-12.438-29.207.26-99.129-166.817H67.357l24.39-24.402-24.57-41.363L294.66 64.049c2.192-.04 4.399-.08 6.603-.08 170.416 0 308.585 138.055 308.846 308.408L460.694 521.792" fill="#d51c2f" stroke-width="1.029"/><path d="M149.093 350.258c0 84.048 68.13 152.151 152.171 152.151 84.028 0 152.139-68.103 152.139-152.151zm342.063-7.017v14.046h44.015c-1.75 59.337-25.556 113.104-63.54 153.419L438.75 477.81l-9.925 9.94 32.875 32.887c-40.314 37.983-94.081 61.79-153.41 63.527l-.015-44.003h-14.035v44.003c-59.34-1.737-113.096-25.556-153.41-63.527l32.887-32.887-9.945-9.92-32.883 32.875c-37.975-40.315-61.781-94.082-63.53-153.419h44.002l-.008-14.034H67.176v-51.511h468.176v51.5h-44.196" fill="#f5f5f5" stroke-width="1.029"/></g></g></symbol><symbol id="pug" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><style>.st0{fill:#c1272d}.hyst1{fill:#efcca3}.st2{fill:#ed1c24}.hyst3{fill:#ccac8d}.hyst4{fill:#fff}.st5{fill:#ff931e}.st6{fill:#ffb81e}.hyst7{fill:#56332b}.hyst8{fill:#442823}.hyst9{fill:#7f4a41}.hyst10{fill:#331712}.st11{fill:#fc6}.st12{fill:#ccc}.st13{fill:#b3b3b3}.st14{fill:#989898}.st15{fill:#323232}.st16{fill:#1e1e1e}.st17{fill:#4c4c4c}.st18{fill:#e6e6e6}.st19{fill:#606060}</style><path class="hyst1" d="M107.4 50.9c-.2-4.4.4-8.3-1.6-11.6-4.8-8.2-16.8-13-40.8-13v.7h-.5.5v-.7c-24 0-36.6 4.8-41.4 13.1-1.9 3.4-1.7 7.2-2 11.6-.2 3.5-1.8 7.2-1.1 11.2.8 5.2 1.1 10.4 1.9 15.2.6 3.9 6 7.2 6.5 10.9 1.4 10.2 12 14.9 36 14.9v.8h-.6.7v-.8c24 0 34.2-4.7 35.5-14.9.5-3.8 5.5-7 6.1-10.9.8-4.8 1.1-10 1.9-15.2.7-4-.9-7.8-1.1-11.3z"/><path class="hyst3" d="M64.6 54.5c4.3.1 7.3 2.8 10.1 5.3 3.3 2.9 8.9 4.9 11.2 7.4 2.3 2.5 5.3 5 6.4 8.9 1.1 3.9 1.4 8.9 1.4 10.2 0 1.3.7 1 2.7 0 4.7-2.3 9.9-8.5 9.9-8.5-.6 3.9-5.7 7.4-6.2 11.1C98.9 99.1 89 104 64.5 104h-.1.6"/><path class="hyst3" d="M80.4 46.7c.9 3.1 4.1 13.6-2.1 10.1 0 0 2.6 1.5 4.2 7.2 1.7 5.7 5.8 6.4 5.8 6.4s6.7 1.3 11.7-3c4.2-3.6 4.9-10 3.1-14.9-1.8-4.8-5-6.3-9.7-7.3-4.7-1.1-14.1-2-13 1.5z"/><circle cx="92.3" cy="58.1" r="8.8"/><circle class="hyst4" cx="90" cy="54.2" r="2.3"/><path class="hyst1" d="M78.9 57.7s7.9 5.4 12.2 10.7c4.3 5.3 4.2 6.3 4.2 6.3l-3.1 1.4s-4.4-8.3-9.8-11.4c-5.5-3.1-6.1-5.7-6.1-5.7l2.6-1.3z"/><path class="hyst3" d="M64.9 54.5c-4.3.1-7.5 2.8-10.4 5.3-3.3 2.9-9.1 4.9-11.4 7.4-2.3 2.5-5.4 5-6.5 8.9-1.1 3.9-1.5 8.9-1.5 10.2 0 1.3.2 1.4-2.7 0-4.7-2.2-9.9-8.5-9.9-8.5.6 3.9 5.7 7.4 6.2 11.1C30.1 99.1 40 104 64.5 104h.5"/><path class="hyst7" d="M88.1 71.4C83.3 65.5 75.6 60 64.9 60h-.1c-10.7 0-18.4 5.5-23.2 11.4-5 6.1-4.6 8.5-4.6 14.3 0 21 7.4 15 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.7 12.3-17.3.1-5.8.4-8.4-4.6-14.5z"/><path class="hyst8" d="M64.4 65.2s-.7 9.7-2.1 11.6l2.6-.6-.5-11z"/><path class="hyst8" d="M65.1 65.2s.7 9.7 2.1 11.6l-2.6-.6.5-11z"/><path class="hyst7" d="M56.7 62.9c-1-2.3 2.6-6 8.3-6.1 5.7 0 9.3 3.7 8.3 6.1-1 2.4-4.6 3.1-8.3 3.2-3.6-.1-7.3-.8-8.3-3.2z"/><path d="M65 65.2c0-.4 3.4-.5 5.2-1.7 0 0-3.7 1.2-4.5.7-.8-.4-1-1.6-1-1.6s-.3 1.2-.9 1.6c-.7.4-4.9-.7-4.9-.7s5.6 1.4 5.6 1.7c0 .3-.1 1.3-.1 2 0 2.5 0 8.7.4 9.2.6.9.4-6.7.4-9.2-.1-.8-.1-1.6-.2-2z"/><path class="hyst9" d="M65.2 78.6c1.7 0 4.7 1.2 7.4 3.1-2.6-2.9-5.7-4.9-7.4-4.9-1.8 0-5.6 2.2-8.3 5.4 2.8-2.2 6.4-3.6 8.3-3.6z"/><path class="hyst8" d="M64.5 96.3c-3.8 0-7.5-1.2-10.9-2.1-.7-.2-1.4.3-2.1.1-6.3-2-11.4-5.4-14.5-9.7v1c0 21 7.4 15.1 12.3 17.6 5 2.5 10.2 1.7 15.5 1.7h.1c5.4 0 10.5.7 15.5-1.8 4.9-2.5 12.3 3.6 12.3-17.4 0-.8 0-1.6.1-2.3-2.9 4.7-8.2 8.4-14.8 10.6-.6.2-2-.3-2.6-.2-3.6 1.2-6.8 2.5-10.9 2.5z"/><path class="hyst8" d="M55 85s-2.5 7.5-.8 10.8l-2.3-1s1.7-7.6 3.1-9.8zM74.8 85s2.5 7.5.8 10.8l2.3-1s-1.8-7.6-3.1-9.8z"/><path class="hyst3" d="M48.6 46.7c-.9 3.1-4.1 13.6 2.1 10.1 0 0-2.6 1.5-4.2 7.2s-5.8 6.4-5.8 6.4-6.7 1.3-11.7-3c-4.2-3.6-4.9-10-3.1-14.9s5-6.3 9.7-7.3c4.7-1.1 14-2 13 1.5z"/><path d="M64.9 76.8c2.7 0 11.1 5.8 11.2 12.9v-.4c0-7.4-6.8-13.3-11.2-13.3-4.4 0-11.2 6-11.2 13.3v.4c.1-7.1 8.5-12.9 11.2-12.9z"/><ellipse transform="rotate(-14.465 66.712 61.468)" class="hyst10" cx="66.7" cy="61.5" rx=".8" ry="1.5"/><ellipse transform="rotate(17.235 62.371 61.462)" class="hyst10" cx="62.4" cy="61.5" rx=".8" ry="1.5"/><circle cx="37.2" cy="58.1" r="8.8"/><circle class="hyst4" cx="39.5" cy="54.2" r="2.3"/><path class="hyst9" d="M67.5 58.2c0-.1-2.3 1-2.9 1.1-.6-.1-2.9-1.2-2.9-1.1h5.8z"/><path class="hyst1" d="M50 57.7s-7.9 5.4-12.2 10.7c-4.3 5.3-4.2 6.3-4.2 6.3l3.1 1.4s4.4-8.3 9.8-11.4 6.1-5.7 6.1-5.7L50 57.7z"/><path class="hyst3" d="M32.7 41.7S30 49.1 24 52.2c0 0 9.4-1.1 8.7-10.5zM95.8 41.7s2.7 7.4 8.7 10.5c0 0-9.4-1.1-8.7-10.5zM78.7 55.5s-5.9-6.2-13.8-6.4h.1.1c-8 .2-13.8 6.4-13.8 6.4 6.9-4.8 12.8-4.7 13.8-4.7-.1 0 6.7-.1 13.6 4.7zM71.8 42.5s-3-4.2-7-4.3h.2c-3 .1-6.9 4.3-6.9 4.3 3.4-3.3 6.9-3.2 6.9-3.2s3.3-.1 6.8 3.2zM37.2 73.2s-4.7 2.3-8.1.9H29c-3-1.7-4.5-6.8-4.5-6.8s3 9 12.7 5.9zM92 73.2s4.7 2.3 8.1.9c4-1.7 4.6-6.8 4.6-6.8s-3 9-12.7 5.9z"/><path class="hyst3" d="M42.6 41.2c2.6-.5 6.9-.6 10.3.5 4.3 1.5.8 7 1.7 7.3.9.3 2.1-3.8 10.1-3.4 8.1.4 9 4 10.1 3.4s-1.1-10 11-7.8c0 0-12.7-3.4-12.1 5.8 0 0-7.3-5.6-17.5-.6.1 0 2.7-8.6-13.6-5.2zM86.9 41.2c.2 0 .3.1.4.1.1 0-.1-.1-.4-.1zM86.9 41.2zM39.1 28.9S28.3 42.5 26.7 47.7c-1.6 5.3-2.8 27-4.2 30.1l-5-21.4 9.2-22.3 12.4-5.2zM89.9 28.9s10.8 13.6 12.4 18.8c1.6 5.3 2.8 27 4.2 30.1l5-21.4-9.2-22.3-12.4-5.2z"/><path class="hyst7" d="M89.4 28.9s11.6 9.7 15 20.9c3.4 11.2 2 24.8 4.6 26.5 3.7 2.4 7.9-11.9 9.3-13.4 2.2-2.4 9.5-8.5 10-9.6.5-1.1-14.8-17.8-21.5-21.1-8.1-3.8-18.1-4.1-17.4-3.3z"/><path class="hyst8" d="M99.3 34.9s13.7 17.5 13.5 39.3l5.5-11.2c-.1 0-4.9-14.3-19-28.1z"/><path class="hyst7" d="M39.1 28.9s-11.6 9.7-15 20.9-2 24.8-4.6 26.5c-3.7 2.4-7.9-11.9-9.3-13.4C8 60.5.7 54.4.2 53.3-.3 52.2 15 35.5 21.7 32.2c8.1-3.8 18.1-4.1 17.4-3.3z"/><path class="hyst8" d="M29.2 34.9S15.5 52.4 15.7 74.2L10.3 63s4.8-14.3 18.9-28.1z"/><path class="hyst3" d="M21.8 74.6s1 5.4 2.6 7.1.5-1.3.5-1.3-1.7-.9-1.4-7.8-1.7 2-1.7 2zM107.1 74.6s-1 5.4-2.6 7.1-.5-1.3-.5-1.3 1.7-.9 1.4-7.8 1.7 2 1.7 2z"/><g><circle class="hyst8" cx="54.5" cy="70.5" r=".8"/><circle class="hyst8" cx="49.9" cy="75.3" r=".8"/><circle class="hyst8" cx="48.4" cy="70.5" r=".8"/></g><g><circle class="hyst8" cx="74" cy="70.5" r=".8"/><circle class="hyst8" cx="78.6" cy="75.3" r=".8"/><circle class="hyst8" cx="80.1" cy="70.5" r=".8"/></g></symbol><symbol viewBox="0 0 50 50" id="puppet" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -247)" fill="#fbc02d"><path stroke-width=".283" d="M11.559 249.467h13.587v13.587H11.559zM27.435 265.056h13.587v13.587H27.435zM11.559 281.074h13.587v13.587H11.559z"/><path stroke-width=".256" d="M16.62 251.615l18.305 18.305-3.236 3.236-18.305-18.305z"/><path stroke-width=".256" d="M37.834 271.331L19.53 289.636l-3.237-3.237 18.305-18.304z"/></g></symbol><symbol viewBox="0 0 100 99.999997" id="purescript" xmlns="http://www.w3.org/2000/svg"><path clip-path="url(#SVGID_2_)" d="M98.079 38.548L79.22 19.68l-5.087 5.088L90.447 41.09 74.134 57.41l5.087 5.087 18.858-18.86a3.59 3.59 0 0 0 1.055-2.55 3.578 3.578 0 0 0-1.055-2.54M25.483 42.794l-5.09-5.089L1.53 56.568a3.566 3.566 0 0 0-1.05 2.545c0 .961.373 1.863 1.05 2.542L20.394 80.52l5.089-5.086L9.162 59.113z" fill="#42a5f5" stroke-width="1.192"/><path clip-path="url(#SVGID_2_)" transform="matrix(1.19175 0 0 1.19175 -306.84 -629.047)" fill="#42a5f5" d="M281.841 551.736l6.461 6.037h28.379l-6.461-6.037zM288.302 566.861l-6.463 6.035h28.381l6.463-6.035zM281.838 581.982l6.464 6.035h28.381l-6.463-6.035z"/></symbol><symbol viewBox="0 0 24 24" id="python" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 7.5A2.86 2.86 0 0 1 22 10.36v3.78A2.86 2.86 0 0 1 19.14 17H12c0 .39.32.96.71.96H17v1.68a2.86 2.86 0 0 1-2.86 2.86H9.86A2.86 2.86 0 0 1 7 19.64v-3.75a2.85 2.85 0 0 1 2.86-2.85h5.25a2.85 2.85 0 0 0 2.85-2.86V7.5h1.18m-4.28 11.79c-.4 0-.72.3-.72.89 0 .59.32.71.72.71a.71.71 0 0 0 .71-.71c0-.59-.32-.89-.71-.89m-10-1.79A2.86 2.86 0 0 1 2 14.64v-3.78A2.86 2.86 0 0 1 4.86 8H12c0-.39-.32-.96-.71-.96H7V5.36A2.86 2.86 0 0 1 9.86 2.5h4.28A2.86 2.86 0 0 1 17 5.36v3.75a2.85 2.85 0 0 1-2.86 2.85H8.89a2.85 2.85 0 0 0-2.85 2.86v2.68H4.86M9.14 5.71c.4 0 .72-.3.72-.89 0-.59-.32-.71-.72-.71-.39 0-.71.12-.71.71s.32.89.71.89z"/><path d="M9.264 22.379c-.895-.24-1.581-.799-1.947-1.582-.228-.489-.237-.606-.238-2.957-.001-2.745.057-3.074.666-3.785.193-.226.568-.517.833-.648.47-.23.579-.239 3.839-.288 3.131-.048 3.386-.065 3.814-.264.626-.291 1.07-.687 1.4-1.247.27-.46.278-.522.311-2.29l.034-1.82.932.051c1.075.058 1.504.211 2.098.748.853.77.869.841.869 3.957 0 2.434-.02 2.783-.18 3.075a3.365 3.365 0 0 1-1.337 1.33l-.517.273-3.95.031-3.951.031.068.274c.037.151.164.377.282.503.209.224.262.229 2.433.229h2.22v1.05c0 1.653-.394 2.437-1.54 3.072l-.545.302-2.644.018c-1.455.01-2.782-.018-2.95-.063zm6.12-1.692c.22-.222.253-.325.206-.675-.07-.523-.278-.73-.732-.73-.467 0-.672.217-.735.78-.042.372-.012.496.163.672.3.3.77.28 1.097-.047z" fill="#fc0" stroke="#fc0" stroke-width=".102"/><path d="M9.349 22.38c-.911-.15-1.936-1.074-2.176-1.963-.073-.273-.101-1.279-.079-2.868.033-2.317.047-2.473.27-2.926.13-.263.401-.623.603-.8.674-.592.87-.63 3.484-.675 4.399-.076 4.927-.166 5.705-.967.642-.662.706-.9.774-2.883l.061-1.784.951.055c.523.031 1.11.122 1.304.204.54.225 1.358 1.042 1.472 1.47.153.572.243 3.18.16 4.617-.071 1.23-.093 1.327-.395 1.78-.193.288-.577.647-.966.903l-.647.425-3.922.008c-2.157.004-3.942.028-3.966.052-.115.115.354.82.587.883.14.038 1.181.073 2.314.079l2.06.01v.91c0 1.739-.326 2.446-1.454 3.162l-.631.4-2.543-.011c-1.398-.007-2.733-.043-2.966-.081zm5.98-1.718c.285-.256.313-.328.251-.658-.09-.483-.301-.682-.722-.682-.436 0-.625.193-.715.73-.065.384-.044.453.2.663.358.308.595.295.985-.053z" fill="#fdd835" stroke-width=".102"/><path d="M4.281 17.396c-.88-.215-1.714-.935-2.024-1.747-.149-.389-.168-.804-.142-3.041.027-2.26.054-2.638.215-2.962.259-.519.851-1.092 1.392-1.346.437-.206.632-.217 4.408-.245l3.95-.03-.067-.275a1.367 1.367 0 0 0-.282-.504c-.21-.224-.263-.23-2.433-.23h-2.22l.002-1.143c.003-1.338.157-1.795.84-2.493.746-.763 1.103-.838 4.025-.838 2.961 0 3.28.06 4.067.768.37.333.572.621.728 1.037.201.539.213.735.183 3.072-.035 2.777-.045 2.824-.78 3.598-.787.829-.76.824-4.59.883-3.812.06-3.797.057-4.61.806-.765.706-.917 1.2-.964 3.133l-.04 1.653-.677-.01c-.371-.007-.813-.045-.98-.086zM9.59 5.551c.237-.204.286-.326.286-.72 0-.547-.201-.763-.71-.763-.502 0-.765.248-.765.724 0 .492.141.782.439.902.345.14.444.12.75-.143z" fill="#3c78aa"/></symbol><symbol viewBox="0 0 24 24" id="r" xmlns="http://www.w3.org/2000/svg"><path d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1.081 1.081 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z" fill="#1976d2" stroke-width="1.035"/></symbol><symbol viewBox="0 0 24 24" id="raml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="razor" xmlns="http://www.w3.org/2000/svg"><path d="M15.45 11.91c-.11-2.21-1.75-3.54-3.73-3.54h-.08c-2.29 0-3.55 1.8-3.55 3.84 0 2.29 1.53 3.74 3.54 3.74 2.25 0 3.72-1.65 3.83-3.59m-3.81-5.97c1.53 0 2.97.68 4.02 1.74 0-.51.33-.89.83-.89h.11c.74 0 .89.7.89.92v7.9c-.04.52.54.78.87.44 1.27-1.29 2.78-6.69-.79-9.81-3.33-2.92-7.8-2.44-10.18-.8-2.52 1.74-4.14 5.61-2.57 9.22 1.71 3.95 6.61 5.13 9.52 3.95 1.48-.59 2.15 1.4.65 2.05-2.34.99-8.77.89-11.78-4.32-2.03-3.52-1.93-9.71 3.46-12.92C10.81 1.42 16.24 2.1 19.5 5.5c3.45 3.6 3.25 10.3-.1 12.91-1.51 1.18-3.76.03-3.74-1.7l-.02-.56a5.611 5.611 0 0 1-3.99 1.66C8.63 17.81 6 15.15 6 12.13c0-3.05 2.63-5.74 5.65-5.74z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="react" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.11c1.03 0 1.87.84 1.87 1.89 0 1-.84 1.85-1.87 1.85-1.03 0-1.87-.85-1.87-1.85 0-1.05.84-1.89 1.87-1.89M7.37 20c.63.38 2.01-.2 3.6-1.7-.52-.59-1.03-1.23-1.51-1.9a22.7 22.7 0 0 1-2.4-.36c-.51 2.14-.32 3.61.31 3.96m.71-5.74l-.29-.51c-.11.29-.22.58-.29.86.27.06.57.11.88.16l-.3-.51m6.54-.76l.81-1.5-.81-1.5c-.3-.53-.62-1-.91-1.47C13.17 9 12.6 9 12 9c-.6 0-1.17 0-1.71.03-.29.47-.61.94-.91 1.47L8.57 12l.81 1.5c.3.53.62 1 .91 1.47.54.03 1.11.03 1.71.03.6 0 1.17 0 1.71-.03.29-.47.61-.94.91-1.47M12 6.78c-.19.22-.39.45-.59.72h1.18c-.2-.27-.4-.5-.59-.72m0 10.44c.19-.22.39-.45.59-.72h-1.18c.2.27.4.5.59.72M16.62 4c-.62-.38-2 .2-3.59 1.7.52.59 1.03 1.23 1.51 1.9.82.08 1.63.2 2.4.36.51-2.14.32-3.61-.32-3.96m-.7 5.74l.29.51c.11-.29.22-.58.29-.86-.27-.06-.57-.11-.88-.16l.3.51m1.45-7.05c1.47.84 1.63 3.05 1.01 5.63 2.54.75 4.37 1.99 4.37 3.68 0 1.69-1.83 2.93-4.37 3.68.62 2.58.46 4.79-1.01 5.63-1.46.84-3.45-.12-5.37-1.95-1.92 1.83-3.91 2.79-5.38 1.95-1.46-.84-1.62-3.05-1-5.63-2.54-.75-4.37-1.99-4.37-3.68 0-1.69 1.83-2.93 4.37-3.68-.62-2.58-.46-4.79 1-5.63 1.47-.84 3.46.12 5.38 1.95 1.92-1.83 3.91-2.79 5.37-1.95M17.08 12c.34.75.64 1.5.89 2.26 2.1-.63 3.28-1.53 3.28-2.26 0-.73-1.18-1.63-3.28-2.26-.25.76-.55 1.51-.89 2.26M6.92 12c-.34-.75-.64-1.5-.89-2.26-2.1.63-3.28 1.53-3.28 2.26 0 .73 1.18 1.63 3.28 2.26.25-.76.55-1.51.89-2.26m9 2.26l-.3.51c.31-.05.61-.1.88-.16-.07-.28-.18-.57-.29-.86l-.29.51m-2.89 4.04c1.59 1.5 2.97 2.08 3.59 1.7.64-.35.83-1.82.32-3.96-.77.16-1.58.28-2.4.36-.48.67-.99 1.31-1.51 1.9M8.08 9.74l.3-.51c-.31.05-.61.1-.88.16.07.28.18.57.29.86l.29-.51m2.89-4.04C9.38 4.2 8 3.62 7.37 4c-.63.35-.82 1.82-.31 3.96a22.7 22.7 0 0 1 2.4-.36c.48-.67.99-1.31 1.51-1.9z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="readme" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="reason" xmlns="http://www.w3.org/2000/svg"><path d="M3 3v18h18V3H3zm5.119 8.993h2.798c.382 0 .71.025.985.075.275.05.534.159.774.326.244.168.435.386.577.654.145.265.218.598.218 1 0 .552-.112 1.001-.335 1.35-.22.348-.536.638-.947.87l2.16 3.203H12.31l-1.763-2.742h-.77v2.742H8.12v-7.478zm6.594 0h4.676v1.447h-3.018v1.29h2.802v1.447h-2.802v1.848h3.018v1.446h-4.676v-7.478zM9.778 13.37v2.014h.513c.266 0 .49-.014.67-.044.18-.03.329-.1.45-.207a.96.96 0 0 0 .253-.34c.055-.128.082-.297.082-.508 0-.187-.034-.35-.1-.483a.698.698 0 0 0-.343-.317 1.086 1.086 0 0 0-.395-.095 6.012 6.012 0 0 0-.526-.02h-.604z" fill="#f44336" stroke-width="1.067"/></symbol><symbol viewBox="0 0 172 193" id="restql" xmlns="http://www.w3.org/2000/svg"><title>Group</title><g transform="translate(14.767 16.713) scale(.82795)" fill="none"><path d="M171.39 55.799c-.975-6.147-4.673-11.642-10.15-14.805L96.381 3.546C93.217 1.72 89.615.756 85.964.756s-7.253.964-10.415 2.788L10.69 40.992A20.896 20.896 0 0 0 .272 59.035v74.89a20.894 20.894 0 0 0 10.416 18.042l64.859 37.446c3.165 1.827 6.767 2.791 10.417 2.791s7.252-.964 10.415-2.79l64.859-37.445c5.479-3.166 9.178-8.66 10.152-14.808zm-16.516 85.147L90.017 178.39a8.104 8.104 0 0 1-8.108 0l-64.857-37.444a8.109 8.109 0 0 1-4.053-7.021v-74.89a8.109 8.109 0 0 1 4.053-7.021l64.857-37.446c1.254-.725 2.654-1.086 4.054-1.086s2.8.361 4.054 1.086l64.857 37.446a8.106 8.106 0 0 1 4.053 7.021v74.89a8.109 8.109 0 0 1-4.053 7.021z" fill="#83e8c2"/><path d="M158.93 59.035a8.109 8.109 0 0 0-4.053-7.021L90.02 14.568c-1.254-.725-2.654-1.086-4.054-1.086s-2.8.361-4.054 1.086L17.055 52.014a8.106 8.106 0 0 0-4.053 7.021v74.89a8.109 8.109 0 0 0 4.053 7.021l64.857 37.444a8.104 8.104 0 0 0 8.108 0l64.857-37.444a8.109 8.109 0 0 0 4.053-7.021zm-46.766 31.681c.119-.069.242-.118.365-.149.044-.012.088-.01.131-.018.076-.012.152-.029.228-.029l.015.001c.02.001.038.005.059.006.093.005.184.019.273.04l.1.03c.077.025.15.057.223.095.028.014.057.027.084.043.094.057.184.122.263.199.007.008.013.017.021.024.07.071.133.15.188.235.018.029.033.059.05.09.04.072.072.148.099.229a1.512 1.512 0 0 1 .081.46v16.209l-3.278 1.893a1.548 1.548 0 0 0-.678.83 1.533 1.533 0 0 0-.098.514v3.785l-14.038 8.104-.01.004a1.55 1.55 0 0 1-.354.146c-.045.012-.09.011-.135.018-.074.012-.15.029-.225.029l-.014-.001c-.02-.001-.039-.005-.059-.006a1.463 1.463 0 0 1-.273-.041c-.034-.008-.066-.019-.1-.03a1.318 1.318 0 0 1-.223-.094c-.029-.015-.057-.027-.084-.044a1.45 1.45 0 0 1-.263-.198c-.009-.008-.015-.019-.023-.027a1.495 1.495 0 0 1-.185-.232c-.019-.029-.034-.06-.051-.09a1.422 1.422 0 0 1-.098-.229 1.702 1.702 0 0 1-.033-.101 1.487 1.487 0 0 1-.048-.358l-.001-.002v-20.053a1.446 1.446 0 0 1 .727-1.255zM85.24 31.369a1.449 1.449 0 0 1 1.452 0l45.741 26.41a1.45 1.45 0 0 1 0 2.512l-17.366 10.027a1.457 1.457 0 0 1-1.452 0l-15.49-8.943 1.727-.996a1.552 1.552 0 0 0 0-2.688l-13.111-7.57c-.239-.139-.508-.207-.775-.207s-.535.068-.775.207l-3.278 1.893-14.038-8.104a1.451 1.451 0 0 1 0-2.513zM57.59 47.558c.251 0 .501.065.726.194l15.489 8.942-1.727.997a1.552 1.552 0 0 0 0 2.688l1.727.996-15.488 8.943a1.457 1.457 0 0 1-1.452 0L39.499 60.291a1.45 1.45 0 0 1 0-2.512l17.366-10.027c.225-.129.475-.194.725-.194zm-9.56 92.328c-.241 0-.489-.062-.724-.196l-17.365-10.026a1.45 1.45 0 0 1-.726-1.256V75.59c0-.847.694-1.453 1.452-1.453.242 0 .49.062.724.197l17.366 10.025c.449.26.726.738.726 1.257v17.886l-1.727-.997a1.552 1.552 0 0 0-2.327 1.344v15.139c0 .555.295 1.067.775 1.344l3.278 1.894v16.209a1.45 1.45 0 0 1-1.452 1.451zm29.828 14.929a1.452 1.452 0 0 1-2.177 1.257l-17.365-10.026a1.452 1.452 0 0 1-.726-1.257v-17.885l1.726.996c.25.145.515.211.773.211.811 0 1.554-.648 1.554-1.555v-1.993l15.489 8.942c.449.26.726.738.726 1.257zm0-32.768c0 .127-.02.246-.049.36-.009.035-.021.067-.032.101-.026.08-.059.157-.099.229-.017.03-.032.061-.05.09a1.48 1.48 0 0 1-.188.235l-.021.025a1.51 1.51 0 0 1-.264.199c-.026.016-.055.028-.082.043a1.597 1.597 0 0 1-.324.124 1.362 1.362 0 0 1-.278.041c-.018.001-.036.006-.055.006l-.015.001c-.077 0-.155-.018-.233-.03-.043-.007-.084-.005-.125-.017a1.484 1.484 0 0 1-.366-.149l-14.035-8.104v-3.784a1.545 1.545 0 0 0-.776-1.343l-3.276-1.892V91.976c0-.127.02-.246.049-.361.009-.034.021-.066.032-.1a1.33 1.33 0 0 1 .099-.229c.017-.03.032-.062.051-.091.054-.084.116-.163.187-.234l.021-.025c.079-.076.168-.142.263-.199.027-.016.056-.029.084-.043a1.476 1.476 0 0 1 .601-.166c.019 0 .036-.005.055-.005l.015-.001c.078 0 .157.018.236.03.04.007.081.005.122.017.124.031.246.08.366.149l17.361 10.023a1.456 1.456 0 0 1 .726 1.259zm-9.984-45.373a1.448 1.448 0 0 1-.544-.55 1.466 1.466 0 0 1 0-1.413c.121-.219.303-.41.544-.55l14.038-8.104 3.277 1.892c.48.276 1.071.276 1.551 0l3.278-1.893 14.038 8.105a1.45 1.45 0 0 1 0 2.513L86.691 86.7a1.447 1.447 0 0 1-1.452 0zm74.842 51.733c0 .518-.276.997-.726 1.256l-45.741 26.409a1.452 1.452 0 0 1-2.177-1.257v-20.053c0-.519.277-.997.727-1.257l15.488-8.941v1.992c0 .906.743 1.555 1.553 1.555.26 0 .523-.066.774-.21l13.11-7.57a1.55 1.55 0 0 0 .776-1.344v-3.784l14.038-8.105a1.452 1.452 0 0 1 2.177 1.257v20.052zm0-32.764c0 .519-.276.997-.726 1.256l-15.489 8.943v-1.993c0-.906-.744-1.554-1.554-1.554a1.519 1.519 0 0 0-.773.21l-1.727.996V85.616c0-.519.277-.997.727-1.257l17.365-10.025c.234-.135.482-.197.724-.197.758 0 1.453.606 1.453 1.453z" fill="#111d5a"/><g fill="#83e8c2"><path d="M59.402 90.568zM94.485 123.06zM94.771 123.29zM77.775 122.51zM77.072 123.33zM77.418 123.09zM77.856 122.05zM76.749 123.45zM94.119 122.41zM77.131 133.51l-15.489-8.942v1.993c0 .906-.743 1.555-1.554 1.555a1.53 1.53 0 0 1-.773-.211l-1.726-.996v17.885c0 .519.276.997.726 1.257l17.365 10.026a1.452 1.452 0 0 0 2.177-1.257v-20.053a1.454 1.454 0 0 0-.726-1.257zM94.25 122.74zM110.28 111.42zM94.494 100.98c.088-.089.189-.168.303-.232l17.365-10.026-17.365 10.026a1.392 1.392 0 0 0-.303.232zM77.627 122.83zM58.027 90.936zM58.374 90.693zM59.044 90.521l-.015.001c.083-.001.167.015.251.029-.079-.012-.158-.03-.236-.03zM57.819 91.195zM58.696 90.568zM57.589 91.977zM76.043 123.46zM57.67 91.516zM75.677 123.31l-14.035-8.11zM76.401 123.5l.015-.001c-.082.001-.166-.016-.248-.029.078.012.156.03.233.03zM112.16 90.716zM77.662 101.27zM113.64 90.734zM96.237 123.31zM113.33 90.597zM112.89 90.52c-.075 0-.151.018-.228.029.081-.014.162-.029.242-.028l-.014-.001zM141.26 74.137c-.241 0-.489.062-.724.197l-17.365 10.025c-.449.26-.727.738-.727 1.257v17.885l1.727-.996c.25-.145.515-.211.773-.21.81 0 1.554.647 1.554 1.554v1.993l15.489-8.943a1.45 1.45 0 0 0 .726-1.256V75.59c0-.847-.695-1.453-1.453-1.453zM112.96 90.526zM95.523 123.5c.074 0 .15-.018.225-.029-.08.013-.159.028-.238.028l.013.001zM95.451 123.5zM85.238 86.7zM95.078 123.43zM141.26 106.9c-.241 0-.489.062-.724.196l-14.038 8.105v3.784c0 .555-.296 1.067-.776 1.344l-13.11 7.57c-.251.144-.515.21-.774.21-.81 0-1.553-.648-1.553-1.555v-1.992l-15.488 8.941c-.449.26-.727.738-.727 1.257v20.053a1.452 1.452 0 0 0 2.177 1.257l45.741-26.409a1.45 1.45 0 0 0 .726-1.256v-20.053a1.454 1.454 0 0 0-1.454-1.452zM67.871 41.396a1.451 1.451 0 0 0 0 2.513l14.038 8.104 3.278-1.893c.24-.139.508-.207.775-.207s.536.068.775.207l13.111 7.57a1.552 1.552 0 0 1 0 2.688l-1.727.996 15.49 8.943a1.457 1.457 0 0 0 1.452 0l17.366-10.027a1.45 1.45 0 0 0 0-2.512l-45.741-26.41a1.449 1.449 0 0 0-1.452 0zM39.497 57.779a1.45 1.45 0 0 0 0 2.512l17.366 10.027a1.457 1.457 0 0 0 1.452 0l15.488-8.943-1.727-.996a1.552 1.552 0 0 1 0-2.688l1.727-.997-15.489-8.942a1.458 1.458 0 0 0-1.451 0zM49.481 138.43v-16.209l-3.278-1.894a1.55 1.55 0 0 1-.775-1.344v-15.139c0-.906.743-1.555 1.554-1.554.259 0 .523.065.773.21l1.727.997V85.611a1.45 1.45 0 0 0-.726-1.257L31.39 74.33a1.436 1.436 0 0 0-.724-.197c-.758 0-1.452.606-1.452 1.453v52.817c0 .518.276.997.726 1.256l17.365 10.026a1.45 1.45 0 0 0 2.176-1.255zM114.34 108.18l-3.278 1.893 3.278-1.893V91.971zM114.11 91.193zM114.16 91.283z"/></g><g fill="#de5941"><path d="M94.494 100.98a1.45 1.45 0 0 0-.424 1.023v20.053l.001.002c0 .126.02.244.048.358.01.034.021.066.033.101.026.08.059.156.098.229.017.03.032.061.051.09.055.084.115.162.185.232.009.009.015.02.023.027.079.077.169.142.263.198.027.017.055.029.084.044a1.46 1.46 0 0 0 .596.165c.02.001.039.005.059.006.079 0 .158-.016.238-.028.045-.007.09-.006.135-.018.119-.031.238-.08.354-.146l.01-.004 14.038-8.104v-3.785c0-.18.04-.35.098-.514.122-.343.353-.643.678-.83l3.278-1.893V91.977c0-.127-.021-.246-.049-.361-.009-.033-.021-.065-.032-.099a1.266 1.266 0 0 0-.099-.229c-.017-.031-.032-.061-.05-.09a1.425 1.425 0 0 0-.188-.235l-.021-.024a1.41 1.41 0 0 0-.263-.199c-.027-.016-.056-.029-.084-.043a1.509 1.509 0 0 0-.323-.125 1.591 1.591 0 0 0-.273-.04c-.021-.001-.039-.005-.059-.006-.08-.001-.161.015-.242.028-.043.008-.087.006-.131.018-.123.031-.246.08-.365.149l-17.365 10.026a1.447 1.447 0 0 0-.302.233zM77.13 100.74L59.769 90.717a1.424 1.424 0 0 0-.366-.149c-.041-.012-.082-.01-.122-.017-.084-.015-.168-.03-.251-.029-.019 0-.036.005-.055.005-.095.005-.188.02-.278.041-.034.009-.065.02-.099.03a1.406 1.406 0 0 0-.224.095c-.028.014-.057.027-.084.043a1.515 1.515 0 0 0-.263.199l-.021.025c-.07.071-.133.15-.187.234-.019.029-.034.061-.051.091-.04.073-.072.149-.099.229a1.463 1.463 0 0 0-.081.461v16.206l3.276 1.892a1.547 1.547 0 0 1 .776 1.343v3.784l14.035 8.104c.119.068.242.117.366.149.041.012.082.01.125.017.082.014.166.03.248.029.019 0 .037-.005.055-.006.095-.004.188-.019.278-.041.034-.008.065-.019.099-.029.077-.025.152-.058.225-.095.027-.015.056-.027.082-.043.095-.058.185-.123.264-.199l.021-.025c.07-.071.133-.15.188-.235.018-.029.033-.06.05-.09.04-.072.072-.149.099-.229a1.448 1.448 0 0 0 .081-.461v-20.047a1.456 1.456 0 0 0-.726-1.259zM86.689 86.7l17.365-10.026a1.45 1.45 0 0 0 0-2.513l-14.038-8.105-3.278 1.893a1.556 1.556 0 0 1-1.551 0l-3.277-1.892-14.038 8.104c-.241.14-.423.331-.544.55a1.466 1.466 0 0 0 0 1.413c.121.218.303.41.544.55L85.238 86.7a1.447 1.447 0 0 0 1.451 0z"/></g></g></symbol><symbol viewBox="0 0 24 24" id="riot" xmlns="http://www.w3.org/2000/svg"><defs><path d="M13.26 3.04l.58.05.54.07.52.09.49.11.46.13.44.14.41.16.39.17.36.19.33.21.32.22.29.23.26.25.22.22.2.22.19.24.17.24.15.25.15.26.12.27.12.28.1.29.08.31.07.31.05.32.04.34.02.35.01.37v.05l-.02.51-.05.49-.09.48-.13.45-.15.43-.19.4-.22.39-.26.37-.28.34-.31.33-.33.3-.37.28-.39.27-.41.24-.44.22L21 21h-7.04l-3.48-5.14H9.17V21H3V3h9.01l.64.01.61.03zm-4.09 8.52h2.66l.99-.11.75-.35.47-.55.16-.74v-.05l-.17-.75-.47-.54-.74-.32-.96-.11H9.17v3.52z" id="ija"/></defs><use xlink:href="#ija" fill="#ff1744"/><use xlink:href="#ija" fill-opacity="0" stroke="#000" stroke-opacity="0"/></symbol><symbol viewBox="0 0 24 24" id="robot" xmlns="http://www.w3.org/2000/svg"><path d="M12.05 2.804a1.787 1.787 0 0 1 1.788 1.788c0 .661-.357 1.242-.893 1.546v1.135h.893a6.256 6.256 0 0 1 6.256 6.256h.894a.894.894 0 0 1 .893.893v2.681a.894.894 0 0 1-.893.894h-.894v.894a1.787 1.787 0 0 1-1.787 1.787H5.795a1.787 1.787 0 0 1-1.787-1.787v-.894h-.894a.894.894 0 0 1-.894-.894v-2.68a.894.894 0 0 1 .894-.894h.894a6.256 6.256 0 0 1 6.255-6.256h.894V6.138a1.773 1.773 0 0 1-.894-1.546 1.787 1.787 0 0 1 1.788-1.788m-4.022 9.83a2.234 2.234 0 0 0-2.234 2.235 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.234-2.234 2.234 2.234 0 0 0-2.234-2.234m8.043 0a2.234 2.234 0 0 0-2.234 2.234 2.234 2.234 0 0 0 2.234 2.234 2.234 2.234 0 0 0 2.235-2.234 2.234 2.234 0 0 0-2.235-2.234z" fill="#ff5722" stroke-width=".894"/></symbol><symbol viewBox="100 100 800 800" id="rollup" xmlns="http://www.w3.org/2000/svg"><style>.ilst0{fill:url(#ilXMLID_4_)}.ilst1{fill:url(#ilXMLID_5_)}.ilst2{fill:url(#ilXMLID_8_)}.ilst3{fill:url(#ilXMLID_9_)}.ilst4{fill:url(#ilXMLID_11_)}.ilst5{opacity:.3;fill:url(#ilXMLID_16_)}</style><g id="ilXMLID_14_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_4_" x1="444.47" x2="598.47" y1="526.05" y2="562.05" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_15_" class="ilst0" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_4_)"/></g><g id="ilXMLID_2_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_5_" x1="420.38" x2="696.38" y1="475" y2="689" gradientUnits="userSpaceOnUse"><stop stop-color="#BF3338" offset="0"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_10_" class="ilst1" d="M721 410c0-33.6-8.8-65.1-24.3-92.4-41.1-42.3-130.5-52.1-152.7-.2-22.8 53.2 38.3 112.4 65 107.7 34-6-6-84-6-84 52 98 40 68-54 158S359 779 345 787c-.6.4-1.2.7-1.9 1h368.7c6.5 0 10.7-6.9 7.8-12.7l-96.4-190.8c-2.1-4.1-.6-9.2 3.4-11.5C683 540.6 721 479.8 721 410z" fill="url(#ilXMLID_5_)"/></g><linearGradient id="ilXMLID_8_" x1="429.39" x2="469.39" y1="517.16" y2="559.16" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_3_" class="ilst2" d="M329.82 813.46c15.58-8.903 122.41-220.34 227.02-320.5s117.96-66.771 60.094-175.83c0 0-221.46 310.49-301.58 464.06" fill="url(#ilXMLID_8_)" stroke-width="1.113"/><g id="ilXMLID_7_" transform="translate(-54.117 -62.353) scale(1.1129)"><linearGradient id="ilXMLID_9_" x1="502.11" x2="490.11" y1="589.46" y2="417.46" gradientUnits="userSpaceOnUse"><stop stop-color="#FF6533" offset="0"/><stop stop-color="#FF5633" offset=".157"/><stop stop-color="#FF4333" offset=".434"/><stop stop-color="#FF3733" offset=".714"/><stop stop-color="#F33" offset="1"/></linearGradient><path id="ilXMLID_12_" class="ilst3" d="M373 537c134.4-247.1 152-272 222-272 36.8 0 73.9 16.6 97.9 46.1-32.7-52.7-90.6-88-156.9-89H307.7c-4.8 0-8.7 3.9-8.7 8.7V691c13.6-35.1 36.7-85.3 74-154z" fill="url(#ilXMLID_9_)"/></g><linearGradient id="ilXMLID_11_" x1="450.12" x2="506.94" y1="514.21" y2="552.85" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FBB040" offset="0"/><stop stop-color="#FB8840" offset="1"/></linearGradient><path id="ilXMLID_6_" class="ilst4" d="M556.84 492.96c-104.61 100.16-211.44 311.6-227.02 320.5s-41.732 10.016-55.643-5.564c-14.801-16.582-37.837-43.401 86.802-272.65 149.57-274.99 169.15-302.7 247.05-302.7 40.953 0 82.24 18.473 108.95 51.302 1.447 2.337 2.893 4.785 4.34 7.233-45.738-47.074-145.23-57.98-169.93-.222-25.373 59.204 42.622 125.08 72.335 119.85 37.837-6.677-6.677-93.48-6.677-93.48 57.757 108.95 44.403 75.563-60.205 175.72z" fill="url(#ilXMLID_11_)" stroke-width="1.113"/><linearGradient id="ilXMLID_16_" x1="508.33" x2="450.33" y1="295.76" y2="933.76" gradientTransform="translate(-54.117 -62.353) scale(1.1129)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFF" offset="0"/><stop stop-color="#FFF" stop-opacity="0" offset="1"/></linearGradient><path id="ilXMLID_13_" class="ilst5" d="M373.22 547.49c149.57-274.99 169.15-302.7 247.05-302.7 33.719 0 67.661 12.575 93.48 35.277-26.708-30.492-66.326-47.519-105.72-47.519-77.9 0-97.486 27.71-247.05 302.7-124.64 229.25-101.6 256.07-86.802 272.65 2.114 2.337 4.563 4.34 7.122 6.01-13.02-18.919-18.807-62.877 91.922-266.42z" fill="url(#ilXMLID_16_)" opacity=".3" stroke-width="1.113"/></symbol><symbol viewBox="0 0 24 24" id="ruby" xmlns="http://www.w3.org/2000/svg"><path d="M16 9h3l-5 7m-4-7h4l-2 8M5 9h3l2 7m5-12h2l2 3h-3m-5-3h2l1 3h-4M7 4h2L8 7H5m1-5L2 8l10 14L22 8l-4-6H6z" fill="#f44336"/></symbol><symbol viewBox="0 0 144 144" id="rust" xmlns="http://www.w3.org/2000/svg"><path d="M68.252 26.206a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0M25.766 58.451a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m84.97.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m-74.661 4.88a3.252 3.252 0 0 0 1.651-4.29l-1.58-3.574h6.214v28.01H29.823a43.847 43.847 0 0 1-1.42-16.738zm25.994.688v-8.256h14.798c.764 0 5.397.883 5.397 4.347 0 2.877-3.553 3.908-6.475 3.908zm-20.203 44.452a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m52.769.166a3.561 3.561 0 0 1 7.123 0 3.561 3.561 0 0 1-7.123 0m1.101-8.076a3.246 3.246 0 0 0-3.856 2.498l-1.787 8.342a43.847 43.847 0 0 1-36.566-.175l-1.787-8.342a3.246 3.246 0 0 0-3.854-2.497l-7.365 1.581a43.847 43.847 0 0 1-3.808-4.488h35.834c.406 0 .676-.074.676-.443V84.527c0-.369-.27-.442-.676-.442h-10.48V76.05h11.335c1.035 0 5.532.296 6.97 6.045.45 1.768 1.44 7.519 2.116 9.36.674 2.065 3.417 6.19 6.34 6.19h18.501a43.847 43.847 0 0 1-4.06 4.7zm19.898-33.468a43.847 43.847 0 0 1 .093 7.612h-4.499c-.45 0-.631.296-.631.737v2.066c0 4.863-2.742 5.92-5.145 6.19-2.288.258-4.825-.958-5.138-2.358-1.35-7.593-3.6-9.214-7.152-12.016 4.409-2.8 8.996-6.93 8.996-12.457 0-5.97-4.092-9.729-6.881-11.572-3.914-2.58-8.246-3.096-9.415-3.096H39.336A43.847 43.847 0 0 1 63.867 28.52l5.484 5.753a3.243 3.243 0 0 0 4.59.105l6.137-5.869a43.847 43.847 0 0 1 30.017 21.38l-4.201 9.487a3.256 3.256 0 0 0 1.652 4.29zm10.477.154l-.143-1.467 4.327-4.036c.88-.82.55-2.472-.574-2.891l-5.532-2.068-.433-1.428 3.45-4.792c.704-.974.058-2.53-1.127-2.724l-5.833-.949-.7-1.31 2.45-5.38c.502-1.095-.43-2.496-1.636-2.45l-5.92.206-.935-1.135 1.36-5.766c.275-1.17-.913-2.36-2.084-2.085l-5.765 1.359-1.136-.935.207-5.92c.046-1.198-1.357-2.135-2.45-1.637l-5.379 2.452-1.31-.703-.95-5.833c-.193-1.183-1.75-1.83-2.723-1.128l-4.796 3.45-1.425-.432-2.068-5.532c-.42-1.127-2.072-1.452-2.89-.576l-4.036 4.33-1.467-.143-3.117-5.036c-.63-1.02-2.318-1.02-2.946 0l-3.117 5.036-1.467.143-4.037-4.33c-.819-.876-2.47-.551-2.89.576l-2.069 5.532-1.426.432-4.795-3.45c-.974-.703-2.53-.055-2.723 1.128l-.951 5.833-1.31.703-5.379-2.452c-1.093-.5-2.496.439-2.45 1.637l.206 5.92-1.136.935-5.765-1.36c-1.171-.272-2.36.915-2.086 2.086l1.358 5.766-.933 1.135-5.92-.206c-1.193-.035-2.134 1.355-1.637 2.45l2.453 5.38-.703 1.31-5.832.949c-1.185.192-1.827 1.75-1.128 2.724l3.45 4.792-.433 1.428-5.532 2.068c-1.123.42-1.452 2.07-.574 2.891l4.328 4.036-.143 1.467-5.035 3.116c-1.02.63-1.02 2.318 0 2.946l5.035 3.117.143 1.467-4.328 4.037c-.878.818-.549 2.468.574 2.89l5.532 2.068.433 1.428-3.45 4.793c-.701.976-.056 2.532 1.129 2.723l5.831.948.703 1.312-2.453 5.378c-.5 1.093.444 2.5 1.638 2.451l5.917-.207.935 1.136-1.358 5.768c-.275 1.168.915 2.355 2.086 2.08l5.765-1.357 1.137.932-.207 5.921c-.046 1.199 1.357 2.136 2.45 1.636l5.379-2.45 1.31.702.95 5.83c.193 1.187 1.75 1.829 2.725 1.13l4.792-3.453 1.427.435 2.069 5.53c.42 1.123 2.072 1.454 2.89.574l4.037-4.328 1.467.146 3.117 5.035c.628 1.016 2.316 1.018 2.946 0l3.117-5.035 1.467-.146 4.036 4.328c.818.88 2.47.549 2.89-.574l2.068-5.53 1.428-.435 4.793 3.453c.974.699 2.53.055 2.722-1.13l.952-5.83 1.31-.703 5.378 2.451c1.093.5 2.493-.435 2.45-1.636l-.206-5.92 1.135-.933 5.765 1.357c1.171.275 2.36-.912 2.085-2.08l-1.358-5.768.932-1.136 5.92.207c1.194.048 2.138-1.358 1.636-2.451l-2.45-5.378.7-1.312 5.833-.948c1.187-.19 1.831-1.747 1.127-2.723l-3.45-4.793.433-1.428 5.532-2.068c1.125-.422 1.454-2.072.574-2.89l-4.327-4.037.143-1.467 5.035-3.117c1.02-.628 1.021-2.315.001-2.946z" fill="#ff7043" stroke-width="1.146"/></symbol><symbol viewBox="0 0 500 500" id="sass" xmlns="http://www.w3.org/2000/svg"><path d="M422.676 96.573c-12.192-47.839-91.508-63.557-166.575-36.892-44.68 15.877-93.029 40.786-127.81 73.311-41.349 38.675-47.943 72.328-45.216 86.395 9.583 49.622 77.585 82.069 105.535 106.126v.144c-8.246 4.05-68.565 34.584-82.684 65.799-14.893 32.932 2.372 56.556 13.804 59.742 35.424 9.859 71.764-7.866 91.311-37.01 18.853-28.12 17.28-64.422 9.086-82.487 11.3-2.976 24.476-4.314 41.218-2.36 47.248 5.52 56.517 35.017 54.747 47.366-1.77 12.35-11.681 19.14-14.998 21.186-3.317 2.045-4.326 2.766-4.05 4.287.405 2.215 1.94 2.137 4.758 1.652 3.894-.656 24.804-10.042 25.709-32.828 1.14-28.933-26.587-61.302-75.684-60.45-20.216.354-32.933 2.268-42.123 5.69-.681-.774-1.363-1.547-2.084-2.307-30.35-32.382-86.46-55.285-84.088-98.824.866-15.823 6.372-57.5 107.817-108.052 83.104-41.415 149.637-30.009 161.135-4.76 16.427 36.08-35.554 103.137-121.858 112.812-32.88 3.684-50.198-9.059-54.498-13.804-4.536-4.995-5.204-5.218-6.909-4.287-2.753 1.533-1.01 5.938 0 8.574 2.583 6.712 13.15 18.603 31.176 24.515 15.863 5.205 54.459 8.063 101.156-9.99 52.283-20.255 93.12-76.523 81.125-123.548zM200.213 340.34c3.92 14.5 3.487 28.016-.564 40.248a65.289 65.289 0 0 1-3.225 7.97c-3.12 6.477-7.316 12.534-12.442 18.132-15.653 17.069-37.507 23.532-46.88 18.092-10.122-5.874-5.048-29.944 13.083-49.11 19.52-20.636 47.602-33.903 47.602-33.903l-.039-.079 2.465-1.35z" fill="#ec407a" stroke="#ec407a" stroke-width="16.286552999999998"/></symbol><symbol viewBox="0 0 300 300" id="sbt" xmlns="http://www.w3.org/2000/svg"><path d="M105.46 209.517c-7.875 0-13.452-7.521-13.452-15.37v-.327c0-7.848 5.578-13.735 13.452-13.735h164.05c1.476-4.905 2.625-11.446 3.281-17.986h-137.81c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h137.31c-.82-6.54-1.969-13.081-3.773-17.986h-104.01c-7.875 0-14.273-6.05-14.273-13.898s6.398-13.898 14.273-13.898h91.87c-21.327-37.607-60.864-61.315-106.14-61.315-67.918 0-123.04 54.448-123.04 122.3 0 67.856 55.122 123.28 123.04 123.28 46.59 0 87.112-25.507 107.95-63.114h-152.73z" fill="#0277bd" stroke-width="1.638"/></symbol><symbol viewBox="0 0 256 256" id="scala" xmlns="http://www.w3.org/2000/svg"><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M59.607 50.647l149.097-21.982v49.488L59.607 100.135zM59.593 114.08L208.69 92.098v49.488L59.593 163.568zM59.587 177.358l149.097-21.982v49.488L59.587 226.846z"/><path fill="#f44336" fill-rule="evenodd" stroke-width=".3" d="M62.425 91.414l95.605 30.923-2.832 8.757-95.605-30.922zM113.084 61.13l95.604 30.922-2.832 8.757-95.605-30.922zM62.425 154.79l95.605 30.922-2.833 8.758-95.604-30.923zM113.097 124.408l95.604 30.923-2.832 8.757-95.605-30.922z"/></symbol><symbol viewBox="0 0 24 24" id="settings" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="shaderlab" xmlns="http://www.w3.org/2000/svg"><path d="M9.11 17H6.5l-4.91-5L6.5 7h2.61l1.31-2.26L17.21 3l1.87 6.74L17.77 12l1.31 2.26L17.21 21l-6.79-1.74L9.11 17m.14-.25l5.13 1.38L11.42 13H5.5l3.75 3.75m6.87.38L17.5 12l-1.38-5.13L13.15 12l2.97 5.13M9.25 7.25L5.5 11h5.92l2.96-5.13-5.13 1.38z" fill="#1976d2"/></symbol><symbol viewBox="0 0 24 24" id="slim" xmlns="http://www.w3.org/2000/svg"><path d="M6.959 2.5a4.605 4.605 0 0 0-4.615 4.615v9.957a4.605 4.605 0 0 0 4.615 4.615h9.957a4.605 4.605 0 0 0 4.615-4.615V7.115A4.605 4.605 0 0 0 16.916 2.5zm4.938 2.691a6.811 6.811 0 0 1 6.81 6.813H13.43L9.938 7.287l.699 4.717H5.086a6.811 6.811 0 0 1 6.81-6.813z" fill="#f57f17"/></symbol><symbol id="smarty" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.iust0{fill:#ffce00}</style><path class="iust0" d="M9.14 20.606c0 .556.398.953.954.953h3.812c.556 0 .953-.397.953-.953v-.953H9.141zM12 2.5c-3.653 0-6.671 3.018-6.671 6.671 0 2.303 1.112 4.289 2.859 5.48v2.144c0 .556.397.953.953.953h5.718c.556 0 .953-.397.953-.953V14.65c1.747-1.191 2.86-3.177 2.86-5.48 0-3.653-3.019-6.671-6.672-6.671zm2.7 10.563l-.794.555v2.224h-3.812v-2.224l-.794-.555A4.712 4.712 0 0 1 7.235 9.17 4.78 4.78 0 0 1 12 4.405a4.78 4.78 0 0 1 4.765 4.765 4.712 4.712 0 0 1-2.065 3.892z"/></symbol><symbol viewBox="0 0 200 200" id="snyk" xmlns="http://www.w3.org/2000/svg"><title>Group 2</title><g transform="translate(15.255 18.22) scale(1.8477)" fill="none" fill-rule="evenodd"><path d="M65.161 24.997c-1.656 5.974-5.255 23.587-5.255 23.587s-6.618-2.464-14.148-2.476h-.055c-.413.002-.822.012-1.23.026v41.649h6.677v.003h5.815v-.003h20.858c.111-8.177-2.036-27.066-2.036-27.066-1.088-2.279.46-7.668.46-7.668-8.869-9.092-11.086-28.051-11.086-28.051zm-3.357 43.958c5.476 0 1.381 4.64.9 5.168H52.35c.944-1.18 4.504-5.168 9.453-5.168z" fill="#607d8b" stroke-width="1.6"/><path d="M26.366 24.995s-2.217 18.961-11.087 28.053c0 0 1.548 5.391.46 7.669 0 0-2.15 18.895-2.038 27.066h19.273v.003h7.079v-.003h5.744V46.107h-.025c-7.532.013-14.151 2.478-14.151 2.478s-3.6-17.615-5.255-23.59zm3.264 43.96c4.95 0 8.51 3.987 9.452 5.168H28.73c-.479-.528-4.573-5.168.9-5.168z" fill="#90a4ae" stroke-width="1.6"/><g transform="translate(23.76 77.45) scale(1.5998)"><g transform="translate(17.526)"><path d="M7.357.06H.177v.075C.177 2.64 2.345 4.67 4.89 4.67 7.431 4.67 9.6 2.64 9.6.135V.059z" fill="#455a64"/><path d="M1.972.06v.075a2.692 2.692 0 1 0 5.386 0V.059z" fill="#fff"/><path d="M5.496.06H4.234c-.012 0-.023.005-.034.007.157.033.243.388.21.624a.721.721 0 0 1-.71.617c.102.471.487.85.997.922a1.188 1.188 0 0 0 1.35-1.007C6.112.743 5.881.06 5.495.06z" fill="#37474f"/></g><path d="M7.552.06H.372v.075c0 2.505 2.17 4.535 4.712 4.535 2.544 0 4.712-2.03 4.712-4.535V.059z" fill="#455a64"/><path d="M2.168.06v.075a2.692 2.692 0 1 0 5.385 0V.059z" fill="#fff"/><path d="M5.692.06H4.428c-.01 0-.022.005-.032.007.156.033.242.388.21.624a.72.72 0 0 1-.712.617c.104.471.488.85.999.922A1.187 1.187 0 0 0 6.24 1.223C6.308.743 6.078.06 5.69.06z" fill="#37474f"/></g><path d="M25.514-.27l-4.202 7.697C19.838 10.17 6.858 34.465 6.858 43.243v.516L12.8 59.573c-.8 7.258-2.203 21.643-1.78 28.21h5.73c-.354-3.787.648-17.008 1.903-28.25l.076-.677-1.075-2.892c3.694-3.868 6.285-9.193 8.073-14.261l.174 1.235 5.869 9.629 2.291-.983c.058-.024 5.935-2.523 11.643-2.523 5.672 0 11.646 2.5 11.702 2.525l2.29.976 5.86-9.626.23-1.608c1.769 5.117 4.358 10.536 8.07 14.49l-1.127 3.035.076.678c1.259 11.286 2.266 24.564 1.916 28.252h5.677c.406-6.567-1.05-20.952-1.848-28.208l5.838-15.817v-.514c0-8.779-12.876-33.074-14.347-35.816L65.923-.27l-5.897 41.229-2.723 4.478c-2.628-.882-7.1-2.11-11.603-2.11-4.498 0-8.94 1.225-11.557 2.108l-2.722-4.476-2.07-14.452a.832.832 0 0 0 .006-.071l-.016-.004zm-3.166 18.39l1.206 8.407c-.46 3.143-2.561 15.47-8.198 23.24l-2.598-6.99c.325-4.554 5.067-15.462 9.59-24.656zm46.763 0c4.523 9.194 9.267 20.104 9.592 24.657L76.166 49.6c-6.09-8.553-8-22.459-8.166-23.73z" fill="#607d8b" stroke-width="1.6"/></g></symbol><symbol viewBox="0 0 24 24" id="solidity" xmlns="http://www.w3.org/2000/svg"><path d="M5.8 14.05l6.253 8.61 6.252-8.61-6.254 3.807z" fill="#0288d1" stroke-width="4.553" stroke-linejoin="round"/><path d="M12.051 1.347L5.8 11.833l6.252 3.807 6.254-3.807z" fill="#0288d1" stroke-width="5.025" stroke-linejoin="round"/></symbol><symbol viewBox="0 0 120 120" id="sonar" xmlns="http://www.w3.org/2000/svg"><style>.a,.b{fill:#fff}.b{stroke:#fff;stroke-miterlimit:10}</style><path d="M115.45 23.033S97.961 33.27 97.534 33.412c-.427.284-.852.57-1.137.854-1.422 1.421-1.848 3.41-1.422 5.26.285.852.711 1.849 1.422 2.56.711.71 1.564 1.137 2.559 1.422 1.848.426 3.84 0 5.262-1.422.426-.427.709-.853.851-1.28l.143-.427 2.56-4.692zm-39.102 9.242c-27.441 0-31.99 13.08-31.99 29.29 0 3.838.569 7.962-1.99 11.942-3.84 5.972-8.957 5.828-10.236 5.828-1.706 0-7.962-.993-8.246-2.841h.994c6.682 0 11.658-5.404 11.658-12.655v-2.56h-5.686c-4.123 0-7.82 1.849-10.238 5.12-2.417-3.271-6.113-5.12-10.236-5.12h-5.83v2.56c0 7.11 5.688 12.795 12.797 12.795h1.848c0 4.124 5.687 20.332 47.63 20.332 16.352 0 40.665-2.843 40.665-33.697 0-5.829-1.848-11.23-4.691-15.78-.996.284-1.992.568-3.13.568a8.92 8.92 0 0 1-8.956-8.957c0-.995.141-1.991.425-2.986-4.265-2.702-8.53-3.838-14.787-3.838z" fill="#1e88e5" stroke-width="1.422"/></symbol><symbol viewBox="0 0 412 395" id="stylelint" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-white</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#cfd8dc" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 412 395" id="stylelint_light" xmlns="http://www.w3.org/2000/svg"><title>stylelint-icon-black</title><g transform="translate(31.478 29.499) scale(.84775)" fill="#546e7a" fill-rule="evenodd"><path d="M208.8 393.05c45.057-161.12 43.75-161.85 76.32-276.73l7.832 4.523c4.255 2.458 7.738.448 7.738-4.455V61.602c8.643-30.27 15.416-53.66 17.4-60.693h35.287l58.618 54.304-38.498 33.27 29.11 31.473-191.86 273.09c-.938 1.542-2.244 1.19-1.947 0zm20.96-347.28c1.733 0 3.148.958 3.148 2.147v28.077c0 1.186-1.415 2.15-3.147 2.15h-47.396c-1.742 0-3.153-.96-3.153-2.15V47.917c0-1.185 1.41-2.147 3.153-2.147h47.396z"/><path d="M288.26 14.688l-52.14 30.1c.605.92.973 1.98.973 3.136v28.078c0 1.457-.565 2.77-1.496 3.83l52.663 30.402c3.59 2.073 6.535.377 6.535-3.764V18.456c0-4.145-2.944-5.836-6.535-3.768zM175.02 76V47.923c0-1.15.368-2.21.966-3.13l-52.14-30.105c-3.588-2.068-6.53-.376-6.53 3.768v88.013c0 4.14 2.938 5.84 6.53 3.76l52.66-30.405c-.926-1.06-1.487-2.37-1.487-3.827z"/><path d="M201.25 393.05h1.947c-45.05-161.12-43.753-161.85-76.32-276.73l-7.833 4.523c-4.253 2.458-7.737.448-7.737-4.455V61.602C102.662 31.332 95.892 7.942 93.902.909H58.619L.002 55.213l38.494 33.27-29.11 31.473z"/><circle cx="204.57" cy="122.54" r="14.231"/><circle cx="204.57" cy="207.16" r="14.231"/><circle cx="204.57" cy="291.78" r="14.23"/></g></symbol><symbol viewBox="0 0 200.00001 200.00001" id="stylus" xmlns="http://www.w3.org/2000/svg"><path d="M126.814 155.9c14.64-17.51 16.362-35.595 5.024-69.18-7.177-21.24-19.09-37.602-10.334-50.807 9.329-14.065 29.135-.43 12.63 18.371l3.301 2.297c19.806 2.296 29.566-24.83 14.783-32.58C113.179 3.621 79.02 42.803 94.09 88.156c6.458 19.232 15.5 39.613 8.18 55.83-6.314 13.923-18.514 22.103-26.695 22.39-17.079.862-5.74-38.32 13.922-48.08 1.722-.861 4.162-2.01 1.866-4.88-24.256-2.727-38.464 8.468-46.645 24.112-23.825 45.497 45.21 62.29 82.095 18.371z" fill="#c0ca33" stroke-width="1.435"/></symbol><symbol viewBox="0 0 24 24" id="swc" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="jba"><stop offset="0" stop-color="#791223"/><stop offset="1" stop-color="#d92f3c"/></linearGradient><linearGradient xlink:href="#jba" id="jbb" x1="12.356" y1="21.559" x2="12.356" y2="2.949" gradientUnits="userSpaceOnUse"/></defs><path d="M6 3c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.5-.17-.93-.46-1.27l-1.39-1.68C18.88 3.21 18.47 3 18 3H6zm-.07 1h12l.94 1H5.12l.81-1z" fill="url(#jbb)"/><path style="line-height:125%" d="M11.053 11.918h-.008c-.244.022-.475.054-.676.11a2.9 2.9 0 0 0-.856.412 3.399 3.399 0 0 0-.67.683 9.36 9.36 0 0 0-.586.95c-.07.131-.134.244-.201.365v.001h-.002l-.768 1.372-.003-.001c-.136.253-.264.485-.38.686-.123.212-.26.39-.411.539a1.599 1.599 0 0 1-.52.34c-.04.016-.092.024-.138.036h-.567v1.383H5.834v-.001c.245-.02.477-.053.679-.11a2.9 2.9 0 0 0 .856-.411c.245-.185.469-.413.67-.683.195-.275.39-.591.585-.95.07-.131.135-.244.202-.366l.004.001.002-.002.02-.038H10.948v-1.378h-.19v-.001H9.624c.125-.234.246-.452.355-.64.123-.21.259-.39.41-.538.152-.148.325-.26.52-.34.04-.015.091-.024.136-.035h.57V13.3h-.002v-1.381h-.56v-.001z" font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#fff"/></symbol><symbol viewBox="0 0 24 24" id="swift" xmlns="http://www.w3.org/2000/svg"><path d="M17.09 19.72c-2.36 1.36-5.59 1.5-8.86.1A13.807 13.807 0 0 1 2 14.5c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17z" fill="#fe5e2f"/></symbol><symbol viewBox="0 0 24 24" id="table" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5m4 7.5h-4v2h1l-2 1.67L10 13h1v-2H7v2h1l3 2.5L8 18H7v2h4v-2h-1l2-1.67L14 18h-1v2h4v-2h-1l-3-2.5 3-2.5h1v-2z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 200 200" id="terraform" xmlns="http://www.w3.org/2000/svg"><g transform="translate(177.03 -58.705) scale(.92881)" fill="#5c6bc0" stroke="#b0aff5" stroke-linejoin="round"><g stroke-width=".288"><path transform="skewY(26.439) scale(.89541 1)" d="M-203.8 170.95h64.714v51.88H-203.8zM-124.37 171.04h64.714v51.88h-64.714zM-124.37 236.09h64.714v51.88h-64.714z"/></g><path transform="skewY(-22.59) scale(-.92328 1)" stroke-width=".284" d="M-19.172 128.27h62.76v51.88h-62.76z"/></g></symbol><symbol viewBox="0 0 24 24" id="test-js" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#ffca28"/></symbol><symbol viewBox="0 0 24 24" id="test-jsx" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#00bcd4"/></symbol><symbol viewBox="0 0 24 24" id="test-ts" xmlns="http://www.w3.org/2000/svg"><path d="M5 19a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1c0-.21-.07-.41-.18-.57L13 8.35V4h-2v4.35L5.18 18.43c-.11.16-.18.36-.18.57m1 3a3 3 0 0 1-3-3c0-.6.18-1.16.5-1.63L9 7.81V6a1 1 0 0 1-1-1V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1v1.81l5.5 9.56c.32.47.5 1.03.5 1.63a3 3 0 0 1-3 3H6m7-6l1.34-1.34L16.27 18H7.73l2.66-4.61L13 16m-.5-4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tex" xmlns="http://www.w3.org/2000/svg"><g font-weight="400" font-size="40" font-family="sans-serif" letter-spacing="0" word-spacing="0" fill="#42a5f5" stroke-linejoin="miter"><text style="line-height:125%" x="9.914" y="364.919"><tspan x="9.914" y="364.919" font-size="287.5">T</tspan></text><text style="line-height:125%" x="136.374" y="435.558"><tspan x="136.374" y="435.558" font-size="287.5">E</tspan></text><text style="line-height:125%" x="307.819" y="361.201"><tspan x="307.819" y="361.201" font-size="287.5">X</tspan></text></g></symbol><symbol viewBox="0 0 24 24" id="todo" xmlns="http://www.w3.org/2000/svg"><path d="M3 5h6v6H3V5m2 2v2h2V7H5m6 0h10v2H11V7m0 8h10v2H11v-2m-6 5l-3.5-3.5 1.41-1.41L5 17.17l4.59-4.58L11 14l-6 6z" fill="#42a5f5"/></symbol><symbol id="travis" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><style id="jkstyle2">.jkst0{fill:#cb3349}.jkst1{fill:#f4edae}.jkst2{fill:#e6ccad}.jkst3{fill:#656c67}.jkst4{fill:#e5caa3}.jkst5{fill:#c7b39a}.jkst6{fill:#ebd599}.jkst7{fill:#2d3136}.jkst8{fill:#edf6fa}.jkst9{opacity:.8}.jkst10{opacity:.75;fill:#ebd599}</style><g id="jkg99" transform="translate(11.017 12.484) scale(.8858)"><g id="jkg10"><path class="jkst0" d="M47.781 86.572s-31.118 21.903-32.335 30.247l2.335-.48S55.045 91.64 84.584 88.628l.669-3.749z" id="jkpath4" fill="#cb3349"/><path class="jkst0" d="M96.629 83.442l-24.511 17.385 1.325 1.063c.999-.806 43.539-13.798 43.539-13.798l8.969-5.623c-6.018.749-29.322.973-29.322.973z" id="jkpath6" fill="#cb3349"/><path class="jkst0" d="M117.932 104.469c17.405 0 43.495-17.046 43.495-17.046l-8.434-1.605c-.417.417-13.6-.462-13.6-.462l-6.258-1.738-14.951 17.036-1.217 2.956c1.075-.437.965.859.965.859z" id="jkpath8" fill="#cb3349"/></g><path class="jkst0" d="M174.728 158.832l-5.377 1.514-24.843-.537-15.541-12.085-18.784 4.7-21.726-1.88-12.166 13.294-22.828 6.819-11.398-3.534-.574-.494 5.116 12.527s11.588 12.424 18.061 13.885c6.472 1.461 18.165-.105 26.935-1.463 8.769-1.357 15.764-4.489 18.582-9.603 2.818-5.117 3.236-6.578 3.236-6.578s8.353 11.797 15.556 13.155c7.203 1.357 28.605-5.952 28.605-5.952s13.051-3.549 15.346-8.038c2.297-4.489 8.353-19.209 8.353-19.209zM44.456 169.038l-.361-.166-2.013-1.736z" id="jkpath12" fill="#cb3349"/><g id="jkg97"><path class="jkst1" d="M195.832 70.085a48.125 48.125 0 0 0-.21-2.009 26.472 26.472 0 0 0-.215-1.424c-1.793-1.509-3.831-2.851-5.952-4.071-2.299-1.343-4.704-2.546-7.159-3.663-2.438-1.15-4.942-2.191-7.461-3.207a134.313 134.313 0 0 0-3.798-1.477c-1.269-.495-2.55-.956-3.835-1.424 2.697.447 5.366 1.059 8.015 1.741 1.723.446 3.437.945 5.14 1.477-12.112-31.655-41.07-52.27-72.687-52.27-31.622 0-60.577 20.615-72.686 52.27a109.044 109.044 0 0 1 5.137-1.477c2.653-.682 5.323-1.294 8.018-1.741-1.289.468-2.567.929-3.84 1.424-1.267.472-2.536.967-3.798 1.477-2.519 1.016-5.016 2.057-7.46 3.207-2.45 1.117-4.857 2.32-7.156 3.663-2.121 1.219-4.157 2.562-5.957 4.071-.075.457-.151.951-.21 1.424a51.768 51.768 0 0 0-.21 2.009 51.354 51.354 0 0 0-.177 4.061 59.216 59.216 0 0 0 .5 8.11c.37 2.692.864 5.366 1.595 7.951.36 1.295.768 2.572 1.24 3.808.237.617.495 1.225.764 1.816.134.294.274.585.413.864l.172.328c.199.101.408.204.607.3l1.204.575c.671.305 1.6.746 2.368 1.09.043-.037.086-.075.123-.114l-2.235-8.513c.474-.13 4.718-1.225 12.032-2.617a38.816 38.816 0 0 1-1.772-.381c-1.665-.414-3.309-.919-4.899-1.564a22.415 22.415 0 0 1-2.309-1.115c-.742-.426-1.472-.908-2.037-1.548 8.036 2.622 24.64 1.434 39.399-.091 13.499-1.391 27.029-2.293 40.63-2.32 13.602.027 27.137.929 40.63 2.32 14.766 1.525 31.37 2.713 39.405.091-.564.64-1.293 1.123-2.035 1.548a22.5 22.5 0 0 1-2.308 1.115c-1.592.645-3.234 1.15-4.899 1.564-.247.059-.496.113-.743.166 8.02 1.488 12.689 2.697 13.188 2.831l-2.138 8.11c.43-.194.864-.381 1.29-.574l1.202-.575c.2-.097.403-.199.607-.3l.166-.328c.146-.279.286-.57.419-.864.27-.591.528-1.199.764-1.816a42.235 42.235 0 0 0 1.241-3.808c.731-2.585 1.225-5.259 1.595-7.951.345-2.685.526-5.398.501-8.11a50.874 50.874 0 0 0-.179-4.059z" id="jkpath14" fill="#f4edae"/><path class="jkst2" d="M116.787 182.661c-1.064.16-2.128.295-3.186.375-.682.033-1.404.102-2.059.102l-.242.005c.822-1.837 1.446-3.26 1.919-4.339.963 1.08 2.188 2.417 3.568 3.857z" id="jkpath16" fill="#e6ccad"/><path class="jkst2" d="M119.101 185.018c3.304 3.272 7.398 5.146 11.904 5.479-7.569 3.074-14.702 4.26-20.197 4.63-5.478.367-11.032-.279-16.474-1.771.456-.082.79-.14 1.193-.189.447-.054 10.206-1.327 14.605-7.868l.413.009 1.08-.009c.731 0 1.395-.06 2.094-.087a43.69 43.69 0 0 0 4.878-.703c.167.171.333.338.504.509z" id="jkpath18" fill="#e6ccad"/><path class="jkst3" d="M128.464 87.071a98.82 98.82 0 0 1-1.048 1.343c-1.933 2.444-4.614 5.57-7.794 8.627a369.585 369.585 0 0 0-11.404-.177c-6.46 0-12.655.171-18.537.457 8.311-3.449 18.296-6.818 29.109-8.842a113.323 113.323 0 0 1 9.674-1.408z" id="jkpath20" fill="#656c67"/><path class="jkst3" d="M79.821 90.792c-2.966 2.084-6.317 4.744-9.566 7.971a360.155 360.155 0 0 0-21.567 2.81c9.207-4.232 19.713-8.127 31.133-10.781z" id="jkpath22" fill="#656c67"/><path class="jkst3" d="M181.48 107.969l-3.384 23.679-16.212 11.355-42.283-4.807-6.365-20.961a1.383 1.383 0 0 0-1.108-.971c-1.567-.253-2.953-.382-4.108-.382-1.16 0-2.541.129-4.115.382-.522.086-.95.461-1.106.971l-6.209 20.45-42.047 9.357-16.662-11.672-3.283-26.572c.715-.404 1.441-.806 2.176-1.209 1.031-.222 2.191-.457 3.475-.704l3.094 25.073c.048.392.264.741.586.967l11.462 8.032a1.425 1.425 0 0 0 1.101.213l34.57-7.692c.119-.027.237-.069.344-.124a1.39 1.39 0 0 0 .682-.827l6.225-20.498c1.67-.43 5.947-1.429 9.706-1.429 3.749 0 8.03.999 9.701 1.429l6.225 20.498c.161.532.624.912 1.176.977l34.57 3.927c.335.037.677-.05.952-.242l11.469-8.025c.31-.22.52-.566.573-.946l3.062-21.421c2.301.444 4.224.846 5.733 1.172z" id="jkpath24" fill="#656c67"/><path class="jkst3" d="M185.751 93.119l-2.976 11.29c-6.086-1.342-19.456-3.975-37.654-5.747 5.946-2.535 12-5.715 17.531-9.69 10.829 1.53 18.78 3.169 23.099 4.147z" id="jkpath26" fill="#656c67"/><g id="jkg32"><path class="jkst4" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath28" fill="#e5caa3"/><path class="jkst4" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath30" fill="#e5caa3"/></g><g id="jkg38"><path class="jkst5" d="M63.841 128.441c2.357-1.274 5.021-1.085 9.19-1.079.447.011.908.005 1.39-.005.41-.005.822-.011 1.258-.022 4.296-.042 7.869.366 7.806-6.381-.065-6.746-3.062-12.198-7.354-12.155-4.297.037-8.454 5.564-8.197 12.306.07 1.756.328 3.023.742 3.937-3.745.938-4.777 3.254-4.835 3.399zm51.657-27.749a46.634 46.634 0 0 1-5.249 3.712l-6.097 3.68a52.065 52.065 0 0 0-7.331 1.467 1.216 1.216 0 0 0-.317.14 1.406 1.406 0 0 0-.629.794l-6.209 20.46-33.185 7.38-10.452-7.321-3.041-24.634c5.936-1.09 13.874-2.352 23.41-3.42a56.802 56.802 0 0 0-2.955 3.855l-5.677 8.149 8.266-5.511c.123-.086 5.387-3.549 13.998-7.761a377.407 377.407 0 0 1 35.468-.99z" id="jkpath34" fill="#c7b39a"/><path class="jkst5" d="M151.835 125.675c-.042-.16-.945-2.873-4.942-2.397.461-1.003.666-2.356.521-4.21-.528-6.731-4.443-12.08-8.735-11.931-4.292.152-7.042 5.731-6.805 12.478.236 6.741 3.84 6.694 8.132 6.543 5.77-.107 8.939-1.88 11.829-.483zm21.18-19.385l-2.992 20.944-10.539 7.379-33.141-3.766-6.183-20.363a1.41 1.41 0 0 0-.945-.934c-.205-.06-4.308-1.23-8.659-1.607l.795-.053c.687-.049 12.118-1.451 25.767-6.157 15.115 1.161 27.458 3.02 35.897 4.557z" id="jkpath36" fill="#c7b39a"/></g><path class="jkst2" d="M187.481 115.502c.508.419.911 1.504.456 6.558-.559 6.188-3.16 17.049-4.771 18.8-1.778.344-5.505-.064-7.778-.595.393-1.559.505-2.306.822-3.9l3.975-2.781c.317-.22.526-.566.58-.941l2.778-19.466c1.686.912 3.421 1.899 3.938 2.325z" id="jkpath40" fill="#e6ccad"/><path class="jkst2" d="M40.937 140.908c.199.704.408 1.407.624 2.1-2.139.628-6.495 1.23-8.465.886-1.633-1.645-4.679-12.966-5.345-18.978-.543-4.871-.162-5.924.333-6.334.575-.483 2.728-1.708 4.593-2.707l2.519 20.449c.048.393.257.741.586.967z" id="jkpath42" fill="#e6ccad"/><path class="jkst2" d="M121.347 141.194l-.151 1.305s-4.581 4.248-11.956 5.199c-7.375.95-13.171-3.582-13.171-3.582.242.788.586 2.567 2.256 4.086a53.184 53.184 0 0 0-6.313-.393c-.804 0-1.616.023-2.401.061-4.539.237-10.924 7.1-15.414 14.014-2.203.697-9.089 2.883-17.06 5.237-7.44-10.309-11.098-20.842-11.469-21.932l.005-.006c-.15-.419-.301-.839-.441-1.268l1.913 1.338v.005l4.726 3.309 1.58 1.101c.236.167.515.253.794.253.102 0 .204-.011.305-.031l43.435-9.67a1.385 1.385 0 0 0 1.025-.95l6.194-20.39c1.069-.145 2.008-.22 2.814-.22.801 0 1.746.075 2.815.22l6.374 20.997c.162.532.624.919 1.171.977z" id="jkpath44" fill="#e6ccad"/><path class="jkst2" d="M170.926 140.066l1.402-.984c-.232.973-.484 1.94-.747 2.896-1.949 6.248-4.25 11.774-6.805 16.656-.565.039-1.161.061-1.8.061-1.972 0-3.986-.167-6.215-.371-3.868-.355-10.007-1.058-11.946-1.283-1.67-1.332-7.385-5.873-12.14-9.615-.187-.151-.348-.291-.505-.42-.837-.708-1.789-1.513-3.717-1.513-1.751 0-4.308.638-10.489 2.508 3.212-2.401 3.233-5.5 3.233-5.5l.151-1.305 40.748 4.629a1.41 1.41 0 0 0 .955-.241l4.094-2.868z" id="jkpath46" fill="#e6ccad"/><path class="jkst6" d="M140.937 54.337c.124 3.625.033 10.194-1.655 16.345a1.335 1.335 0 0 0 0 .704 259.298 259.298 0 0 0-6.446-.591c2.412-5.054 2.938-10.436 3.052-12.332 1.852-1.317 3.696-2.896 5.049-4.126z" id="jkpath48" fill="#ebd599"/><path class="jkst6" d="M79.456 58.462c.112 1.896.638 7.267 3.046 12.317-2.149.171-4.297.37-6.441.596a1.328 1.328 0 0 0 0-.694c-1.686-6.139-1.772-12.714-1.654-16.345 1.353 1.231 3.19 2.81 5.049 4.126z" id="jkpath50" fill="#ebd599"/><path class="jkst7" d="M151.835 125.675c-2.89-1.396-6.059.377-11.828.484-4.292.151-7.896.198-8.132-6.543-.237-6.747 2.513-12.326 6.805-12.478 4.292-.15 8.207 5.2 8.735 11.931.145 1.854-.06 3.207-.521 4.21 3.996-.477 4.899 2.235 4.941 2.396zm-13.488-9.878a2.203 2.203 0 0 0 2.154-2.235 2.186 2.186 0 0 0-2.235-2.153 2.194 2.194 0 0 0 .081 4.388z" id="jkpath52" fill="#2d3136"/><circle transform="rotate(-1.049 138.093 113.428)" class="jkst8" cx="138.307" cy="113.602" id="jkellipse54" r="2.194" fill="#edf6fa"/><path class="jkst7" d="M83.484 120.953c.063 6.747-3.509 6.339-7.806 6.381-.435.011-.848.016-1.258.022-.482.011-.944.016-1.39.005-4.168-.005-6.833-.194-9.19 1.079.058-.145 1.09-2.461 4.835-3.4-.414-.914-.673-2.181-.742-3.937-.257-6.741 3.9-12.269 8.197-12.306 4.292-.042 7.289 5.411 7.354 12.156zm-6.634-3.529a2.195 2.195 0 1 0-.122-4.388 2.195 2.195 0 0 0 .122 4.388z" id="jkpath56" fill="#2d3136"/><circle transform="rotate(-1.473 76.78 115.216)" class="jkst8" cx="76.79" cy="115.23" id="jkellipse58" r="2.195" fill="#edf6fa"/><g class="jkst9" id="jkg64" opacity=".8"><path class="jkst6" d="M50.691 75.155s.667-8.692 2.03-12.023c.702-1.717 4.996-2.81 8.276-3.591 3.278-.78 8.508-2.342 9.524 2.264 1.015 4.606 2.653 7.963 3.746 9.446l-1.404-18.97-22.562 5.464-1.484 16.786.703 1.327 1.171-.703" id="jkpath60" fill="#ebd599"/><path class="jkst6" d="M164.855 75.155s-.666-8.692-2.029-12.023c-.703-1.717-4.997-2.81-8.275-3.591-3.28-.78-8.51-2.342-9.526 2.264-1.013 4.606-2.654 7.963-3.748 9.446l1.407-18.97 22.562 5.464 1.483 16.786-.703 1.327-1.171-.703" id="jkpath62" fill="#ebd599"/></g><path class="jkst10" d="M132.965 18.378s-.598 45.49-11.224 45.49h-14.875-12.752c-10.626 0-11.484-45.47-11.484-45.47l-5.22 15.438.085 21.183 3.707 2.947 1.685 9.096 2.357 5.307 45.482.084 2.105-3.791 1.769-6.4.254-4.043 5.023-14.341z" id="jkpath66" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M166.429 60.794s2.187 15.692 7.974 18.522c5.788 2.829 0 0 0 0l-8.103-2.444z" id="jkpath68" opacity=".75" fill="#ebd599"/><path class="jkst10" d="M48.908 60.794s-2.187 15.692-7.975 18.522c-5.788 2.829 0 0 0 0l8.104-2.444z" id="jkpath70" opacity=".75" fill="#ebd599"/><path class="jkst7" d="M167.987 76.8c2.755.902 5.526 1.858 8.036 3.325-1.343-.532-2.729-.913-4.126-1.257a70.385 70.385 0 0 0-4.201-.924c-2.82-.531-5.65-.982-8.498-1.327-2.841-.37-5.687-.682-8.546-.924-2.858-.241-5.709-.483-8.573-.65-11.446-.704-22.924-.88-34.41-.892-11.483.006-22.962.221-34.409.897-2.862.166-5.715.409-8.572.651-2.857.241-5.71.548-8.546.923-2.847.345-5.678.796-8.498 1.327-1.407.264-2.81.57-4.206.919-1.391.344-2.783.725-4.126 1.257 2.509-1.466 5.28-2.427 8.041-3.331.232-.075.467-.139.703-.214-.015-.059-.032-.113-.043-.177-.048-.317-1.069-7.859.709-18.645.086-.516.456-.935.962-1.075l2.917-.831c.634-22.625 9.952-33.266 10.243-33.594-8.326 13.397-8.25 29.286-8.106 32.986l18.128-5.152c.016-.005.026-.005.042-.01.076-.016.151-.027.226-.032.021 0 .049-.006.075-.006a1.19 1.19 0 0 1 .297.027c.015 0 .031.011.053.016.075.016.145.042.224.075.033.016.054.033.086.049.058.033.119.07.177.112.016.011.034.016.049.033l.032.032c.016.016.037.027.054.044.012.016.494.493 1.262 1.209-.182-5.973.102-23.108 8.262-37.31-.172.498-6.646 19.428-4.415 40.645.724.58 1.486 1.149 2.229 1.649.359.247.58.655.585 1.09.006.07.161 6.833 3.148 12.586.042.086.074.177.102.268 7.429-.505 14.878-.709 22.312-.714 7.436.005 14.88.22 22.307.731.027-.097.06-.193.109-.285 2.986-5.753 3.142-12.516 3.142-12.586.01-.436.231-.843.591-1.09.741-.5 1.493-1.069 2.224-1.649 2.234-21.217-4.24-40.147-4.411-40.645 8.153 14.201 8.444 31.336 8.262 37.31a62.536 62.536 0 0 0 1.261-1.209c.016-.016.039-.027.053-.044.012-.01.018-.021.033-.032.016-.016.033-.022.049-.033.06-.042.119-.079.177-.118.028-.01.054-.027.081-.043.081-.033.155-.059.236-.08.016 0 .033-.011.049-.011.096-.021.2-.032.296-.027.027 0 .049.006.07.006.075.005.156.016.231.032.012.006.028.006.042.01l18.129 5.152c.146-3.7.221-19.59-8.104-32.986.289.328 9.609 10.969 10.237 33.594l2.922.831c.499.14.875.559.962 1.075 1.777 10.786.752 18.328.708 18.645-.01.065-.026.124-.042.182.239.07.47.139.707.215zm-3.297-.968c.14-1.207.789-7.809-.591-16.801l-20.52-5.833c.184 3.475.265 11.012-1.707 18.199a1.619 1.619 0 0 1-.101.258c.203.021.408.037.606.064 5.769.661 11.511 1.584 17.189 2.83 1.712.398 3.426.823 5.124 1.283zm-25.409-5.151c1.688-6.15 1.779-12.72 1.655-16.345-1.353 1.23-3.197 2.809-5.049 4.125-.114 1.896-.64 7.278-3.052 12.332 2.149.173 4.298.366 6.446.591a1.33 1.33 0 0 1 0-.703zm-56.78.098c-2.408-5.05-2.934-10.422-3.046-12.317-1.858-1.316-3.696-2.895-5.049-4.125-.119 3.631-.032 10.206 1.654 16.345.065.237.058.473 0 .694 2.145-.227 4.292-.425 6.441-.597zm-8.933.864a1.65 1.65 0 0 1-.098-.247c-1.975-7.187-1.889-14.723-1.712-18.199L51.244 59.03c-1.38 8.982-.736 15.583-.597 16.797 1.703-.462 3.411-.887 5.131-1.284 2.835-.628 5.693-1.154 8.556-1.638 2.869-.478 5.747-.843 8.626-1.192.205-.027.404-.042.608-.07z" id="jkpath72" fill="#2d3136"/><g id="jkXMLID_1_"><g id="jkg78"><path class="jkst7" d="M129.293 18.973v17.025h-12.068v-4.974h-2.72v22.981h4.109v12.85H97.505v-12.85h4.092v-22.98h-2.711v4.974h-12.06V18.973zm-3.626 13.408v-9.789H90.443v9.789h4.816v-4.974h9.964v30.225h-4.1v5.606h13.865v-5.606h-4.1V27.407h9.964v4.974z" id="jkpath74" fill="#2d3136"/><path class="jkst0" id="jkpolygon76" fill="#cb3349" d="M101.123 57.632h4.1V27.407h-9.964v4.974h-4.816v-9.79h35.224v9.79h-4.816v-4.974h-9.964v30.225h4.1v5.606h-13.864z"/></g></g><path class="jkst3" d="M30.694 93.119c1.759-.399 4.136-.907 7.051-1.47a104.37 104.37 0 0 0-6.222 4.597z" id="jkpath83" fill="#656c67"/><path class="jkst5" d="M95.111 139.78s.492 3.165-3.938 4.519c-4.428 1.355-32.482 9.716-35.682 9.263-3.199-.451-11.319-5.874-11.319-5.874l-1.969-7.004 12.016 7.492z" id="jkpath85" fill="#c7b39a"/><path class="jkst5" d="M120.242 139.167s-.354 3.182 4.131 4.345c4.484 1.161 32.875 8.295 36.05 7.704 3.176-.591 11.053-6.361 11.053-6.361l1.663-7.084-11.045 6.588z" id="jkpath87" fill="#c7b39a"/><path class="jkst5" d="M28.412 133.956s3.887 7.775 10.166 5.083l4.485 1.645-.448 3.29-9.419 1.195-2.541-1.494z" id="jkpath89" fill="#c7b39a"/><path class="jkst5" d="M187.551 131.822s-6.353 8.115-12.632 5.424l-2.019 1.302.448 3.289 9.419 1.196 2.54-1.495z" id="jkpath91" fill="#c7b39a"/><path class="jkst5" d="M89.279 192.904s23.03 11.611 49.106-4.188l-8.374-.571s-18.272 7.232-32.738 3.235z" id="jkpath93" fill="#c7b39a"/><path class="jkst7" d="M112.626 171.509l1.594 1.899c.036.046 3.577 4.26 7.906 8.552 2.879 2.853 6.357 4.297 10.343 4.297 1.361 0 2.791-.175 4.235-.523 1.34-.326 2.796-.673 4.287-1.03 5.384-1.287 11.482-2.749 14.438-3.577.585-.166 1.238-.315 1.925-.472 3.935-.909 9.329-2.163 12.187-7.889 2.149-4.297 5.047-9.874 7.197-13.961-1.863.859-3.816 1.79-5.203 2.52-2.138 1.123-4.938 1.667-8.558 1.667-2.152 0-4.266-.181-6.605-.389-4.675-.43-12.586-1.361-12.667-1.372l-.606-.067-.478-.383c-.071-.052-7.003-5.575-12.606-9.981-.227-.186-.434-.358-.621-.513-.59-.503-.59-.503-.942-.503-1.797 0-7.02 1.62-18.462 5.167l-.703.223-.689-.26c-.078-.026-7.585-2.81-16.581-2.81-.736 0-1.47.019-2.185.056-.901.046-5.958 2.448-12.425 12.68l-.419.657-.741.238c-.107.037-11.238 3.63-23.042 7.005l-.766.218-.725-.337c-.077-.031-4.696-2.174-9.091-4.194 2.397 3.541 5.462 7.958 8.159 11.422 4.711 6.067 10.649 11.674 22.034 11.674 1.428 0 2.945-.088 4.503-.265 11.581-1.309 14.563-1.837 16.168-2.117.543-.092.973-.171 1.522-.238.088-.011 9.571-1.237 12.232-7.206 2.744-6.134 3.298-7.595 3.319-7.651l.968-2.583s.12-.669.317-.877c0 .005 0 .005.005.005l.019.016c.305.219.757.902.757.902zM40.499 55.71c-2.516 1.014-5.016 2.06-7.46 3.209-2.449 1.119-4.856 2.32-7.155 3.66-2.121 1.222-4.157 2.563-5.954 4.076-.077.455-.149.952-.211 1.423a51.357 51.357 0 0 0-.388 6.068c-.026 2.713.16 5.426.502 8.112.372 2.692.864 5.369 1.594 7.952a41.963 41.963 0 0 0 1.243 3.804c.233.623.492 1.228.762 1.818.134.294.274.585.413.864l.172.326c.201.104.409.207.605.3l1.206.574c.673.311 1.6.751 2.366 1.093.046-.037.088-.078.124-.114l-2.231-8.511c.471-.129 4.717-1.227 12.032-2.619a33.744 33.744 0 0 1-1.775-.379 36.704 36.704 0 0 1-4.898-1.563 22.857 22.857 0 0 1-2.309-1.119c-.741-.425-1.471-.905-2.035-1.547 8.035 2.624 24.637 1.433 39.398-.088 13.501-1.393 27.028-2.293 40.628-2.325 13.6.031 27.138.931 40.63 2.325 14.77 1.522 31.374 2.713 39.406.088-.564.642-1.293 1.122-2.034 1.547-.739.42-1.522.782-2.309 1.119a36.965 36.965 0 0 1-4.903 1.563c-.244.056-.492.114-.741.166 8.02 1.486 12.689 2.697 13.186 2.832l-2.138 8.107c.43-.192.864-.377 1.288-.574l1.207-.574c.196-.094.404-.196.606-.3l.166-.326c.144-.279.284-.57.419-.864.27-.591.528-1.196.767-1.818.471-1.231.879-2.51 1.236-3.804.731-2.583 1.228-5.26 1.595-7.952.346-2.686.528-5.4.502-8.112a52.755 52.755 0 0 0-.176-4.059 51.573 51.573 0 0 0-.213-2.009 29.83 29.83 0 0 0-.213-1.423c-1.797-1.513-3.831-2.853-5.954-4.076-2.299-1.34-4.704-2.541-7.159-3.66-2.438-1.149-4.943-2.195-7.46-3.209a140.105 140.105 0 0 0-3.801-1.476c-1.267-.491-2.552-.956-3.835-1.423 2.696.445 5.369 1.06 8.013 1.739 1.724.446 3.444.948 5.141 1.481-12.11-31.658-41.07-52.272-72.685-52.272-31.622 0-60.576 20.614-72.684 52.272a107.832 107.832 0 0 1 5.135-1.481c2.651-.678 5.322-1.294 8.02-1.739-1.29.466-2.568.931-3.842 1.423-1.268.47-2.535.967-3.799 1.475zm159.43 18.316a53.972 53.972 0 0 1-.258 8.733 55.462 55.462 0 0 1-1.619 8.605c-.4 1.414-.86 2.811-1.404 4.198a38.295 38.295 0 0 1-.89 2.071c-.161.341-.331.678-.523 1.025l-.284.512a8.975 8.975 0 0 1-.348.574l-.294.457-.461.237c-.492.254-.895.445-1.342.653l-1.298.585a88.22 88.22 0 0 1-2.62 1.065c-.611.239-1.15.457-1.662.674l-1.444 5.487c-.036-.009-.471-.12-1.283-.315l-.078.574c1.594.833 4.726 2.522 5.793 3.403 2.148 1.775 2.299 4.587 1.823 9.841-.244 2.697-1.139 7.946-2.381 12.767-2.144 8.298-3.283 9.273-4.753 9.649-.746.192-1.894.383-3.008.383-2.266 0-5.353.063-7.429-.439-.533 1.888-2.055 6.812-5.068 12.962.151-.073.3-.135.435-.207 3.717-1.952 10.861-5.064 11.162-5.199l5.643-2.452-2.89 5.435c-.067.118-6.264 11.773-10.059 19.383-3.769 7.538-10.835 9.179-15.065 10.151-.637.151-1.241.291-1.733.425-3.035.854-9.18 2.319-14.599 3.623-.064.016-.13.033-.197.042a64.057 64.057 0 0 1-10.955 5.411c-14.568 5.518-29.923 5.208-43.844.092a647.05 647.05 0 0 1-9.193 1.097 45.12 45.12 0 0 1-4.985.291c-13.264 0-20.294-6.736-25.425-13.331-5.493-7.062-12.212-17.546-12.497-17.985L31 158.426l6.585 2.961c3.152 1.419 12.524 5.757 15.205 7 .217-.061.43-.124.642-.186-4.457-6.357-8.112-13.605-10.695-21.634-2.195.662-5.576 1.175-8.206 1.175-.961 0-1.822-.072-2.484-.228-1.471-.336-3.148-1.754-5.431-9.795-1.325-4.668-2.314-9.764-2.603-12.387-.57-5.121-.466-7.864 1.662-9.636 1.283-1.071 5.611-3.344 6.507-3.809l-.192-1.58c-13.75 8.08-21.991 15.22-22.157 15.366L0 134.302l7.005-11.047c5.544-8.755 11.948-15.832 17.84-21.284-.244-.098-.471-.196-.71-.294l-1.299-.585a34.907 34.907 0 0 1-1.34-.653l-.461-.237-.295-.457c-.166-.249-.238-.388-.347-.574l-.29-.512c-.181-.347-.358-.684-.518-1.025a30.878 30.878 0 0 1-.89-2.071 44.74 44.74 0 0 1-1.404-4.198 54.745 54.745 0 0 1-1.62-8.605 54.664 54.664 0 0 1-.259-8.733c.078-1.455.218-2.909.419-4.354.104-.725.213-1.45.358-2.17.15-.734.296-1.418.518-2.221l.155-.564.404-.317c2.294-1.802 4.768-3.163 7.284-4.369a78.87 78.87 0 0 1 6.311-2.616c5.943-16.493 16.162-31.118 29.591-41.311C74.337 5.57 90.664 0 107.671 0s33.334 5.57 47.218 16.106c13.43 10.193 23.649 24.819 29.588 41.307a78.282 78.282 0 0 1 6.316 2.62c2.515 1.206 4.99 2.567 7.283 4.369l.404.317.156.564c.227.803.372 1.487.517 2.221.146.72.26 1.445.357 2.17.203 1.443.348 2.897.419 4.352zm-11.995 48.031c.456-5.052.058-6.139-.455-6.554-.513-.43-2.247-1.412-3.935-2.329l-2.779 19.464a1.39 1.39 0 0 1-.58.942l-3.977 2.781c-.315 1.593-.429 2.345-.817 3.903 2.273.528 5.999.938 7.775.595 1.612-1.748 4.214-12.61 4.768-18.802zm-5.161-17.648l2.977-11.29c-4.318-.978-12.27-2.615-23.1-4.148-5.53 3.976-11.582 7.155-17.53 9.691 18.199 1.771 31.57 4.406 37.653 5.747zm-4.68 27.237l3.385-23.676a240.127 240.127 0 0 0-5.731-1.169l-3.059 21.422a1.415 1.415 0 0 1-.575.943l-11.472 8.023c-.27.192-.616.28-.947.243l-34.572-3.929a1.391 1.391 0 0 1-1.176-.973l-6.227-20.5c-1.668-.431-5.949-1.43-9.696-1.43-3.764 0-8.041.999-9.708 1.43l-6.228 20.5a1.388 1.388 0 0 1-1.025.947l-34.572 7.692a1.483 1.483 0 0 1-.306.033 1.36 1.36 0 0 1-.792-.25l-11.467-8.029a1.396 1.396 0 0 1-.585-.968l-3.091-25.072c-1.284.249-2.443.487-3.479.703-.734.405-1.46.809-2.174 1.213l3.281 26.568 16.666 11.675 42.047-9.354 6.207-20.449a1.389 1.389 0 0 1 1.108-.975c1.574-.253 2.95-.382 4.116-.382 1.153 0 2.536.129 4.105.382.528.083.957.461 1.108.975l6.366 20.956 42.282 4.808zm-8.07-4.411l2.992-20.948c-8.439-1.536-20.78-3.394-35.897-4.554-13.647 4.707-25.077 6.108-25.766 6.155l-.797.057c4.353.374 8.454 1.544 8.66 1.605.452.135.804.481.944.933l6.186 20.366 33.138 3.764zm2.303 11.845l-1.404.983-3.779 2.651-4.095 2.868c-.279.192-.621.28-.954.243l-40.746-4.633-2.966-.337a1.39 1.39 0 0 1-1.171-.977l-6.377-20.998c-1.066-.145-2.014-.219-2.81-.219-.809 0-1.751.073-2.817.219l-6.192 20.392a1.383 1.383 0 0 1-1.025.946l-43.435 9.672c-.103.02-.206.03-.305.03-.279 0-.559-.083-.798-.253l-1.578-1.098-4.726-3.307v-.011l-1.91-1.335c.135.43.289.85.441 1.268l-.006.006c.368 1.092 4.028 11.622 11.467 21.929a873.96 873.96 0 0 0 17.057-5.234c4.488-6.917 10.877-13.777 15.418-14.014a51.12 51.12 0 0 1 2.402-.061c2.221 0 4.344.16 6.31.393-1.671-1.517-2.013-3.298-2.256-4.085 0 0 5.793 4.53 13.17 3.584 7.378-.953 11.959-5.204 11.959-5.204s-.021 3.102-3.236 5.503c6.182-1.869 8.739-2.511 10.489-2.511 1.931 0 2.883.808 3.717 1.519.161.129.322.268.507.419a3519.302 3519.302 0 0 1 12.141 9.614c1.936.227 8.075.926 11.943 1.283 2.23.201 4.245.372 6.217.372.637 0 1.233-.026 1.797-.063 2.558-4.88 4.857-10.411 6.808-16.653.261-.96.516-1.928.743-2.901zm-15.034-51.593c-.01-.006-.02-.012-.031-.012a551.624 551.624 0 0 0-9.826-.651 905.6 905.6 0 0 0-13.667-.668 72.95 72.95 0 0 1-1.574 2.225c-2.479 3.355-7.398 9.51-13.704 14.729 8.926-1.6 24.409-5.56 37.803-14.905.336-.238.668-.486.999-.718zm-29.876.926c.377-.471.729-.926 1.044-1.34-3.281.331-6.512.808-9.67 1.408-10.814 2.024-20.801 5.389-29.11 8.837a383.259 383.259 0 0 1 18.54-.455c3.908 0 7.708.067 11.404.176 3.179-3.056 5.861-6.182 7.792-8.626zm3.587 102.085c-4.503-.332-8.598-2.205-11.903-5.477a271.86 271.86 0 0 0-.502-.512 44.25 44.25 0 0 1-4.881.704c-.698.026-1.361.087-2.091.087l-1.083.011-.413-.011c-4.396 6.539-14.159 7.813-14.605 7.87-.403.046-.734.103-1.191.186 5.442 1.491 10.996 2.138 16.474 1.77 5.492-.367 12.627-1.558 20.195-4.628zm-17.4-7.461a45.604 45.604 0 0 0 3.184-.378 138.958 138.958 0 0 1-3.568-3.857 398.441 398.441 0 0 1-1.92 4.339h.243c.658.001 1.378-.071 2.061-.104zm-3.354-78.632c1.827-1.103 3.582-2.366 5.249-3.712a422.33 422.33 0 0 0-7.278-.072c-10.137 0-19.606.415-28.189 1.061-8.61 4.209-13.875 7.672-13.998 7.76l-8.268 5.514 5.679-8.149a52.452 52.452 0 0 1 2.956-3.857c-9.536 1.066-17.477 2.329-23.41 3.422l3.038 24.632 10.453 7.321 33.184-7.378 6.212-20.464c.104-.337.331-.621.627-.793.098-.063.202-.109.315-.14.192-.052 3.51-.999 7.336-1.465zm3.816-18.788c-2.31-.036-4.623-.057-6.933-.062h-.005c-3.39.005-6.787.041-10.189.109l-6.269 2.971c-.005.005-.041.021-.088.048-.942.46-9.174 4.613-16.919 12.021 6.943-3.65 17.146-8.418 29.153-12.115a144.186 144.186 0 0 1 11.25-2.972zM70.251 98.761c3.251-3.225 6.605-5.886 9.567-7.967-11.415 2.651-21.923 6.543-31.128 10.778a360.846 360.846 0 0 1 21.561-2.811zm2.159-9.949a150.122 150.122 0 0 1 11.813-2.796c-5.798.212-11.6.481-17.393.808-3.366.186-6.715.414-10.065.667-1.678.129-3.345.263-5.007.445-.476.046-.942.098-1.418.16-4.369 2.614-21.127 13.134-32.631 26.889 11.179-7.769 30.654-19.443 54.701-26.173zm-30.85 54.197a68.861 68.861 0 0 1-.621-2.102l-5.162-3.612a1.391 1.391 0 0 1-.586-.969l-2.516-20.449c-1.864.999-4.017 2.225-4.592 2.707-.497.409-.875 1.46-.336 6.332.668 6.01 3.712 17.333 5.348 18.979 1.968.347 6.327-.258 8.465-.886zm-3.815-51.36a229.005 229.005 0 0 0-7.051 1.47l.829 3.127a103.93 103.93 0 0 1 6.222-4.597z" id="jkpath95" fill="#2d3136"/></g></g></symbol><symbol viewBox="0 0 24 24" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="tune" xmlns="http://www.w3.org/2000/svg"><path d="M6.85 2.852h-2v6h2v-6m12 0h-2v10h2v-10m-16 10h2v8h2v-8h2v-2h-6v2m12-6h-2v-4h-2v4h-2v2h6v-2m-4 14h2v-10h-2v10m4-6v2h2v4h2v-4h2v-2h-6z" fill="#fbc02d" fill-rule="nonzero"/></symbol><symbol viewBox="0 0 50 50" id="twig" xmlns="http://www.w3.org/2000/svg"><path d="M9.727 47.556c-.125-.223-.297-2.168-.183-2.087.034.025.171.267.304.537.132.27.282.487.332.482.123-.011.075-1.196-.1-2.454-.331-2.398-1.176-4.435-2.358-5.69-.2-.212-.344-.4-.319-.419.093-.067 1.327.843 1.842 1.359.293.293.735.825.981 1.181.328.474.465.618.51.534.078-.147-.21-9.903-.376-12.701-.074-1.255.063-1.023.61 1.035 1.064 4.006 1.858 7.922 2.342 11.55.086.637.173 1.172.195 1.19.022.016.092.001.157-.034.888-.483 1.524-.667 2.55-.736.727-.048.945.062.35.178-1.15.222-1.99 1.013-2.344 2.201-.315 1.061-.327 2.707-.024 3.434.152.366.037.426-1.067.56-.716.088-.977.096-1.202.037-.356-.092-1.118-.098-1.195-.008-.031.036-.243.066-.47.066-.38 0-.423-.017-.535-.215zm1.974-3.233c.152-.205.072-.41-.204-.522-.225-.09-.263-.088-.437.025-.21.137-.252.43-.08.554.18.13.607.096.72-.057zm1.248.086a.763.763 0 0 0 .214-.203c.241-.33-.352-.622-.745-.366-.406.265.08.785.531.569zm2.288 3.094c-.033-.039.117-.387.334-.775.216-.387.411-.665.433-.618.07.152-.201 1.28-.33 1.372-.15.108-.354.117-.437.02zM8.2 47.092c-.29-.343-.221-.434.14-.182.176.123.321.263.321.31 0 .165-.279.087-.46-.128zm8.649-.145c0-.053.102-.18.227-.282.25-.204.312-.113.143.207-.095.18-.37.236-.37.075zm8.065-.827c-.243-.025-.48-.088-.527-.141-.11-.125-.114-3.043-.004-3.043.045 0 .132.149.193.331.127.38.228.42.31.124.094-.337.065-3.472-.039-4.297-.449-3.55-1.865-6.124-4.342-7.89-1.086-.774-2.653-1.436-4.047-1.711-.764-.15-.522-.224.598-.182 2.364.089 4.167.706 5.847 2.001a11.046 11.046 0 0 1 2.32 2.502c.453.682.64.854.64.584 0-.07.063-.882.139-1.805.679-8.26 2.396-15.1 4.984-19.86 1.86-3.422 5.108-6.817 7.885-8.244 1.397-.718 2.539-.988 4.02-.952.933.023 1.01.036 1.77.307a6.822 6.822 0 0 1 1.363.662c.612.407 1.309 1.004 1.235 1.058-.026.018-.343-.165-.705-.407-2.657-1.771-5.062-1.52-7.12.742-1.108 1.22-2.651 3.53-3.634 5.443-2.828 5.503-4.541 11.464-5.291 18.413-.163 1.509-.282 3.76-.195 3.703.032-.022.266-.52.518-1.108 1.597-3.723 3.578-6.428 5.79-7.908.672-.449 1.612-.904 1.715-.83.022.016-.172.22-.432.454-1.957 1.754-3.248 3.76-4.232 6.572-.938 2.68-1.366 5.588-1.368 9.3-.002 1.741.188 4.385.366 5.101.125.505.08.546-.585.546-.55 0-2.306.138-3.416.27-.414.05-.817.04-1.609-.036-.58-.056-1.129-.119-1.218-.14-.165-.037-.18-.014-.2.302-.01.186-.098.203-.728.139zm2.507-6.725c.294-.11.375-.22.375-.517 0-.63-1.309-.706-1.524-.088-.074.211.13.51.42.616.297.108.413.106.73-.011zm2.369-.052c.277-.222.318-.364.174-.611-.4-.691-1.755-.307-1.428.404.121.266.299.35.738.354.227 0 .387-.045.516-.147zm3.011 6.681c-.027-.05.088-.268.256-.484.879-1.135 1.22-1.544 1.284-1.544.04 0 .056.037.036.082l-.423.964c-.212.485-.445.924-.519.977-.169.122-.57.125-.634.005zm2.446-.596c0-.121.853-.683.896-.59.018.04-.056.209-.166.376-.168.259-.238.305-.464.305-.164 0-.266-.035-.266-.091zm-13.04-.124c-.177-.159-.493-.656-.462-.725.018-.038.248.1.512.309.264.207.457.405.428.438-.075.088-.371.074-.478-.022z" fill="#9bb92f" stroke-width=".078"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript" xmlns="http://www.w3.org/2000/svg"><path d="M49 51h408v408H49V51zm246.669 314.879l19.463-1.702c.922 7.8 3.067 14.199 6.435 19.198 3.368 4.998 8.597 9.04 15.688 12.124 7.09 3.085 15.067 4.627 23.93 4.627 7.87 0 14.819-1.17 20.845-3.51 6.027-2.34 10.512-5.548 13.455-9.625 2.942-4.077 4.413-8.526 4.413-13.348 0-4.892-1.418-9.164-4.254-12.816-2.836-3.651-7.516-6.718-14.039-9.2-4.183-1.63-13.436-4.165-27.759-7.604s-24.355-6.683-30.099-9.732c-7.445-3.899-12.993-8.739-16.644-14.517-3.652-5.779-5.478-12.249-5.478-19.41 0-7.871 2.234-15.227 6.701-22.069 4.467-6.842 10.99-12.036 19.569-15.581 8.58-3.546 18.116-5.318 28.61-5.318 11.557 0 21.75 1.861 30.577 5.584 8.828 3.722 15.617 9.199 20.368 16.432 4.75 7.232 7.303 15.421 7.657 24.568l-19.782 1.489c-1.064-9.856-4.662-17.301-10.795-22.335-6.133-5.034-15.191-7.551-27.174-7.551-12.479 0-21.573 2.286-27.281 6.86-5.707 4.573-8.561 10.086-8.561 16.538 0 5.602 2.021 10.21 6.062 13.826 3.971 3.617 14.34 7.321 31.109 11.115 16.769 3.793 28.273 7.108 34.513 9.944 9.076 4.183 15.776 9.483 20.101 15.9 4.325 6.417 6.488 13.809 6.488 22.175 0 8.296-2.375 16.113-7.126 23.452-4.751 7.338-11.575 13.046-20.474 17.123-8.898 4.077-18.913 6.116-30.045 6.116-14.11 0-25.933-2.056-35.47-6.169-9.537-4.112-17.017-10.299-22.441-18.559-5.424-8.26-8.278-17.602-8.562-28.025zm-65.728 50.094V278.454h51.583v-18.399H157.938v18.399h51.37v137.519h20.633z" fill="#0288d1"/></symbol><symbol viewBox="0 0 500 500" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414" id="typescript-def" xmlns="http://www.w3.org/2000/svg"><path d="M457 459H49V51h408v408zM69 71v368h368V71H69z" fill="#0288d1"/><text x="342.219" y="344.544" font-family="ArialMT" font-size="12" fill="#0288d1" transform="translate(-6058.94 -5838) scale(18.1514)"><tspan style="-inkscape-font-specification:sans-serif" font-family="sans-serif" font-weight="400">TS</tspan></text></symbol><symbol viewBox="0 0 24 24" id="url" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h-3v1.9h3a4.1 4.1 0 0 1 4.1 4.1 4.1 4.1 0 0 1-4.1 4.1h-3V18h3a6 6 0 0 0 6-6c0-3.32-2.69-6-6-6M3.9 12A4.1 4.1 0 0 1 8 7.9h3V6H8a6 6 0 0 0-6 6 6 6 0 0 0 6 6h3v-1.9H8c-2.26 0-4.1-1.84-4.1-4.1M8 13h8v-2H8v2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="verilog" xmlns="http://www.w3.org/2000/svg"><path d="M17.282 17.08H6.718V6.513h10.564m4.226 4.226V8.627h-2.113V6.514c0-1.173-.95-2.113-2.113-2.113H15.17V2.288h-2.113v2.113h-2.112V2.288H8.83v2.113H6.718c-1.173 0-2.113.94-2.113 2.113v2.113H2.492v2.113h2.113v2.113H2.492v2.113h2.113v2.113a2.113 2.113 0 0 0 2.113 2.113H8.83v2.113h2.113v-2.113h2.112v2.113h2.113v-2.113h2.113a2.113 2.113 0 0 0 2.113-2.113v-2.113h2.113v-2.113h-2.113V10.74m-6.339 2.113h-2.112V10.74h2.112m2.113-2.113H8.831v6.34h6.338z" fill="#ff7043" stroke-width="1.056"/></symbol><symbol viewBox="0 0 24 23.999999" id="vfl" xmlns="http://www.w3.org/2000/svg"><defs><style>.jra{fill:#f05223}.jrb{fill:url(#jra)}</style><radialGradient id="jra" cx="205.45" cy="208.29" r="225.35" gradientTransform="matrix(.04556 0 0 .0456 2.888 2.88)" gradientUnits="userSpaceOnUse"><stop stop-color="#ffd104" offset="0"/><stop stop-color="#faa60e" offset=".35"/><stop stop-color="#f05023" offset="1"/></radialGradient></defs><title>houdinibadge</title><g stroke-width=".046"><path class="jra" d="M19.97 3H4.03A1.03 1.031 0 0 0 3 4.031v4.135C4.548 6.977 6.563 6.21 8.948 6.21c5.107.003 8.35 3.574 8.348 8.081 0 3.13-1.46 5.485-3.746 6.71h6.42A1.03 1.031 0 0 0 21 19.968V4.031a1.03 1.031 0 0 0-1.03-1.03z" fill="#f4511e"/><path class="jrb" d="M3 17.722v2.247A1.03 1.031 0 0 0 4.03 21h1.837C4.474 20.21 3.49 19 3 17.722z" fill="url(#jra)"/><path class="jra" d="M8.948 8.231c-2.586-.09-4.598.86-5.948 2.264v3.163c.918-2.654 3.447-3.87 5.565-3.85 2.647.027 4.689 2.025 4.7 4.284.012 2.159-.892 3.748-3.33 4.14-1.33.213-3.411-.567-3.318-2.578.046-1.037.854-1.622 1.777-1.58-.905 1.213.293 2.102 1.139 1.921 1.048-.224 1.475-1.156 1.475-1.878 0-.762-.718-1.994-2.498-1.951-2.204.052-3.591 1.639-3.638 3.602-.056 2.468 2.253 4.091 4.622 4.121 3.48.046 5.543-2.24 5.539-5.586-.005-3.029-2.434-5.946-6.085-6.072z" fill="#f05223"/></g></symbol><symbol viewBox="0 0 24 24" id="virtual" xmlns="http://www.w3.org/2000/svg"><path d="M21 14H3V4h18m0-2H3c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h7l-2 3v1h8v-1l-2-3h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 281.25 281.25" id="visualstudio" xmlns="http://www.w3.org/2000/svg"><path d="M196.18 101.74l-52.778 42.444 52.778 40.889V101.74m-136.67 110l-30-18.889v-100L62.843 81.74l47.778 37 96.666-89.222 44.444 27.778v172.22l-55.555 22.222-85.111-81.555-51.555 41.555m3.333-48.889l20.667-19.111-20.667-19.778z" fill="#ab47bc" stroke-width="11.111"/></symbol><symbol viewBox="0 0 300 300" id="vscode" xmlns="http://www.w3.org/2000/svg"><defs><style>.icon-canvas-transparent{fill:#f6f6f6;opacity:0}.icon-white{fill:#fff}</style></defs><title>BrandVisualStudioCode</title><path d="M218.62 29.953l-105.41 96.92L54.301 82.47 29.955 96.64l58.068 53.359-58.068 53.359 24.346 14.212 58.909-44.402 105.41 96.878 51.424-24.976V54.93zm0 63.744v112.6l-74.719-56.302z" fill="#2196f3" stroke-width="17.15"/></symbol><symbol viewBox="0 0 24 24" id="vue" xmlns="http://www.w3.org/2000/svg"><path d="M1.821 4.15l10.21 17.618L22.24 4.235V4.15h-7.692L12.113 8.33 9.691 4.15H1.82z" fill="#41b883"/><path d="M5.937 4.15l6.152 10.616 6.18-10.617h-3.722l-2.434 4.179-2.422-4.179H5.937z" fill="#35495e"/></symbol><symbol viewBox="0 0 420 419" id="watchman" xmlns="http://www.w3.org/2000/svg"><g stroke="#fff" stroke-linecap="round" stroke-linejoin="bevel"><path d="M166.95 145.32a93.935 123.23 0 0 1 92.934 3.263" fill="none" stroke-width="18.467"/><path d="M162.92 137.96L44.63 256.25a174.07 173.93 0 0 0 5.705 16.486l123.68-123.68-11.096-11.096zM266.54 144.04l-11.096 11.096 117.16 117.16a174.07 173.93 0 0 0 5.691-16.5l-111.76-111.76zm170.65 170.65v22.193l17.1 17.1 11.096-11.098-28.195-28.195z" fill="#fff" stroke-width="1.963"/><path d="M167.52 273.36a93.935 123.23 0 0 1 92.934-3.263" fill="none" stroke-width="18.467"/><path d="M49.516 144.56a174.07 173.93 0 0 0-.809 2.213 174.07 173.93 0 0 0-4.757 14.344 174.07 173.93 0 0 0-.016.055l119.56 119.56 11.098-11.096-125.07-125.07zM454.87 64.703l-17.668 17.668v22.191l28.764-28.764-11.096-11.096zm-80.984 80.984l-117.86 117.86 11.098 11.096 112.18-112.18a174.07 173.93 0 0 0-5.416-16.777z" fill="#fff" stroke-width="1.963"/></g><image x="21.229" y="20.262" width="378" height="377.1" preserveAspectRatio="none" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAaQAAAGjCAYAAABjSWGNAAAgAElEQVR4AeydB3hUVdrH/+fOpNMF JAFUmivFXtZuIBRRQUUTil1RV3et6Lr6rSu6rg3B1dXVtXeBCCioCASIimLDhkFsgAIJSAkhPZmZ 8z3vjReGMJlMueeWmfc88MzMLaf8zp3855zznvcV4MQEkoxA7sVr01PrUrv4pdZNBNA1AHQRkJ0g ZAcJrYOA7Aipv8/Q6JgUKdBkG0iZISDSDVwSaAfAY3z+/bVSAD56L+mfwA5INAqgSkrUCKA2IOQO IcQOIUU5pNwhNZQLia2Q2OyH3OJBxqaiwk4VzfLlj0wg4QmIhG8hNzDJCEgxJH9DjgfevgGBAyCx vxSyh5DoAWA/CHQH0MEFUOoBbJbAeiHEBiED6wMSv0DgF02In1J2Vq+ZP78fXcOJCSQMARakhOnK 5GrICaO3tE3P8B0kERgkgP6Qop8E+gqgL4Bdo5gEphKAwHpIrIHAjxL4DpCrAo1yVfHsHhsSuN3c tAQmwIKUwJ2bGE2TYsjY33qLQOBwCHk4JA6HhgE08kmM9ilpRQUgvwPE1xD4MiC0r2R67dfFz/eq U1IaZ8oETCLAgmQSSM7GHAIj8td38sN7HAROkpDHATgMTWs15hSQvLn4JLBaQHwqEPgQQiwvmpHz XfLi4JY7kQALkhN7JYnqlDd2w4GQnuMk5AkCOBHAQQD4ubTgGZDAFg1ieQCBj6Dhg7SKmhW8LmUB eC6iRQL8xW8RDZ8wn4AUQ8eVHib9YjCELj4nAOhqfjmcY4wE6iGxAkIsCwDvSel5v7iwa1WMefFt TCBqAixIUSPjG6IhkDtmQw/N4xkKTQ4TEkNZgKKhZ/u1DZD4CAKLIMSik/p3WzF5sgjYXiuuQMIS YEFK2K61p2GTJ0tt2Xebj5EyMArAab+vAdlTGS7VVAI0xSeAd6QQb2uBtIW8V8pUvJwZz9XzM2AG AdpoKqpTh3uEdlYA8gwBdDEjX87D0QQaALlMCLzha5Rz2NTc0X3lmsrxCMk1XeWsig6/YFOWr0Ge JoBzICWNhNo6q4ZcGwsJkFOKT4TUZiOgzSqate8aC8vmohKIAAtSAnWm6qaMHPljWl2bzBGapk2A lKMBZKguk/N3JYHPpMCrHuGbuWj6fqWubAFX2hYCLEi2YHdToVIMKSg7SQAXABgDoJObas91tZWA H5DvCeCV2rrUwg/ndqm0tTZcuOMJsCA5vovsqeDwszd19af4LwbEZQAOtKcWXGoCEaiCwAwB7emi Gd0+TqB2cVNMJMCCZCJMt2dFFnIflJQNh8BEgKbkRIrb28T1dySBlULIpzyBwCsLCntud2QNuVK2 EGBBsgW7swqlvUIer+cSQE7UPWI7q3pcm8QlUAtgtibFU4sKu70PCJm4TeWWRUKABSkSSgl5DTkt 3XSqJuVfJDAiRFyfhGw1N8qxBH4AxJP+hrpnit/otcOxteSKKSXAgqQUr/My18216wMXCOB6AH9w Xg25RklOoEoI8bzw+R5ZNKvnj0nOIumaz4KUJF0+YsyWbL/Xdx0gr5BAxyRpNjfTvQQCkPIdDdqD iwqz33NvM7jm0RBgQYqGlguvbfKmrd2MJrPtNBc2gauc5ASElJ8EIO4/eWD2m+xLL7EfBhakBO3f wfmlR2oCtwI4G4CWoM3kZiURASmwGpAPVLTPeXnFk6IxiZqeNE1lQUqwrs4bW3Y0AoF/QIjT2Vdh gnUuN8cgsFYK3LNPoPzFwsKBDcZBfnU/ARYk9/eh3oIh+WXHaELeIZs8bCdIq7gZTCAsgXUQuK9T oPw5FqawnFxzkgXJNV0VuqJD8zcdDCHvlvpG1tDX8FEmkOAE1gohJp/Yv9vLvMbk7p5mQXJp/w0Z u7mPkP7JACbwGpFLO5GrbS4BiRII/H3xzOw3eZOtuWityo0FySrSJpWTO760s8cv7wQEeVVINSlb zoYJJBAB+bGQ4uaiwpxlCdSopGgKC5JLuvm4/PUZmcJ7NSD/DqCDS6rN1WQCdhKY45f+vxYX9vzJ zkpw2ZETYEGKnJVNV1L4h9JxAuI+9jNnUxdwsW4mQFZ4//E31N/NLomc340sSA7uo2H5Gw7zC+3f AjjFwdXkqjEBNxDYKgVuPbl/9rNs+ODc7mJBcmDf5J61toMnNfVfgLgCgNeBVeQqMQF3EpD4XGja NRyTyZndx4LkqH6RYkj+pouEkFMAdHZU1bgyTCBxCEgIPFvvabx52av7lydOs9zfEhYkh/Th0HM2 95Ye//8ADHVIlbgaTCDRCWwWENcWzcyemegNdUv7WJBs7qncXOnVum66XoBMuZFpc3W4eCaQdAQE xFyfz//n4tk9NiRd4x3WYBYkGzuEjBak0J6WwJE2VoOLZgJMANgJIW49qX+3J9jowb7HgQXJBva0 pyhLeO+QkJPYaMGGDuAimUDLBJYJgSuKZuR81/IlfEYVARYkVWRbyHfouLJcBPCUhOzbwiV8mAkw AXsJ1EOKe3Z07HYvh7mwtiNYkCzinXvx2nRvTdq9EriOw0JYBJ2LYQLxEfgCUlywuDB7VXzZ8N2R EmBBipRUHNcNGVt6hJB4CcCAOLLhW5kAE7CeQB1tqF0yI/thdtiqHj4LkkLG+fnSUy7KbpWQ/wBE isKiOGsmwASUEpBLPBouXji9+3qlxSR55ixIih6A3DEbemhe7WV2+6MIMGfLBKwnsA3AxMUzc96w vujkKJEFSUE/D87fOFoT4lkA+yjInrNkAkzAVgLyv/7MhknFz/eqs7UaCVg4C5KJnTpy5I9p9W3b TBGQf2HDBRPBclZMwHkEVnr8KFg4K2e186rm3hqxIJnUd0PGlO4vPHgdAkeZlCVnwwSYgLMJVEHI yxfP6D7d2dV0T+1YkEzoq6H5ZadLIV/gKToTYHIWTMBlBIQQj6bsrLpp/vx+9S6ruuOqy4IUR5fo VnRa2Z1S4jaeoosDJN/KBNxP4DMhcW5RYc6v7m+KfS1gQYqRvR6zKCXtVQiMjDELvo0JMIEEIiCB LR4p8hcVZr+XQM2ytCmapaUlSGF5+WUDvKnpn7EYJUiHcjOYgAkEBNAlIAKLho7deI0J2SVlFjxC irLbh+SXni0EXgTQJspb+XImwASShICAeC6lsuoqXleKrsNZkKLglVdQeiuAf/F6URTQ+FImkLwE lnkatXMWzun2W/IiiK7lLEgR8KL9RQ1tsyia60URXM6XMAEmwAQMAmsDHjFq6WvZJcYBfm2ZAAtS y2z0M7njSzt7/JgD4MRWLuXTTIAJMIFQBCqkkAVLZnRfGOokH9tNgI0adrPY611u/vq+Xr9YzmK0 Fxo+wASYQOQE2gsp3h4ytnRi5Lck55UsSC30+5D8smM8wvMhB9JrARAfZgJMIBoCXiHxVN7YjXcC kmemWiDHYEKAyRu76QzIALkDyQpxmg8xASbABGImQBZ4vt+6XVFcLHwxZ5KgN7IgNevYvPyNl0EI MmDwNDvFH5kAE2AC5hCQmJ9Zh3PnzcupMSfDxMiFp+yC+nFIQdlNEOIpFqMgKPyWCTAB8wkIjKxJ xyLy+GJ+5u7NkUdIet9JkVdQRvuLaJ8RJybABJiAVQS+9kvvqcWFXTdZVaCTy0l6QZo8WWrLVpU9 JoE/ObmjuG5MgAkkJgEB8ZNPw7Di6dnrErOFkbcqqQWJvHVvF2UU2fXCyJHxlUyACTAB0wn86pf+ vOLCnj+ZnrOLMkzaNaTcXOndLja9zGLkoqeVq8oEEpfAfh7hfW/4OaUHJW4TW29ZUo6Q8vNLUreh 42tCYEzriPgKJsAEmIBlBDZDiiGLC7NXWVaigwpKOkEiMSoXnQol5GgH9QNXhQkwASagE6C4SprU 8ooKu61MNiRJNWVH03Q0MmIxSrbHnNvLBNxDgOIqSREoGjq2tL97am1OTZNmhEQGDNtE6asCosAc dJwLE2ACTEAlAVkGIXMXz+jxg8pSnJR3UoyQfreme57FyEmPHteFCTCB8ARENqS2mJw8h78ucc4m gSBJUS7KHgVwfuJ0G7eECTCBJCHQwyM8i3LHbOiRDO1NeEEaMrbsHt70mgyPMreRCSQsgQM8Hu3d kfllXRK2hb83LKEFaUjBxluExN8SvRO5fUyACSQ4AYGBjQjMG5q/vX0itzRhBSlvbOmVAuLeRO48 bhsTYALJQ0AK8UcpamfT1pVEbXVCCtLg/I2jIfEYgKSxIkzUB5TbxQSYQDABMWS76Phsogb5SzhB Gjxu0x81IV7jEBLBDzG/ZwJMIIEInJc3tuyhBGrPrqYklCCReaQIBOYByNzVQn7DBJgAE0g0AhLX Dc0vTbj18YSZ0iILlEaBjyRk0tjsJ9p3jNvDBJhAVAQkpBy/uLD7jKjucvDFCTFCokW+Bsg3WIwc /KRx1ZgAEzCbgIAQz9IyhdkZ25VfAgiSFPoin8DxdkHkcpkAE2ACNhHI1AKBuYmycdb1gjS0oOz/ AJxn08PAxTIBJsAE7CbQ1ePV3kmEPUquFqS8/LJzJXCX3U8Dl88EmAATsJnAwVLUv+R2c3CPzRBj Ln5o/qaDIeRbABJ2k1jMcPhGJsAEkpHAH3oPrNTWlkxb6tbGu9LKLu/sDfsgRfsEQB+3gud6MwEm wAQUEJBC4NyiGTmzFeStPEvXTdlRKAmkaLTxlcVI+ePBBTABJuAyAkJKvKDPILms4lRd1wlSudj0 LwDDXMiaq8wEmAATsIJAG6kFZrnRyMFVgkQ+6iTkX63oUS6DCTABJuBaAhL9Aqhznc871wjS0HM2 99aEeIEdprr2K8IVZwJMwEICQmBMXsGmGy0sMu6iXGHUkHvx2nRPddpHEDg87hZzBkyACTCBpCEg G4UUQ4oKc5a5ocmuGCF5a9IeYjFyw+PEdWQCTMBZBESKFGKGbpnsrIqFrI3jBWno2NIxHII8ZN/x QSbABJhABARkDlI8z7lh06yjBWnImNL9IfF0BMT5EibABJgAE2iRgByVl192bYunHXLCsWtItN9o O8reBztNdcijwtVgAkzA5QTqhSaPK5re/UuntsOxI6RyUXo7i5FTHxuuFxNgAi4kkBaQ4tVRo0od G8DUkYI0JL/sGAlBXrw5MQEmwASYgEkEhMRBtZnifpOyMz0bx03ZDb9gU5a/PvAFgANNby1nyASY ABNgAlIKeeqSGd0XOg2F40ZI/nr/AyxGTntMuD5MgAkkEAEhpHh2RP76Tk5rk6MEacjYjcMBcZXT IHF9mAATYAIJRqC7X3gedVqbHDNld8LoLW3T0xu/BbCf0yBxfZgAE2ACiUhASoxZUpgzxyltc8wI KT29cQqLkVMeC64HE2ACyUBACPnYiRN+6eiUtjpCkIbll50C4AqnQOF6MAEmwASSg4DITvN7pzml rbZP2ZFNfE0GvuGAe055JLgeTIAJJB0BiZGLC3Petbvdto+QajPEP1iM7H4MuHwmwASSmoDA407Y MGurIOWNX3+IRMBV8TqS+qHlxjMBJpCoBA6oyRB32N042wRp8mSpwe99AhApdkPg8pkAE2ACTEDe OHjshkPt5GCbIC0r2XQlII+zs/FcNhNgAkyACewi4NWkeEIfLOw6ZO0bWwRpZH5ZFynkPdY2lUtj AkyACTCB8ATEse9/V3Zp+GvUnbVFkBqaxKiDumZxzkyACTABJhATAYl7cs9aa8vfZ8sFadi40qMA 2KbAMXUQ38QEmAATSBICAuiipabfZUdzLRYkKQIB+R8AFpdrB1oukwkwASbgTgIC8uph+RsOs7r2 lgrDkPxNFwHiWKsbyeUxASbABJhAVAQ8AWgPRXWHCRdb5qnhd48MPwDobkK9OQsmwASYABNQTEAI eVbRjO5vKi5mV/aWjZBq0sVNLEa7uPMbJsAEmIDjCUgpHjjyCmnZXlFLBGnEmC3ZEPJmx9PnCjIB JsAEmEAwgQM7lJddHXxA5XtLBMmX0vhPAG1UNoTzZgJMgAkwAQUEBG63KkSFckEafk7pQZC4SAEm zpIJMAEmwATUE9gn1Z/yV/XFWGB+7feARkdeKxrDZTABJsAEmID5BITENXnjN+9rfs575qh0hPT7 Jthz9iySPzEBJsAEmIDLCGSJQODvquusVJACAZC/OstMy1XD4vyZABNgAslKQEp5xfD8Tb1Utl+Z IA0pKD0ZwDCVlee8mQATYAJMwDICqX7NTwFVlSVlgiQgbQ/2pIwaZ8wEmAATSEYCUpw/ZOzmPqqa rkSQ8saVngSIIaoqzfkyASbABJiALQS8Aj5la0lKBAkBOdkWVFwoE2ACTIAJqCUgxflDz9ncW0Uh pgvS0PzSE3l0pKKrOE8mwASYgCMIeOFRY3FnuiBB4FZHIONKMAEmwASYgBICEvK84eM29jQ7c1MF KW/8+kMkMNLsSnJ+TIAJMAEm4CgCqb6AuMHsGpkqSPB7yL0E7zsyu5c4PybABJiAwwgI4PIR+es7 mVkt0wQpd1zZAQDGmlk5zosJMAEmwAQcS6CNT3j/bGbtTBMkLYAb2WedmV3DeTEBJsAEnE5AXntc /voMs2ppitPT3LPWdhCQl5hVKc6HCdhNwOsVaN9WQ1amQFaGQEaGhox0gdQUAaEBaali19x0Q6PU j1OdG30S/gDQ0CDh9wPVtQFUVUlUVgdQXRNATa20u2lcPhMwk0DnLOE5H8BTZmRqiiBpqekTAcnx jszoEc7DUgIkLL3396JvrxT03T8FPXK8yNnXgy77eJTUo7pG4tdSH9Zv9OGXDT78vK4Rq39uRFV1 QEl5nCkTUE1AAtcD8mlAxP1rK24DhNxc6fV0LfsZwH6qG875M4F4CXTqoOHQAWk4dGAqBv0hFQf0 NOU3WbzV0gXqu58asXJ1A75YWY/NW/xx58kZMAGrCAjg1KKZOQviLS/ub6O3a9mZksUo3n7g+xUR SEkROKR/Kv54eJr+v3t23I+8kpr27O4F/R9+StN0/MZNPnzxTQM++7oeK76pR31D3D8+ldSbM2UC REBKXAUgbkGKe4SUl1+6FAK53C1MwCkEaK3n2CPSMfiEdBx/VLpTqhVXPZZ9WocPP6vDxyvq9fWo uDLjm5mA+QQCwu/pVzRr3zXxZB2XIOXllw2AkCXxVIDvZQJmEKCR0PFHpWHw8Rk48ZjEEKGWuJAw LXq/Fp98WY/GRh45tcSJj1tLQEDcXzQz+2/xlBqfIBVsfBQQptqhx9MYvjf5CPQ5IAUjB2fgrFOz kq/xAN4qqsE7i2vww5rGpGw/N9pRBLZ2kuXdCwsHNsRaq5gFadSo0syaDGwE0CHWwvk+JhALAbKM yzsxA6OGZ6Jfr5RYski4e0iQ5i2qwdIPa1FXz6OmhOtglzRISHleUWH3V2OtbsyCNLRg46US4plY C+b7mEC0BPbt4sGoYZkYdybvMAjHbtbb1Zi7qAYby3zhLuNzTMB8AhIfLC7MoWjhMaWYBElKeeCM udULXp1TeQDtq+DEBFQSGHRQKkYPy8SQE03bEK6yuo7J+73ldZi7sBpfr4p5BsUxbeGKuIOApkE+ PLnTjQMOSv93LDWOWpBIjAB8T4XNW1iDp1/bCRalWNDzPa0ROO7IdH1a7pjD0lq7lM+HIfDNqgZ9 xFT8UW2Yq/gUE4iPAE2fP35fZyOTvwsh/mV8iPQ1FkG6F8AuSwoWpUhR83WREsg9PkMfER0yIDXS W/i6CAjQxtt5C6t1Cz3JExsREONLIiXQe78UPDlllxgZt3mFEFHt8I5KkKSU5E9lr4lpFiWDP7/G Q+CUY2lElIXDBrIQxcMxknsfeqoCbxfVRHIpX8MEwhJoQYzontOEEPPD3tzsZLSCdCqAkAWQKP3v 5Z1s4dMMMH9snQBNyZHFHE3RcbKOwKofGnTLPNrTxIkJxEIgjBhRdjOFEFGFJIpWkF4EcEFLFSdR eviZipZO83EmsAeBA3unYPTwTJw6OHOP4/zBWgLkmohMxskbBCcmECkB8gP59INdWru8vRBiZ2sX GecjFiQpJf3VqDZubOmVRaklMnzcINCxvaabbp9zuns3s1IYCWMdJjNDoLau6XNKCnaFojDa65ZX GimRVd53P/ImW7f0mV31pC0Y40a30Wc2WqnDJUKI51u5ZtfpaASJhl7Td90Z5g2LUhg4SXxKCGD8 WW1w6bi2jqZAISHWl/qwcZMf5OR0yzY/tu8IoKIygIqdgV1CFK4RXg/QsYMHnTtq6NTRg25dPdi/ hxf75XhBZuxOTrSPqfCtamzdHtV6tJObxHUzkUAUYkSlLhJCDI+0+GgE6U0AoyPNmEUpUlLJcR15 Vrj1Guc59aA/urRP59vVDbr7nTW/+pT7hyNh7pHtxYADU3BQ31TdiKNnjvO8kD8/sxKvzK6KSICT 4ynmVkYpRgawbCHEJuNDuNeIBElK2RHA9nAZhTrHohSKSnIdoz+8Z52a6Shfcx9/UY9PvqjDFysb 9BGQE3qka2cPjjg4DUcflgayNnRSuu/RHSj6gA0fnNQndtQlRjGiql4jhHg0kjpHKkgUnvzZSDJs fg2LUnMiyfP5vDFtcMlYZ0zP0R/UpR/V4qtvGxwfW4g8lx91SBpOOCYNp+Y6w+CDnLjSd/nnX3h9 KXm+wbtb2r6thosL2kayZrT7pt3vioUQg3d/bPldpIL0NtmUt5xN+DMsSuH5JNpZp3hYIKuxhe/V 6kHu3BqmgcSJggtS4D4nxHZ66fUqvPh6JU/jJdqXNkx7sjIFJo5vF6sYGTnvK4T4zfjQ0murgiSl bA9gR0sZRHqcRSlSUu69zuMBLhvXDgWj7bOeW7fBh/lLavQpJjJASKTUrq2GYSdl4KqL2tnerH8+ VI73PmYzcds7QnEFTBIjquWVQognW6tuJII0HkDM7sSDK8CiFEwjsd4POzkDt/zZPqMFGg3NXViD L1bWJxbYEK0ho4hDB6TqXi3sXG+a/U41XplTpVsehqgmH3I5ARPFiEgsFEKMaA1JJII0E0B+axlF ep5FKVJS7riOwoVfPLYtzjnNnlHRzHnVmLeoGmWbk9NEOWdfjy5M+WfYw5+e0keeqdB/DLjjieVa RkKA9tZdPiHuabrmRXUQQoT1nNCqIAUCcqYQ5gkS1ZBFqXk/ufOznaOiFwsrMXt+DaqqE2taLtYn gf6AnD0yyzYjkjcX1GD2/GqOwRRrBzrsvusuax/vmlGoFh0phPgi1AnjWKuCNOutquvHnJ71kHGD Wa8sSmaRtD4fWiuiX0/n2vCr/IXCSsycW+14Sznre6WpRK9XYMxpmbjiPHvWmZ54aSdef6tVhy52 4eFyIyCgSIyoZE0IEdbPPHnvDpu2+f5y7uqfGo/PO8nc4Gh/6JOCju09+OTLxJ/zDwvYZSfJ0uvZ aV0w4EBrvQ2QEP3ffeX63iF/cs7ORfSkBAJAyfeN+tpOba3EkYdYG0vqqEPT0KG9B6Wb/dhZyaPX iDrNQRepEqM/3bK14sWnMqesWnVnfILUa9Ckh0s3+Tuv/qkRLEoOenJsqArtKbpuIhldWpdmzK3C P6aU47Ov6sFCFDl38rNX8kMjXn2jGo0+icMHWSdM9GPzrFOzUF0r2S9e5F1m+5WqxOiav28lLyjp dahatGbV1F/DNTTsCGnI2M19hJR3UQbk14tFKRzKxD3X94AUfVOclc5Q58yvxgOPV2Dph3U8PRfH o0UjppXfNWDGvGpoAjjYQj96Rx+ahvbtPPh5nU93PhtHM/hWxQRUidH1d2zDqh9+30wtsGltydQl 4ZoSVpB6DbxxrADOMDIgUfplgw+nHMfTdwaTRH+lX7p33dwR/XqnWNLU5Svq8PgLO/HGuzU85WMi cRpdfvltAxYva9o71L+fNVOuB/VNAVkA/rYtgJ/WsZcHE7vUtKyuubQ9Ro8w3yMIiRH5iAxKaWtL pj4d9Hmvt2GNGobkl84SAmOa30V7H26/gdzbmZvY0MFcnvHmRhswrTTnfvQ5EiJnLoiTFdthA9NA 01H7dfcip5sHbTI1tMnSdMwNjVIfBZBn8A2lPtAG3a9K6rH2170CLMfbLabcT/uYRg/PwinHWec3 j4wdyOiBk3MIkBidaY0YUaP9XunvuqCwZ4t+UVsUpPx86dkuyrYCCLnbUZUokfnof54Na6runN5M 0JrQegMFzjvpj9b8saJ1ohdmVoH+qDsppaUKDD4hA8NPzsAhA2IbUWwvD+CDT+vwzmJn+oEbNSzT 0nXBjz6v04MB0pogJ3sJqBKjG+7YhpV7jox2N1SK/MWF2a/vPrDnuxYFKS9/w3EQ2kd7Xr7nJxal PXkkwieaXrnyAmtMht9bTt4VqvXwD05iRzvUzz29DS44t42p1fr863p9Ayn9UXZS0jToa4QTzja3 veHa+NyMptAW4a7hc+oIqBKj2+7djk/D/dgQeHLxjJwrW2pZi2tIvQbedJEQGNLSjXSc1pNUrCnR vDMthn7KJuHh8Jt+jqboLjjXGu/cT76yU18r2rzFOTbcNCKiP8r33baP7prHbMA53bz6iOsPfVJR XRvAxjJntJ0s8mh9icJy0B6zvr3UrxfSKJy+4zSlSdF3OVlH4M8Xt1MSDqZVMWpqYoe1JVMfaam1 LY6QhuaXLpACEUX645FSS3jdcZz2FNHUDXleUJ30Hf3vVDsmDhG1lzaTjh1tvZcDGimR/z0aOTkp 0ZoC/YK2Kt37nx1YvIzjLVnBm8SIPHqYnSIUI71YKf09lhT23BiqDiFHSLm50ivaVP0XQEQT5zxS CoXWHcdGDc/EnZM6os/+6n8VP/x0BV6aVYXKKmdsmKTRwLgz22DaHftYuk/HeDIoSuzQkzJ0I4kd lQE4ZbT4/c+NmPVONVJThCUboGmt0uMR+KpkD4ssAxO/mkTACWJETdEgVqxZNW1lqGaFFKRex151 tJDy6lA3tHSMRaklMs49ftn4tpg4Qf16EVnO0eZWChXuhETesmmt7N93ddajtNpdpwN6pmBEbia6 dfVie7kfW7fbL9iNjdBHbr+W+nGyBRFsDx63vWsAACAASURBVOmfqntuIevE6hqewjP7mVQlRpOn lmP5iihH+EJsXVsy9a1QbQwpSH37T5oAgWGhbgh3jEUpHB3nnOvYXsOV57eDFRtdyWKSgrrV1tn/ R4aEiPZVPfqvziAXN05LfQ5IwWl5mejU0YMt2wIor7BfmNat9+mjWlpfG/SHiCZMYsZKJvW0zWB9 qR9ULidzCKgSI4qJ9cEnMRnoZK0tmfpYqNaFXEPKKyibC8hRoW6I5BivKUVCyZ5rjjksDffc2kl5 4bQ2MnNuFTY5wGiBhIj+0N9wuXXrImYApvW2eQur9T1NZuQXbx4nHJ2OO28yf/9hqHqRN/cXX68K dYqPRUHgTxeocYIcb4DGVCm6zi/M3tK8KSFHSL0GTnpYADGvfPFIqTlmZ3ymxcxbrwm5rczUCj79 WiWeea0SVQ6YeqHQ3/+7vwsorLrbElmbjh6RpW++JWG321np+tKm0VJ6qoaBikdLhw5MA0XI/ea7 BvicYYzotscHThUjAtkoRPG6kqk/Noe6lyANz9/USwp5W/MLo/3MohQtMbXXTxzfFpeOU2vSvWRZ LR5+Zifo1e405MQMkH8uFRZFVreN3PzQVGN6uoYNZfavsaxYWY9fNvpwyrFqrTIP6puqm+GvXN2I Tb+xKkXz3CkTo3+bE7peCPnz2pJpxc3btJcgHXDwDacC4tzmF8bymUUpFmrm3pOWJnD1Re2Vxy4i bwsPP70Tv2219w8HucK59rL2utFC1857Pd7mwrU4NxqV0BoLWb+tXe9DXb1963L03SaHrRlpAqr9 4tEot6IyALL+49Q6AVViNOVxMs+Pac0oRKVFw9qSqS81P7HXN7b3oJsmAji2+YWxfmZRipVc/PfR lM+F57bF6UPNd5wYXLsHn6jQg+YFH7P6PcVposXb8We1Qbcuez3WVldHaXmDDkpFwag2ujB9v6YR ZBFnRyKHrZ99XY/ynQEce4TaKdE/Hp6u7xejDbycWiYwcUJbFIw23+MGidGCYlNnPjpflP/gA8XF e8ZH2suoIa9g43JAmCZIBjo2dDBIWPOad2KG8vUicoY7Y16VrdMpZC1HfvdIkJI1vTK7Cq+9UWXr iClnXw/yz2ijIuz1Ht1Khh5Pv7rTEVabe1TMAR9IjGhfndlJgRjpVfT40X/hrJzVwfXd46ckOVSt FVX/BmD6LkkeKQVjV/ueHkrVgfTojyB5bq6qtmfaiJydXj6haR8VbTBN5kR7eMjlEfXEdz82gmIg WZ0qq6Ue/ZnqQF7RVSUa9Xfr6sGOioDt08Oq2hhLvm4TI72NQi5vvkF2D0HKOfjKgwBcHwuQSO5h UYqEUnzXkPHChflqjRfuf2yHvpM/vprGdnf/vim4dFw7XH1RO9CGUjsTTR9R8Luff/FhQ5kfASn1 zZ121YmE4PwxbdDogx5M0w5h+mZVA35c68OQE9QZPPTaLwWnDs7E9h0BikRqF27HlKtKjMizyjtL TJ2m24OZEFi7pmTaouCDe0zZ5Y0tPQ8SLwdfoOL9iNwM3HyV+ebHyR66QtUGOOMZoCm619+2xw9d v14p+nTQaUPUrocZbW3p1fDYTYEEySlp89Q2S9MNSM4bY/7USfOyWvtMDmxnvV1tS+j3lBShj2DH nBbz7pHWmqeff3lWFZ6fWRnRtYl4kSoxeuSZCt3PokpmUorFSwqzhwaXsccIqffASRcBOD74AhXv KaTx5q1+0EY7M1Oyegnvso8Hl09ohzNPVfflnzm3Gv99YaflfugO6OnVR3yTrmwPEiW7Erk9euqV nfr+KtqP01KimE7kk418wdEIhabT7EpHHpKGC85pq6+30FSelYnaTgYPZAlI9VCVaOqW9iuFDXmg qnCb8724oK0+VWt2NawQI6qzEGi/tmTqA8H133OEVFBKw6c9FCv4YrPf80gpfqL0B2/a5H3izyhM DnZEcqV1IfJArvoXdphm66dKvm/QfynG6o2a3DSRqfa4s+wfMVE/vrmgOuTIrjUO8Zy3IuAjjd4L 36pC6WZ7tx3Ewymae0mMzj/H/GfKKjEy2iok9i8qzPl112fjDb3mFWwsBUR28DHV71WJ0pz51Xjs +cQOl2yFJd3N/9ymx8pR/RwY+dOUF5luF4xWN9ozymrt9YH/7sDC98yZQ9+3iwdnjshCwSj72/XQ UxV4u6imteabep6CAF51oZrQB8EVjSYMQvB9bnqfKGKkM5cYubgw512D/64puxMn/NLRG/DcbZyw 6lXV9B1t1mvbRkOihkomx6g3XKHONxv5orvlX9vx68aWp6fMfkbox8kjd3dW7pamtXrTH2zyTk7P plmJPFiv+KYeRR/U6lN5FIPKrkRulAb8IVWfygs3/Whm/Wi9jb6LZHBxxMHqpvDyTsrAth0B/Jig xg6qxOh/L+3EnPnW/kjRny8hvlpbMnW58aztEqQDD/rrURC41Dhh5SuLUnS06aGk0BGqEnldePyF naD1EKsSmS3/5RJ1AhtJO2hK6//u367UcovMo8kwYulHTUYRqr0ctNTunH29GHx8Bsj4wMrNpt+u brJKpLJVJRJcenLJ4i+REhnKXKTAgpbEqPCtaptQiQ1rS6bONQrfJUi9Dp40EsAZxgmrX1mUIiNO bkFUrkfQw0lB9KxMNNq74jz1cZlaahO1+dZ7t+um0i1dY/ZxcpRKI4YPP6unxV0c2Nseg42DD0rV nbeSAYJViUZlbxXVIC1NA4WcUJHIBD4zQ8Pn31jXLhXtMPIkMbpkrPk/Qu0VI2qdrF9bMu0Zo527 BKn3oEkXmOkyyCggmlcWpfC0/nJJO6WL/HdOK8f8peasmYRvye6zfQ9IsSykwe5Sm96RR3ISom+/ t9YCLbgeFPPo4y/qdXGiUOoUE8nqRKO01FSBL1ZaN6Kg+FiffFkP8rWoKs4STYt2bO9ByQ+Nlo72 ze6/xBUjIiXarS2Zep/BbJcg9Rk46RoA/YwTdr2yKO1Nnhb6aUGYFsVVJLJQuu/RHVi52ro/SEY7 aPqxn8Wjg+dmkBCV61M6ofYSGXWz8pWixH74WR2+WtUACoZn9aZf8o9nbFy3st0kghSm5OjD1Kwr 0QisXRtND3hIG2ndllSJ0bPTKzFjrl3TdHv0QlrvQ/76xNpvH9Qrs0uQeg2c9A8Anfe41KYPLEq7 we/X3YsJZ6nzETb7nWrQ2okdsXbItFvFBund9PZ890JhJf7vvnJ9zcQOLwZ71ib0p81b/Hj/kzpQ yIWMdIH9e1jnFql9O49pVoWhWxf6KO2RIiexZDWqItEPnjOGZeplbCxzj1m4KjGiH2SvzrF2Wj5c v2oy8Maakmnr6RpdkJp82FVOBcQugQqXgRXnWJSAwwel4rF7OiubZ6fQ4k+/at8ud/rCDein3tqM /O7d/sB2fP51gy1eC2L5vlD8n/eW12HVj43IzBCwwl8f+Yhb9UOjLXt5SCjeXlyL9FSh7Hknwftt WwA/rbNvijbSZyH/jCxMnGD+uiqJEX0fHJWE9t6akqlfU510Aeox8KoDpMCNjqokoJvdqvDo4AaT 8NzjM3D3X9WFGievC9PftPfBJFdH7dtqyh676W9UYfK0cny8ot62EA3xNo42epJF3k/rfPo2hpxu akdM6ekCxcvNinkTXeuNdSUKRKgqIi15hSfvEbSu5NREYnTlBUkiRmTWAJSsLZm6lPpDf7r98PcF 9nDa4Ji+MmJwmD21Y0QSdeLmWfJQoNJb9z8p6qNNf3SMB8vrgbJf/TPnVYOmIrdud8/0jMGlpdeP Pq8D/acfKqOHZYJc5qhIJx6Trlv92bm29uTLO1G+w6/kjzIxu+L8droFnhN94CWbGFF/aEAf41nW BUlqYn9h3ZYTo+yIX5NJlFQ9kAbsa2/fhlU/WG+8YJRvvHbsYP7sMDkSJQ8dm7YkjhAZvIzX4o9q Qf8piuqo4Vkg7+dmp306emwXc9oXQ/14x40dzW6enh+53UlPE3oIFSUFxJCpqu8+OaB13DRdEB8p sb/xUf+r0HvQjWcC4hTjoBNfk2FNidZUVMwbU3+SJR25VdlQZp73gXieE/pjkD/KHF9cNBp64L8V IH9zZLGVDIlCXsxfUqOviZgdnHDRB7Uod4BFGnkJ+fDzemiK9mmRWTgZcnz6pf17lc4ckYmrLzZ/ Y7grvKELYG3J1Ifoe6uPkITUekp9b7Ozv8qJPFJS5RKEetSJfv0aTJjCp82VJLQ//2JCZs5+9Fus 3btLa7CguAan5WXihsvN/4PWYsEWnfh5XaPuZd7nB+iPttmJ8iTBe+H1Sj3on9n5R5IfRTy+5lLz +84VYtQEKCc3V3qLi4VPHyEdMOCma4RA70jg2X1NIo6UKKYJuc5RkWio/uQr9lnStdQm2ogZT7hl cm1EFkO0sZQTdN9t5GFjZ6XEMYfHt6eHhN4JIySjX/1+6KMYVcYOtFcpI03Tpwgrdlr7PJEYXXtZ UosRdbOW0rby6Z+/nbZTN3ESQvY0Ot8NrzRSomiGZicydCDLLyuT7groTDVi9NQrlfofbSvbw2XZ R4AMEd54txpTHt9hXyUUlkzGDuRdQ0UaNTwTz0ztAnKlZFVSJUZkPetEg41wXGUA+9F5w+a2R7iL nXhu3qIaUOwOs5OVokTid+4Zarwv/PupCpCTVE5MIJEIvPZGFR58wvzvvcHooTv3gdlrckbewa8q xcjOvYXBbYzmfUBqetgjbdSoUpqYNX9yNpraxHgthUhwqyjRnLFheh5j81u87Z8PlevOK1u8gE8w ARcToHUzitOlKt11c0cMO1mN1wiqM4VZUTFNRyMjN4oRMZEIdKNXrTrTY2lAPrMfIpWiRNNpZidy B3PdZe2VLNBSXW+6axve+9iejY1ms+L8mEBLBChkxgXX/qYbtbR0TTzHb/lzB9AoxuykKiCpm8WI GAsNXejVK4Xs7OQ9SJE8ECRKlMz+1WFMpz3xkjmRZ7MyBSaObwearzY7kbXZ7Hersd7CgHpmt4Hz YwLRECjb7Mdjz1cgINVY4NHfE/JcMdMkJ6QsRmF6V4ocOuvVAgFXj5CMJjpdlLp29mD8mWqcpJIY Pf3aTlBUUk5MIF4CNbXWWprFU18yB//PsxXw+SQorpbZieJ0kfd18vsYT1IlRq+/Ve3aabpmPHXH 3l4BdEmUP2NOFaVuXTwYO1qNGJFVFXnr5sQEzCIg3aNHu5r8+Is7dR91tLnc7ERRWj2aiNlyjdw9 me36jNpIYmTW7I3ZzKLPT+xL93ghRQe4fc4uqPVOE6Xe+6XgySlqonok1gMZ1In8lgnEQID2pdXU Slx+nvmRVcnVkNeLqEcjpxybjr9f1yGG1oS/JfG++1KHpEnR9CZ88911lkRJhfUdrSlFY+jQr5c6 MaJFzMT5deSu54tr61wCtNVh2pNqzMJpI3c0338So9tvMN8XX+KJkf486aA0IPEEiZpntyhRBM7H 71MzMiKXIG4173TunzKuWaIQeGdxDf4xpVxJcyL9UapKjMgNWIL+EG0aIQkI8yVcyaMQfaZ2iRIF 1vv3nftEX+EI7qBpCbftwo6gWXwJEzCVAIXquO4favYqkSiF8+hysqKRkRN9UprYaem5F69NJ08N 5jtSMrGW8WZltSgdc1gaptyuRoz+99JOR7uRj7ev+H4mYCaBku8bcOmNW5TsVaJN7aEcotL3/x8K pukSXIyaur06q4NXAubv/jTzqTIhLxIlSqr3KdHDeM+taqK8PvxMhZIvlgl4OQsm4FgCFMLi+cIm /3dm7/8zPIXTd5OSqu9/UogRgFQEMryQSHdosFhTH3LVovTFynplYnT/Yzuw6P1aU3lwZkwgWQiQ B+9HnlWzgdYQueUr6pR8/5NFjOhZDABtvBCIz1e9i55qlaJkeHUwG8fkqeVY9im7AjKbK+eXXATI EzptoA0EpOk+JEmUDGEyk+qbC2rw2PPJs8fQ70EmBehL6DWk5g+IKlFqXo4ZnynC66df2R/N0oy2 cB5MwAkE6A88xVdS9QPSrDaSGJGAJlMSgUAaCVLSjJCMznWDKN04eRu++a7BqDK/MgEmYBIBMpuu b5BQ4dXBjComoxgRN02KtmRll24GRLflQaLk1OHw9XewGLnteeL6uosAbZ+g/XxOS8kqRkY/0Agp aRMtGHo9wJUKwkzECvWKm7diza+Nsd7O9zEBJhAhAdrP1+iTuGSs+a6GIqzCHpeRk+Rkm6YLBiAR aJPUgkQwCt+q1pnYLUr0ML65sBrr1vuC+4jfMwEmoJDAK7Or0NAgbf9RSt9/w3xcYXMdnbUAPCRI zvh5YCMqu0WJHsbpc6uweYvfRgpcNBNITgL0/acwFuG8L6gkw2K0my4JEq0jJX2yS5ToYXx5dhW2 lbMYJf1DyABsI0DT936/NH3zfGsNYjHak1DST9kF47BalOhh5MB6wT3A75mAfQTI0Ims71TELgrV Khajvanw6KgZExIl8hmnOrEYqSbM+TOB6AksKK7FPx9S4yk8uDb0/f/fy+r/zgSX6Yb3JEgujA+p Fq1qUWIxUtt/nDsTiIfAex/XKRUl4/tfV58osbrjob3nvSRITZ4H9zye1J+yMgVy9lU3m0luRkYO yUxqxtx4JuBkAocMUOcvgL7/+WeYH2rdyTwjrZu6v7qR1sBh15EYTRzfTolvquCmUuRJEWR2Hnwu Gd5npFPrOTEB5xG47rL2yr//FBKdEsc2293/EvCzIO3mAavEyCiS9j4JAcyc17QXyjieDK8eXr1M hm52VRszMwQun6D+x6gBhUXJINH0KqBV0Z8FdiUNWC5GRldccX47x/rUMurIr0wg0Ql0bK9ZKkYG TxKliwuSfiuogQM0Qkp6d9JWj4x20f/9DbkuSfEKHr43BxPmcy0vCIehw6eiIdCtiwdjR7dRPk3X Up14pNREJiBkJQlScvk4b/ZU0DDdijWjZsXu9ZEeSq8XePpVtjHZC06IA7SJkRMTiJdA7/1S8OSU zvFmE/f99P0nv3rkyihZk9S0eg0yuUdIVs4Zt/agjTuzDSZO4OF7a5z4PBMwg0D/fs4QI6MtNFPi 1JAYRh1Vvnr8qNEgkncNyQprmmg7kESJLPA4MQG7CGRkJL4F5CEDUvGfu+0fGTXv42QWJQ2o0gSQ lNuFnShGxsNJ0SztcvRo1IFf3UugPs64jhr9VUjgdPxR6Zh2xz6ObWGyilIDtNqkXENyshgZ35Kz R2aB/jAkc3wUgwW/RkegsZHX11oiNuzkDNzy5w4tnXbMcRIlSkm1ppRVvUOTkOodNzmmmwFVYvT6 73GVzGzqmSMy9fqmpyX2L1YzmXFeTKAlAqOHZyoTI4pAa3YiUco/I8vsbJ2aX13x873qvIDY4dQa ml0vVWJ0273b8elXTdbzNN1mZiI3I5QK36pC6WYOUWEmW84reQgUjMoC7flTkcgZK/m/o2SMbMwq xwgcakQiMCtfB+aj65AmZHIIkhVi9MRLO6FipESi9OIjXXFQ3xQHPkdcJSbgbAIXntvGEjGi6bWX Z5lvtk2ilAQjJX2mToOQCT9CskKMjK8kiZIqV0CP/qszjj5MndNHow38ygQShQBto7gwX81Witsf 2D0yMniRbzoWJYNGNK9NAyNNAluiuc1t11opRgabJ1/eielvmv9LifK/99ZOGHJChlEUvzIBJtAC Ado+QdsoVKS/3bMdy1eE9rqmUpTMXhJQwSa2POVmuk8LaFpZbBk4/y47xMigQh4XVPxSovxvu7YD Rg3j8BUGa35lAs0JXHNpe6j64339Hdvw+dfhPa6pEiUSWTLOSMC0ldrkFVLobxKtgXaKkcGSHkpy B2L2Qiflf93E9qANjDPnJp+ncIMvvzKB5gQ6ddBwwTltlfmlu/KWrfh5XWPzYkN+pu8/JcNXXciL Yjh47WXt9bso5HrCJCFLqS3erBp/WU2CzQA5QYyMB4UWOn0+4PLzzJ/HvuK8dsjK0KDC5NSoP78y AbcQ+EOfFDx2jxrvCxTldfa71Vi/0RcVDhIl8lFp9tRhoomSDDQtHWnz5uWQzCaM1DpJjIwnd8bc KvzvJTUOMcj31VUXqjFnNerPr0zA6QTI+4JKMZo+typqMTKY0fS9ijVlEqVEmb4T0DYRLyNM2gYD nptfad7Y2LdjZjuC9xnFmi/tI3jkGTWO1c85PQvU9pQU3kAba//wfe4lcFpeJu66uaOSBtDI6OnX dmLzlvj2AKoUpTOGun9NSRMB3ZZBjxgrpVgvhDxQSY9alCn9QSbPBmanux4q37XpNd68ac63vkHi 5qvMd11CbScXZLPnV2N9aXTTCvG2i+9nAnYRoDhGKqbDqT1vLqjBo89VQJrkickILWP29N31l7fX 16oXFNfa1Q1xlys0/EqZ6CMkoQXWx52jjRmoEiPagf3+7zuwzWoePTQkcioSjQ6fe6gLDh/Ee5VU 8OU8nUWAjIVUidGc+dW6H0mzxMggp2qkRD9yR+S61hjA37ApRx8hNU3ZSbhWkFSKkeEOxHiYzHol kbv5n9vNym6vfKbc3glDT3Ltw7lXe/gAE2hO4OqL2imLHUTeVh57Xs2aL7WDREmFRxcXi1JpcbHQ p3V0QZJC6MOl5p3u9M9uFCOD6Zff1uNPt2zFO4vV2JP87S8dcM5p5vrVM+ruhFdaxD7qEB4JOqEv rKxD504e3eHwGEXP9qtzqkDeVlQnVW7GXClKQQMifQ0JkL8C7loQd7MYGQ/7T+sa8eLrVfD7ocQY 46qL2qFDew3PvGa+J2KjDXa9nnB0Ouh/8Ue1mLuoBt+sijMIkF0NMbFcjwf6jxBVTkRNrGpMWR06 IBVTFcYxou0TVoZ7MITP7A28JEqNPmDJMpesKQUNiHRB8gY8P/pFIKaHxI6bEkGMDG5bt/vx3xeb fpGpsBAcf1YbZKQLPPqc+l99RpusfM09PgP0f+F7tZi3sBrf/RTZpkUr66i6LCEAGjEksvk/rY/Q H1pVibZl2OFRW5Uo3XZNB/h9cpcXclXczMlXrjHy8dCbIwZOqawVlbcCQv9snHTiayKJkcE3EAA+ +bIebdto6N8v1Ths2utBfVPRsb0Hm7b4UbHTGT88AhIgsTQr9TkgBWT+26mjB1u2BVBe4Yx2mtW+ UPmQEJ11ahZ0p7uHmjd9+eaCauxwyHNC7R53Vhtcc0mTd4JQHOI99u+nKvDGu2qmziOp2+ff1CMz Q8OAA8397p9yXAZ+2eDT/0dSD7uuEZDPrymZ9iWVrwvQqlV3yt4DbzofgHPj+gL6XhsVpt3B8Uzs 6hQq97Ov1DyYlDftYj9zRBbW/OrDr1HuNlfBhKYUzj2jjel7pw7snaL7+WvXVmsS4MrEFKbTh2bi v/d2xjGHmydERj/TNHJdvUm2zkamMb6SN5ILzjXvh0vzakyeWo6iD+yf2iJRUvGD1BWipImpa7+d qtsx7BoR9Rk4aSSAfs07zCmfE3FkFIotPZgej8Ah/c39tWSURdNbldUSqx0wtdW/Xwp65vy+jGlU 0KRXGhWSAGdladj0mx87qxJDmIafkoHrr1DnXLeqOoDnZ6rxVB9N12ZmCPzpwvagTd+q0rW3b8OK b8I7SVVVdqh86QdpMoqSP+C9bd2qKfpDt0uQeg2adAyAY0OBsvtYsoiRwfmrkgb9F+qRiqzIjjks TRc9KsfOJCFw8rHpSqswoF+qPq2Vnq5hQ5kP1TXO+OUfbaPph8S1l7bX14q6dNr1tY02m1avX/5F vel771ottNkFBx+UiosK2mLkYPM3ulNR5H2BRkY0neW0pFKUflzr078DDmvzzqWF3f5u1GnXk917 0KT9AZxhnHDKa7KJkcG95IdG/LY1gOOPVvMHm0ZgNK21YmW9aTvRjbpH+kpThxeca77T2VDlD/xD qm6BlpoisHa9zzFTUqHqGnzsxGPS8eeLm+L67Ntl19c1+BJT39Pifumm+NzkxFMhcoMzeVJH9NpP TXRk8r7wyLMVqKl17g8TVaJEcdRoZmSjjf0b4tn4Zm3J1KeM47ue8D4DJmVA4FLjhBNek1WMDPZk Fk4PUJ6iTa40rUW/trduD2BbufVTWrQLnkYsVkbBHXRQKgpGNa1d/bCmEY0ONcr74xFpuPrC9nro gpx91UxrGs+Z8frdj436pk3js9Wv5HlBpck6bUb97wvusDZVJUr0t8RhorR4bcnUN4xnbZcg9Rt4 fVVAaLcYJ+x+/csl7fSpFrPr4RQDhkjbRb9mPvi0DgJCN0yI9L5Ir+vbKwW0QL55qx8/r7N+CoP+ CLZv58FBfdX8Im6JA00LkZUfrddRHWgvmBPSUYem4U8XtsPFBW3RI9saITLa/ez0Sqz5xfpngJwC k+cF+qGgKlGwzKddth+PREnFd8NJoiQhX1tbMu1Do993CdLPqx6q7T3wxisBYc0cilGDEK80RXH2 SPMXM+9/bAeWfBg67HCIajjm0I6KAFaubkCbTE2JKFFDaZMppa9t2GBK0Tc7tPMoa1u4jqSpSwrh QRM4q35oBJng25EOGZCKyye0xcQJ7ZQZeoRr18x51SicZ32wx4EHpuLisW11k/1w9Yvn3JMv78Qr c+w31IilDZ9+mdiipEnt32tWTf3RYLNLkOhArwE3jxQCvY2TdryqEqMpj+/QN0/a0SYzyiQzaZV7 laiOhw5M09eVSJSsHDHQ1N2n9GuwrfUjJaNvDhuYpk+PEWea0rBKmPr3TcGl49rpI4QDelo7SjTa To5En/h9c7ZxzIrXkUOawkb03l9du+/9zw68VWTfHiMzOKoUpa9WNcQdWiOeNgrg1jWrpu6Ky7OH IPUeNOkQAMfHU0A896oUIze7Zg9mSsN4r1fgYEVm4bSuRCOGb75r0PfxBJet+j198eoaJFRZF0ZS /yMObhKm2jqJ739uVGbw0Wf/FN2SjEIH0KZeuxK5ynnyFetdS106ri2uPF9tYMnrbt+m/4izi62Z 5aoSpRG5mfji2wb8ttWWOettiwtzCjVkfQAAIABJREFU/i+Y056CNHBSVwBjgi+w6j2LUeSkv/y2 AVWKjQGGn5Jpy36lku8b8frb1boQqBLdSEjTWs4F57RFTZ3U15giuSeSa2jf1QXntMHNV3cAbeK1 Ky1fUYfHX9iJtxU5922pXZ06aLj8vHYw239bcHlk1v23e7Y70cQ5uJpRv1clSqcOtkeUpBTL166a +mIwiD0Eqc+gSWRz9OfgC6x4z2IUPWVaiCevC7Q/RVWi/UoZGZrlmwdp2oxEd96iGgT8AFnG2ZVI mC48ty12VjWNmGKtR86+Hpx/Tlvcek0HJe6hIq0X+fp7bnqlbk1ntfkvmbBTmHHyGqIqGaEjGhqc a9YdT9tJlMgNmNkM7RAlTcjZa0qmLQrmsYcgHTngwfJaUXUTAHVPTHDppH6KDBhozShRpumaIdv1 kfbxqLTAo4Jo0blrZ3tMw8l9DU0nLHivVl/Tor1EdiVy0UPCtG1HAD+uidxWvMs+TUL09+s7mu6r LFoW0/5XgUeeqcDPNljSnX9OG9D0pMr0YmElnplu/fSjyjaFypvWkhNBlITEY2tWTVsZ3Ma9Yk7k FWxcDghLPDawGAV3RezvyckmsSRHmyoT/TGjMOx2pe7ZXowelqnUnUwkbaNRBnkWX/R+bYtrTB3b a/pGXHIManci56E0NWd29NNI2kWbry/KbwsVPiiDy3/46Qp9RB18LNHfX3dZeyVha668ZSt+Xhf5 j65YOQuBAUUzcr4Lvn9vQRpb+m9IXBd8kYr3LEbmU504vq3uGdn8nHfnSFMihsv83Uetfdeze5Mw qdgaEG1LSJRopEpGEB4N6NhBQ98DUkBTfXYnCjlCnrvtECJqO7mF+scNHZVj+L/7tieM8UK0sFSJ 0hU3b8WaX5WKUsVJA7I7TZ68Z9yjEIK0cRykeC1aMNFcz2IUDa3orqXF4j9doNZ6iUK70wjBbl94 ZKlGMaTI3Qyn3QTI/Y9hGLL7qLXvaGMvTdOpTGS88PLsKmwrt8VCTGXTosrbpaK0aPHMnOHNG7rH GhKd7HPQLTXQpLIREotR8y4w9zNt7ly3wQdyO68qHdDDC7LCa2gEvv3ePgetFPPo4y/q9bAdZApv p/m0KtbR5EuRgW+9dzu+/V7pL9uwVdqvu1f3MpE/Su30MW3kJTdANDJN9kTfQRWb5unH3rJP69XE FhPylbUl04qb991eIyS6YEhB6W8C6NL84ng/sxjFSzDy+7t38+Lc07OUzDEH14KcVU5/swpbttn/ K5W8HdAak0rLw+C2O+X9C4WVeO2Navh89v5xpj9g9GtddbIruqvqdsWTf1amwMTx7ZR835VM3wlt 1OIZ3d5q3ua9Rkh0Qe+BN51EMd2aXxzPZxajeOhFf29lVUCfV8/KND8SZXBtyAcdCd9v2wIgZ7B2 ps1b/Hj/kzqQp3QK206/1hM5kX82GhF9sbLBMs8SoXimpQndKSo5R1Wd/jGlPOGtZ2NhSE6CVY6U Fr5Xq+99jKVuoe5Jlbj+p1VT97KQakGQbuwJiGGhMorlGDlOHHOa+UP4ZDDtjoV38D3kJ67BB5AH ApXp+KPS9TDMX5XUg8KT25nKNvtRvLwOZA1Hgd5UBQG0q43kXeH2B7brU5VWungK1V4ajf7v/s6g uFMqE60X3fVQue7WSWU5bs5bpSileIW+FGBSPLHvFhbmTAnFOqQg9Rn0Vz8gJ4a6IdpjtMCuIuoj mbLOX2J/6OFoedhx/berG/S9J4MVbqKldg04MFXf/PnLRp8jgp9RXJ+lH9Xhp3U+PRJnTjd3j5im v1GFO6eVY/mKekeEzZg4oa3ug0/1M134VjUee36na4MrquYTnD+JEq0jZ2WY64iZNuKmmidKs9eW TN1ruo7aEVKQ9usyZZOWVTUJQFw/e0iMVLgIof0wtIufU+QE1pf68M6SWqSlqgljEVyTU47NAE3j kLcFJyRqe9EHtfi11I8O7TRYEejOzHbTAv49/9mBDz6pc0RgwWOPSMNVF7XHqbnqrRtpi8FLr7vT U7eZz0A0eTU0Sn00QwJipkcHs0RJSPlQ8w2xRvtCCtK6dXcG+gyYdAoE+hgXRvuqUozs3JwZLQcn XU9RMmmXN8WfoXhAKtOgP6Tqng0oTLRTQkWvW+/TvT5s2uJHp44ePTihSgbx5j3r7Wrc/98KFH9U 65jRAe11u3Zie0tiNd3yr+1Y/AHPgsTyHNHUGlnbOlGU/H55/brvpoWMlBhSkAjAAQMn7S8EhsQC g8UoFmrW3UMjF4oSe9xRasKjB7eEzM8zMzR8/Z29C+/BdSLXOfOX1FjGILjsSN6/8W41pjxeoY/q yDjFCYnWCGktmLxDq07U/kl3bQeNbDnFTkC1KJERRdQRlwXWLH29+90ttapFQeo98AYfhLispRtb Os5i1BIZZx3/cW2jbpGmKhJtcGv1taUxbWyLShtcl+D3xOClWbSxMoDjjlQvzsFlh3pPcXumPlGh W5FV7HSGENH+LgoaSBGcaSuB6vTcjErQfiq7jTVUt9Oq/FWKEu19ilqUBApbWj8iJi0K0lEDp5bV iqprAUT8TWUxsuoxM6ecHTubTMPT0zRY4biUotKSb7MNZT49tIU5rYg/F3KWSsK0s1KCnKhancik lox0SJBos69T0ojcDDx+b2dLng1qM5l0v2NxOAynsFZZDxKlX0t9GGNyFG5aU4pWlATEA2tKppa0 1N6QG2ONi/MKSmcDONv4HO6VxSgcHeefOy0vEzdeoX5To0Hi6dcqQVZjTkvkqJb2VV2p2P0StZvW huYuqsE3NoSND8edfAWeOTxTubNeow40RUcjI/rDyUkdgd77peDJKZ1NL4BM8p9+LSIryIBX+rss KOy5vaVKtDhCohv6DLipEwROb+lm4ziLkUHCva80fbXkwzp4NHMtc1oiQvuiaCqPDC2ctlZAZrOv zKlCQ4Oa/VsffV6nmzFPf7Pa1vDRofrmgnPb4I4bO4IiB1uR6IfJs9Mro1+LsKJyCVYGjb7JFRB5 1DAzGSOllasbQLHMwqTPFxX2eCTMeYQdIeXmr+/rEZ4fw2XAYhSOjjvP0Y57CmNuVZozv1p3Bkqe FpyWPB6A9m+dPjQzbstEmpKjX5M//2KvR4tQjIeckKH/oVJtfWmU7RQHvUZ9kulV5Ujp4WcqwqG8 Z/HMPUOWN784rCDRxXkFpd8DOLD5jfSZxSgUlcQ4RtE9J09SHzogmBYFWCPvzQHnLKMEVw8d2ms4 8uA0HH5wKg7okYIe2R60ydL2uMb4QKbltFb2/U+NesTdVT80wOc8vdVDZYwengmasrUq0b6qJ18O afVrVRWSvhw7RElInFRUmLMsHPxIBOlBALRJdo/EYrQHjoT8QPuVaDf+OQrcPoUDZncgwHB1a36O XBNlpGtITxfw+yRq6qTugbqx0dnrISSk487MwrgzrRsJE7up/yMPK7ypvflzZMfnfr1S8Ph9ataU QoyUtneS2V0LC0XYn2Vh15AIUu+BN9H8wkXBwFiMgmkk7nsaqZAvvPKdARx7RMTGlnED+eMR6Ti4 f6rulYCC3zk50Zw5hUCg/UJVNRL1DdKxIzziqGnAhLPb4P7/64RBijdHB/cbGS7c8eAOfPOdM7x3 BNctWd9v3xHAF9824NTB5o6OaU2JQqzTJvygNOutwnazgj6HfNuqIHU64cGN6XW7zb9ZjEJyTOiD P/zciIXv18LjESDv3lak7K5e5B6Xgb69UnQT8dLNYX9YWVElV5dB1oMU4v4/d3fG4YOsNW0n9z/P z6zi2EUOfIJ+2+q3RJSkEHevLZm6qjUErU7ZUQZDC0pnSiCfpm9UDPEp0Nbsd6pbqyufdwABKyLS hmqmU02kQ9XVScdIiGh96IbLrTPpN9pPBhxvLqwGuWzi5GwCNFr+9537mF5Jipf2n2d3NAqZ0aWo sFNYiwcqPCJByhu7cdzE8e1eUyFGHGzL9GdAeYb79/DirBHqg/+FasiyT+t0x7orvtljOiDUpUl9 jIRoxCmZuOkq64WIwD/1SiVmzHXePrOkfihaabwqUVr6Ue36ISdk7tdK8frpiASp9LfGU7O7eOdH kmE017AYRUPLeddSWJGrLmxnS8U+/6ZeN6H+8LM6W8p3aqFkpj5qWJbu6seOOvKoyA7q5pV5+KBU TLnd/JESgH5CiJ9aq2lEgiSlfA3AuNYyi+Y8i1E0tJx7bbcuHj3ECK1P2JHW/NqoC9O7xbVwumWb Sj5tszR9H9Gl49RHbW2pHbRW9PpbPPXeEh+3HD/msDTcc2sns6vbTQixubVMIxWkCwG80FpmkZ5n MYqUlHuus9r1UCgy09+swluLakB7gJIl0X4S2nk/api5llLR8CMLOgqi58SNzdG0g6/dTcBkUSoW QgzenXvL7yIVpA4AylvOJvIzLEaRs3LblakpAhcXtEXBaHtGSwavT76ox4L3an6PrOrs/UBGnaN5 peCHucel47QhmZY5Pm2pfryvqCUy7j9uoihdI4R4NBIiEQkSZSSlfBPA6EgybekaFqOWyCTW8aMO SdN/tZN3b7sTWfkUfVCD1T81QrpYm8hI4ZD+qcg7McNSrwot9R+NRskHnVO9arRUbz4eHQGTRClb CLEpkpKjEaSxAKZHkmmoa1iMQlFJ7GMFo7Jwxfn2GD2EIkvrGxQGfNWPDa4QJxKhAf1SccIx6SCW TkhLljV5KP92NW9wdUJ/WFEH+mF5500xuxFbJIQYHmk9oxEkmqSOacWSxSjS7ki869q20XDe2W10 wwcnta7og1p8+mU9Vqysh1OC4RGfrEyBwwam4Y9HpOlTck5i9uhzO0HrRZySj8Apx6bj9htiEqVL hBDPR0osYkGiDKWULwK4INLM6ToWo2hoJe61hw5IxejhWTjlOPun8ZpTXrfBh5XfNej/v1/TiNJN PstGUB3bayBXKxQgkTwoWOUJozmDcJ9fnVOFFwo5ims4RslwLkZRai+EiNiTbrSCdCqAiPcjsRgl w2MaXRuHnZyBW/5MNjLOTuRzjTwMbNzkQ+kmP0o3+0ARdmMZTZGT2k4dNHTu6EGPHA96ZnuxXw+v 7mm7a+dWvXfZBmreoqZwGWRaz4kJEIEoRalQCFEQDbloBYm+PRH5AWExiqYbkutaWhspGNUGl59n 356ZWIn7/UBFZUDf80SOVBt9TZYSXo8Atcv4nJmu6Y5MMzMFaI+QmxJZKc5dVA165cQEmhOIQpRO F0K80/z+cJ+jEiTKSEp5L4C/hcuUxSgcHT5nEKCRw4Sz2oCilHJyBoEHn6jAu0s5PIQzesO5tYhw psMrRPhwE81bGIsg/QHA6uYZGZ/Zh5VBgl8jJdC+rYb8M7Iw7iwWpkiZmX0dGSy8uaDasrUzs+vP +VlPYERuBm6+qsXp9/uEELdGW6uoBYkKkFJ+AODE5oU9N6MSr8xmh4rNufDnyAjQesroYZksTJHh MuUqms14/W0WIlNgJmEmYUTpICEERRuPKsUqSJcCeCa4JBajYBr8Ph4CZHlGUWp5xBQPxfD3khDN nl8NWhPjxATiIRBClJYJIU6KJc9YBYl26e0aCrEYxYKe72mNAIXZPuvUTN0dUWvX8vnICDz8dAXe XlzDHhYiw8VXRUigmSidJ4R4NcJb97gsJkGiHKSU1wJ4+LHnd2LOfN4stwdV/mAqAa9X4PS8DFxz qT2xfUxtjA2Zfb2qAXMXVuP9j+t4jcgG/slSZJssbdurj+57aVaWmBtrm2MWJCrw7oe3n7j0wzpa T+LEBJQTILPqIw9Jw+jhmTj+KOdtsFUOIMoCFhTXYt6iat2PX5S38uVMIGoCAuL+opnZYS2wW8s0 LkGizPPyS5dCILe1gvg8EzCTABlAnDE0ExPOZsu85lzJ6ek7S2qwoyLQ/BR/ZgKqCASE39OvaNa+ a+IpwARBKjsXQhbGUwm+lwnESsDrAY45PB3DT8nAicck76jpvY/r9FhQX5XU87RcrA8T3xczAQEx t2hm9pkxZ/D7jXELUm6u9Hq6lv0MIKKY6fFWmO9nAi0RoP1Mg0/I0GMFDTootaXLEub4N6sasPjD Wn1tqLKKR0MJ07EubIgATi2ambMg3qrHLUhUgSEFZTcJyCnxVobvZwJmEdinowcn/TEdJx+brscR Mitfu/MhH3sffV6H95bXYcs2ttm2uz+4fJ3AqsUzswcBIu6IY6YIUu5Zazt4UtPWA+AJfX5CHUeA zMePPCQVfzw8HUcflgba5+SmRNNxH6+ow8df1INHQm7queSoqwCuKJqZ85QZrTVFkKgiQwrKHhGQ 15hRKc6DCagk0LO7F4f2T8XB/VP10VOXfZzjcZsct67+sRG0FvTFtw1Y84u7I92q7EfO2xEEttZI /37LC3vWmlEb0wRpeP6mXn4R+AGA14yKcR5MwCoC7dtpOLB3CvockIJ+vVLQM8eD7vt6kZZm2tcj ZFPKNvvx60Yfftnow/c/NYBiMW36jafhQsLig84kIMUdiwuz7zKrcqZ+4/IKSl8GcJ5ZleN8mICd BMhIonMnD9q1FaBpP4p+2yZT6EKV4hWgDbspv//8CgRoszjg+X2wVVsndW8IdQ0S9fUSOysD+nTb zqqAbo69dbsfPtYeO7uXy46fQJVX+vdfUNhze/xZNeVg7mjG438Afs8EAKYKnVmN5XyYQDQEaPqM /nNiAkxgbwISeMpMMaISTF3dXfxaz29EFBFl924iH2ECTIAJMAEXEGjwavIhs+tpqiBR5aSG+8yu JOfHBJgAE2ACziEgIF5ZOL07WVabmkwXpMXTcz4A5BJTa8mZMQEmwASYgFMI+ODX7lZRGdMFiSop Ie5UUVnOkwkwASbABGwmIOTL8fqsa6kFSgRpycyc9wEsbalQPs4EmAATYAKuJODzBwL/UlVzJYJE lZXAZFWV5nyZABNgAkzAFgKvFBf2/ElVycoE6fdR0iJVFed8mQATYAJMwFICDR6pKV2OUSZIhEnT cFvTYMlSaFwYE2ACTIAJmE5APrWwsNta07MNylCpIC2anvM5gFlB5fFbJsAEmAATcB+BGq8vVdna kYFDqSBRIR4/bgfgMwrkVybABJgAE3AXASnwyILZXcpU11q5IC2clbMaAi+obgjnzwSYABNgAkoI bGvwND6gJOdmmSoXJCrP25hCo6TqZmXzRybABJgAE3A4ASlw97JX9y+3opqWCBIN9SQER5S1oke5 DCbABJiAWQQEfqxon/2YWdm1lo8lgkSVyKqlEOeitLUK8XkmwASYABNwBgEZwC0rnhSNVtXGMkGa Ny+nRorA361qGJfDBJgAE2ACsROQwHtLCnPmxJ5D9HdaJkhUtZP757wACTIF58QEmAATYALOJeD3 yMD1VlfP8kB6eWPLjoaUH5sdi8lqcFweE2ACTCBRCUghH18yo/vVVrfP0hESNW7xjOzPADxrdUO5 PCbABJgAE2idgAS2BOobyMuO5clyQaIWpkpBjd1heWu5QCbABJgAEwhPQOC24jd62fL32RZBml+Y vQVC/F94KnyWCTABJsAErCQgpPzk5P7Zts1g2SJIBPik/t2eAPS1JCt5c1lMgAkwASYQmoDPr8kr J08WgdCn1R+13KghuEmDx244VJMaWd15g4/zeybABJgAE7CYgJBTFs/o/leLS92jONtGSFSLpTN6 fA2IaXvUiD8wASbABJiA1QTWeVI9SmMdRdIgWwWJKphZKwmC0hgbkYDga5gAE2ACyUpACnH1wpe6 2e5v1HZBIg8Omha4nAP5JetXgdvNBJiAzQReXjIje77NddCLt12QqBaLpvdYDOBJJwDhOjABJsAE kojAJq/0X+eU9jpCkAhGXV3KzQB+dQoYrgcTYAJMINEJSImrFxT23O6UdjpGkD6c26VSCslTd055 MrgeTIAJJDgBMd1q56mtAXWMIFFFl8zovhCQj7dWaT7PBJgAE2AC8RAQpV7p+3M8Oai411GCRA30 pHnIDv4HFY3lPJkAE2ACTABSQF7qpKk6o08cJ0hkehjQtAsB+IxK8isTYAJMgAmYRUD+t2hmzgKz cjMzH8cJEjVu6fRun0DIe8xsKOfFBJgAE0h2AlJgdWatsNUbQ7g+cKQgUYX9m3P+CYmPwlWezzEB JsAEmEDEBBoAnEd7PyO+w+ILHStIxcXCJ/2YIIByi5lwcUyACTCBxCMg8dclM3K+cHLDHCtIBG3J 7JxfpBRXOBkg140JMAEm4AICby8uzH7E6fV0tCARvMWF2a8L4Amng+T6MQEmwAScSUCU+j24GBDS mfXbXSvHCxJVtVr6b4TEl7urze+YABNgAkwgAgI+aHJc8Ws5WyO41vZLXCFIywt71krNkw+gwnZi XAEmwASYgEsISIhbF0/P+cAl1YUrBIlgLpmx789CyIvYK7hbHi2uJxNgAjYTmLNkZrepNtchquJd I0jUqqIZ3d+EkA9G1UK+mAkwASaQbAQEfhQy/RI3rBsFd42rBIkq3imQc6uUgsJVcGICTIAJMIG9 CVSLgHZOUWEn1y1xuE6QCguFPw0YD4Ff9u4HPsIEmAATSGoCUgAXFRV2W+lGCq4TJII8vzB7SwCB MwE4dsexGx8GrjMTYALuJiCBfxXNzJnl1la4UpAI9tIZPb6WQlzMRg5uffS43kyACZhKQMq3Th6Q fYepeVqcmbC4PNOLyysoux2Qd5meMWfIBJgAE3APgZVCpp/kxnWjYMSuFyRAiryCspfIaWBww/g9 E2ACTCAZCEhgS8AXOKJ4do8Nbm+va6fsdoMXspMsvxSQH+8+xu+YABNgAklBoEZq2qhEECPqrQQY ITU9dCPzy7o0CnwkIfsmxWPIjWQCTCDZCUgJed6Smd1fSxQQCTBCauoKsrzzSd9IGr4mSudwO5gA E2ACLREQErclkhhROxNmhGR02uBxm/6oBQJLAGQax/iVCTABJpBIBKSQjy+Z0f3qRGoTtSVhRkhG x1D484CU4ynorHGMX5kAE2ACCUTglX0COdckUHt2NSXhBIlatrSw+1wJ/IX3KO3qZ37DBJhAQhCQ S8iIizzWJERzmjUi4absgts3NL/0b1Lg3uBj/J4JMAEm4FICnwqZPtzte43CsU/IEZLR4KLCnPsE xP3GZ35lAkyACbiUwKpUKc5IZDGifknoEVLTg6dvnH0cwJUufRC52kyACSQzAYFf/I2BExNlr1G4 rkzoEVJTw2njbPafAbwSDgSfYwJMgAk4kMAGf8A/NBnEiNgnwQip6RHLz5eebaL0VQFR4MCHjqvE BJgAE2hGQJZByNzFM3r80OxEwn5MGkGiHjzyCpnSvrxsuhAYk7A9yg1jAkzA9QRog78mcErRjJzv XN+YKBqQBFN2u2mseFI07oPy8QJi7u6j/I4JMAEm4BwCuhhJLS/ZxIh6IKkEiRpcWDiwoaPcni8l ZjvnEeSaMAEmwAR0ApulRwx2a8TXePswqabsgmHl5kqvZ99NL0PKscHH+T0TYAJMwB4CotTjl3kL Z+Wstqd8+0tNuhGSgby4WPg6BbpRDKWXjWP8ygSYABOwicB6v/SdksxiRNyTVpCo8eR+o5PMpjDo /7PpIeRimQATSHICAuInvyZOLi7s+VOSo0ges+/wHS3FkLFl9wiJv4W/js8yASbABEwkIMU3fnhG FBd23WRirq7NKqlHSLt7TcglM3JuFVL8lR2y7qbC75gAE1BIQOKj+pSGXBaj3YyT1qhhN4I93w0Z WzpRSDwBwLPnGf7EBJgAEzCNwLuZtThn3rycGtNyTICMWJBCdOKQgo2jBASFBc4KcZoPMQEmwARi JiAgnivv0O1K2hcZcyYJeiMLUgsdS5FnRSAwTwBdWriEDzMBJsAEoiQg/7l4Zs4dgJBR3pgUl7Mg henm3Pz1fb3CO19C9g1zGZ9iAkyACbRGwC+Aq4pm5jzV2oXJfJ4FqZXezx1f2tnjxxwAJ7ZyKZ9m AkyACYQiUCGAsUUzcxaEOsnHdhNgK7vdLEK+K34tZ2tqZfVQ3kAbEg8fZAJMIDyBtQGPOIHFKDwk 4yyPkAwSEbzmFZTeCuBfyRS2IwIsfAkTYAIhCYjlnkZx1sI53X4LeZoP7kWABWkvJOEPDMkvPVsI vAigTfgr+SwTYAJJS0DI51N31vxp/vx+9UnLIIaGsyDFAG342E2DAlLOYWOHGODxLUwgsQn4hJA3 Fs3o/p/Ebqaa1rEgxcj1xAm/dEzzpbwK4NQYs+DbmAATSCACehwjTRQUTc8uTqBmWdoUNmqIEfey V/cv7ySzzwBwL7sbihEi38YEEoWAxOfw4WgWo/g6lEdI8fHT7x6aX3a6FPIFAPuYkB1nwQSYgIsI CCEeTdlZdROvF8XfaSxI8TPUcxiev6mXH4GZEDjKpCw5GybABJxNoAoCf1o8I+cVZ1fTPbXjKTuT +mphYbe1qVXVJ9KvJZ7CMwkqZ8MEnEtgpcePo1mMzO0gHiGZy1PPbejYjWdKKZ7hKTwFcDlLJmAz ASnk44GMhhuLn+9VZ3NVEq54FiRFXZo7ZkMPzau9LIBTFBXB2TIBJmAtge1SYuKSwhxyJcZJAQGe slMAlbIsnt1jwz4yOw8Q/wAku5lXxJmzZQIWEVgqJA5nMVJLm0dIavnquQ8bV3pUIACywhtgQXFc BBNgAuYRqBPAbScOyH548mQRMC9bzikUARakUFQUHDsuf31Gpua5FxLXsi88BYA5SyZgNgGJLwFx /uLC7FVmZ835hSbAghSai7KjeQVlgwXw5P+3d26xVVRRGP7/PbW2oqYgSIGC8a6QoPLgJYKppYL1 Fi+p0AcUrw/6oDHxHhU1XuOLiYmJqVGjRvAIGjERQ9GCGNEgmGgUlYhSORRRqQhKy5lZZho1EUs9 9zMz5386ObPXWnutb03yZ86Zvbe2HSoZYgUWgUIJDBj48K8NjQ/rVNdCUebmL0HKjVdRrAeflliz ALBbANQUJaiCiIAIFEzAgA/DPS8CAAAGbElEQVRovF5PRQWjzCuABCkvbMVxap275RQL2AlgWnEi KooIiECeBHYCvGvG5Man9V9RngSL4CZBKgLEQkI0N1uNO7z3ZsIeAFBfSCz5ioAI5EOAS/2Mf0P4 Zmw+3vIpHgEJUvFYFhSp9bJtRwUueIa0mQUFkrMIiEC2BLbB7KYVqQmLsnWQXWkJSJBKyzfH6MaW Oen5ND4OYHSOzjIXARHIjoABfK7GMre+k5r4S3YusioHAQlSOSjnOMfs9p5RGboHAV6vlx5yhCdz ERiewDrS3di1qHHN8GYarQQBCVIlqGc55+BLDz6fBDEjSxeZiYAIDE3gJxrunj5lXKdeWhgaUBSu SpCi0IVhczC2XJ6eS/BRAJOGNdWgCIjAvgQGQDzl9/c/2P3GkX37Dup7tAhIkKLVj/1mE65dGgHv JiNuB9CwX0MNiIAI/E3gdd/827pTEzf+fUGf0SYgQYp2f/6TXXNHerTn2/0ArgN4wH8MdEEEqpwA zT6C5+7QceLxuxEkSPHr2WDGze09x3j0FgDoAKBd22PaR6VdVAJfkLina9G41wFaUSMrWFkISJDK grl0k8zs6JmKjHsI5AWlm0WRRSDCBIjvYbxvlDW+lErRj3CmSu1/CEiQ/gdQXIZb5/SebkFwL4i2 uOSsPEWgQAKbaXx0JH55NpWaMlBgLLlHgIAEKQJNKGYKLe1bTyXtXgDnFzOuYolAhAh8R8MjI7Hj eQlRhLpShFQkSEWAGMUQ4aGAvo87SVys/5ii2CHllAeBrwh7fEfD+Bd1LEQe9GLgIkGKQZMKSfHs 9i3HO8dbYZgHoLaQWPIVgQoR+JjEY9NPHPeGFrVWqANlmlaCVCbQlZ7mnLmbxwfm3QzjdVrHVOlu aP4sCAQElhn4xIpXx72Xhb1MEkBAgpSAJuZSwqx5vSP8Absy3OUYwHG5+MpWBMpAYDdgL4D25IpF TV+XYT5NESECEqQINaOcqSxYYG7lF73nerAbDZgNwCvn/JpLBP5FgPjGjM8EA3s6tcXPv8hU1RcJ UlW1e+hiW9vTkwLyKsKu1n55QzPS1ZIQ6AfwGh07uxY2rtRi1pIwjlVQCVKs2lXaZMOnplVf9s5G YNeSdqG2Jiot7yqO/hkMz9bAf1HnEVXxXTBE6RKkIaDoEjCzY9tY8zPzSV4Dw7FiIgIFEtgF8FWY 37ki1fRhgbHknlACEqSENrZ4ZYXHX2ydQeIKGi41YGTxYitSwgkEAFbC7OXa2rrU2y8ftjPh9aq8 AglIkAoEWE3ubW3fHDhwyIg2g3UQvBBAfTXVr1qzJGBYC+AV52UWLl84KZ2ll8xEABIk3QR5ETjz ou2H1B04cB7Jyww4D8CIvALJKQkEDLCPaG4JAre4a/HYb5NQlGooPwEJUvmZJ27G8PDAg+iFr45f YkAbgTGJK1IF7UsgA9gqI5cGe4PXupc0/bCvgb6LQK4EJEi5EpP9sATa28372UufxoAXwHg+aFOH ddBgnAj8BGAZzN4i6pd1pUb9GqfklWv0CUiQot+jWGfYfOkPTTU1nGVw5xhspp6eYtXO8EiHNQSW +84tH+2PXavzhmLVv9glK0GKXcvim3C4zmn1hvRJ8F2rITgL5HTtqxepfvowrIfDKgZ8NwNvZXfq 8F2RylDJJJqABCnR7Y12ceHPezu89FTzeRaIGQBOBzAh2lknKrvdMKwDsdrI9/v/qFn9wZtjfktU hSomVgQkSLFqV/KTbWnvmeCcdxrMzjDwVAAnAzg0+ZWXvMKMARsIfGLAGs+CNXu3T/i8u5uZks+s CUQgSwISpCxByaxSBIwtc348CgimATaNNihQk7Xn3rD92EmzL438FMT6gO7Tg3cHny1dOv73Yb00 KAIVJiBBqnADNH1+BMJ1ULUH+ZO9wJ9ixAk0d7yZHQ3iaAB1+UWNlZcBCF+13mjARho2mLPPXcAN Xanxm2NViZIVgb8ISJB0KySMgHHW3HSTH7hjBp+iDEcYrYmGpsHvxMSY/AQY7oS9zYAewLYQDD83 G7DJkRsz9f2bup8/ck/CmqdyqpyABKnKb4BqLL95/qa62j21Y3xzjQDHGvwxNDfKYA2ObDCzBpg1 GFy9ozWY8QA4Oxhm9QT/efraz75+vxMIxQTh9gUg+mDYS2CXGcKxPwJaH8k+GnfArC8g+hyw3Rx+ 9PZie+DqtmqNTzXemar5T7boKrYfCqI6AAAAAElFTkSuQmCC"/></symbol><symbol viewBox="0 0 24 24" id="webpack" xmlns="http://www.w3.org/2000/svg"><path d="M19.376 15.988l-7.709 4.45-7.708-4.45V7.087l7.708-4.45 7.709 4.45z" fill="#fff" fill-opacity=".785" stroke-width="0"/><path d="M12.286 1.98c-.21 0-.41.059-.57.179l-7.9 4.44c-.32.17-.53.5-.53.88v9c0 .38.21.711.53.881l7.9 4.44c.16.12.36.18.57.18.21 0 .41-.06.57-.18l7.9-4.44c.32-.17.53-.5.53-.88v-9c0-.38-.21-.712-.53-.882l-7.9-4.44a.945.945 0 0 0-.57-.179zm0 2.15l7 3.939v2.104h-.016v5.177h.016v.54l-7 3.939-7-3.94V8.07l7-3.94zm0 2.08l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#8ed6fb"/><path d="M12.286 6.21l-4.9 2.83 4.9 2.83 4.9-2.83-4.9-2.83zm-5 5.08v3.58l4 2.308v-3.58l-4-2.308zm10 0l-4 2.308v3.58l4-2.308v-3.58z" fill="#1c78c0"/></symbol><symbol viewBox="0 0 24 24" id="wolframlanguage" xmlns="http://www.w3.org/2000/svg"><title>wolframLanguage</title><g transform="scale(.12121)" fill="none" fill-rule="evenodd"><circle cx="99.197" cy="98.946" r="83.28" fill="#212121" stroke-width=".841"/><path d="M182.529 98.828a83.406 83.406 0 0 1-39.14 70.721.064.064 0 0 1-.038.019l-28.62-35.665 23.71 2.612s11.385 1.177 13.978 0c2.373-.938 15.175-18.963 15.175-18.963s-36.75-23.23-49.312-36.032c1.434-21.575-1.656-50.269-1.656-50.03-9.251 9.234-10.429 10.669-19.68 19.203-4.028-13.04-5.923-17.547-9.95-30.588-12.104 9.95-21.337 26.799-27.977 46.48a78.68 78.68 0 0 0-4.23 5.094 109.774 109.774 0 0 0-2.667 3.66 114.558 114.558 0 0 0-5.132 8.002 172.555 172.555 0 0 0-3.403 6.051c-7.706 14.475-14.034 31.066-19.515 46.001a.858.858 0 0 1-.092-.184c-14.988-30.912-9.502-67.85 13.822-93.072 23.325-25.223 59.723-33.575 91.71-21.045 31.988 12.53 53.029 43.382 53.017 77.736z" fill="#e53935"/><path d="M101.452 69.178s-1.416-8.295-2.373-11.367c6.401-6.18 7.357-7.118 13.52-13.04.477 11.845.238 18.006-.479 32.481-3.55-3.568-10.668-8.074-10.668-8.074zm-27.737 40.778s-6.64-4.029-11.624-4.728c1.435-3.329 5.223-7.596 6.18-8.773-1.913.699-15.653 6.86-17.087 12.084a74.804 74.804 0 0 1 11.385 3.79 35.993 35.993 0 0 0-8.774 20.158s21.815-3.33 38.185-1.196c.283.168.609.251.938.24l8.534.239 27.111 45.136.221.35c-.037.018-.055.037-.073.037-51.133 18.485-88.085-15.543-95.976-27.443.034-.102.058-.206.074-.313 7.1-30.017 15.855-65.939 30-76.552 7.356-12.82 9.49-31.783 22.751-41.734 3.33 9.951 8.553 30.588 12.103 40.539 15.653 15.652 39.361 35.094 55.234 43.15 1.656.956 3.79 7.596 3.79 7.596l-6.401 8.056-68.276-6.879a54.462 54.462 0 0 0-4.58-.183 86.848 86.848 0 0 0-14.144 1.36c3.311-8.295 10.43-14.935 10.43-14.935zm22.054-8.774c3.789-.46 7.817.956 12.323 3.568 4.267-1.195 4.745-1.434 9.013-2.612-5.463-4.028-11.386-8.295-19.442-7.118a47.249 47.249 0 0 0-1.894 6.162z" fill="#fff" stroke-width=".936"/></g></symbol><symbol viewBox="0 0 24 24" id="word" xmlns="http://www.w3.org/2000/svg"><path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m7 1.5V9h5.5L13 3.5M7 13l1.5 7h2l1.5-3 1.5 3h2l1.5-7h1v-2h-4v2h1l-.9 4.2L13 15h-2l-1.1 2.2L9 13h1v-2H6v2h1z" fill="#01579b"/></symbol><symbol viewBox="0 0 24 24" id="xaml" xmlns="http://www.w3.org/2000/svg"><path d="M18.93 12l-3.47 6H8.54l-3.47-6 3.47-6h6.92l3.47 6m4.84 0l-4.04 7L18 18l3.46-6L18 6l1.73-1 4.04 7M.23 12l4.04-7L6 6l-3.46 6L6 18l-1.73 1-4.04-7z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 24 24" id="xml" xmlns="http://www.w3.org/2000/svg"><path d="M13 9h5.5L13 3.5V9M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5l3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41-3.74 3.74m11.16 0l-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41 3.74-3.74z" fill="#8bc34a"/></symbol><symbol viewBox="0 0 24 24" id="yaml" xmlns="http://www.w3.org/2000/svg"><path d="M5 3h2v2H5v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3h2m-7 12a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1 1 1 0 0 1-1 1 1 1 0 0 1-1-1 1 1 0 0 1 1-1z" fill="#f44336"/></symbol><symbol viewBox="0 0 24 24" id="yang" xmlns="http://www.w3.org/2000/svg"><path d="M12 2a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2m0 2a8 8 0 0 0-8 8 8 8 0 0 0 8 8 4 4 0 0 1-4-4 4 4 0 0 1 4-4 4 4 0 0 0 4-4 4 4 0 0 0-4-4m0 2.5A1.5 1.5 0 0 1 13.5 8 1.5 1.5 0 0 1 12 9.5 1.5 1.5 0 0 1 10.5 8 1.5 1.5 0 0 1 12 6.5m0 8a1.5 1.5 0 0 0-1.5 1.5 1.5 1.5 0 0 0 1.5 1.5 1.5 1.5 0 0 0 1.5-1.5 1.5 1.5 0 0 0-1.5-1.5z" fill="#42a5f5"/></symbol><symbol viewBox="0 0 289.99999 290.00001" id="yarn" xmlns="http://www.w3.org/2000/svg"><path d="M250.733 218.418c-12.39 2.943-18.661 5.653-33.993 15.641-24.004 15.487-50.176 22.688-50.176 22.688s-2.168 3.252-8.44 4.723c-10.84 2.633-51.647 4.878-55.364 4.956-9.988.077-16.105-2.555-17.809-6.66-5.188-12.388 7.434-17.809 7.434-17.809s-2.788-1.703-4.414-3.252c-1.471-1.47-3.02-4.413-3.484-3.33-1.936 4.724-2.943 16.261-8.13 21.45-7.125 7.2-20.598 4.8-28.573.619-8.75-4.646.62-15.564.62-15.564s-4.724 2.788-8.518-2.942c-3.407-5.266-6.582-14.248-5.73-25.32 1.084-12.777 15.176-25.011 15.176-25.011s-2.477-18.661 5.653-37.787c7.356-17.422 27.179-31.437 27.179-31.437s-16.648-18.352-10.454-35c4.027-10.84 5.653-10.763 6.97-11.227 4.645-1.781 9.136-3.717 12.466-7.356 16.648-17.964 37.864-14.557 37.864-14.557s9.911-30.431 19.203-24.469c2.865 1.859 13.163 24.778 13.163 24.778s10.996-6.426 12.235-4.026c6.659 12.931 7.433 37.632 4.49 52.654-4.955 24.778-17.344 38.096-22.3 46.459-1.161 1.936 13.319 8.053 22.456 33.373 8.44 23.152.929 42.587 2.245 44.756.232.387.31.542.31.542s9.679.774 29.114-11.228c10.376-6.427 22.688-13.628 36.703-13.783 13.55-.232 14.247 15.719 4.104 18.12z" fill="#2c8ebb" stroke-width=".774"/></symbol><symbol viewBox="0 0 24 24" id="zip" xmlns="http://www.w3.org/2000/svg"><path d="M14 17h-2v-2h-2v-2h2v2h2m0-6h-2v2h2v2h-2v-2h-2V9h2V7h-2V5h2v2h2m5-4H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z" fill="#afb42b"/></symbol></svg> diff --git a/app/assets/images/select2-spinner.gif b/app/assets/images/select2-spinner.gif Binary files differdeleted file mode 100644 index 5b33f7e54f4..00000000000 --- a/app/assets/images/select2-spinner.gif +++ /dev/null diff --git a/app/assets/images/select2.png b/app/assets/images/select2.png Binary files differdeleted file mode 100644 index 1d804ffb996..00000000000 --- a/app/assets/images/select2.png +++ /dev/null diff --git a/app/assets/images/select2x2.png b/app/assets/images/select2x2.png Binary files differdeleted file mode 100644 index 4bdd5c961d4..00000000000 --- a/app/assets/images/select2x2.png +++ /dev/null diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue index c716afbbcf0..4a7c12e5e51 100644 --- a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue +++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue @@ -18,14 +18,17 @@ export default { reportAbusePath: { default: '', }, + }, + props: { reportedUserId: { - default: '', + type: Number, + required: true, }, reportedFromUrl: { + type: String, + required: false, default: '', }, - }, - props: { showDrawer: { type: Boolean, required: true, @@ -39,8 +42,8 @@ export default { }, categoryOptions: [ { value: 'spam', text: s__("ReportAbuse|They're posting spam.") }, - { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") }, - { value: 'phishing', text: s__("ReportAbuse|They're phising.") }, + { value: 'offensive', text: s__("ReportAbuse|They're being offensive or abusive.") }, + { value: 'phishing', text: s__("ReportAbuse|They're phishing.") }, { value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") }, { value: 'credentials', @@ -53,13 +56,22 @@ export default { data() { return { selected: '', + mounted: false, }; }, computed: { drawerOffsetTop() { + // avoid calculating this in advance because it causes layout thrashing + // https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 + if (!this.showDrawer) return '0'; return getContentWrapperHeight('.content-wrapper'); }, }, + mounted() { + // this is required for the component to properly animate + // when it is shown with v-if + this.mounted = true; + }, methods: { closeDrawer() { this.$emit('close-drawer'); @@ -71,7 +83,7 @@ export default { <gl-drawer :header-height="drawerOffsetTop" :z-index="300" - :open="showDrawer" + :open="showDrawer && mounted" @close="closeDrawer" > <template #title> @@ -94,7 +106,7 @@ export default { data-testid="input-referer" /> - <gl-form-group :label="$options.i18n.label"> + <gl-form-group :label="$options.i18n.label" label-class="gl-text-black-normal"> <gl-form-radio-group v-model="selected" :options="$options.categoryOptions" diff --git a/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue new file mode 100644 index 00000000000..02fe131553c --- /dev/null +++ b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue @@ -0,0 +1,68 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'LinksToSpamInput', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + i18n: { + label: s__('ReportAbuse|Link to spam'), + description: s__('ReportAbuse|URL of this user posting spam'), + addAnotherText: s__('ReportAbuse|Add another link'), + }, + props: { + previousLinks: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + links: this.previousLinks.length > 0 ? this.previousLinks : [''], + }; + }, + methods: { + addAnotherInput() { + this.links.push(''); + }, + }, +}; +</script> +<template> + <div> + <template v-for="(link, index) in links"> + <div :key="index" class="row"> + <div class="col-lg-8"> + <gl-form-group class="gl-mt-5"> + <template #label> + <div class="gl-pb-2"> + {{ $options.i18n.label }} + </div> + <div class="gl-font-weight-normal"> + {{ $options.i18n.description }} + </div> + </template> + <gl-form-input + v-model.trim="links[index]" + type="url" + name="abuse_report[links_to_spam][]" + autocomplete="off" + /> + </gl-form-group> + </div> + </div> + </template> + <div class="row"> + <div class="col-lg-8"> + <gl-button variant="link" icon="plus" class="gl-float-right" @click="addAnotherInput"> + {{ $options.i18n.addAnotherText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/abuse_reports/index.js b/app/assets/javascripts/abuse_reports/index.js new file mode 100644 index 00000000000..fff4ad8daa0 --- /dev/null +++ b/app/assets/javascripts/abuse_reports/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import LinksToSpamInput from './components/links_to_spam_input.vue'; + +export const initLinkToSpam = () => { + const el = document.getElementById('js-links-to-spam'); + + if (!el) return false; + + const { links } = el.dataset; + + return new Vue({ + el, + name: 'LinksToSpamRoot', + render(createElement) { + return createElement(LinksToSpamInput, { + props: { + previousLinks: JSON.parse(links), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 7cc4a0d349d..8e335dbda32 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -42,7 +42,7 @@ export default { <gl-collapsible-listbox v-model="selected" :items="databases" - right + placement="right" :toggle-text="selectedDatabase" toggle-aria-labelled-by="label" @select="selectDatabase" diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue index 8bf5be1afd1..9f42aa27097 100644 --- a/app/assets/javascripts/admin/topics/components/topic_select.vue +++ b/app/assets/javascripts/admin/topics/components/topic_select.vue @@ -1,22 +1,14 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__, n__ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; export default { components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { selectedTopic: { @@ -48,15 +40,13 @@ export default { return { topics: [], search: '', + selected: null, }; }, computed: { loading() { return this.$apollo.queries.topics.loading; }, - isResultEmpty() { - return this.topics.length === 0; - }, dropdownText() { if (Object.keys(this.selectedTopic).length) { return this.selectedTopic.name; @@ -64,10 +54,35 @@ export default { return this.$options.i18n.dropdownText; }, + items() { + return this.topics.map(({ id, title, name, avatarUrl }) => ({ + value: id, + text: title, + secondaryText: name, + icon: avatarUrl, + })); + }, + searchSummary() { + return n__('TopicSelect|%d topic found', 'TopicSelect|%d topics found', this.topics.length); + }, + labelId() { + if (!this.labelText) { + return null; + } + + return uniqueId('topic-listbox-label-'); + }, }, methods: { - selectTopic(topic) { - this.$emit('click', topic); + onSelect(topicId) { + const topicObj = this.topics.find((topic) => topic.id === topicId); + + if (!topicObj) return; + + this.$emit('click', topicObj); + }, + onSearch(query) { + this.search = query; }, }, i18n: { @@ -81,26 +96,34 @@ export default { <template> <div> - <label v-if="labelText">{{ labelText }}</label> - <gl-dropdown block :text="dropdownText"> - <gl-search-box-by-type - v-model="search" - :is-loading="loading" - :placeholder="$options.i18n.searchPlaceholder" - /> - <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)"> + <label v-if="labelText" :id="labelId">{{ labelText }}</label> + <gl-collapsible-listbox + v-model="selected" + block + searchable + is-check-centered + :items="items" + :toggle-text="dropdownText" + :searching="loading" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + :toggle-aria-labelled-by="labelId" + @select="onSelect" + @search="onSearch" + > + <template #list-item="{ item: { text, secondaryText, icon } }"> <gl-avatar-labeled - :label="topic.title" - :sub-label="topic.name" - :src="topic.avatarUrl" - :entity-name="topic.name" + :label="text" + :sub-label="secondaryText" + :src="icon" + :entity-name="secondaryText" :size="32" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> - </gl-dropdown-item> - <gl-dropdown-text v-if="isResultEmpty && !loading"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> + </template> + <template #search-summary-sr-only> + {{ searchSummary }} + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/airflow/dags/components/dags.vue b/app/assets/javascripts/airflow/dags/components/dags.vue new file mode 100644 index 00000000000..88eb3fd5aba --- /dev/null +++ b/app/assets/javascripts/airflow/dags/components/dags.vue @@ -0,0 +1,111 @@ +<script> +import { GlTableLite, GlEmptyState, GlPagination, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; + +export default { + name: 'AirflowDags', + components: { + GlTableLite, + GlEmptyState, + IncubationAlert, + GlPagination, + TimeAgo, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + dags: { + type: Array, + required: true, + }, + pagination: { + type: Object, + required: true, + }, + }, + computed: { + fields() { + return [ + { key: 'dag_name', label: this.$options.i18n.dagLabel }, + { key: 'schedule', label: this.$options.scheduleLabel }, + { key: 'next_run', label: this.$options.nextRunLabel }, + { key: 'is_active', label: this.$options.isActiveLabel }, + { key: 'is_paused', label: this.$options.isPausedLabel }, + { key: 'fileloc', label: this.$options.fileLocLabel }, + ]; + }, + hasPagination() { + return this.dags.length > 0; + }, + prevPage() { + return this.pagination.page > 1 ? this.pagination.page - 1 : null; + }, + nextPage() { + return !this.pagination.isLastPage ? this.pagination.page + 1 : null; + }, + emptyState() { + return { + svgPath: '/assets/illustrations/empty-state/empty-dag-md.svg', + }; + }, + }, + methods: { + generateLink(page) { + return setUrlParams({ page }); + }, + formatDate(dateString) { + return formatDate(new Date(dateString)); + }, + }, + i18n: { + emptyStateLabel: s__('Airflow|There are no DAGs to show'), + emptyStateDescription: s__( + 'Airflow|Either the Airflow instance does not contain DAGs or has yet to be configured', + ), + dagLabel: s__('Airflow|DAG'), + scheduleLabel: s__('Airflow|Schedule'), + nextRunLabel: s__('Airflow|Next run'), + isActiveLabel: s__('Airflow|Is active'), + isPausedLabel: s__('Airflow|Is paused'), + fileLocLabel: s__('Airflow|DAG file location'), + featureName: s__('Airflow|GitLab Airflow integration'), + }, + linkToFeedbackIssue: + 'https://gitlab.com/gitlab-org/incubation-engineering/airflow/meta/-/issues/2', +}; +</script> + +<template> + <div> + <incubation-alert + :feature-name="$options.i18n.featureName" + :link-to-feedback-issue="$options.linkToFeedbackIssue" + /> + <gl-empty-state + v-if="!dags.length" + :title="$options.i18n.emptyStateLabel" + :description="$options.i18n.emptyStateDescription" + :svg-path="emptyState.svgPath" + /> + <gl-table-lite v-else :items="dags" :fields="fields" class="gl-mt-0!"> + <template #cell(next_run)="data"> + <time-ago v-gl-tooltip.hover :time="data.value" :title="formatDate(data.value)" /> + </template> + </gl-table-lite> + <gl-pagination + v-if="hasPagination" + :value="pagination.page" + :prev-page="prevPage" + :next-page="nextPage" + :total-items="pagination.totalItems" + :per-page="pagination.perPage" + :link-gen="generateLink" + align="center" + /> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index a0d5cb7f4c3..38bcdef3e04 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -119,7 +119,10 @@ export default { </gl-form-group> <gl-form-group class="gl-pl-0 gl-mb-5"> - <gl-form-checkbox v-model="sendEmailEnabled"> + <gl-form-checkbox + v-model="sendEmailEnabled" + data-qa-selector="enable_email_notification_checkbox" + > <span>{{ $options.i18n.sendEmail.label }}</span> </gl-form-checkbox> </gl-form-group> diff --git a/app/assets/javascripts/analytics/shared/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue index 8d90e7b2392..373a7fac6f7 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_popover.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue @@ -1,5 +1,6 @@ <script> import { GlPopover, GlLink, GlIcon } from '@gitlab/ui'; +import { METRIC_POPOVER_LABEL } from '../constants'; export default { name: 'MetricPopover', @@ -19,34 +20,34 @@ export default { }, }, computed: { - metricLinks() { - return this.metric.links?.filter((link) => !link.docs_link) || []; + metricLink() { + return this.metric.links?.find((link) => !link.docs_link); }, docsLink() { return this.metric.links?.find((link) => link.docs_link); }, }, + metricPopoverLabel: METRIC_POPOVER_LABEL, }; </script> <template> - <gl-popover :target="target" placement="bottom"> + <gl-popover :target="target" placement="top"> <template #title> - <span class="gl-display-block gl-text-left" data-testid="metric-label">{{ - metric.label - }}</span> + <div + class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1 gl-align-items-center" + > + <span data-testid="metric-label">{{ metric.label }}</span> + <gl-link + v-if="metricLink" + :href="metricLink.url" + class="gl-font-sm gl-font-weight-normal" + data-testid="metric-link" + >{{ $options.metricPopoverLabel }} + <gl-icon name="chart" /> + </gl-link> + </div> </template> - <div - v-for="(link, idx) in metricLinks" - :key="`link-${idx}`" - class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1" - data-testid="metric-link" - > - <span>{{ link.label }}</span> - <gl-link :href="link.url" class="gl-font-sm"> - {{ link.name }} - </gl-link> - </div> <span v-if="metric.description" data-testid="metric-description">{{ metric.description }}</span> <gl-link v-if="docsLink" diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index c62736d55a8..7ced658f483 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,5 +1,6 @@ import { masks } from '~/lib/dateformat'; import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const DATE_RANGE_LIMIT = 180; export const PROJECTS_PER_PAGE = 50; @@ -12,8 +13,103 @@ export const dateFormats = { month: 'mmmm', }; -// Some content is duplicated due to backward compatibility. -// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9 +export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); + +export const KEY_METRICS = { + LEAD_TIME: 'lead_time', + CYCLE_TIME: 'cycle_time', + ISSUES: 'issues', + COMMITS: 'commits', + DEPLOYS: 'deploys', +}; + +export const DORA_METRICS = { + DEPLOYMENT_FREQUENCY: 'deployment_frequency', + LEAD_TIME_FOR_CHANGES: 'lead_time_for_changes', + TIME_TO_RESTORE_SERVICE: 'time_to_restore_service', + CHANGE_FAILURE_RATE: 'change_failure_rate', +}; + +export const VSA_METRICS_GROUPS = [ + { + key: 'key_metrics', + title: s__('ValueStreamAnalytics|Key metrics'), + keys: Object.values(KEY_METRICS), + }, + { + key: 'dora_metrics', + title: s__('ValueStreamAnalytics|DORA metrics'), + keys: Object.values(DORA_METRICS), + }, +]; + +export const METRIC_TOOLTIPS = { + [DORA_METRICS.DEPLOYMENT_FREQUENCY]: { + description: s__( + 'ValueStreamAnalytics|Average number of deployments to production per day. This metric measures how often value is delivered to end users.', + ), + groupLink: '-/analytics/ci_cd?tab=deployment-frequency', + projectLink: '-/pipelines/charts?chart=deployment-frequency', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'deployment-frequency' }), + }, + [DORA_METRICS.LEAD_TIME_FOR_CHANGES]: { + description: s__( + 'ValueStreamAnalytics|The time to successfully deliver a commit into production. This metric reflects the efficiency of CI/CD pipelines.', + ), + groupLink: '-/analytics/ci_cd?tab=lead-time', + projectLink: '-/pipelines/charts?chart=lead-time', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'lead-time-for-changes' }), + }, + [DORA_METRICS.TIME_TO_RESTORE_SERVICE]: { + description: s__( + 'ValueStreamAnalytics|The time it takes an organization to recover from a failure in production.', + ), + groupLink: '-/analytics/ci_cd?tab=time-to-restore-service', + projectLink: '-/pipelines/charts?chart=time-to-restore-service', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'time-to-restore-service' }), + }, + [DORA_METRICS.CHANGE_FAILURE_RATE]: { + description: s__( + 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.', + ), + groupLink: '-/analytics/ci_cd?tab=change-failure-rate', + projectLink: '-/pipelines/charts?chart=change-failure-rate', + docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }), + }, + [KEY_METRICS.LEAD_TIME]: { + description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_stream_analytics', { + anchor: 'view-the-lead-time-and-cycle-time-for-issues', + }), + }, + [KEY_METRICS.CYCLE_TIME]: { + description: s__( + "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", + ), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_stream_analytics', { + anchor: 'view-the-lead-time-and-cycle-time-for-issues', + }), + }, + [KEY_METRICS.ISSUES]: { + description: s__('ValueStreamAnalytics|Number of new issues created.'), + groupLink: '-/issues_analytics', + projectLink: '-/analytics/issues_analytics', + docsLink: helpPagePath('user/analytics/issue_analytics'), + }, + [KEY_METRICS.DEPLOYS]: { + description: s__('ValueStreamAnalytics|Total number of deploys to production.'), + groupLink: '-/analytics/productivity_analytics', + projectLink: '-/analytics/merge_request_analytics', + docsLink: helpPagePath('user/analytics/merge_request_analytics'), + }, +}; + +// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete +// https://gitlab.com/gitlab-org/gitlab/-/issues/388067 export const METRICS_POPOVER_CONTENT = { lead_time: { description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), @@ -47,19 +143,3 @@ export const METRICS_POPOVER_CONTENT = { ), }, }; - -const KEY_METRICS_TITLE = s__('ValueStreamAnalytics|Key metrics'); -const KEY_METRICS_KEYS = ['lead_time', 'cycle_time', 'issues', 'commits', 'deploys']; - -const DORA_METRICS_TITLE = s__('ValueStreamAnalytics|DORA metrics'); -const DORA_METRICS_KEYS = [ - 'deployment_frequency', - 'lead_time_for_changes', - 'time_to_restore_service', - 'change_failure_rate', -]; - -export const VSA_METRICS_GROUPS = [ - { key: 'key_metrics', title: KEY_METRICS_TITLE, keys: KEY_METRICS_KEYS }, - { key: 'dora_metrics', title: DORA_METRICS_TITLE, keys: DORA_METRICS_KEYS }, -]; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 71b719d1ed2..aafbf642766 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,5 +1,4 @@ import { flatten } from 'lodash'; -import { hideFlash } from '~/flash'; import dateFormat from '~/lib/dateformat'; import { slugify } from '~/lib/utils/text_utility'; import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -74,10 +73,8 @@ export const getDataZoomOption = ({ }; export const removeFlash = (type = 'alert') => { - const flashEl = document.querySelector(`.flash-${type}`); - if (flashEl) { - hideFlash(flashEl); - } + // flash-warning don't have dismiss button. + document.querySelector(`.flash-${type} .js-close`)?.click(); }; /** diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js new file mode 100644 index 00000000000..9912b1ab696 --- /dev/null +++ b/app/assets/javascripts/api/environments_api.js @@ -0,0 +1,15 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +export const STOP_STALE_ENVIRONMENTS_PATH = '/api/:version/projects/:id/environments/stop_stale'; + +export function stopStaleEnvironments(projectId, before, query, options) { + const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH).replace(':id', projectId); + const defaults = { + before: before.toISOString(), + }; + + return axios.post(url, null, { + params: Object.assign(defaults, options), + }); +} diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index e859160c2e7..1b216e6f721 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -4,6 +4,8 @@ import { buildApiUrl } from './api_utils'; const GROUP_PATH = '/api/:version/groups/:id'; const GROUPS_PATH = '/api/:version/groups.json'; +const GROUP_MEMBERS_PATH = '/api/:version/groups/:id/members'; +const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all'; const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations'; @@ -45,3 +47,10 @@ export const getGroupTransferLocations = (groupId, params = {}) => { return axios.get(url, { params: { ...defaultParams, ...params } }); }; + +export const getGroupMembers = (groupId, inherited = false) => { + const path = inherited ? GROUP_ALL_MEMBERS_PATH : GROUP_MEMBERS_PATH; + const url = buildApiUrl(path).replace(':id', groupId); + + return axios.get(url); +}; diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index 5b5abbdf50b..5c0d101ef5b 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -3,6 +3,8 @@ import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; const PROJECTS_PATH = '/api/:version/projects.json'; +const PROJECT_MEMBERS_PATH = '/api/:version/projects/:id/members'; +const PROJECT_ALL_MEMBERS_PATH = '/api/:version/projects/:id/members/all'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size'; const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations'; @@ -19,6 +21,10 @@ export function getProjects(query, options, callback = () => {}) { defaults.membership = true; } + if (gon.features.fullPathProjectSearch && query?.includes('/')) { + defaults.search_namespaces = true; + } + return axios .get(url, { params: Object.assign(defaults, options), @@ -50,3 +56,10 @@ export const getTransferLocations = (projectId, params = {}) => { return axios.get(url, { params: { ...defaultParams, ...params } }); }; + +export const getProjectMembers = (projectId, inherited = false) => { + const path = inherited ? PROJECT_ALL_MEMBERS_PATH : PROJECT_MEMBERS_PATH; + const url = buildApiUrl(path).replace(':id', projectId); + + return axios.get(url); +}; diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/artifacts/components/app.vue new file mode 100644 index 00000000000..3a07be65341 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/app.vue @@ -0,0 +1,65 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import getBuildArtifactsSizeQuery from '../graphql/queries/get_build_artifacts_size.query.graphql'; +import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '../constants'; +import JobArtifactsTable from './job_artifacts_table.vue'; + +export default { + name: 'ArtifactsApp', + components: { + GlSkeletonLoader, + JobArtifactsTable, + }, + inject: ['projectPath'], + apollo: { + buildArtifactsSize: { + query: getBuildArtifactsSizeQuery, + variables() { + return { projectPath: this.projectPath }; + }, + update({ + project: { + statistics: { buildArtifactsSize }, + }, + }) { + return buildArtifactsSize; + }, + }, + }, + data() { + return { + buildArtifactsSize: null, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.buildArtifactsSize.loading; + }, + humanReadableArtifactsSize() { + return numberToHumanSize(this.buildArtifactsSize); + }, + }, + i18n: { + PAGE_TITLE, + TOTAL_ARTIFACTS_SIZE, + SIZE_UNKNOWN, + }, +}; +</script> +<template> + <div> + <h1 class="page-title gl-font-size-h-display gl-mb-0" data-testid="artifacts-page-title"> + {{ $options.i18n.PAGE_TITLE }} + </h1> + <div class="gl-mb-6" data-testid="build-artifacts-size"> + <gl-skeleton-loader v-if="isLoading" :lines="1" /> + <template v-else> + <strong>{{ $options.i18n.TOTAL_ARTIFACTS_SIZE }}</strong> + <span v-if="buildArtifactsSize !== null">{{ humanReadableArtifactsSize }}</span> + <span v-else>{{ $options.i18n.SIZE_UNKNOWN }}</span> + </template> + </div> + <job-artifacts-table /> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js index 28fd81fa641..da562b03bf8 100644 --- a/app/assets/javascripts/artifacts/constants.js +++ b/app/assets/javascripts/artifacts/constants.js @@ -1,5 +1,9 @@ import { __, s__, n__, sprintf } from '~/locale'; +export const PAGE_TITLE = s__('Artifacts|Artifacts'); +export const TOTAL_ARTIFACTS_SIZE = s__('Artifacts|Total artifacts size'); +export const SIZE_UNKNOWN = __('Unknown'); + export const JOB_STATUS_GROUP_SUCCESS = 'success'; export const STATUS_BADGE_VARIANTS = { diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql new file mode 100644 index 00000000000..23da65ad0bb --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql @@ -0,0 +1,8 @@ +query getBuildArtifactsSize($projectPath: ID!) { + project(fullPath: $projectPath) { + id + statistics { + buildArtifactsSize + } + } +} diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql index 89a24d7891e..5737f9f8e8d 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -10,13 +10,12 @@ query getJobArtifacts( project(fullPath: $projectPath) { id jobs( - statuses: [SUCCESS, FAILED] + withArtifacts: true first: $firstPageSize last: $lastPageSize after: $nextPageCursor before: $prevPageCursor ) { - count nodes { id name diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js index e0b2ab2bf47..a62b3daa961 100644 --- a/app/assets/javascripts/artifacts/index.js +++ b/app/assets/javascripts/artifacts/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JobArtifactsTable from './components/job_artifacts_table.vue'; +import App from './components/app.vue'; Vue.use(VueApollo); @@ -27,6 +27,6 @@ export const initArtifactsTable = () => { canDestroyArtifacts: parseBoolean(canDestroyArtifacts), artifactsManagementFeedbackImagePath, }, - render: (createElement) => createElement(JobArtifactsTable), + render: (createElement) => createElement(App), }); }; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 5bb310afac7..cc524c71c1e 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,22 +1,17 @@ <script> -import { GlButton, GlBadge } from '@gitlab/ui'; +import { GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableNote from '~/notes/components/noteable_note.vue'; -import PublishButton from './publish_button.vue'; export default { components: { NoteableNote, - PublishButton, - GlButton, GlBadge, }, directives: { SafeHtml, }, - mixins: [glFeatureFlagMixin()], props: { draft: { type: Object, @@ -89,8 +84,7 @@ export default { :note="draft" :line="line" :discussion-root="true" - :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" - class="draft-note-component draft-note" + class="draft-note-component draft-note gl-mb-0!" @handleEdit="handleEditing" @cancelForm="handleNotEditing" @updateSuccess="handleNotEditing" @@ -109,23 +103,6 @@ export default { v-safe-html:[$options.safeHtmlConfig]="draftCommands" class="referenced-commands draft-note-commands" ></div> - - <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex"> - <publish-button - :show-count="true" - :should-publish="false" - category="secondary" - :disabled="isPublishingDraft(draft.id)" - /> - <gl-button - :disabled="isPublishing" - :loading="isPublishingDraft(draft.id)" - class="gl-ml-3" - @click="publishNow" - > - {{ __('Add comment now') }} - </gl-button> - </p> </template> </noteable-note> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index ba5cc0d1a76..4ac0c8c4894 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,31 +1,37 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlIcon, GlDisclosureDropdown, GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import PreviewItem from './preview_item.vue'; import DraftsCount from './drafts_count.vue'; export default { components: { - GlDropdown, - GlDropdownItem, GlIcon, PreviewItem, DraftsCount, + GlDisclosureDropdown, + GlButton, }, - mixins: [glFeatureFlagMixin()], computed: { ...mapState('diffs', ['viewDiffsFileByFile']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), ...mapGetters(['getNoteableData']), + listItems() { + const sortedDraftCount = this.sortedDrafts.length - 1; + return this.sortedDrafts.map((item, index) => ({ + text: item.id.toString(), + action: () => { + this.onClickDraft(item); + }, + last: index === sortedDraftCount, + ...item, + })); + }, }, methods: { ...mapActions('diffs', ['setCurrentFileHash']), ...mapActions('batchComments', ['scrollToDraft']), - isLast(index) { - return index === this.sortedDrafts.length - 1; - }, isOnLatestDiff(draft) { return draft.position?.head_sha === this.getNoteableData.diff_head_sha; }, @@ -47,23 +53,23 @@ export default { </script> <template> - <gl-dropdown - :header-text="n__('%d pending comment', '%d pending comments', draftsCount)" - dropup - data-qa-selector="review_preview_dropdown" - > - <template #button-content> - {{ __('Pending comments') }} - <drafts-count v-if="glFeatures.mrReviewSubmitComment" variant="neutral" /> - <gl-icon class="dropdown-chevron" name="chevron-up" /> + <gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown"> + <template #toggle> + <gl-button + >{{ __('Pending comments') }} <drafts-count variant="neutral" /><gl-icon + class="dropdown-chevron" + name="chevron-up" + /></gl-button> + </template> + + <template #header> + <p class="gl-dropdown-header-top"> + {{ n__('%d pending comment', '%d pending comments', draftsCount) }} + </p> + </template> + + <template #list-item="{ item }"> + <preview-item :draft="item" :is-last="item.last" @click="onClickDraft(item)" /> </template> - <gl-dropdown-item - v-for="(draft, index) in sortedDrafts" - :key="draft.id" - data-testid="preview-item" - @click="onClickDraft(draft)" - > - <preview-item :draft="draft" :is-last="isLast(index)" /> - </gl-dropdown-item> - </gl-dropdown> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue deleted file mode 100644 index d4fc4ad744a..00000000000 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ /dev/null @@ -1,52 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import DraftsCount from './drafts_count.vue'; - -export default { - components: { - GlButton, - DraftsCount, - }, - props: { - showCount: { - type: Boolean, - required: false, - default: false, - }, - category: { - type: String, - required: false, - default: 'primary', - }, - variant: { - type: String, - required: false, - default: 'confirm', - }, - }, - computed: { - ...mapState('batchComments', ['isPublishing']), - }, - methods: { - ...mapActions('batchComments', ['publishReview']), - onClick() { - this.publishReview(); - }, - }, -}; -</script> - -<template> - <gl-button - :loading="isPublishing" - class="js-publish-draft-button" - data-qa-selector="submit_review_button" - :category="category" - :variant="variant" - @click="onClick" - > - {{ __('Submit review') }} - <drafts-count v-if="showCount" /> - </gl-button> -</template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 3cd1a2525e9..798ab301c90 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -3,13 +3,11 @@ import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants'; import PreviewDropdown from './preview_dropdown.vue'; -import PublishButton from './publish_button.vue'; import SubmitDropdown from './submit_dropdown.vue'; export default { components: { PreviewDropdown, - PublishButton, SubmitDropdown, }, mixins: [glFeatureFlagMixin()], @@ -42,8 +40,7 @@ export default { data-qa-selector="review_bar_content" > <preview-dropdown /> - <publish-button v-if="!glFeatures.mrReviewSubmitComment" class="gl-ml-3" show-count /> - <submit-dropdown v-else /> + <submit-dropdown /> </div> </nav> </div> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index 0c81ae63f21..970864eef74 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -8,7 +8,7 @@ class CopyCodeButton extends HTMLElement { this.for = uniqueId('code-'); const target = this.parentNode.querySelector('pre'); - if (!target) return; + if (!target || this.closest('.suggestions')) return; target.setAttribute('id', this.for); diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 220064e6673..1d36661ee63 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -7,7 +7,6 @@ import { loadStartupCSS } from './load_startup_css'; import initCopyAsGFM from './markdown/copy_as_gfm'; import './quick_submit'; import './requires_input'; -import initSelect2Dropdowns from './select2'; import initPageShortcuts from './shortcuts'; import './toggler_behavior'; import './preview_markdown'; @@ -21,7 +20,6 @@ initCopyToClipboard(); initPageShortcuts(); initCollapseSidebarOnWindowResize(); -initSelect2Dropdowns(); window.requestIdleCallback( () => { diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index 7852a909160..b1bf6ebcb13 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -1,17 +1,19 @@ +import { escape } from 'lodash'; import { spriteIcon } from '~/lib/utils/common_utils'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; import { unrestrictedPages } from './constants'; -// Renders math using KaTeX in any element with the -// `js-render-math` class +// Renders math using KaTeX in an element. // -// ### Example Markup -// -// <code class="js-render-math"></div> +// Typically for elements with the `js-render-math` class such as +// <code class="js-render-math"></code> // +// See app/assets/javascripts/behaviors/markdown/render_gfm.js const MAX_MATH_CHARS = 1000; +const MAX_MACRO_EXPANSIONS = 1000; +const MAX_USER_SPECIFIED_EMS = 20; const MAX_RENDER_TIME_MS = 2000; // Wait for the browser to reflow the layout. Reflowing SVG takes time. @@ -69,17 +71,28 @@ class SafeMathRenderer { codeElement.className = 'code'; codeElement.textContent = el.textContent; + codeElement.dataset.mathStyle = el.dataset.mathStyle; const { parentNode } = el; parentNode.replaceChild(wrapperElement, el); + let message; + if (text.length > MAX_MATH_CHARS) { + message = sprintf( + s__( + 'math|This math block exceeds %{maxMathChars} characters, and may cause performance issues on this page.', + ), + { maxMathChars: MAX_MATH_CHARS }, + ); + } else { + message = s__('math|Displaying this math block may cause performance issues on this page.'); + } + const html = ` <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert"> - ${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')} + ${spriteIcon('warning', 'gl-text-orange-600 s16 gl-alert-icon')} <div class="display-flex gl-alert-content"> - <div>${s__( - 'math|Displaying this math block may cause performance issues on this page', - )}</div> + <div>${message}</div> <div class="gl-alert-actions"> <button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button> </div> @@ -116,8 +129,10 @@ class SafeMathRenderer { displayContainer.innerHTML = this.katex.renderToString(text, { displayMode: el.dataset.mathStyle === 'display', throwOnError: true, - maxSize: 20, - maxExpand: 20, + maxSize: MAX_USER_SPECIFIED_EMS, + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111107 for + // reasoning behind this value + maxExpand: MAX_MACRO_EXPANSIONS, trust: (context) => // this config option restores the KaTeX pre-v0.11.0 // behavior of allowing certain commands and protocols @@ -127,8 +142,17 @@ class SafeMathRenderer { }); } catch (e) { // Don't show a flash for now because it would override an existing flash message - el.textContent = s__('math|There was an error rendering this math block'); - // el.style.color = '#d00'; + if (e.message.match(/Too many expansions/)) { + // this is controlled by the maxExpand parameter + el.textContent = s__('math|Too many expansions. Consider using multiple math blocks.'); + } else { + // According to https://katex.org/docs/error.html, we need to ensure that + // the error message is escaped. + el.textContent = sprintf( + s__('math|There was an error rendering this math block. %{katexMessage}'), + { katexMessage: escape(e.message) }, + ); + } el.className = 'katex-error'; } diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js deleted file mode 100644 index 1f222d8c1f6..00000000000 --- a/app/assets/javascripts/behaviors/select2.js +++ /dev/null @@ -1,30 +0,0 @@ -import $ from 'jquery'; -import { loadCSSFile } from '../lib/utils/css_utils'; - -export default () => { - const $select2Elements = $('select.select2'); - if ($select2Elements.length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $select2Elements.select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - requestAnimationFrame(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }); - }); - }) - .catch(() => {}); - }) - .catch(() => {}); - } -}; diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js index 7352be0dbd5..12fdb2e2981 100644 --- a/app/assets/javascripts/behaviors/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts.js @@ -28,7 +28,10 @@ export default function initPageShortcuts() { // TODO: replace this whitelist with something more automated/maintainable if (page && !pagesWithCustomShortcuts.includes(page)) { import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts') - .then(({ default: Shortcuts }) => new Shortcuts()) + .then(({ default: Shortcuts }) => { + const shortcuts = new Shortcuts(); + window.toggleShortcutsHelp = shortcuts.onToggleHelp; + }) .catch(() => {}); } return false; diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue index b3410b94b98..28e81b83713 100644 --- a/app/assets/javascripts/blob/components/table_contents.vue +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; function getHeaderNumber(el) { return parseInt(el.tagName.match(/\d+/)[0], 10); @@ -7,8 +7,7 @@ function getHeaderNumber(el) { export default { components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, data() { return { @@ -43,33 +42,40 @@ export default { } }, methods: { + close() { + this.$refs.disclosureDropdown?.close(); + }, generateHeaders() { + const BASE_PADDING = 16; const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')]; - if (headers.length) { - const firstHeader = getHeaderNumber(headers[0]); - - this.items = headers.map((el) => ({ - text: el.textContent.trim(), - anchor: el.querySelector('a').getAttribute('id'), - spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0), - })); + if (headers.length === 0) { + return; } + + const firstHeader = getHeaderNumber(headers[0]); + + this.items = headers.map((el) => ({ + text: el.textContent.trim(), + href: `#${el.querySelector('a').getAttribute('id')}`, + extraAttrs: { + style: { + paddingLeft: `${BASE_PADDING + Math.max((getHeaderNumber(el) - firstHeader) * 8, 0)}px`, + }, + }, + })); }, }, }; </script> <template> - <gl-dropdown v-if="!isHidden && items.length" icon="list-bulleted" class="gl-mr-2" lazy> - <gl-dropdown-item v-for="(item, index) in items" :key="index" :href="`#${item.anchor}`"> - <span - :style="{ 'padding-left': `${item.spacing}px` }" - class="gl-display-block" - data-testid="tableContentsLink" - > - {{ item.text }} - </span> - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown + v-if="!isHidden && items.length" + ref="disclosureDropdown" + icon="list-bulleted" + class="gl-mr-2" + :items="items" + @action="close" + /> </template> diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index dc1a9cb865a..ade92f2562b 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import NotebookLab from '~/notebook/index.vue'; export default { @@ -51,7 +52,7 @@ export default { this.loading = false; }) .catch((e) => { - if (e.status !== 200) { + if (e.status !== HTTP_STATUS_OK) { this.loadError = true; } this.error = true; diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 2386508aef5..d81aa05c44e 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,10 +1,19 @@ import { setAttributes } from '~/lib/utils/dom_utils'; import axios from '~/lib/utils/axios_utils'; +import { getBaseURL, relativePathToAbsolute, joinPaths } from '~/lib/utils/url_utility'; + +const SANDBOX_FRAME_PATH = '/-/sandbox/swagger'; + +const getSandboxFrameSrc = () => { + const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); + return relativePathToAbsolute(path, getBaseURL()); +}; const createSandbox = () => { const iframeEl = document.createElement('iframe'); + setAttributes(iframeEl, { - src: '/-/sandbox/swagger', + src: getSandboxFrameSrc(), sandbox: 'allow-scripts allow-popups allow-forms', frameBorder: 0, width: '100%', diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 8062460f052..3a22b06c72e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,7 +1,18 @@ import { sortBy, cloneDeep } from 'lodash'; -import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; +import { + TYPENAME_BOARD, + TYPENAME_ITERATION, + TYPENAME_MILESTONE, + TYPENAME_USER, +} from '~/graphql_shared/constants'; import { isGid, convertToGraphQLId } from '~/graphql_shared/utils'; -import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants'; +import { + ListType, + MilestoneIDs, + AssigneeFilterType, + MilestoneFilterType, + boardQuery, +} from './constants'; export function getMilestone() { return null; @@ -40,9 +51,7 @@ export function formatListIssues(listIssues) { const boardItems = {}; const listData = listIssues.nodes.reduce((map, list) => { - let sortedIssues = list.issues.edges.map((issueNode) => ({ - ...issueNode.node, - })); + let sortedIssues = list.issues.nodes; if (list.listType !== ListType.closed) { sortedIssues = sortBy(sortedIssues, 'relativePosition'); } @@ -82,19 +91,19 @@ export function fullBoardId(boardId) { if (!boardId) { return null; } - return convertToGraphQLId(TYPE_BOARD, boardId); + return convertToGraphQLId(TYPENAME_BOARD, boardId); } export function fullIterationId(id) { - return convertToGraphQLId(TYPE_ITERATION, id); + return convertToGraphQLId(TYPENAME_ITERATION, id); } export function fullUserId(id) { - return convertToGraphQLId(TYPE_USER, id); + return convertToGraphQLId(TYPENAME_USER, id); } export function fullMilestoneId(id) { - return convertToGraphQLId(TYPE_MILESTONE, id); + return convertToGraphQLId(TYPENAME_MILESTONE, id); } export function fullLabelId(label) { @@ -305,6 +314,10 @@ export function transformBoardConfig() { return ''; } +export function getBoardQuery(boardType) { + return boardQuery[boardType].query; +} + export default { getMilestone, formatIssue, diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 970e3509d20..d41fc1e9300 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -11,7 +11,12 @@ export default { BoardSettingsSidebar, BoardTopBar, }, - inject: ['fullBoardId'], + inject: ['initialBoardId'], + data() { + return { + boardId: this.initialBoardId, + }; + }, computed: { ...mapGetters(['isSidebarOpen']), }, @@ -21,13 +26,18 @@ export default { destroyed() { window.removeEventListener('popstate', refreshCurrentPage); }, + methods: { + switchBoard(id) { + this.boardId = id; + }, + }, }; </script> <template> <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> - <board-top-bar /> - <board-content :board-id="fullBoardId" /> + <board-top-bar :board-id="boardId" @switchBoard="switchBoard" /> + <board-content :board-id="boardId" /> <board-settings-sidebar /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 0c64cbad5b1..3071c1f334e 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -9,7 +9,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], - inject: ['disabled'], + inject: ['disabled', 'isApolloBoard'], props: { list: { type: Object, @@ -63,6 +63,15 @@ export default { colorClass() { 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; + }, }, methods: { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), @@ -106,7 +115,7 @@ export default { > <board-card-inner :list="list" - :item="item" + :item="formattedItem" :update-filters="true" :index="index" :show-work-item-type-icon="showWorkItemTypeIcon" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 77df111afc1..88f51c71e06 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -214,7 +214,9 @@ export default { <template> <div> <div class="gl-display-flex" dir="auto"> - <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word"> + <h4 + class="board-card-title gl-min-w-0 gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word" + > <issuable-blocked-icon v-if="item.blocked" :item="item" 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 706b453e868..f58f7838576 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,19 +1,17 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; - import Tracking from '~/tracking'; +import { + BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, + BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION, +} from '../constants'; export default { - i18n: { - moveToStartText: s__('Boards|Move to start of list'), - moveToEndText: s__('Boards|Move to end of list'), - }, + BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS, name: 'BoardCardMoveToPosition', components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, mixins: [Tracking.mixin()], props: { @@ -96,30 +94,30 @@ export default { allItemsLoadedInList: !this.listHasNextPage, }); }, + selectMoveAction({ text }) { + if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) { + this.moveToStart(); + } else { + this.moveToEnd(); + } + }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="dropdown" :key="itemIdentifier" - icon="ellipsis_v" - :text="s__('Boards|Move card')" - :text-sr-only="true" - class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3" + class="move-to-position gl-display-block gl-mb-2 gl-ml-auto gl-mt-n3 gl-mr-n3 js-no-trigger" category="tertiary" + :items="$options.BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS" + icon="ellipsis_v" :tabindex="index" + :toggle-text="s__('Boards|Move card')" + :text-sr-only="true" no-caret - @keydown.esc.native="$emit('hide')" - > - <div> - <gl-dropdown-item @click.stop="moveToStart"> - {{ $options.i18n.moveToStartText }} - </gl-dropdown-item> - <gl-dropdown-item @click.stop="moveToEnd"> - {{ $options.i18n.moveToEndText }} - </gl-dropdown-item> - </div> - </gl-dropdown> + data-testid="board-move-to-position" + @action="selectMoveAction" + /> </template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index b728b8dd22a..708e1539c6e 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -9,17 +9,17 @@ export default { BoardListHeader, BoardList, }, - inject: { - boardId: { - default: '', - }, - }, + inject: ['isApolloBoard'], props: { list: { type: Object, default: () => ({}), required: false, }, + boardId: { + type: String, + required: true, + }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -28,7 +28,7 @@ export default { return this.highlightedLists.includes(this.list.id); }, listItems() { - return this.getBoardItemsByList(this.list.id); + return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id); }, isListDraggable() { return isListDraggable(this.list); @@ -84,7 +84,13 @@ export default { :class="{ 'board-column-highlighted': highlighted }" > <board-list-header :list="list" /> - <board-list ref="board-list" :board-items="listItems" :list="list" /> + <board-list + ref="board-list" + :board-id="boardId" + :board-items="listItems" + :list="list" + :filter-params="filterParams" + /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 92f79e61f14..8a37719eae8 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -116,6 +116,8 @@ export default { value: this.boardListsToUse, delay: 100, delayOnTouchOnly: true, + filter: 'input', + preventOnFilter: false, }; return this.canDragColumns ? options : {}; @@ -172,6 +174,7 @@ export default { v-for="(list, index) in boardListsToUse" :key="index" ref="board" + :board-id="boardId" :list="list" :data-draggable-item-type="$options.draggableItemTypes.list" :class="{ 'gl-xs-display-none!': addColumnFormVisible }" diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e6d1e558c37..6227f185eda 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -6,12 +6,13 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow import { __, sprintf } from '~/locale'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { BoardType, ISSUABLE, INCIDENT, issuableTypes } from '~/boards/constants'; +import { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; -import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; +import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; @@ -30,7 +31,7 @@ export default { SidebarSubscriptionsWidget, SidebarDropdownWidget, SidebarTodoWidget, - SidebarSeverity, + SidebarSeverityWidget, MountingPortal, SidebarHealthStatusWidget: () => import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'), @@ -66,7 +67,7 @@ export default { default: false, }, issuableType: { - default: issuableTypes.issue, + default: TYPE_ISSUE, }, isGroupBoard: { default: false, @@ -174,7 +175,7 @@ export default { /> </template> <template #default> - <board-sidebar-title /> + <board-sidebar-title data-testid="sidebar-title" /> <sidebar-assignees-widget :iid="activeBoardItem.iid" :full-path="fullPath" @@ -237,7 +238,7 @@ export default { > {{ __('None') }} </sidebar-labels-widget> - <sidebar-severity + <sidebar-severity-widget v-if="isIncidentSidebar" :iid="activeBoardItem.iid" :project-path="fullPath" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index ce86a4d3123..1bc5d910561 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -327,7 +327,6 @@ export default { if (Array.isArray(value)) { return value.map((valueItem) => encodeURIComponent(valueItem)); } - return encodeURIComponent(value); } diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 060a708a22f..6f2b35f5191 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -8,7 +8,13 @@ import { sortableStart, sortableEnd } from '~/sortable/utils'; import Tracking from '~/tracking'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; -import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; +import { + DEFAULT_BOARD_LIST_ITEMS_SIZE, + toggleFormEventPrefix, + DraggableItemTypes, + listIssuablesQueries, + ListType, +} from 'ee_else_ce/boards/constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -31,12 +37,24 @@ export default { BoardCardMoveToPosition, }, mixins: [Tracking.mixin()], - inject: ['isEpicBoard', 'disabled'], + inject: [ + 'isEpicBoard', + 'isGroupBoard', + 'disabled', + 'fullPath', + 'boardType', + 'issuableType', + 'isApolloBoard', + ], props: { list: { type: Object, required: true, }, + boardId: { + type: String, + required: true, + }, boardItems: { type: Array, required: true, @@ -48,6 +66,8 @@ export default { showCount: false, showIssueForm: false, showEpicForm: false, + currentList: null, + isLoadingMore: false, }; }, apollo: { @@ -66,15 +86,50 @@ export default { return this.isEpicBoard; }, }, + currentList: { + query() { + return listIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: this.list.id, + ...this.listQueryVariables, + }; + }, + skip() { + return !this.isApolloBoard || this.list.collapsed; + }, + update(data) { + return data[this.boardType].board.lists.nodes[0]; + }, + context: { + isSingleRequest: true, + }, + }, }, computed: { ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']), + boardListItems() { + return this.isApolloBoard + ? this.currentList?.[`${this.issuableType}s`].nodes || [] + : this.boardItems; + }, + listQueryVariables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + filters: this.filterParams, + isGroup: this.isGroupBoard, + isProject: !this.isGroupBoard, + first: DEFAULT_BOARD_LIST_ITEMS_SIZE, + }; + }, listItemsCount() { return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; }, paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { - pageSize: this.boardItems.length, + pageSize: this.boardListItems.length, total: this.listItemsCount, issuableType: this.isEpicBoard ? 'epics' : 'issues', }); @@ -86,13 +141,17 @@ export default { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { - return this.pageInfoByListId[this.list.id]?.hasNextPage; + return this.isApolloBoard + ? this.currentList?.[`${this.issuableType}s`].pageInfo?.hasNextPage + : this.pageInfoByListId[this.list.id]?.hasNextPage; }, loading() { - return this.listsFlags[this.list.id]?.isLoading; + return this.isApolloBoard + ? this.$apollo.queries.currentList.loading && !this.isLoadingMore + : this.listsFlags[this.list.id]?.isLoading; }, loadingMore() { - return this.listsFlags[this.list.id]?.isLoadingMore; + return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore; }, epicCreateFormVisible() { return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; @@ -105,7 +164,7 @@ export default { return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list; }, showingAllItems() { - return this.boardItems.length === this.listItemsCount; + return this.boardListItems.length === this.listItemsCount; }, showingAllItemsText() { return this.isEpicBoard @@ -128,7 +187,7 @@ export default { tag: 'ul', 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, - value: this.boardItems, + value: this.boardListItems, delay: 100, delayOnTouchOnly: true, }; @@ -138,9 +197,12 @@ export default { disableScrollingWhenMutationInProgress() { return this.hasNextPage && this.isUpdateIssueOrderInProgress; }, + showMoveToPosition() { + return !this.disabled && this.list.listType !== ListType.closed; + }, }, watch: { - boardItems() { + boardListItems() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); @@ -165,10 +227,10 @@ export default { methods: { ...mapActions(['fetchItemsForList', 'moveItem']), listHeight() { - return this.listRef.getBoundingClientRect().height; + return this.listRef?.getBoundingClientRect()?.height || 0; }, scrollHeight() { - return this.listRef.scrollHeight; + return this.listRef?.scrollHeight || 0; }, scrollTop() { return this.listRef.scrollTop + this.listHeight(); @@ -176,8 +238,20 @@ export default { scrollToTop() { this.listRef.scrollTop = 0; }, - loadNextPage() { - this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); + 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 }); + } }, toggleForm() { if (this.isEpicBoard) { @@ -292,7 +366,7 @@ export default { :data-board="list.id" :data-board-type="list.listType" :class="{ - 'bg-danger-100': boardItemsSizeExceedsMax, + 'gl-bg-red-100 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': boardItemsSizeExceedsMax, 'gl-overflow-hidden': disableScrollingWhenMutationInProgress, 'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress, }" @@ -303,7 +377,7 @@ export default { @end="handleDragOnEnd" > <board-card - v-for="(item, index) in boardItems" + v-for="(item, index) in boardListItems" ref="issue" :key="item.id" :index="index" @@ -312,13 +386,12 @@ export default { :data-draggable-item-type="$options.draggableItemTypes.card" :show-work-item-type-icon="!isEpicBoard" > - <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> <board-card-move-to-position - v-if="!isEpicBoard && !disabled" + v-if="showMoveToPosition" :item="item" :index="index" :list="list" - :list-items-length="boardItems.length" + :list-items-length="boardListItems.length" /> </board-card> <gl-intersection-observer @appear="onReachingListBottom"> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 14dff8de70f..749fae0c426 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -125,7 +125,7 @@ export default { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; }, chevronIcon() { - return this.list.collapsed ? 'chevron-right' : 'chevron-down'; + return this.list.collapsed ? 'chevron-lg-right' : 'chevron-lg-down'; }, isNewIssueShown() { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; @@ -135,7 +135,9 @@ export default { }, isSettingsShown() { return ( - this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed + this.listType !== ListType.backlog && + this.listType !== ListType.closed && + !this.list.collapsed ); }, uniqueKey() { @@ -321,6 +323,7 @@ export default { v-if="listType !== 'label'" v-gl-tooltip.hover :class="{ + 'gl-text-gray-500': list.collapsed, 'gl-display-block': list.collapsed || listType === 'milestone', }" :title="listTitle" @@ -376,7 +379,7 @@ export default { <!-- EE end --> <div - class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary" + class="gl-font-sm issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary" data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, @@ -386,7 +389,7 @@ export default { <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }"> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> - <gl-icon class="gl-mr-2" :name="countIcon" :size="16" /> + <gl-icon class="gl-mr-2" :name="countIcon" :size="14" /> <item-count v-if="!isLoading" :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" @@ -397,7 +400,7 @@ export default { <template v-if="canShowTotalWeight"> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3" data-testid="weight"> - <gl-icon class="gl-mr-2" name="weight" /> + <gl-icon class="gl-mr-2" name="weight" :size="14" /> {{ totalWeight }} </span> </template> @@ -413,6 +416,7 @@ export default { :aria-label="$options.i18n.newIssue" :title="$options.i18n.newIssue" class="no-drag" + size="small" icon="plus" @click="showNewIssueForm" /> @@ -424,6 +428,7 @@ export default { :aria-label="$options.i18n.newEpic" :title="$options.i18n.newEpic" class="no-drag" + size="small" icon="plus" @click="showNewEpicForm" /> @@ -434,6 +439,7 @@ export default { v-gl-tooltip.hover :aria-label="$options.i18n.listSettings" class="no-drag" + size="small" :title="$options.i18n.listSettings" icon="settings" @click="openSidebarSettings" diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 368feba9a44..2e20ed70bb0 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -2,6 +2,7 @@ import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; +import { getBoardQuery } from 'ee_else_ce/boards/boards_util'; import ConfigToggle from './config_toggle.vue'; import NewBoardButton from './new_board_button.vue'; import ToggleFocus from './toggle_focus.vue'; @@ -19,7 +20,46 @@ export default { EpicBoardFilteredSearch: () => import('ee_component/boards/components/epic_filtered_search.vue'), }, - inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn', 'isIssueBoard'], + inject: [ + 'swimlanesFeatureAvailable', + 'canAdminList', + 'isSignedIn', + 'isIssueBoard', + 'fullPath', + 'boardType', + 'isEpicBoard', + 'isApolloBoard', + ], + props: { + boardId: { + type: String, + required: true, + }, + }, + data() { + return { + board: {}, + }; + }, + apollo: { + board: { + query() { + return getBoardQuery(this.boardType, this.isEpicBoard); + }, + variables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + }; + }, + skip() { + return !this.isApolloBoard; + }, + update(data) { + return data.workspace.board; + }, + }, + }, }; </script> @@ -31,7 +71,7 @@ export default { <div 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" > - <boards-selector /> + <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" /> <new-board-button /> <issue-board-filtered-search v-if="isIssueBoard" /> <epic-board-filtered-search v-else /> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index d26aeb69dd5..a1a49386b37 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -51,6 +51,7 @@ export default { 'weights', 'boardType', 'isGroupBoard', + 'isApolloBoard', ], props: { throttleDuration: { @@ -58,15 +59,20 @@ export default { default: 200, required: false, }, + boardApollo: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { hasScrollFade: false, - loadingBoards: 0, - loadingRecentBoards: false, scrollFadeInitialized: false, boards: [], recentBoards: [], + loadingBoards: false, + loadingRecentBoards: false, throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), contentClientHeight: 0, maxPosition: 0, @@ -77,11 +83,14 @@ export default { computed: { ...mapState(['board', 'isBoardLoading']), + boardToUse() { + return this.isApolloBoard ? this.boardApollo : this.board; + }, parentType() { return this.boardType; }, loading() { - return this.loadingRecentBoards || Boolean(this.loadingBoards); + return this.loadingRecentBoards || this.loadingBoards; }, filteredBoards() { return this.boards.filter((board) => @@ -94,6 +103,9 @@ export default { showDelete() { return this.boards.length > 1; }, + showDropdown() { + return this.showCreate || this.hasMissingBoards; + }, scrollFadeClass() { return { 'fade-out': !this.hasScrollFade, @@ -116,7 +128,7 @@ export default { this.scrollFadeInitialized = false; this.$nextTick(this.setScrollFade); }, - board(newBoard) { + boardToUse(newBoard) { document.title = newBoard.name; }, }, @@ -159,8 +171,10 @@ export default { return { fullPath: this.fullPath }; }, query: this.boardQuery, - loadingKey: 'loadingBoards', update: (data) => this.boardUpdate(data, 'boards'), + watchLoading: (isLoading) => { + this.loadingBoards = isLoading; + }, }); this.loadRecentBoards(); @@ -171,8 +185,10 @@ export default { return { fullPath: this.fullPath }; }, query: this.recentBoardsQuery, - loadingKey: 'loadingRecentBoards', update: (data) => this.boardUpdate(data, 'recentIssueBoards'), + watchLoading: (isLoading) => { + this.loadingRecentBoards = isLoading; + }, }); }, isScrolledUp() { @@ -210,9 +226,14 @@ export default { boardType: this.boardType, }); }, + fullBoardId(boardId) { + return fullBoardId(boardId); + }, async switchBoard(boardId, e) { if (isMetaKey(e)) { window.open(`${this.boardBaseUrl}/${boardId}`, '_blank'); + } else if (this.isApolloBoard) { + this.$emit('switchBoard', this.fullBoardId(boardId)); } else { this.unsetActiveId(); this.fetchCurrentBoard(boardId); @@ -230,12 +251,13 @@ export default { <div class="boards-switcher gl-mr-3" data-testid="boards-selector"> <span class="boards-selector-wrapper"> <gl-dropdown + v-if="showDropdown" data-testid="boards-dropdown" data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle" menu-class="flex-column dropdown-extended-height" :loading="isBoardLoading" - :text="board.name" + :text="boardToUse.name" @show="loadBoards" > <p class="gl-dropdown-header-top" @mousedown.prevent> @@ -333,7 +355,7 @@ export default { :can-admin-board="canAdminBoard" :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" - :current-board="board" + :current-board="boardToUse" :current-page="currentPage" @cancel="cancel" /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 38a171e8889..7749391ec6f 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -7,7 +7,7 @@ import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_sea import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import issueBoardFilters from '~/boards/issue_board_filters'; -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { @@ -181,7 +181,7 @@ export default { return gon?.current_user_id ? [ { - id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, avatarUrl: gon.current_user_avatar_url, diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index b09b1d48ca5..c3f7c7d3ca2 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -102,7 +102,7 @@ export default { <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> <br /> - <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span> + <span :class="{ 'gl-text-red-300': isPastDue }">{{ title }}</span> </gl-tooltip> </span> </template> 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 53e574e9942..43a2b13b81c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -13,6 +13,7 @@ export default { GlButton, GlFormGroup, GlFormInput, + GlLink, BoardEditableItem, }, directives: { @@ -130,7 +131,11 @@ export default { @off-click="handleOffClick" > <template #title> - <span data-testid="item-title">{{ item.title }}</span> + <span data-testid="item-title"> + <gl-link class="gl-reset-color gl-hover-text-blue-800" :href="item.webUrl"> + {{ item.title }} + </gl-link> + </span> </template> <template #collapsed> <span class="gl-text-gray-800">{{ item.referencePath }}</span> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 91b7f5004ad..712e3e1ac4a 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,5 +1,6 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { __ } from '~/locale'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { s__, __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; @@ -7,6 +8,9 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; +import groupBoardQuery from './graphql/group_board.query.graphql'; +import projectBoardQuery from './graphql/project_board.query.graphql'; +import listIssuesQuery from './graphql/lists_issues.query.graphql'; /* eslint-disable-next-line @gitlab/require-i18n-strings */ export const AssigneeIdParamValues = ['Any', 'None']; @@ -59,26 +63,35 @@ export const INCIDENT = 'INCIDENT'; export const flashAnimationDuration = 2000; +export const boardQuery = { + [BoardType.group]: { + query: groupBoardQuery, + }, + [BoardType.project]: { + query: projectBoardQuery, + }, +}; + export const listsQuery = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { query: boardListsQuery, }, }; export const updateListQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: updateBoardListMutation, }, }; export const deleteListQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: destroyBoardListMutation, }, }; export const titleQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: issueSetTitleMutation, }, [issuableTypes.epic]: { @@ -87,7 +100,7 @@ export const titleQueries = { }; export const subscriptionQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { mutation: issueSetSubscriptionMutation, }, [issuableTypes.epic]: { @@ -95,8 +108,14 @@ export const subscriptionQueries = { }, }; +export const listIssuablesQueries = { + [TYPE_ISSUE]: { + query: listIssuesQuery, + }, +}; + export const FilterFields = { - [issuableTypes.issue]: [ + [TYPE_ISSUE]: [ 'assigneeUsername', 'assigneeWildcardId', 'authorUsername', @@ -141,3 +160,21 @@ export default { }; export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10; + +export const BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION = s__('Boards|Move to start of list'); +export const BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION = s__('Boards|Move to end of list'); + +/** + * Actions are stubbed in order to pass validation + * for GlDisclosureDropdown items property + */ +export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [ + { + text: BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION, + action: () => {}, + }, + { + text: BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION, + action: () => {}, + }, +]; diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index ae6394f9a2f..0b9e416d408 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -19,10 +19,8 @@ query BoardListsEE( id listType issues(first: $first, filters: $filters, after: $after) { - edges { - node { - ...Issue - } + nodes { + ...Issue } pageInfo { endCursor @@ -42,10 +40,8 @@ query BoardListsEE( id listType issues(first: $first, filters: $filters, after: $after) { - edges { - node { - ...Issue - } + nodes { + ...Issue } pageInfo { endCursor diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 968832a092d..4c6f341828c 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,8 +3,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; -import { BoardType, issuableTypes } from '~/boards/constants'; +import { BoardType } from '~/boards/constants'; import store from '~/boards/stores'; +import { TYPE_ISSUE } from '~/issues/constants'; import { NavigationType, isLoggedIn, @@ -24,6 +25,7 @@ const apolloProvider = new VueApollo({ function mountBoardApp(el) { const { boardId, groupId, fullPath, rootPath } = el.dataset; + const isApolloBoard = window.gon?.features?.apolloBoards; const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -33,20 +35,22 @@ function mountBoardApp(el) { const boardType = el.dataset.parent; - store.dispatch('fetchBoard', { - fullPath, - fullBoardId: fullBoardId(boardId), - boardType, - }); + if (!isApolloBoard) { + store.dispatch('fetchBoard', { + fullPath, + fullBoardId: fullBoardId(boardId), + boardType, + }); - store.dispatch('setInitialBoardData', { - boardId, - fullBoardId: fullBoardId(boardId), - fullPath, - boardType, - disabled: parseBoolean(el.dataset.disabled) || true, - issuableType: issuableTypes.issue, - }); + store.dispatch('setInitialBoardData', { + boardId, + fullBoardId: fullBoardId(boardId), + fullPath, + boardType, + disabled: parseBoolean(el.dataset.disabled) || true, + issuableType: TYPE_ISSUE, + }); + } // eslint-disable-next-line no-new new Vue({ @@ -55,8 +59,8 @@ function mountBoardApp(el) { store, apolloProvider, provide: { - isApolloBoard: window.gon?.features?.apolloBoards, - fullBoardId: fullBoardId(boardId), + isApolloBoard, + initialBoardId: fullBoardId(boardId), disabled: parseBoolean(el.dataset.disabled), groupId: Number(groupId), rootPath, @@ -72,7 +76,7 @@ function mountBoardApp(el) { labelsFilterBasePath: el.dataset.labelsFilterBasePath, releasesFetchPath: el.dataset.releasesFetchPath, timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours), - issuableType: issuableTypes.issue, + issuableType: TYPE_ISSUE, emailsDisabled: parseBoolean(el.dataset.emailsDisabled), hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards), weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [], diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 07b127d86e2..1b4e6334723 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -11,7 +11,6 @@ import { deleteListQueries, listsQuery, updateListQueries, - issuableTypes, FilterFields, ListTypeTitles, DraggableItemTypes, @@ -35,6 +34,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe import { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; +import { TYPE_ISSUE } from '~/issues/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -138,7 +138,7 @@ export default { fullPath, boardId: fullBoardId, filters: filterParams, - ...(issuableType === issuableTypes.issue && { + ...(issuableType === TYPE_ISSUE && { isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, }), diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 9e746f1a1b8..0ad71165996 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,5 +1,6 @@ import { find } from 'lodash'; -import { inactiveId, issuableTypes } from '../constants'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { inactiveId } from '../constants'; export default { isSidebarOpen: (state) => state.activeId !== inactiveId, @@ -43,7 +44,7 @@ export default { }, isIssueBoard: (state) => { - return state.issuableType === issuableTypes.issue; + return state.issuableType === TYPE_ISSUE; }, isEpicBoard: () => { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 44abb2030c7..fef5862f319 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -15,9 +15,11 @@ const updateListItemsCount = ({ state, listId, value }) => { } }; -export const removeItemFromList = ({ state, listId, itemId }) => { +export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => { Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId)); - updateListItemsCount({ state, listId, value: -1 }); + if (!reordering) { + updateListItemsCount({ state, listId, value: -1 }); + } }; export const addItemToList = ({ @@ -28,6 +30,7 @@ export const addItemToList = ({ moveAfterId, atIndex, positionInList, + reordering = false, }) => { const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; @@ -41,7 +44,9 @@ export const addItemToList = ({ } listIssues.splice(newIndex, 0, itemId); Vue.set(state.boardItemsByListId, listId, listIssues); - updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); + if (!reordering) { + updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); + } }; export default { diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js index 9914ce05a95..9ea1331d563 100644 --- a/app/assets/javascripts/branches/branch_sort_dropdown.js +++ b/app/assets/javascripts/branches/branch_sort_dropdown.js @@ -1,8 +1,9 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import SortDropdown from './components/sort_dropdown.vue'; const mountDropdownApp = (el) => { - const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset; + const { projectBranchesFilteredPath, sortOptions, showDropdown, sortedBy } = el.dataset; return new Vue({ el, @@ -11,9 +12,10 @@ const mountDropdownApp = (el) => { SortDropdown, }, provide: { - mode, projectBranchesFilteredPath, sortOptions: JSON.parse(sortOptions), + showDropdown: parseBoolean(showDropdown), + sortedBy, }, render: (createElement) => createElement(SortDropdown), }); diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 263efcaa788..99c82fc9a5a 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -1,10 +1,8 @@ <script> import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; -import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; +import { mergeUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -const OVERVIEW_MODE = 'overview'; - export default { i18n: { searchPlaceholder: s__('Branches|Filter by branch name'), @@ -13,17 +11,20 @@ export default { GlCollapsibleListbox, GlSearchBoxByClick, }, - inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'], + // external parameters + inject: [ + 'projectBranchesFilteredPath', + 'sortOptions', // dropdown choices (value, text) pairs + 'showDropdown', // if not set, only text filter is shown + 'sortedBy', // (required) value of choice to sort by + ], + // own attributes, also in created() data() { return { - selectedKey: 'updated_desc', searchTerm: '', }; }, computed: { - shouldShowDropdown() { - return this.mode !== OVERVIEW_MODE; - }, selectedSortMethodName() { return this.sortOptions[this.selectedKey]; }, @@ -31,26 +32,16 @@ export default { return Object.entries(this.sortOptions).map(([value, text]) => ({ value, text })); }, }, + // contructor or initialization function created() { - const sortValue = getParameterValues('sort'); - const searchValue = getParameterValues('search'); - - if (sortValue.length > 0) { - [this.selectedKey] = sortValue; - } - - if (searchValue.length > 0) { - [this.searchTerm] = searchValue; - } + this.selectedKey = this.sortedBy; }, methods: { visitUrlFromOption(sortKey) { this.selectedKey = sortKey; const urlParams = {}; - if (this.mode !== OVERVIEW_MODE) { - urlParams.sort = sortKey; - } + urlParams.sort = sortKey; urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null; @@ -71,7 +62,7 @@ export default { /> <gl-collapsible-listbox - v-if="shouldShowDropdown" + v-if="showDropdown" v-model="selectedKey" :items="listboxItems" :toggle-text="selectedSortMethodName" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 4466a6a8081..9c79adffdae 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -1,12 +1,8 @@ <script> +import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - GRAPHQL_GROUP_TYPE, - UPDATE_MUTATION_ACTION, -} from '../constants'; +import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; @@ -24,7 +20,7 @@ export default { return this.glFeatures.groupScopedCiVariables; }, graphqlId() { - return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, }, mutationData: { diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue index 6326940148a..43938e9b88f 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue @@ -1,12 +1,8 @@ <script> +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - GRAPHQL_PROJECT_TYPE, - UPDATE_MUTATION_ACTION, -} from '../constants'; +import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql'; @@ -22,7 +18,7 @@ export default { inject: ['projectFullPath', 'projectId'], computed: { graphqlId() { - return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId); + return convertToGraphQLId(TYPENAME_PROJECT, this.projectId); }, }, mutationData: { diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 967125c7b0a..16034cce381 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -14,9 +14,11 @@ import { GlModal, GlSprintf, } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { allEnvironments, @@ -31,6 +33,7 @@ import { EVENT_ACTION, EXPANDED_VARIABLES_NOTE, EDIT_VARIABLE_ACTION, + FLAG_LINK_TITLE, VARIABLE_ACTIONS, variableOptions, } from '../constants'; @@ -41,13 +44,6 @@ import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); export default { - modalId: ADD_CI_VARIABLE_MODAL_ID, - tokens: awsTokens, - tokenList: awsTokenList, - awsTipMessage: AWS_TIP_MESSAGE, - containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, - environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, - expandedVariablesNote: EXPANDED_VARIABLES_NOTE, components: { CiEnvironmentsDropdown, GlAlert, @@ -64,7 +60,7 @@ export default { GlModal, GlSprintf, }, - mixins: [trackingMixin], + mixins: [glFeatureFlagsMixin(), trackingMixin], inject: [ 'awsLogoSvgPath', 'awsTipCommandsLink', @@ -74,8 +70,8 @@ export default { 'environmentScopeLink', 'isProtectedByDefault', 'maskedEnvironmentVariablesLink', + 'maskableRawRegex', 'maskableRegex', - 'protectedEnvironmentVariablesLink', ], props: { areScopedVariablesAvailable: { @@ -121,7 +117,7 @@ export default { }, computed: { canMask() { - const regex = RegExp(this.maskableRegex); + const regex = RegExp(this.useRawMaskableRegexp ? this.maskableRawRegex : this.maskableRegex); return regex.test(this.variable.value); }, canSubmit() { @@ -138,7 +134,10 @@ export default { return this.mode === EDIT_VARIABLE_ACTION; }, isExpanded() { - return !this.variable.raw; + return !this.isRaw; + }, + isRaw() { + return this.variable.raw; }, isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); @@ -174,6 +173,9 @@ export default { return true; }, + useRawMaskableRegexp() { + return this.isRaw; + }, variableValidationFeedback() { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, @@ -273,7 +275,20 @@ export default { this.validationErrorEventProperty = ''; }, }, - defaultScope: allEnvironments.text, + i18n: { + awsTipMessage: AWS_TIP_MESSAGE, + containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, + defaultScope: allEnvironments.text, + environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedVariablesNote: EXPANDED_VARIABLES_NOTE, + flagsLinkTitle: FLAG_LINK_TITLE, + }, + flagLink: helpPagePath('ci/variables/index', { + anchor: 'define-a-cicd-variable-in-the-ui', + }), + modalId: ADD_CI_VARIABLE_MODAL_ID, + tokens: awsTokens, + tokenList: awsTokenList, variableOptions, }; </script> @@ -315,11 +330,7 @@ export default { class="gl-font-monospace!" spellcheck="false" /> - <p - v-if="variable.raw" - class="gl-mt-2 gl-mb-0 text-secondary" - data-testid="raw-variable-tip" - > + <p v-if="isRaw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> {{ __('Variable value will be evaluated as raw string.') }} </p> </gl-form-group> @@ -340,15 +351,20 @@ export default { data-testid="environment-scope" > <template #label> - {{ __('Environment scope') }} - <gl-link - :title="$options.environmentScopeLinkTitle" - :href="environmentScopeLink" - target="_blank" - data-testid="environment-scope-link" - > - <gl-icon name="question" :size="12" /> - </gl-link> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ __('Environment scope') }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> </template> <ci-environments-dropdown v-if="areScopedVariablesAvailable" @@ -358,12 +374,27 @@ export default { @create-environment-scope="createEnvironmentScope" /> - <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly /> + <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly /> </gl-form-group> </template> </div> - <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> + <gl-form-group> + <template #label> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ __('Flags') }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.flagsLinkTitle" + :href="$options.flagLink" + target="_blank" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> <gl-form-checkbox v-model="variable.protected" class="gl-mb-0" @@ -371,9 +402,6 @@ export default { :data-is-protected-checked="variable.protected" > {{ __('Protect variable') }} - <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 text-secondary"> {{ __('Export variable to pipelines running on protected branches and tags only.') }} </p> @@ -384,9 +412,6 @@ export default { data-testid="ci-variable-masked-checkbox" > {{ __('Mask variable') }} - <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 text-secondary"> {{ __('Variable will be masked in job logs.') }} <span @@ -397,7 +422,7 @@ export default { {{ __('Requires values to meet regular expression requirements.') }}</span > <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ - __('More information') + __('Learn more.') }}</gl-link> </p> </gl-form-checkbox> @@ -408,11 +433,8 @@ export default { @change="setVariableRaw" > {{ __('Expand variable reference') }} - <gl-link target="_blank" :href="containsVariableReferenceLink"> - <gl-icon name="question" :size="12" /> - </gl-link> <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> - <gl-sprintf :message="$options.expandedVariablesNote"> + <gl-sprintf :message="$options.i18n.expandedVariablesNote"> <template #code="{ content }"> <code>{{ content }}</code> </template> @@ -428,10 +450,10 @@ export default { data-testid="aws-guidance-tip" @dismiss="dismissTip" > - <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap"> + <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3"> <div> <p> - <gl-sprintf :message="$options.awsTipMessage"> + <gl-sprintf :message="$options.i18n.awsTipMessage"> <template #deployLink="{ content }"> <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> </template> @@ -467,7 +489,7 @@ export default { variant="warning" data-testid="contains-variable-reference" > - <gl-sprintf :message="$options.containsVariableReferenceMessage"> + <gl-sprintf :message="$options.i18n.containsVariableReferenceMessage"> <template #code="{ content }"> <code>{{ content }}</code> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 828d0724d93..627ace1b28e 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -72,14 +72,14 @@ export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_S export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', ); - +export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', +); export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__( 'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.', ); -export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( - 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', -); +export const FLAG_LINK_TITLE = s__('CiVariable|Define a CI/CD variable in the UI'); export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__( 'CiVariables|Maximum number of variables reached.', ); @@ -88,9 +88,6 @@ export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION]; -export const GRAPHQL_PROJECT_TYPE = 'Project'; -export const GRAPHQL_GROUP_TYPE = 'Group'; - export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js index 10203383ba0..cafe3df35d0 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js @@ -3,14 +3,9 @@ import { convertObjectPropsToCamelCase, convertObjectPropsToSnakeCase, } from '~/lib/utils/common_utils'; +import { TYPENAME_CI_VARIABLE, TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { - GRAPHQL_GROUP_TYPE, - GRAPHQL_PROJECT_TYPE, - groupString, - instanceString, - projectString, -} from '../constants'; +import { groupString, instanceString, projectString } from '../constants'; import getProjectVariables from './queries/project_variables.query.graphql'; import getGroupVariables from './queries/group_variables.query.graphql'; import getAdminVariables from './queries/variables.query.graphql'; @@ -30,7 +25,7 @@ const mapVariableTypes = (variables = [], kind) => { return { __typename: `Ci${kind}Variable`, ...convertObjectPropsToCamelCase(ciVar), - id: convertToGraphQLId('Ci::Variable', ciVar.id), + id: convertToGraphQLId(TYPENAME_CI_VARIABLE, ciVar.id), variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType, }; }); @@ -40,10 +35,10 @@ const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => { return { errors, project: { - __typename: GRAPHQL_PROJECT_TYPE, - id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id), + __typename: TYPENAME_PROJECT, + id: convertToGraphQLId(TYPENAME_PROJECT, id), ciVariables: { - __typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`, + __typename: 'CiProjectVariableConnection', pageInfo: { __typename: 'PageInfo', hasNextPage: false, @@ -61,10 +56,10 @@ const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => { return { errors, group: { - __typename: GRAPHQL_GROUP_TYPE, - id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id), + __typename: TYPENAME_GROUP, + id: convertToGraphQLId(TYPENAME_GROUP, id), ciVariables: { - __typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`, + __typename: `CiGroupVariableConnection`, pageInfo: { __typename: 'PageInfo', hasNextPage: false, diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index 174a59aba42..4270c3c67fc 100644 --- a/app/assets/javascripts/ci/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -21,11 +21,11 @@ const mountCiVariableListApp = (containerEl) => { isGroup, isProject, maskedEnvironmentVariablesLink, + maskableRawRegex, maskableRegex, projectFullPath, projectId, protectedByDefault, - protectedEnvironmentVariablesLink, } = containerEl.dataset; const parsedIsProject = parseBoolean(isProject); @@ -63,10 +63,10 @@ const mountCiVariableListApp = (containerEl) => { isProject: parsedIsProject, isProtectedByDefault, maskedEnvironmentVariablesLink, + maskableRawRegex, maskableRegex, projectFullPath, projectId, - protectedEnvironmentVariablesLink, }, render(createElement) { return createElement(component); diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 375db7f3054..ea7201efcd9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -1,6 +1,8 @@ <script> import { GlDrawer } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { __ } from '~/locale'; +import { DRAWER_CONTAINER_CLASS } from '../job_assistant_drawer/constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; @@ -26,14 +28,15 @@ export default { required: false, default: false, }, + zIndex: { + type: Number, + required: false, + default: 200, + }, }, computed: { - drawerCardStyles() { - return ''; - }, drawerHeightOffset() { - const wrapperEl = document.querySelector('.content-wrapper'); - return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; + return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); }, }, methods: { @@ -47,7 +50,7 @@ export default { <gl-drawer :header-height="drawerHeightOffset" :open="isVisible" - :z-index="200" + :z-index="zIndex" @close="closeDrawer" > <template #title> 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 201fba837e2..b78224e93b0 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 @@ -1,24 +1,30 @@ <script> import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; export default { i18n: { browseTemplates: __('Browse templates'), help: __('Help'), + jobAssistant: s__('JobAssistant|Job assistant'), }, TEMPLATE_REPOSITORY_URL, components: { GlButton, }, - mixins: [Tracking.mixin()], + mixins: [glFeatureFlagMixin(), Tracking.mixin()], props: { showDrawer: { type: Boolean, required: true, }, + showJobAssistantDrawer: { + type: Boolean, + required: true, + }, }, methods: { toggleDrawer() { @@ -29,6 +35,11 @@ export default { this.trackHelpDrawerClick(); } }, + toggleJobAssistantDrawer() { + this.$emit( + this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer', + ); + }, trackHelpDrawerClick() { const { label, actions } = pipelineEditorTrackingOptions; this.track(actions.openHelpDrawer, { label }); @@ -64,5 +75,15 @@ export default { > {{ $options.i18n.help }} </gl-button> + <gl-button + v-if="glFeatures.ciJobAssistantDrawer" + icon="bulb" + size="small" + data-testid="job-assistant-drawer-toggle" + data-qa-selector="job_assistant_drawer_toggle" + @click="toggleJobAssistantDrawer" + > + {{ $options.i18n.jobAssistant }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 84c29e48114..7368d1a3a91 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-mb-4"> + <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3"> <gl-button v-if="showFileTreeToggle" id="file-tree-toggle" 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 feadc60a22a..a4dfb401f4c 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 @@ -1,5 +1,6 @@ <script> import { __ } from '~/locale'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; @@ -43,7 +44,8 @@ export default { }, computed: { downstreamPipelines() { - return this.linkedPipelines?.downstream?.nodes || []; + const downstream = this.linkedPipelines?.downstream?.nodes; + return keepLatestDownstreamPipelines(downstream); }, hasPipelineStages() { return this.pipelineStages.length > 0; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js new file mode 100644 index 00000000000..1c122fd5e38 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; + +export const i18n = { + ADD_JOB: s__('JobAssistant|Add job'), +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue new file mode 100644 index 00000000000..65c87df21cb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -0,0 +1,62 @@ +<script> +import { GlDrawer, GlButton } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { DRAWER_CONTAINER_CLASS, i18n } from './constants'; + +export default { + i18n, + components: { + GlDrawer, + GlButton, + }, + props: { + isVisible: { + type: Boolean, + required: false, + default: false, + }, + zIndex: { + type: Number, + required: false, + default: 200, + }, + }, + computed: { + drawerHeightOffset() { + return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); + }, + }, + methods: { + closeDrawer() { + this.$emit('close-job-assistant-drawer'); + }, + }, +}; +</script> +<template> + <gl-drawer + class="job-assistant-drawer" + :header-height="drawerHeightOffset" + :open="isVisible" + :z-index="zIndex" + @close="closeDrawer" + > + <template #title> + <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2> + </template> + <template #footer> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + category="primary" + class="gl-mr-3" + data-testid="cancel-button" + @click="closeDrawer" + >{{ __('Cancel') }}</gl-button + > + <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{ + __('Add') + }}</gl-button> + </div> + </template> + </gl-drawer> +</template> 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 ed5466ff99c..fd6547468d9 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 @@ -95,6 +95,10 @@ export default { type: Boolean, required: true, }, + showJobAssistantDrawer: { + type: Boolean, + required: true, + }, }, apollo: { appStatus: { @@ -187,7 +191,11 @@ export default { @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> - <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" /> + <ci-editor-header + :show-drawer="showDrawer" + :show-job-assistant-drawer="showJobAssistantDrawer" + v-on="$listeners" + /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> <editor-tab 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 1972125ed56..59863edbe0b 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -3,6 +3,7 @@ import { GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; import CommitSection from './components/commit/commit_section.vue'; import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; +import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorFileTree from './components/file_tree/container.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; @@ -28,6 +29,7 @@ export default { CommitSection, GlModal, PipelineEditorDrawer, + JobAssistantDrawer, PipelineEditorFileNav, PipelineEditorFileTree, PipelineEditorHeader, @@ -63,6 +65,9 @@ export default { scrollToCommitForm: false, shouldLoadNewBranch: false, showDrawer: false, + showJobAssistantDrawer: false, + drawerIndex: 200, + jobAssistantIndex: 200, showFileTree: false, showSwitchBranchModal: false, }; @@ -85,11 +90,19 @@ export default { closeDrawer() { this.showDrawer = false; }, + closeJobAssistantDrawer() { + this.showJobAssistantDrawer = false; + }, handleConfirmSwitchBranch() { this.showSwitchBranchModal = true; }, openDrawer() { this.showDrawer = true; + this.drawerIndex = this.jobAssistantIndex + 1; + }, + openJobAssistantDrawer() { + this.showJobAssistantDrawer = true; + this.jobAssistantIndex = this.drawerIndex + 1; }, toggleFileTree() { this.showFileTree = !this.showFileTree; @@ -153,9 +166,12 @@ export default { :current-tab="currentTab" :is-new-ci-config-file="isNewCiConfigFile" :show-drawer="showDrawer" + :show-job-assistant-drawer="showJobAssistantDrawer" v-on="$listeners" @open-drawer="openDrawer" @close-drawer="closeDrawer" + @open-job-assistant-drawer="openJobAssistantDrawer" + @close-job-assistant-drawer="closeJobAssistantDrawer" @set-current-tab="setCurrentTab" @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> @@ -174,8 +190,15 @@ export default { /> <pipeline-editor-drawer :is-visible="showDrawer" + :z-index="drawerIndex" v-on="$listeners" @close-drawer="closeDrawer" /> + <job-assistant-drawer + :is-visible="showJobAssistantDrawer" + :z-index="jobAssistantIndex" + v-on="$listeners" + @close-job-assistant-drawer="closeJobAssistantDrawer" + /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index 5692627abef..8837b7a1917 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -16,18 +16,29 @@ import { import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; +import { fetchPolicies } from '~/lib/graphql'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; +import { + CC_VALIDATION_REQUIRED_ERROR, + CONFIG_VARIABLES_TIMEOUT, + FILE_TYPE, + VARIABLE_TYPE, +} from '../constants'; import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql'; import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; import filterVariables from '../utils/filter_variables'; import RefsDropdown from './refs_dropdown.vue'; +let pollTimeout; +export const POLLING_INTERVAL = 2000; const i18n = { variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + 'Pipeline|Specify variable values to be used in this run. The variables specified in the configuration file as well as %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', ), defaultError: __('Something went wrong on our end. Please try again.'), refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), @@ -115,10 +126,11 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, }, + configVariablesWithDescription: {}, form: {}, errorTitle: null, error: null, - predefinedValueOptions: {}, + predefinedVariables: null, warnings: [], totalWarnings: 0, isWarningDismissed: false, @@ -128,6 +140,7 @@ export default { }, apollo: { ciConfigVariables: { + fetchPolicy: fetchPolicies.NO_CACHE, query: ciConfigVariablesQuery, // Skip when variables already cached in `form` skip() { @@ -140,46 +153,40 @@ export default { }; }, update({ project }) { - return project?.ciConfigVariables || []; + return project?.ciConfigVariables; }, result({ data }) { - const predefinedVars = data?.project?.ciConfigVariables || []; - const params = {}; - const descriptions = {}; - - predefinedVars.forEach(({ description, key, value, valueOptions }) => { - if (description) { - params[key] = value; - descriptions[key] = description; - this.predefinedValueOptions[key] = valueOptions; - } - }); - - Vue.set(this.form, this.refFullName, { descriptions, variables: [] }); + this.predefinedVariables = data?.project?.ciConfigVariables; - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + // API cache is empty when predefinedVariables === null, so we need to + // poll while cache values are being populated in the backend. + // After CONFIG_VARIABLES_TIMEOUT ms have passed, we stop polling + // and populate the form regardless. + if (this.isFetchingCiConfigVariables && !pollTimeout) { + pollTimeout = setTimeout(() => { + this.predefinedVariables = []; + this.clearPolling(); + this.populateForm(); + }, CONFIG_VARIABLES_TIMEOUT); } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + if (!this.isFetchingCiConfigVariables) { + this.clearPolling(); + this.populateForm(); } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); }, error(error) { Sentry.captureException(error); }, + pollInterval: POLLING_INTERVAL, }, }, computed: { + isFetchingCiConfigVariables() { + return this.predefinedVariables === null; + }, isLoading() { - return this.$apollo.queries.ciConfigVariables.loading; + return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables; }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; @@ -228,6 +235,48 @@ export default { value: '', }); }, + clearPolling() { + clearTimeout(pollTimeout); + this.$apollo.queries.ciConfigVariables.stopPolling(); + }, + populateForm() { + this.configVariablesWithDescription = this.predefinedVariables.reduce( + (accumulator, { description, key, value, valueOptions }) => { + if (description) { + accumulator.descriptions[key] = description; + accumulator.values[key] = value; + accumulator.options[key] = valueOptions; + } + + return accumulator; + }, + { descriptions: {}, values: {}, options: {} }, + ); + + Vue.set(this.form, this.refFullName, { + descriptions: this.configVariablesWithDescription.descriptions, + variables: [], + }); + + // Add default variables from yml + this.setVariableParams( + this.refFullName, + VARIABLE_TYPE, + this.configVariablesWithDescription.values, + ); + + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }, setVariable(refValue, type, key, value) { const { variables } = this.form[refValue]; @@ -255,7 +304,7 @@ export default { }); }, shouldShowValuesDropdown(key) { - return this.predefinedValueOptions[key]?.length > 1; + return this.configVariablesWithDescription.options[key]?.length > 1; }, removeVariable(index) { this.variables.splice(index, 1); @@ -362,7 +411,7 @@ export default { <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> - <gl-form-group v-else :label="s__('Pipeline|Variables')"> + <gl-form-group v-else class="gl-mb-3" :label="s__('Pipeline|Variables')"> <div v-for="(variable, index) in variables" :key="variable.uniqueId" @@ -403,13 +452,13 @@ export default { data-qa-selector="ci_variable_value_dropdown" > <gl-dropdown-item - v-for="value in predefinedValueOptions[variable.key]" - :key="value" + v-for="option in configVariablesWithDescription.options[variable.key]" + :key="option" data-testid="pipeline-form-ci-variable-value-dropdown-items" data-qa-selector="ci_variable_value_dropdown_item" - @click="setVariableAttribute(variable.key, 'value', value)" + @click="setVariableAttribute(variable.key, 'value', option)" > - {{ value }} + {{ option }} </gl-dropdown-item> </gl-dropdown> <gl-form-textarea @@ -457,6 +506,15 @@ export default { </gl-sprintf></template > </gl-form-group> + <div class="gl-mb-4 gl-text-gray-500"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> <div class="gl-pt-5 gl-display-flex"> <gl-button type="submit" diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js index 5e81245037f..e1486649dbb 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/constants.js +++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js @@ -1,10 +1,10 @@ export const SEVERITY_CLASSES = { - info: 'text-primary-400', - minor: 'text-warning-200', - major: 'text-warning-400', - critical: 'text-danger-600', - blocker: 'text-danger-800', - unknown: 'text-secondary-400', + info: 'gl-text-blue-400', + minor: 'gl-text-orange-200', + major: 'gl-text-orange-400', + critical: 'gl-text-red-600', + blocker: 'gl-text-red-800', + unknown: 'gl-text-gray-400', }; export const SEVERITY_ICONS = { diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue new file mode 100644 index 00000000000..5401c7c1c28 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue @@ -0,0 +1,78 @@ +<script> +import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; +import { DEFAULT_PLATFORM, DEFAULT_ACCESS_LEVEL } from '../constants'; + +export default { + name: 'AdminNewRunnerApp', + components: { + GlLink, + GlSprintf, + RunnerInstructionsModal, + RunnerPlatformsRadioGroup, + RunnerFormFields, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + legacyRegistrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + platform: DEFAULT_PLATFORM, + runner: { + description: '', + maintenanceNote: '', + paused: false, + accessLevel: DEFAULT_ACCESS_LEVEL, + runUntagged: false, + tagList: '', + maximumTimeout: ' ', + }, + }; + }, + modalId: 'runners-legacy-registration-instructions-modal', +}; +</script> + +<template> + <div> + <h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1> + <p> + <gl-sprintf + :message=" + s__( + 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{ + content + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="legacyRegistrationToken" + /> + </template> + </gl-sprintf> + </p> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Platform') }} + </h2> + <runner-platforms-radio-group v-model="platform" /> + + <hr aria-hidden="true" /> + + <runner-form-fields v-model="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js new file mode 100644 index 00000000000..502d9d33b4d --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import AdminNewRunnerApp from './admin_new_runner_app.vue'; + +Vue.use(VueApollo); + +export const initAdminNewRunner = (selector = '#js-admin-new-runner') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { legacyRegistrationToken } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(AdminNewRunnerApp, { + props: { + legacyRegistrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 66d790acb00..8d4303778af 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,53 +1,29 @@ <script> -import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; -import VueRouter from 'vue-router'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { formatJobCount } from '../utils'; + import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; -import RunnerDetails from '../components/runner_details.vue'; -import RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants'; +import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; + +import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; -const ROUTE_DETAILS = 'details'; -const ROUTE_JOBS = 'jobs'; - -const routes = [ - { - path: '/', - name: ROUTE_DETAILS, - component: RunnerDetails, - }, - { - path: '/jobs', - name: ROUTE_JOBS, - component: RunnerJobs, - }, - { path: '*', redirect: { name: ROUTE_DETAILS } }, -]; - export default { name: 'AdminRunnerShowApp', components: { - GlBadge, - GlTabs, - GlTab, RunnerDeleteButton, RunnerEditButton, RunnerPauseButton, RunnerHeader, + RunnerDetailsTabs, }, - router: new VueRouter({ - routes, - }), props: { runnerId: { type: String, @@ -68,7 +44,7 @@ export default { query: runnerQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { @@ -85,20 +61,11 @@ export default { canDelete() { return this.runner.userPermissions?.deleteRunner; }, - jobCount() { - return formatJobCount(this.runner?.jobCount); - }, - tabIndex() { - return routes.findIndex(({ name }) => name === this.$route.name); - }, }, errorCaptured(error) { this.reportToSentry(error); }, methods: { - goTo(name) { - this.$router.push({ name }); - }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -107,10 +74,6 @@ export default { redirectTo(this.runnersPath); }, }, - ROUTE_DETAILS, - ROUTE_JOBS, - I18N_DETAILS, - I18N_JOBS, }; </script> <template> @@ -122,26 +85,6 @@ export default { <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> </template> </runner-header> - - <gl-tabs :value="tabIndex"> - <gl-tab @click="goTo($options.ROUTE_DETAILS)"> - <template #title>{{ $options.I18N_DETAILS }}</template> - </gl-tab> - <gl-tab @click="goTo($options.ROUTE_JOBS)"> - <template #title> - {{ $options.I18N_JOBS }} - <gl-badge - v-if="jobCount" - data-testid="job-count-badge" - class="gl-tab-counter-badge" - size="sm" - > - {{ jobCount }} - </gl-badge> - </template> - </gl-tab> - - <router-view v-if="runner" :runner="runner" /> - </gl-tabs> + <runner-details-tabs v-if="runner" :runner="runner" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 3bd20dff9cc..ce2c511ddd4 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -1,5 +1,5 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; @@ -33,12 +33,14 @@ import { INSTANCE_TYPE, I18N_FETCH_ERROR, FILTER_CSS_CLASSES, + JOBS_ROUTE_PATH, } from '../constants'; import { captureException } from '../sentry_utils'; export default { name: 'AdminRunnersApp', components: { + GlButton, GlLink, RegistrationDropdown, RunnerFilteredSearchBar, @@ -54,6 +56,10 @@ export default { mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], props: { + newRunnerPath: { + type: String, + required: true, + }, registrationToken: { type: String, required: true, @@ -121,6 +127,10 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow feature flag + return this.glFeatures.createRunnerWorkflow; + }, }, watch: { search: { @@ -141,7 +151,7 @@ export default { methods: { jobsUrl(runner) { const url = new URL(runner.adminUrl); - url.hash = '#/jobs'; + url.hash = `#${JOBS_ROUTE_PATH}`; return url.href; }, @@ -183,7 +193,11 @@ export default { nav-class="gl-border-none!" /> + <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New instance runner') }} + </gl-button> <registration-dropdown + v-else class="gl-w-full gl-sm-w-auto gl-mr-auto" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" @@ -204,6 +218,7 @@ export default { v-if="noRunnersFound" :registration-token="registrationToken" :is-search-filtered="isSearchFiltered" + :new-runner-path="newRunnerPath" :svg-path="emptyStateSvgPath" :filtered-svg-path="emptyStateFilteredSvgPath" /> @@ -214,17 +229,17 @@ export default { :checkable="true" @deleted="onDeleted" > - <template #runner-name="{ runner }"> - <gl-link :href="runner.adminUrl"> - <runner-name :runner="runner" /> - </gl-link> - </template> <template #runner-job-status-badge="{ runner }"> <runner-job-status-badge :href="jobsUrl(runner)" :job-status="runner.jobExecutionStatus" /> </template> + <template #runner-name="{ runner }"> + <gl-link :href="runner.adminUrl"> + <runner-name :runner="runner" /> + </gl-link> + </template> <template #runner-actions-cell="{ runner }"> <runner-actions-cell :runner="runner" diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js index c6db7148eb1..881dc3613e9 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/index.js +++ b/app/assets/javascripts/ci/runner/admin_runners/index.js @@ -31,6 +31,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { const { runnerInstallHelpPage, + newRunnerPath, registrationToken, onlineContactTimeoutSecs, staleTimeoutSecs, @@ -58,6 +59,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { render(h) { return h(AdminRunnersApp, { props: { + newRunnerPath, registrationToken, }, }); diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index cfbe37f5ba2..4d04b5d4b14 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -36,5 +36,6 @@ export default { v-if="paused" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> + <slot :runner="runner" name="runner-job-status-badge"></slot> </div> </template> 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 4a72023b6a0..97dfbe1a051 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 @@ -6,7 +6,6 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; -import RunnerJobStatusBadge from '../runner_job_status_badge.vue'; import { formatJobCount } from '../../utils'; import { @@ -27,7 +26,6 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, - RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), TooltipOnTruncate, @@ -90,10 +88,6 @@ export default { </div> <div> - <slot :runner="runner" name="runner-job-status-badge"> - <runner-job-status-badge :job-status="runner.jobExecutionStatus" /> - </slot> - <runner-summary-field icon="clock"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue index 6740065e860..ac2793654c8 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; @@ -58,12 +58,12 @@ export default { }; case GROUP_TYPE: return { - id: convertToGraphQLId(TYPE_GROUP, this.groupId), + id: convertToGraphQLId(TYPENAME_GROUP, this.groupId), type: this.type, }; case PROJECT_TYPE: return { - id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + id: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), type: this.type, }; default: diff --git a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue index 2fa87bdd776..5e61e4d7377 100644 --- a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue @@ -55,7 +55,7 @@ export default { <div> <div class="gl-mb-1"> <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> - <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge> + <gl-badge v-if="isOwner" variant="info">{{ s__('Runners|Owner') }}</gl-badge> </div> <div v-if="description">{{ description }}</div> </div> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue index 1ec3f8da7c3..8dde3ac4e19 100644 --- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue @@ -162,22 +162,28 @@ export default { </script> <template> - <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> - <div class="gl-display-flex gl-align-items-center"> - <div> - <gl-sprintf :message="bannerMessage"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <div class="gl-ml-auto"> - <gl-button variant="default" @click="onClearChecked">{{ - s__('Runners|Clear selection') - }}</gl-button> - <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ - s__('Runners|Delete selected') - }}</gl-button> + <div> + <div + v-if="checkedCount" + data-testid="runner-bulk-delete-banner" + class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100" + > + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="bannerMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> </div> </div> <gl-modal diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue index 32d4076b00f..f02e6bce5c3 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -122,7 +122,7 @@ export default { onError(error) { this.deleting = false; const { message } = error; - const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), { + const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { runnerName: this.runnerName, }); diff --git a/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue new file mode 100644 index 00000000000..e4190a4dffd --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue @@ -0,0 +1,95 @@ +<script> +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import VueRouter from 'vue-router'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { JOBS_ROUTE_PATH, I18N_DETAILS, I18N_JOBS } from '../constants'; +import { formatJobCount } from '../utils'; +import RunnerDetails from './runner_details.vue'; +import RunnerJobs from './runner_jobs.vue'; + +const ROUTE_DETAILS = 'details'; +const ROUTE_JOBS = 'jobs'; + +const routes = [ + { + path: '/', + name: ROUTE_DETAILS, + component: RunnerDetails, + }, + { + path: JOBS_ROUTE_PATH, + name: ROUTE_JOBS, + component: RunnerJobs, + }, + { path: '*', redirect: { name: ROUTE_DETAILS } }, +]; + +export default { + name: 'RunnerDetailsTabs', + components: { + GlBadge, + GlTabs, + GlTab, + HelpPopover, + }, + router: new VueRouter({ + routes, + }), + props: { + runner: { + type: Object, + required: false, + default: null, + }, + showAccessHelp: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, + tabIndex() { + return routes.findIndex(({ name }) => name === this.$route.name); + }, + }, + methods: { + goTo(name) { + if (this.$route.name !== name) { + this.$router.push({ name }); + } + }, + }, + ROUTE_DETAILS, + ROUTE_JOBS, + I18N_DETAILS, + I18N_JOBS, +}; +</script> +<template> + <gl-tabs :value="tabIndex"> + <gl-tab @click="goTo($options.ROUTE_DETAILS)"> + <template #title>{{ $options.I18N_DETAILS }}</template> + </gl-tab> + <gl-tab @click="goTo($options.ROUTE_JOBS)"> + <template #title> + {{ $options.I18N_JOBS }} + <gl-badge + v-if="jobCount" + data-testid="job-count-badge" + class="gl-tab-counter-badge" + size="sm" + > + {{ jobCount }} + </gl-badge> + <help-popover v-if="showAccessHelp" class="gl-ml-3"> + {{ s__('Runners|Jobs in projects you have access to.') }} + </help-popover> + </template> + </gl-tab> + + <router-view v-if="runner" :runner="runner" /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue new file mode 100644 index 00000000000..e37ac5e6e26 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -0,0 +1,140 @@ +<script> +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants'; + +export default { + name: 'RunnerFormFields', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlLink, + GlSprintf, + RunnerMaintenanceNoteField: () => + import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), + }, + props: { + value: { + type: Object, + default: null, + required: false, + }, + }, + data() { + return { + model: { + ...this.value, + }, + }; + }, + watch: { + model: { + handler() { + this.$emit('input', this.model); + }, + deep: true, + }, + }, + HELP_LABELS_PAGE_PATH: helpPagePath('ci/runners/configure_runners', { + anchor: 'use-tags-to-control-which-jobs-a-runner-can-run', + }), + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> +<template> + <div> + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Details') }} + {{ __('(optional)') }} + </h2> + <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description"> + <gl-form-input id="runner-description" v-model="model.description" name="description" /> + </gl-form-group> + + <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" /> + + <hr aria-hidden="true" /> + + <h2 class="gl-font-weight-normal gl-font-lg gl-my-5"> + {{ s__('Runners|Configuration') }} + {{ __('(optional)') }} + </h2> + + <div class="gl-mb-5"> + <gl-form-checkbox v-model="model.paused" name="paused"> + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting new jobs.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-model="model.accessLevel" + name="protected" + :value="$options.ACCESS_LEVEL_REF_PROTECTED" + :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" + > + {{ __('Protected') }} + <template #help> + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox v-model="model.runUntagged" name="run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }} + </template> + </gl-form-checkbox> + </div> + + <gl-form-group :label="__('Tags')" label-for="runner-tags"> + <template #description> + <gl-sprintf + :message=" + s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.') + " + > + <template #example> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>macos, shared</code> + </template> + </gl-sprintf> + </template> + <template #label-description> + <gl-sprintf + :message=" + s__( + 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}', + ) + " + > + <template #helpLink="{ content }"> + <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" /> + </gl-form-group> + + <gl-form-group + :label="__('Maximum job timeout')" + :label-description=" + s__( + 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.', + ) + " + label-for="runner-max-timeout" + :description="s__('Runners|Enter the number of seconds.')" + > + <gl-form-input + id="runner-max-timeout" + v-model.number="model.maximumTimeout" + name="max-timeout" + type="number" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue index 1e52acecfb8..bed592e3f30 100644 --- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -45,8 +45,7 @@ export default { <gl-badge v-if="badge" v-bind="$attrs" - size="sm" - class="gl-mr-3 gl-bg-transparent!" + class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!" variant="muted" :class="badge.classes" > 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 e359344ab77..ebcda4f0ac3 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -37,10 +37,10 @@ export default { return job.detailedStatus?.detailsPath; }, projectName(job) { - return job.pipeline?.project?.name; + return job.project?.name; }, projectWebUrl(job) { - return job.pipeline?.project?.webUrl; + return job.project?.webUrl; }, commitShortSha(job) { return job.shortSha; diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index b2aad0aac4f..ec04701db2c 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -150,16 +150,17 @@ export default { </template> <template #cell(status)="{ item }"> - <runner-status-cell :runner="item" /> + <runner-status-cell :runner="item"> + <template #runner-job-status-badge="{ runner }"> + <slot name="runner-job-status-badge" :runner="runner"></slot> + </template> + </runner-status-cell> </template> - <template #cell(summary)="{ item, index }"> + <template #cell(summary)="{ item }"> <runner-summary-cell :runner="item"> <template #runner-name="{ runner }"> - <slot name="runner-name" :runner="runner" :index="index"></slot> - </template> - <template #runner-job-status-badge="{ runner }"> - <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot> + <slot name="runner-name" :runner="runner"></slot> </template> </runner-summary-cell> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue index e6576c83e69..d2f7912fabb 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -1,5 +1,6 @@ <script> import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; export default { @@ -12,6 +13,7 @@ export default { directives: { GlModal: GlModalDirective, }, + mixins: [glFeatureFlagMixin()], props: { isSearchFiltered: { type: Boolean, @@ -33,6 +35,17 @@ export default { required: false, default: null, }, + newRunnerPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + shouldShowCreateRunnerWorkflow() { + // create_runner_workflow feature flag + return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow; + }, }, modalId: 'runners-empty-state-instructions-modal', svgHeight: 145, @@ -61,15 +74,17 @@ export default { ) " > - <template #link="{ content }"> + <template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }"> + <gl-link :href="newRunnerPath">{{ content }}</gl-link> + </template> + <template v-else #link="{ content }"> <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> </template> </gl-sprintf> - - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> </template> <template v-else #description> {{ diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue new file mode 100644 index 00000000000..d70c51e83f9 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue @@ -0,0 +1,76 @@ +<script> +import { GlFormRadio } from '@gitlab/ui'; + +export default { + components: { + GlFormRadio, + }, + model: { + event: 'input', + prop: 'checked', + }, + props: { + image: { + type: String, + required: false, + default: null, + }, + checked: { + type: String, + required: false, + default: null, + }, + value: { + type: String, + required: false, + default: null, + }, + }, + computed: { + isChecked() { + return this.value && this.value === this.checked; + }, + }, + methods: { + onInput($event) { + if (!$event) { + return; + } + this.$emit('input', $event); + }, + onChange($event) { + this.$emit('change', $event); + }, + }, +}; +</script> + +<template> + <div + class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6" + :class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }" + @click="onInput(value)" + > + <gl-form-radio + v-if="value" + class="gl-min-h-5" + :checked="checked" + :value="value" + @input="onInput($event)" + @change="onChange($event)" + > + <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" /> + <span class="gl-font-weight-bold"><slot></slot></span> + </gl-form-radio> + <div v-else class="gl-h-5"> + <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" /> + <span class="gl-font-weight-bold"><slot></slot></span> + </div> + </div> +</template> + +<style> +.runner-platforms-radio { + min-width: 173px; +} +</style> diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue new file mode 100644 index 00000000000..273226141d2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue @@ -0,0 +1,108 @@ +<script> +import AWS_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/aws.svg?url'; +import DOCKER_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/ci_cd-template-logos/docker.png'; +import KUBERNETES_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/kubernetes.svg?url'; +import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui'; + +import { + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + AWS_PLATFORM, + DOCKER_HELP_URL, + KUBERNETES_HELP_URL, +} from '../constants'; + +import RunnerPlatformsRadio from './runner_platforms_radio.vue'; + +export default { + components: { + GlFormRadioGroup, + GlLink, + GlIcon, + RunnerPlatformsRadio, + }, + props: { + value: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + model: this.value, + }; + }, + watch: { + model() { + this.$emit('input', this.model); + }, + }, + LINUX_PLATFORM, + MACOS_PLATFORM, + WINDOWS_PLATFORM, + + AWS_PLATFORM, + AWS_LOGO_URL, + DOCKER_HELP_URL, + DOCKER_LOGO_URL, + KUBERNETES_HELP_URL, + KUBERNETES_LOGO_URL, +}; +</script> + +<template> + <gl-form-radio-group v-model="model"> + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Operating systems') }}</label> + + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM"> + Linux + </runner-platforms-radio> + <runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM"> + macOS + </runner-platforms-radio> + <runner-platforms-radio v-model="model" :value="$options.WINDOWS_PLATFORM"> + Windows + </runner-platforms-radio> + </div> + </div> + + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Cloud templates') }}</label> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <runner-platforms-radio + v-model="model" + :image="$options.AWS_LOGO_URL" + :value="$options.AWS_PLATFORM" + > + AWS + </runner-platforms-radio> + </div> + </div> + + <div class="gl-mt-3 gl-mb-6"> + <label>{{ s__('Runners|Containers') }}</label> + + <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <runner-platforms-radio :image="$options.DOCKER_LOGO_URL"> + <gl-link :href="$options.DOCKER_HELP_URL" target="_blank"> + Docker + <gl-icon name="external-link" /> + </gl-link> + </runner-platforms-radio> + <runner-platforms-radio :image="$options.KUBERNETES_LOGO_URL"> + <gl-link :href="$options.KUBERNETES_HELP_URL" target="_blank"> + Kubernetes + <gl-icon name="external-link" /> + </gl-link> + </runner-platforms-radio> + </div> + </div> + </gl-form-radio-group> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 31900a1fe89..318eb7e74bd 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -98,6 +98,8 @@ export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details +export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path + export const I18N_DETAILS = s__('Runners|Details'); export const I18N_JOBS = s__('Runners|Jobs'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); @@ -150,6 +152,8 @@ export const JOB_STATUS_IDLE = 'IDLE'; export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED'; +export const DEFAULT_ACCESS_LEVEL = ACCESS_LEVEL_NOT_PROTECTED; + // CiRunnerSort export const CREATED_DESC = 'CREATED_DESC'; @@ -170,3 +174,17 @@ export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS; export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners'; export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners'; + +// Platforms + +export const LINUX_PLATFORM = 'linux'; +export const MACOS_PLATFORM = 'osx'; +export const WINDOWS_PLATFORM = 'windows'; +export const AWS_PLATFORM = 'aws'; + +export const DEFAULT_PLATFORM = LINUX_PLATFORM; + +// Runner docs are in a separate repository and are not shipped with GitLab +// they are rendered as external URLs. +export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html'; +export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html'; diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index 075dbb06190..b6d6996a857 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -15,13 +15,10 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, icon text } - pipeline { + project { id - project { - id - name - webUrl - } + name + webUrl } shortSha commitPath diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue index 75138b1bd81..273a9aa823c 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue @@ -1,13 +1,15 @@ <script> import { createAlert, VARIANT_SUCCESS } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { redirectTo } from '~/lib/utils/url_utility'; + import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; -import RunnerDetails from '../components/runner_details.vue'; +import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; + import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; @@ -20,7 +22,7 @@ export default { RunnerEditButton, RunnerPauseButton, RunnerHeader, - RunnerDetails, + RunnerDetailsTabs, }, props: { runnerId: { @@ -47,7 +49,7 @@ export default { query: runnerQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { @@ -89,6 +91,6 @@ export default { </template> </runner-header> - <runner-details v-if="runner" :runner="runner" /> + <runner-details-tabs :runner="runner" :show-access-help="true" /> </div> </template> diff --git a/app/assets/javascripts/ci/runner/group_runner_show/index.js b/app/assets/javascripts/ci/runner/group_runner_show/index.js index e75f337b38e..a6c1ee1d232 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/index.js +++ b/app/assets/javascripts/ci/runner/group_runner_show/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); +Vue.use(VueRouter); export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { showAlertFromLocalStorage(); 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 57ceaa24b6e..e66a1c7b1aa 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 @@ -24,6 +24,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; import RunnerMembershipToggle from '../components/runner_membership_toggle.vue'; +import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; @@ -34,6 +35,7 @@ import { PROJECT_TYPE, I18N_FETCH_ERROR, FILTER_CSS_CLASSES, + JOBS_ROUTE_PATH, } from '../constants'; import { captureException } from '../sentry_utils'; @@ -51,6 +53,7 @@ export default { RunnerPagination, RunnerTypeTabs, RunnerActionsCell, + RunnerJobStatusBadge, }, mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], @@ -64,10 +67,6 @@ export default { type: String, required: true, }, - groupRunnersLimitedCount: { - type: Number, - required: true, - }, }, data() { return { @@ -175,6 +174,12 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + jobsUrl(runner) { + const url = new URL(this.webUrl(runner)); + url.hash = `#${JOBS_ROUTE_PATH}`; + + return url.href; + }, refetchCounts() { this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] }); }, @@ -255,6 +260,12 @@ export default { :loading="runnersLoading" @deleted="onDeleted" > + <template #runner-job-status-badge="{ runner }"> + <runner-job-status-badge + :href="jobsUrl(runner)" + :job-status="runner.jobExecutionStatus" + /> + </template> <template #runner-name="{ runner }"> <gl-link :href="webUrl(runner)"> <runner-name :runner="runner" /> diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js index 0e7efd2b8a1..46514d5afe8 100644 --- a/app/assets/javascripts/ci/runner/group_runners/index.js +++ b/app/assets/javascripts/ci/runner/group_runners/index.js @@ -20,7 +20,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => { runnerInstallHelpPage, groupId, groupFullPath, - groupRunnersLimitedCount, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, @@ -50,7 +49,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => { props: { registrationToken, groupFullPath, - groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10), }, }); }, diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue index 879162916a9..4593c9ae52b 100644 --- a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue +++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue @@ -1,6 +1,6 @@ <script> import { createAlert } from '~/flash'; -import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import RunnerHeader from '../components/runner_header.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; @@ -35,7 +35,7 @@ export default { query: runnerFormQuery, variables() { return { - id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId), }; }, error(error) { diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue new file mode 100644 index 00000000000..799c6ec79d4 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue @@ -0,0 +1,54 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + secureFile: { + type: Object, + required: true, + }, + admin: { + type: Boolean, + required: true, + }, + modalId: { + type: String, + required: true, + }, + }, + i18n: { + metadataLabel: __('View File Metadata'), + }, + metadataModalId: 'metadataModalId', + methods: { + selectSecureFile() { + this.$emit('selectSecureFile', this.secureFile); + }, + hasMetadata() { + return this.secureFile.metadata !== null; + }, + }, +}; +</script> + +<template> + <gl-button + v-if="admin && hasMetadata()" + v-gl-modal="modalId" + v-gl-tooltip.hover.top="$options.i18n.metadataLabel" + category="secondary" + variant="info" + icon="doc-text" + :aria-label="$options.i18n.metadataLabel" + data-testid="metadata-button" + @click="selectSecureFile()" + /> +</template> diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue new file mode 100644 index 00000000000..a459b721394 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue @@ -0,0 +1,129 @@ +<script> +import { GlModal, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import { __, s__, createDateTimeFormat } from '~/locale'; +import Tracking from '~/tracking'; +import MetadataTable from './table.vue'; + +const dateFormat = createDateTimeFormat({ + dateStyle: 'long', + timeStyle: 'long', +}); + +export default { + components: { + GlModal, + GlSprintf, + MetadataTable, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [Tracking.mixin()], + props: { + name: { + type: String, + required: false, + default: '', + }, + fileExtension: { + type: String, + required: false, + default: '', + }, + metadata: { + type: Object, + required: false, + default: Object.new, + }, + modalId: { + type: String, + required: true, + }, + }, + i18n: { + metadataLabel: __('View File Metadata'), + metadataModalTitle: s__('SecureFiles|%{name} Metadata'), + }, + metadataModalId: 'metadataModalId', + methods: { + teamName() { + return `${this.metadata.subject.O} (${this.metadata.subject.OU})`; + }, + issuerName() { + return [this.metadata.issuer.CN, '-', this.metadata.issuer.OU].join(' '); + }, + expiresAt() { + return dateFormat.format(new Date(this.metadata.expires_at)); + }, + mobileprovisionTeamName() { + return `${this.metadata.team_name} (${this.metadata.team_id.join(', ')})`; + }, + platformNames() { + return this.metadata.platforms.join(', '); + }, + appName() { + return [this.metadata.app_name, '-', this.metadata.app_id].join(' '); + }, + certificates() { + return this.metadata.certificate_ids.join(', '); + }, + cerItems() { + return [ + { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN }, + { name: s__('SecureFiles|Serial'), data: this.metadata.id }, + { name: s__('SecureFiles|Team'), data: this.teamName() }, + { name: s__('SecureFiles|Issuer'), data: this.issuerName() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + p12Items() { + return [ + { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN }, + { name: s__('SecureFiles|Serial'), data: this.metadata.id }, + { name: s__('SecureFiles|Team'), data: this.teamName() }, + { name: s__('SecureFiles|Issuer'), data: this.issuerName() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + mobileprovisionItems() { + return [ + { name: s__('SecureFiles|UUID'), data: this.metadata.id }, + { name: s__('SecureFiles|Platforms'), data: this.platformNames() }, + { name: s__('SecureFiles|Team'), data: this.mobileprovisionTeamName() }, + { name: s__('SecureFiles|App'), data: this.appName() }, + { name: s__('SecureFiles|Certificates'), data: this.certificates() }, + { name: s__('SecureFiles|Expires at'), data: this.expiresAt() }, + ]; + }, + items() { + if (this.metadata) { + if (this.fileExtension === 'cer') { + this.track('load_secure_file_metadata_cer'); + return this.cerItems(); + } else if (this.fileExtension === 'p12') { + this.track('load_secure_file_metadata_p12'); + return this.p12Items(); + } else if (this.fileExtension === 'mobileprovision') { + this.track('load_secure_file_metadata_mobileprovision'); + return this.mobileprovisionItems(this.metadata); + } + } + + return []; + }, + }, +}; +</script> +`` + +<template> + <gl-modal :ref="modalId" :modal-id="modalId" title-tag="h4" category="primary" hide-footer> + <template #modal-title> + <gl-sprintf :message="$options.i18n.metadataModalTitle"> + <template #name>{{ name }}</template> + </gl-sprintf> + </template> + + <metadata-table :items="items()" /> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue new file mode 100644 index 00000000000..92043ff0a31 --- /dev/null +++ b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue @@ -0,0 +1,36 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; + +export default { + components: { + GlTableLite, + }, + props: { + items: { + required: true, + type: Array, + }, + }, + fields: [ + { + key: 'item_name', + thClass: 'hidden', + }, + { + key: 'item_data', + thClass: 'hidden', + }, + ], +}; +</script> + +<template> + <gl-table-lite :items="items" :fields="$options.fields"> + <template #cell(item_name)="{ item }"> + <strong>{{ item.name }}</strong> + </template> + <template #cell(item_data)="{ item }"> + {{ item.data }} + </template> + </gl-table-lite> +</template> 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 661389f4059..dd80698ec1a 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 @@ -17,6 +17,8 @@ import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import MetadataButton from './metadata/button.vue'; +import MetadataModal from './metadata/modal.vue'; export default { components: { @@ -29,6 +31,8 @@ export default { GlSprintf, GlTable, TimeagoTooltip, + MetadataButton, + MetadataModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -57,6 +61,7 @@ export default { deleteModalButton: s__('SecureFiles|Delete secure file'), }, deleteModalId: 'deleteModalId', + metadataModalId: 'metadataModalId', data() { return { page: 1, @@ -68,6 +73,7 @@ export default { projectSecureFiles: [], deleteModalFileId: null, deleteModalFileName: null, + metadataSecureFile: {}, }; }, fields: [ @@ -162,6 +168,9 @@ export default { this.deleteModalFileId = secureFile.id; this.deleteModalFileName = secureFile.name; }, + updateMetadataSecureFile(secureFile) { + this.metadataSecureFile = secureFile; + }, uploadFormData(file) { const formData = new FormData(); formData.append('name', file.name); @@ -208,6 +217,12 @@ export default { </template> <template #cell(actions)="{ item }"> + <metadata-button + :secure-file="item" + :admin="admin" + modal-id="$options.metadataModalId" + @selectSecureFile="updateMetadataSecureFile" + /> <gl-button v-if="admin" v-gl-modal="$options.deleteModalId" @@ -272,5 +287,12 @@ export default { <template #name>{{ deleteModalFileName }}</template> </gl-sprintf> </gl-modal> + + <metadata-modal + :name="metadataSecureFile.name" + :file-extension="metadataSecureFile.file_extension" + :metadata="metadataSecureFile.metadata" + modal-id="$options.metadataModalId" + /> </div> </template> 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 1f8096da94d..a1b264cfe54 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 @@ -8,7 +8,8 @@ import { GlTable, GlTooltipDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { thWidthPercent } from '~/lib/utils/table_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; @@ -43,43 +44,70 @@ export default { default: () => [], }, }, + data() { + return { + areValuesHidden: true, + }; + }, fields: [ { key: 'token', label: s__('Pipelines|Token'), + thClass: thWidthPercent(70), }, { key: 'description', label: s__('Pipelines|Description'), + thClass: thWidthPercent(15), }, { key: 'owner', label: s__('Pipelines|Owner'), + thClass: thWidthPercent(5), }, { key: 'lastUsed', label: s__('Pipelines|Last Used'), + thClass: thWidthPercent(5), }, { key: 'actions', label: '', tdClass: 'gl-text-right gl-white-space-nowrap', + thClass: thWidthPercent(5), }, ], + computed: { + valuesButtonText() { + return this.areValuesHidden ? __('Reveal values') : __('Hide values'); + }, + hasTriggers() { + return this.triggers.length; + }, + maskedToken() { + return '*'.repeat(47); + }, + }, + methods: { + toggleHiddenState() { + this.areValuesHidden = !this.areValuesHidden; + }, + }, }; </script> <template> <div> <gl-table - v-if="triggers.length" + v-if="hasTriggers" :fields="$options.fields" :items="triggers" class="triggers-list" responsive > <template #cell(token)="{ item }"> - {{ item.token }} + <span v-if="!areValuesHidden">{{ item.token }}</span> + <span v-else>{{ maskedToken }}</span> <clipboard-button v-if="item.hasTokenExposed" :text="item.token" @@ -157,5 +185,11 @@ export default { > {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} </gl-alert> + <gl-button + v-if="hasTriggers" + data-testid="reveal-hide-values-button" + @click="toggleHiddenState" + >{{ valuesButtonText }}</gl-button + > </div> </template> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 76af552181f..e97d6500260 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -22,7 +22,7 @@ export const EVENT_DETAILS = { body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'), titleIcon: { name: 'status-success', - class: 'text-success-500', + class: 'gl-text-green-500', }, }, agent_disconnected: { @@ -31,7 +31,7 @@ export const EVENT_DETAILS = { body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'), titleIcon: { name: 'severity-critical', - class: 'text-danger-800', + class: 'gl-text-red-800', }, }, }; @@ -50,12 +50,12 @@ export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}'; export const INTEGRATION_STATUS_VALID_TOKEN = { icon: 'status-success', - iconClass: 'text-success-500', + iconClass: 'gl-text-green-500', text: s__('ClusterAgents|Valid access token'), }; export const INTEGRATION_STATUS_NO_TOKEN = { icon: 'status-alert', - iconClass: 'text-danger-500', + iconClass: 'gl-text-red-500', text: s__('ClusterAgents|No agent access token'), }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 21524c5b29e..a788703fd08 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -3,11 +3,11 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; import { createAlert } from '~/flash'; import AccessorUtilities from '~/lib/utils/accessor'; -import initProjectSelectDropdown from '~/project_select'; import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; import PersistentUserCallout from '~/persistent_user_callout'; import initSettingsPanels from '~/settings_panels'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; @@ -62,7 +62,7 @@ export default class Clusters { this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); this.tokenField = document.querySelector('.js-cluster-token'); - initProjectSelectDropdown(); + initProjectSelects(); Clusters.initDismissableCallout(); initSettingsPanels(); diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 615754459d6..fe3fa22fea3 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -144,7 +144,7 @@ export const AGENT_STATUSES = { active: { name: s__('ClusterAgents|Connected'), icon: 'status-success', - class: 'text-success-500', + class: 'gl-text-green-500', tooltip: { title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')), }, @@ -152,7 +152,7 @@ export const AGENT_STATUSES = { inactive: { name: s__('ClusterAgents|Not connected'), icon: 'status-alert', - class: 'text-danger-500', + class: 'gl-text-red-500', tooltip: { title: s__('ClusterAgents|Agent might not be connected to GitLab'), body: sprintf( @@ -165,7 +165,7 @@ export const AGENT_STATUSES = { unused: { name: s__('ClusterAgents|Never connected'), icon: 'status-neutral', - class: 'text-secondary-500', + class: 'gl-text-gray-500', tooltip: { title: s__('ClusterAgents|Agent never connected to GitLab'), body: s__('ClusterAgents|Make sure you are using a valid token.'), diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 9cb7cd9607f..c937e65abe3 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -1,11 +1,10 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, props: { projects: { @@ -19,32 +18,37 @@ export default { }, }, computed: { - dropdownText() { - if (Object.keys(this.selectedProject).length) { - return this.selectedProject.name; - } - - return __('Select private project'); + selectedProjectValue() { + return this.selectedProject?.id && String(this.selectedProject.id); + }, + toggleText() { + return this.selectedProject?.name || __('Select private project'); + }, + listboxItems() { + return this.projects.map(({ id, name }) => { + return { + value: String(id), + text: name, + }; + }); }, }, methods: { - selectProject(project) { - this.$emit('click', project); + selectProject(projectId) { + const project = this.projects.find(({ id }) => String(id) === projectId); + this.$emit('select', project); }, }, }; </script> <template> - <gl-dropdown block icon="lock" :text="dropdownText"> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - is-check-item - :is-checked="project.id === selectedProject.id" - @click="selectProject(project)" - > - {{ project.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + icon="lock" + :items="listboxItems" + :selected="selectedProjectValue" + :toggle-text="toggleText" + block + @select="selectProject" + /> </template> diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index e95424eef4d..196f5537a90 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -114,7 +114,7 @@ export default { v-if="projects.length" :projects="projects" :selected-project="selectedProject" - @click="selectProject" + @select="selectProject" /> <p class="gl-text-gray-600 gl-mt-1 gl-mb-0"> <template v-if="projects.length"> diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 93b31ea7d20..ca17443081c 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -1,18 +1,63 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlTooltip, GlDisclosureDropdown } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +import { __ } from '~/locale'; export default { components: { - GlDropdown, - GlDropdownItem, - }, - directives: { + GlDisclosureDropdown, GlTooltip, }, inject: ['tiptapEditor'], data() { return { - isActive: {}, + toggleId: uniqueId('dropdown-toggle-btn-'), + items: [ + { + text: __('Comment'), + action: () => this.insert('comment'), + }, + { + text: __('Code block'), + action: () => this.insert('codeBlock'), + }, + { + text: __('Details block'), + action: () => this.insertList('details', 'detailsContent'), + }, + { + text: __('Bullet list'), + action: () => this.insertList('bulletList', 'listItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Ordered list'), + action: () => this.insertList('orderedList', 'listItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Task list'), + action: () => this.insertList('taskList', 'taskItem'), + wrapperClass: 'gl-sm-display-none!', + }, + { + text: __('Horizontal rule'), + action: () => this.execute('setHorizontalRule', 'horizontalRule'), + }, + { + text: __('Mermaid diagram'), + action: () => this.insert('diagram', { language: 'mermaid' }), + }, + { + text: __('PlantUML diagram'), + action: () => this.insert('diagram', { language: 'plantuml' }), + }, + { + text: __('Table of contents'), + action: () => this.execute('insertTableOfContents', 'tableOfContents'), + }, + ], }; }, methods: { @@ -46,47 +91,17 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip - size="small" - category="tertiary" - icon="plus" - :text="__('More')" - :title="__('More')" - text-sr-only - class="content-editor-dropdown" - right - lazy - > - <gl-dropdown-item @click="insert('comment')"> - {{ __('Comment') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('codeBlock')"> - {{ __('Code block') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insertList('details', 'detailsContent')"> - {{ __('Details block') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')"> - {{ __('Bullet list') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')"> - {{ __('Ordered list') }} - </gl-dropdown-item> - <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')"> - {{ __('Task list') }} - </gl-dropdown-item> - <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')"> - {{ __('Horizontal rule') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })"> - {{ __('Mermaid diagram') }} - </gl-dropdown-item> - <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })"> - {{ __('PlantUML diagram') }} - </gl-dropdown-item> - <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')"> - {{ __('Table of contents') }} - </gl-dropdown-item> - </gl-dropdown> + <div class="gl-display-inline-flex gl-vertical-align-middle"> + <gl-disclosure-dropdown + :items="items" + :toggle-id="toggleId" + size="small" + category="tertiary" + icon="plus" + :toggle-text="__('More options')" + text-sr-only + right + /> + <gl-tooltip :target="toggleId" placement="top">{{ __('More options') }}</gl-tooltip> + </div> </template> diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 131c79357bf..540815f57c9 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -514,6 +514,7 @@ export const code = { open: generateCodeTag(), close: generateCodeTag(closeTag), mixable: true, + escape: false, expelEnclosingWhitespace: true, }; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 4e4c21328ca..17e6cc87ff8 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -1,19 +1,33 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { debounce, uniq } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { visitUrl } from '~/lib/utils/url_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { __ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; import { xAxisLabelFormatter, dateFormatter } from '../utils'; +const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; + export default { + i18n: { + history: __('History'), + refSelectorTranslations: { + dropdownHeader: __('Switch branch/tag'), + searchPlaceholder: __('Search branches and tags'), + }, + }, components: { GlAreaChart, + GlButton, GlLoadingIcon, ResizableChartContainer, + RefSelector, }, props: { endpoint: { @@ -24,7 +38,16 @@ export default { type: String, required: true, }, + projectId: { + type: String, + required: true, + }, + commitsPath: { + type: String, + required: true, + }, }, + refTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], data() { return { masterChart: null, @@ -32,6 +55,7 @@ export default { svgs: {}, masterChartHeight: 264, individualChartHeight: 216, + selectedBranch: this.branch, }; }, computed: { @@ -190,6 +214,11 @@ export default { ), ); }, + visitBranch(selected) { + const graphsPathPrefix = this.endpoint.match(GRAPHS_PATH_REGEX)?.[0]; + + visitUrl(`${graphsPathPrefix}/${selected}`); + }, }, }; </script> @@ -197,48 +226,66 @@ export default { <template> <div> <div v-if="loading" class="gl-text-center gl-pt-13"> - <gl-loading-icon :inline="true" size="xl" /> + <gl-loading-icon :inline="true" size="xl" data-testid="loading-app-icon" /> </div> - <div v-else-if="showChart" class="contributors-charts"> - <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> - <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> - <resizable-chart-container> - <template #default="{ width }"> - <gl-area-chart - class="gl-mb-5" - :width="width" - :data="masterChartData" - :option="masterChartOptions" - :height="masterChartHeight" - @created="onMasterChartCreated" - /> - </template> - </resizable-chart-container> + <template v-else-if="showChart"> + <div class="gl-border-b gl-border-gray-100 gl-mb-6 gl-bg-gray-10 gl-p-5"> + <div class="gl-display-flex"> + <div class="gl-mr-3"> + <ref-selector + v-model="selectedBranch" + :project-id="projectId" + :enabled-ref-types="$options.refTypes" + :translations="$options.i18n.refSelectorTranslations" + @input="visitBranch" + /> + </div> + <gl-button :href="commitsPath" data-testid="history-button" + >{{ $options.i18n.history }} + </gl-button> + </div> + </div> + <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> + <resizable-chart-container> + <template #default="{ width }"> + <gl-area-chart + class="gl-mb-5" + :width="width" + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </template> + </resizable-chart-container> - <div class="row"> - <div - v-for="(contributor, index) in individualChartsData" - :key="index" - class="col-lg-6 col-12 gl-my-5" - > - <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> - <p class="gl-mb-3"> - {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) - </p> - <resizable-chart-container> - <template #default="{ width }"> - <gl-area-chart - :width="width" - :data="contributor.dates" - :option="individualChartOptions" - :height="individualChartHeight" - @created="onIndividualChartCreated" - /> - </template> - </resizable-chart-container> + <div class="row"> + <div + v-for="(contributor, index) in individualChartsData" + :key="index" + class="col-lg-6 col-12 gl-my-5" + > + <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> + <p class="gl-mb-3"> + {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) + </p> + <resizable-chart-container> + <template #default="{ width }"> + <gl-area-chart + :width="width" + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </template> + </resizable-chart-container> + </div> </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js index f66133a074d..1bb7360547c 100644 --- a/app/assets/javascripts/contributors/index.js +++ b/app/assets/javascripts/contributors/index.js @@ -7,18 +7,19 @@ export default () => { if (!el) return null; - const { projectGraphPath, projectBranch, defaultBranch } = el.dataset; + const { projectGraphPath, projectBranch, defaultBranch, projectId, commitsPath } = el.dataset; const store = createStore(defaultBranch); return new Vue({ el, store, - render(createElement) { return createElement(ContributorsGraphs, { props: { endpoint: projectGraphPath, branch: projectBranch, + projectId, + commitsPath, }, }); }, diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue index a851c7a9e85..57931121629 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -1,7 +1,7 @@ <script> import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; +import { TYPENAME_CRM_CONTACT, TYPENAME_GROUP } from '~/graphql_shared/constants'; import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; @@ -44,10 +44,10 @@ export default { contactGraphQLId() { if (!this.isEditMode) return null; - return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id); + return convertToGraphQLId(TYPENAME_CRM_CONTACT, this.$route.params.id); }, groupGraphQLId() { - return convertToGraphQLId(TYPE_GROUP, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, mutation() { if (this.isEditMode) return updateContactMutation; diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 01bff4b69d6..4d2a038458d 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -1,7 +1,7 @@ <script> import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants'; +import { TYPENAME_CRM_ORGANIZATION, TYPENAME_GROUP } from '~/graphql_shared/constants'; import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; import createOrganizationMutation from './graphql/create_organization.mutation.graphql'; @@ -23,10 +23,10 @@ export default { organizationGraphQLId() { if (!this.isEditMode) return null; - return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id); + return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, this.$route.params.id); }, groupGraphQLId() { - return convertToGraphQLId(TYPE_GROUP, this.groupId); + return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, mutation() { if (this.isEditMode) return updateOrganizationMutation; diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 8019a10a042..7503df9194b 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -17,6 +17,7 @@ import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { createAlert, VARIANT_INFO } from '~/flash'; +import { sanitize } from '~/lib/dompurify'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -517,8 +518,36 @@ export default class Notes { if (discussionContainer.length === 0) { if (noteEntity.diff_discussion_html) { const discussionElement = document.createElement('table'); - // eslint-disable-next-line no-unsanitized/method - discussionElement.insertAdjacentHTML('afterbegin', noteEntity.diff_discussion_html); + let internalNote; + let discussionDOM; + + if (!noteEntity.on_image) { + /* + DOMPurify will strip table-less <tr>/<td>, so to get it to stop deleting + nodes (since our note HTML starts with a table-less <tr>), we need to wrap + the noteEntity discussion HTML in a <table> to perform the other + sanitization. + */ + internalNote = sanitize(`<table>${noteEntity.diff_discussion_html}</table>`, { + RETURN_DOM: true, + }); + /* + Since we wrapped the <tr> in a <table>, we need to extract the <tr> back out. + DOMPurify returns a Body Element, so we have to start there, then get the + wrapping table, and then get the content we actually want. + Curiously, DOMPurify **ADDS** a totally novel <tbody>, so we're actually + inserting a completely as-yet-unseen <tbody> element here. + */ + discussionDOM = internalNote.querySelector('table').firstChild; + } else { + // Image comments don't need <table> manipulation, they're already <div>s + internalNote = sanitize(noteEntity.diff_discussion_html, { + RETURN_DOM: true, + }); + discussionDOM = internalNote.firstChild; + } + + discussionElement.insertAdjacentElement('afterbegin', discussionDOM); renderGFM(discussionElement); const $discussion = $(discussionElement).unwrap(); diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 674415ec449..4ce6395140e 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -262,6 +262,7 @@ export default { <div class="gl-absolute gl-top-0 gl-left-0 frame" :style="overlayStyle" + data-testid="design-overlay" @mousemove="onOverlayMousemove" @mouseleave="onNoteMouseup" > @@ -287,6 +288,7 @@ export default { :is-inactive="isNoteInactive(note)" :is-resolved="note.resolved" is-on-image + data-testid="note-pin" @mousedown.stop="onNoteMousedown($event, note)" @mouseup.stop="onNoteMouseup(note)" /> @@ -294,6 +296,7 @@ export default { <design-note-pin v-if="currentCommentForm" :position="currentCommentPositionStyle" + data-testid="comment-badge" @mousedown.stop="onNoteMousedown" @mouseup.stop="onNoteMouseup" /> diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue index 5354c7756f5..fd691d1f04e 100644 --- a/app/assets/javascripts/design_management/components/image.vue +++ b/app/assets/javascripts/design_management/components/image.vue @@ -72,12 +72,19 @@ export default { }, setBaseImageSize() { const { contentImg } = this.$refs; - if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + if (!contentImg) return; + if (contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) { + this.baseImageSize = { + height: contentImg.naturalHeight, + width: contentImg.naturalWidth, + }; + } else { + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + } - this.baseImageSize = { - height: contentImg.offsetHeight, - width: contentImg.offsetWidth, - }; this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); }, setImageNaturalScale() { @@ -96,6 +103,11 @@ export default { const { height, width } = this.baseImageSize; + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + this.$parent.$emit( 'setMaxScale', Math.round(((height + width) / (naturalHeight + naturalWidth)) * 100) / 100, diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 1e36aa686a4..f52486f0629 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -64,17 +64,17 @@ export default { const icons = { creation: { name: 'file-addition-solid', - classes: 'text-success-500', + classes: 'gl-text-green-500', tooltip: __('Added in this version'), }, modification: { name: 'file-modified-solid', - classes: 'text-primary-500', + classes: 'gl-text-blue-500', tooltip: __('Modified in this version'), }, deletion: { name: 'file-deletion-solid', - classes: 'text-danger-500', + classes: 'gl-text-red-500', tooltip: __('Archived in this version'), }, }; @@ -144,7 +144,7 @@ export default { /> </span> </div> - <gl-intersection-observer @appear="onAppear"> + <gl-intersection-observer class="gl-flex-grow-1" @appear="onAppear"> <gl-loading-icon v-if="showLoadingSpinner" size="lg" /> <gl-icon v-else-if="showImageErrorIcon" @@ -156,7 +156,7 @@ export default { v-show="showImage" :src="imageLink" :alt="filename" - class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full design-img" + class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full gl-w-auto design-img" data-qa-selector="design_image" :data-qa-filename="filename" :data-testid="`design-img-${id}`" diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index 8c44c5a5d0a..cef2d5e1a18 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -14,7 +14,7 @@ import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages'; Vue.use(VueApollo); -const resolvers = { +export const resolvers = { Mutation: { updateActiveDiscussion: (_, { id = null, source }, { cache }) => { const sourceData = cache.readQuery({ query: activeDiscussionQuery }); diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index e5695c4390f..dfca6d61270 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -60,6 +60,16 @@ export default { type: Boolean, required: true, }, + isFirstHighlightedLine: { + type: Boolean, + required: false, + default: false, + }, + isLastHighlightedLine: { + type: Boolean, + required: false, + default: false, + }, fileLineCoverage: { type: Function, required: true, @@ -81,12 +91,23 @@ export default { ), parallelViewLeftLineType: memoize( (props) => { - return utils.parallelViewLeftLineType(props.line, props.isHighlighted || props.isCommented); + return utils.parallelViewLeftLineType({ + line: props.line, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, + }); }, (props) => - [props.line.left?.type, props.line.right?.type, props.isHighlighted, props.isCommented].join( - ':', - ), + [ + props.line.left?.type, + props.line.right?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), coverageStateLeft: memoize( (props) => { @@ -118,20 +139,40 @@ export default { classNameMapCellLeft: memoize( (props) => { return utils.classNameMapCell({ - line: props.line.left, - hll: props.isHighlighted || props.isCommented, + line: props.line?.left, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, }); }, - (props) => [props.line.left.type, props.isHighlighted, props.isCommented].join(':'), + (props) => + [ + props.line?.left?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), classNameMapCellRight: memoize( (props) => { return utils.classNameMapCell({ - line: props.line.right, - hll: props.isHighlighted || props.isCommented, + line: props.line?.right, + highlighted: props.isHighlighted, + commented: props.isCommented, + selectionStart: props.isFirstHighlightedLine, + selectionEnd: props.isLastHighlightedLine, }); }, - (props) => [props.line.right.type, props.isHighlighted, props.isCommented].join(':'), + (props) => + [ + props.line?.right?.type, + props.isHighlighted, + props.isCommented, + props.isFirstHighlightedLine, + props.isLastHighlightedLine, + ].join(':'), ), shouldRenderCommentButton: memoize( (props) => { @@ -303,15 +344,24 @@ export default { !props.inline || (props.line.left && props.line.left.type === $options.CONFLICT_MARKER) " > - <div data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell"> + <div + data-testid="left-empty-cell" + class="diff-td diff-line-num old_line empty-cell" + :class="$options.classNameMapCellLeft(props)" + > </div> - <div v-if="props.inline" class="diff-td diff-line-num old_line empty-cell"></div> - <div class="diff-td line-coverage left-side empty-cell"></div> - <div v-if="props.inline" class="diff-td line-codequality left-side empty-cell"></div> + <div + class="diff-td line-coverage left-side empty-cell" + :class="$options.classNameMapCellLeft(props)" + ></div> + <div + class="diff-td line-codequality left-side empty-cell" + :class="$options.classNameMapCellLeft(props)" + ></div> <div class="diff-td line_content with-coverage left-side empty-cell" - :class="[{ parallel: !props.inline }]" + :class="[{ parallel: !props.inline }, ...$options.classNameMapCellLeft(props)]" ></div> </template> </div> @@ -390,13 +440,13 @@ export default { :class="[ props.line.right.type, $options.coverageStateRight(props).class, - { hll: props.isHighlighted, hll: props.isCommented }, + ...$options.classNameMapCellRight(props), ]" class="diff-td line-coverage right-side has-tooltip" ></div> <div class="diff-td line-codequality right-side" - :class="[props.line.right.type, { hll: props.isHighlighted, hll: props.isCommented }]" + :class="$options.classNameMapCellRight(props)" > <component :is="$options.CodeQualityGutterIcon" @@ -414,10 +464,9 @@ export default { :class="[ props.line.right.type, { - hll: props.isHighlighted, - hll: props.isCommented, 'gl-font-weight-bold': props.line.right.type === $options.CONFLICT_MARKER_THEIR, }, + ...$options.classNameMapCellRight(props), ]" class="diff-td line_content with-coverage right-side parallel" v-html=" @@ -426,10 +475,23 @@ export default { ></div> </template> <template v-else> - <div data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell"></div> - <div class="diff-td line-coverage right-side empty-cell"></div> - <div class="diff-td line-codequality right-side empty-cell"></div> - <div class="diff-td line_content with-coverage right-side empty-cell parallel"></div> + <div + data-testid="right-empty-cell" + class="diff-td diff-line-num old_line empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line-coverage right-side empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line-codequality right-side empty-cell" + :class="$options.classNameMapCellRight(props)" + ></div> + <div + class="diff-td line_content with-coverage right-side empty-cell parallel" + :class="$options.classNameMapCellRight(props)" + ></div> </template> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 479853caae3..a489c96b0c9 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -40,19 +40,33 @@ export const lineCode = (line) => { return line.line_code || line.left?.line_code || line.right?.line_code; }; -export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => { - if (!line) return []; - const { type } = line; +export const classNameMapCell = ({ + line, + highlighted, + commented, + selectionStart, + selectionEnd, + isLoggedIn, + isHover, +}) => { + const classes = { + 'highlight-top': highlighted || selectionStart, + 'highlight-bottom': highlighted || selectionEnd, + hll: highlighted, + commented, + }; - return [ - type, - { - hll, + if (line) { + const { type } = line; + Object.assign(classes, { + [type]: true, [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type), - old_line: line.type === 'old', - new_line: line.type === 'new', - }, - ]; + old_line: type === 'old', + new_line: type === 'new', + }); + } + + return [classes]; }; export const addCommentTooltip = (line) => { @@ -88,14 +102,28 @@ export const addCommentTooltip = (line) => { return tooltip; }; -export const parallelViewLeftLineType = (line, hll) => { +export const parallelViewLeftLineType = ({ + line, + highlighted, + commented, + selectionStart, + selectionEnd, +}) => { if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) { return OLD_NO_NEW_LINE_TYPE; } const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE; - return [lineTypeClass, { hll }]; + return [ + lineTypeClass, + { + hll: highlighted, + commented, + 'highlight-top': highlighted || selectionStart, + 'highlight-bottom': highlighted || selectionEnd, + }, + ]; }; export const shouldShowCommentButton = (hover, context, meta, discussions) => { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index aa9a17d18e3..a2e052e0f93 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -59,7 +59,12 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), - ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']), + ...mapState('diffs', [ + 'codequalityDiff', + 'highlightedRow', + 'coverageLoaded', + 'selectedCommentPosition', + ]), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -144,6 +149,14 @@ export default { false, ); }, + isFirstHighlightedLine(line) { + const lineCode = line.left?.line_code || line.right?.line_code; + return lineCode && lineCode === this.selectedCommentPosition?.start.line_code; + }, + isLastHighlightedLine(line) { + const lineCode = line.left?.line_code || line.right?.line_code; + return lineCode && lineCode === this.selectedCommentPosition?.end.line_code; + }, handleParallelLineMouseDown(e) { const line = e.target.closest('.diff-td'); if (line) { @@ -230,10 +243,14 @@ export default { :line="line" :is-bottom="index + 1 === diffLinesLength" :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" + :is-highlighted="isHighlighted(line)" + :is-first-highlighted-line=" + isFirstHighlightedLine(line) || index === commentedLines.startLine + " + :is-last-highlighted-line="isLastHighlightedLine(line) || index === commentedLines.endLine" :inline="inline" :index="index" :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))" - :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index abf77fa2ede..8bb1872567c 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -101,7 +101,7 @@ export default { </button> </div> </div> - <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> + <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList || search }" class="tree-list-scroll"> <template v-if="filteredTreeList.length"> <file-tree v-for="file in filteredTreeList" @@ -112,6 +112,9 @@ export default { :hide-file-stats="hideFileStats" :file-row-component="$options.DiffFileRow" :current-diff-file-id="currentDiffFileId" + :style="{ '--level': 0 }" + :class="{ 'tree-list-parent': file.tree.length }" + class="gl-relative" @toggleTreeOpen="toggleTreeOpen" @clickFile="(path) => scrollToFile({ path })" /> diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index e6f7a31e07b..f90d29c84b8 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -87,8 +87,8 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length; const shouldPad = processingFileCount >= 1; + addFileToForm(response.link.url, header.size); pasteText(response.link.markdown, shouldPad); - addFileToForm(response.link.url); }, error: (file, errorMessage = __('Attaching the file failed.'), xhr) => { // If 'error' event is fired by dropzone, the second parameter is error message. diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 87d869cc996..57477a993c5 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -417,6 +417,20 @@ "type": "object", "additionalProperties": false, "properties": { + "component": { + "description": "Local path to component directory or full path to external component directory.", + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "component" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "remote": { "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.", "type": "string", @@ -777,7 +791,7 @@ "properties": { "value": { "type": "string", - "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#prefill-variables-in-manual-pipelines)" + "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesvalue)" }, "options": { "type": "array", @@ -786,7 +800,7 @@ }, "minItems": 1, "uniqueItems": true, - "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#configure-a-list-of-selectable-values-for-a-prefilled-variable)" + "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesoptions)" }, "description": { "type": "string", @@ -1955,4 +1969,4 @@ "additionalProperties": false } } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 1bac0ef1359..ee5d95ae6f0 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -3,6 +3,10 @@ import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@ import { helpPagePath } from '~/helpers/help_page_helper'; import { isAbsolute } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { + ENVIRONMENT_NEW_HELP_TEXT, + ENVIRONMENT_EDIT_HELP_TEXT, +} from 'ee_else_ce/environments/constants'; export default { components: { @@ -13,6 +17,7 @@ export default { GlLink, GlSprintf, }, + inject: ['protectedEnvironmentSettingsPath'], props: { environment: { required: true, @@ -34,9 +39,8 @@ export default { }, i18n: { header: __('Environments'), - helpMessage: __( - 'Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}.', - ), + helpNewMessage: ENVIRONMENT_NEW_HELP_TEXT, + helpEditMessage: ENVIRONMENT_EDIT_HELP_TEXT, nameLabel: __('Name'), nameFeedback: __('This field is required'), nameDisabledHelp: __("You cannot rename an environment after it's created."), @@ -62,6 +66,9 @@ export default { isNameDisabled() { return Boolean(this.environment.id); }, + showEditHelp() { + return this.isNameDisabled && Boolean(this.protectedEnvironmentSettingsPath); + }, valid() { return { name: this.visited.name && this.environment.name !== '', @@ -89,9 +96,14 @@ export default { {{ $options.i18n.header }} </h4> <p class="gl-w-full"> - <gl-sprintf :message="$options.i18n.helpMessage"> + <gl-sprintf + :message="showEditHelp ? $options.i18n.helpEditMessage : $options.i18n.helpNewMessage" + > <template #link="{ content }"> - <gl-link :href="$options.helpPagePath">{{ content }}</gl-link> + <gl-link + :href="showEditHelp ? protectedEnvironmentSettingsPath : $options.helpPagePath" + >{{ content }}</gl-link + > </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 55e6a891e27..b2a69cdb6c6 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -15,6 +15,7 @@ import { ENVIRONMENTS_SCOPE } from '../constants'; import EnvironmentFolder from './environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import StopStaleEnvironmentsModal from './stop_stale_environments_modal.vue'; import EnvironmentItem from './new_environment_item.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; @@ -31,6 +32,7 @@ export default { EnableReviewAppModal, EnvironmentItem, StopEnvironmentModal, + StopStaleEnvironmentsModal, GlBadge, GlPagination, GlSearchBoxByType, @@ -75,6 +77,7 @@ export default { i18n: { newEnvironmentButtonLabel: s__('Environments|New environment'), reviewAppButtonLabel: s__('Environments|Enable review app'), + cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'), available: __('Available'), stopped: __('Stopped'), prevPage: __('Go to previous page'), @@ -85,11 +88,13 @@ export default { searchPlaceholder: s__('Environments|Search by environment name'), }, modalId: 'enable-review-app-info', + stopStaleEnvsModalId: 'stop-stale-environments-modal', data() { const { page = '1', search = '', scope } = queryToObject(window.location.search); return { interval: undefined, isReviewAppModalVisible: false, + isStopStaleEnvModalVisible: false, page: parseInt(page, 10), pageInfo: {}, scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) @@ -107,6 +112,9 @@ export default { canSetupReviewApp() { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, + canCleanUpEnvs() { + return this.environmentApp?.canStopStaleEnvironments; + }, folders() { return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; }, @@ -149,6 +157,19 @@ export default { }, }; }, + openCleanUpEnvsModal() { + if (!this.canCleanUpEnvs) { + return null; + } + + return { + text: this.$options.i18n.cleanUpEnvsButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, stoppedCount() { return this.environmentApp?.stoppedCount; }, @@ -178,6 +199,9 @@ export default { showReviewAppModal() { this.isReviewAppModalVisible = true; }, + showCleanUpEnvsModal() { + this.isStopStaleEnvModalVisible = true; + }, setScope(scope) { this.scope = scope; this.moveToPage(1); @@ -219,16 +243,24 @@ export default { :modal-id="$options.modalId" data-testid="enable-review-app-modal" /> + <stop-stale-environments-modal + v-if="canCleanUpEnvs" + v-model="isStopStaleEnvModalVisible" + :modal-id="$options.stopStaleEnvsModalId" + data-testid="stop-stale-environments-modal" + /> <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" /> <gl-tabs - :action-secondary="addEnvironment" - :action-primary="openReviewAppModal" + :action-secondary="openReviewAppModal" + :action-primary="openCleanUpEnvsModal" + :action-tertiary="addEnvironment" sync-active-tab-with-query-params query-param-name="scope" - @primary="showReviewAppModal" + @secondary="showReviewAppModal" + @primary="showCleanUpEnvsModal" > <gl-tab :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE" diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 9a100e0199e..73dfd993c5b 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -323,6 +323,7 @@ export default { > <deployment :deployment="upcomingDeployment" + :visible="visible" :class="{ 'gl-ml-7': inFolder }" class="gl-pl-4" > diff --git a/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue new file mode 100644 index 00000000000..57873b28d37 --- /dev/null +++ b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue @@ -0,0 +1,104 @@ +<script> +import { GlTooltipDirective, GlModal, GlDatepicker, GlFormGroup } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { stopStaleEnvironments } from '~/rest_api'; +import { MIN_STALE_ENVIRONMENT_DATE, MAX_STALE_ENVIRONMENT_DATE } from '../constants'; + +export default { + id: 'stop-stale-environments-modal', + name: 'StopStaleEnvironmentsModal', + + components: { + GlModal, + GlDatepicker, + GlFormGroup, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + projectId: { + default: '', + }, + }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + modalId: { + type: String, + required: true, + }, + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + modalProps: { + primary: { + text: s__('Environments|Clean up'), + attributes: [{ variant: 'info' }], + }, + cancel: { + text: __('Cancel'), + }, + dateRange: { + minDate: MIN_STALE_ENVIRONMENT_DATE, // 10 years ago + maxDate: MAX_STALE_ENVIRONMENT_DATE, + }, + }, + + data() { + return { + stopEnvironmentsBefore: MAX_STALE_ENVIRONMENT_DATE, + }; + }, + + methods: { + onSubmit() { + stopStaleEnvironments(this.projectId, this.stopEnvironmentsBefore || this.maxDate); + }, + }, +}; +</script> + +<template> + <gl-modal + :action-primary="$options.modalProps.primary" + :action-cancel="$options.modalProps.cancel" + :visible="visible" + :modal-id="modalId" + :title="s__('Environments|Clean up environments')" + static + @primary="onSubmit" + @change="$emit('change', $event)" + > + <p> + {{ + s__( + 'Environments|Select which environments to clean up. \ + Protected environments are excluded. Learn more about cleaning up environments.', + ) + }} + </p> + + <gl-form-group + :label="s__('Environments|Stop unused environments')" + :label-description=" + s__('Environments|Stop environments that have not been updated since the specified date:') + " + label-for="stop_environments-before" + > + <gl-datepicker + v-model="stopEnvironmentsBefore" + input-id="stop-environments-before" + data-testid="stop-environments-before" + :min-date="$options.modalProps.dateRange.minDate" + :max-date="$options.modalProps.dateRange.maxDate" + :default-date="$options.modalProps.dateRange.maxDate" + /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index c4d02da9d21..28424322dd2 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -1,4 +1,5 @@ import { __, s__ } from '~/locale'; +import { getDateInPast } from '~/lib/utils/datetime_utility'; // These statuses are based on how the backend defines pod phases here // lib/gitlab/kubernetes/pod.rb @@ -77,3 +78,12 @@ export const REVIEW_APP_MODAL_I18N = { viewMoreExampleProjects: s__('EnableReviewApp|View more example projects'), copyToClipboardText: s__('EnableReviewApp|Copy snippet'), }; + +export const MIN_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 3650); // 10 years ago +export const MAX_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 7); // one week ago + +export const ENVIRONMENT_NEW_HELP_TEXT = __( + 'Environments allow you to track deployments of your application.%{linkStart} More information.%{linkEnd}', +); + +export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT; diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js index dd6680f64bd..a128d2fb3c7 100644 --- a/app/assets/javascripts/environments/edit.js +++ b/app/assets/javascripts/environments/edit.js @@ -7,6 +7,7 @@ export default (el) => provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath, updateEnvironmentPath: el.dataset.updateEnvironmentPath, + protectedEnvironmentSettingsPath: el.dataset.protectedEnvironmentSettingsPath, }, render(h) { return h(EditEnvironment, { diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue new file mode 100644 index 00000000000..77d9311743c --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue @@ -0,0 +1,31 @@ +<script> +import ActionsComponent from '~/environments/components/environment_actions.vue'; + +export default { + components: { + ActionsComponent, + }, + props: { + actions: { + // actions shape: + /* Array<{ + playable: boolean, + playPath: url, + name: string + scheduledAt: ISO_timestamp | null + }> + */ + type: Array, + required: true, + }, + }, + computed: { + isActionsShown() { + return this.actions.length > 0; + }, + }, +}; +</script> +<template> + <actions-component v-if="isActionsShown" :actions="actions" graphql /> +</template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index bf690ffedeb..3b33d6a676e 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -45,6 +45,12 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ columnClass: 'gl-w-10p', tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, + { + key: 'actions', + label: __('Actions'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, ]; export const translations = { diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index 41570ee44c0..10f8c06e581 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -5,11 +5,13 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DeploymentStatusLink from './components/deployment_status_link.vue'; import DeploymentJob from './components/deployment_job.vue'; import DeploymentTriggerer from './components/deployment_triggerer.vue'; +import DeploymentActions from './components/deployment_actions.vue'; import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; export default { components: { DeploymentTriggerer, + DeploymentActions, DeploymentJob, Commit, TimeAgoTooltip, @@ -51,5 +53,8 @@ export default { <template #cell(deployed)="{ item }"> <time-ago-tooltip :time="item.deployed" /> </template> + <template #cell(actions)="{ item }"> + <deployment-actions :actions="item.actions" /> + </template> </gl-table-lite> </template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index b43f4233b9c..f4657c5100a 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -59,7 +59,11 @@ export default { }, computed: { deployments() { - return this.project.environment?.deployments.nodes.map(convertToDeploymentTableRow) || []; + return ( + this.project.environment?.deployments.nodes.map((deployment) => + convertToDeploymentTableRow(deployment, this.project.environment), + ) || [] + ); }, isLoading() { return this.$apollo.queries.project.loading; diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql index 1a572208a1c..7a50ded7d6c 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -4,5 +4,6 @@ query getEnvironmentApp($page: Int, $scope: String, $search: String) { stoppedCount environments reviewApp + canStopStaleEnvironments } } diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index c6c2024c840..0182b3a7234 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -13,6 +13,13 @@ query getEnvironmentDetails( environment(name: $environmentName) { id name + lastDeployment(status: SUCCESS) { + id + job { + id + name + } + } deployments( orderBy: { createdAt: DESC } first: $first @@ -36,6 +43,19 @@ query getEnvironmentDetails( name id webPath + playable + deploymentPipeline: pipeline { + id + jobs(whenExecuted: ["manual"], retried: false) { + nodes { + id + name + playable + scheduledAt + webPath + } + } + } } commit { id diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index afd56d0cf0d..e21670870b8 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -54,6 +54,7 @@ export const resolvers = (endpoint) => ({ ...convertObjectPropsToCamelCase(res.data.review_app), __typename: 'ReviewApp', }, + canStopStaleEnvironments: res.data.can_stop_stale_environments, stoppedCount: res.data.stopped_count, __typename: 'LocalEnvironmentApp', }; diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js index bfe92fe3125..9802dcbcf78 100644 --- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -41,22 +41,46 @@ export const getCommitFromDeploymentNode = (deploymentNode) => { }; }; +export const convertJobToDeploymentAction = (job) => { + return { + name: job.name, + playable: job.playable, + scheduledAt: job.scheduledAt, + playPath: `${job.webPath}/play`, + }; +}; + +export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName) => { + if (!deploymentNode || !lastDeploymentName) { + return []; + } + + return ( + deploymentNode.job?.deploymentPipeline?.jobs?.nodes + ?.filter((deployment) => deployment.name !== lastDeploymentName) + .map(convertJobToDeploymentAction) || [] + ); +}; + /** * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table * @param {Object} deploymentNode * @returns {Object} */ -export const convertToDeploymentTableRow = (deploymentNode) => { +export const convertToDeploymentTableRow = (deploymentNode, environment) => { + const { lastDeployment } = environment; + const commit = getCommitFromDeploymentNode(deploymentNode); return { status: deploymentNode.status.toLowerCase(), id: deploymentNode.iid, triggerer: deploymentNode.triggerer, - commit: getCommitFromDeploymentNode(deploymentNode), + commit, job: deploymentNode.job && { webPath: deploymentNode.job.webPath, label: `${deploymentNode.job.name} (#${getIdFromGraphQLId(deploymentNode.job.id)})`, }, created: deploymentNode.createdAt || '', deployed: deploymentNode.finishedAt || '', + actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name), }; }; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index cebf73ef8e5..483f1d2c7a0 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -3,56 +3,11 @@ import Vue from 'vue'; import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -const FLASH_TYPES = { - ALERT: 'alert', - NOTICE: 'notice', - SUCCESS: 'success', - WARNING: 'warning', -}; - -const VARIANT_SUCCESS = 'success'; -const VARIANT_WARNING = 'warning'; -const VARIANT_DANGER = 'danger'; -const VARIANT_INFO = 'info'; -const VARIANT_TIP = 'tip'; - -const FLASH_CLOSED_EVENT = 'flashClosed'; - -const getCloseEl = (flashEl) => { - return flashEl.querySelector('.js-close-icon'); -}; - -const hideFlash = (flashEl, fadeTransition = true) => { - if (fadeTransition) { - Object.assign(flashEl.style, { - transition: 'opacity 0.15s', - opacity: '0', - }); - } - - flashEl.addEventListener( - 'transitionend', - () => { - flashEl.remove(); - window.dispatchEvent(new Event('resize')); - flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT)); - if (document.body.classList.contains('flash-shown')) - document.body.classList.remove('flash-shown'); - }, - { - once: true, - passive: true, - }, - ); - - if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend')); -}; - -const addDismissFlashClickListener = (flashEl, fadeTransition) => { - // There are some flash elements which do not have a closeEl. - // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml - getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); -}; +export const VARIANT_SUCCESS = 'success'; +export const VARIANT_WARNING = 'warning'; +export const VARIANT_DANGER = 'danger'; +export const VARIANT_INFO = 'info'; +export const VARIANT_TIP = 'tip'; /** * Render an alert at the top of the page, or, optionally an @@ -96,7 +51,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {boolean} [options.captureError] - Whether to send error to Sentry * @param {object} [options.error] - Error to be captured in Sentry */ -const createAlert = function createAlert({ +export const createAlert = ({ message, title, variant = VARIANT_DANGER, @@ -108,7 +63,7 @@ const createAlert = function createAlert({ onDismiss = null, captureError = false, error = null, -}) { +}) => { if (captureError && error) Sentry.captureException(error); const alertContainer = parent.querySelector(containerSelector); @@ -180,16 +135,3 @@ const createAlert = function createAlert({ }, }); }; - -export { - hideFlash, - addDismissFlashClickListener, - FLASH_TYPES, - FLASH_CLOSED_EVENT, - createAlert, - VARIANT_SUCCESS, - VARIANT_WARNING, - VARIANT_DANGER, - VARIANT_INFO, - VARIANT_TIP, -}; diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index a4e883c96b5..947d3053094 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -6,6 +6,7 @@ import { 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'; @@ -13,6 +14,8 @@ 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, @@ -24,7 +27,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [frequentItemsMixin], + mixins: [frequentItemsMixin, trackingMixin], inject: ['vuexModule'], props: { currentUserName: { @@ -84,6 +87,13 @@ export default { 'toggleItemsListEditablity', 'fetchFrequentItems', ]), + toggleItemsListEditablityTracked() { + this.track('click_button', { + label: 'toggle_edit_frequent_items', + property: 'navigation_top', + }); + this.toggleItemsListEditablity(); + }, dropdownOpenHandler() { if (this.searchQuery === '' || isMobile()) { this.fetchFrequentItems(); @@ -155,7 +165,7 @@ export default { :title="translations.headerEditToggle" :class="{ 'gl-bg-gray-100!': isItemsListEditable }" class="gl-p-2!" - @click="toggleItemsListEditablity" + @click="toggleItemsListEditablityTracked" > <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> </gl-button> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 75ea9beb5cf..056dedf8757 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { snakeCase } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -61,10 +60,17 @@ export default { return highlight(this.itemName, this.matcher); }, itemTrackingLabel() { - return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`; + 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']), }, }; @@ -78,7 +84,7 @@ export default { 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 })" + @click="track('click_link', { label: itemTrackingLabel, property: 'navigation_top' })" > <div class="gl-flex-grow-1"> <project-avatar @@ -116,9 +122,9 @@ export default { category="tertiary" :aria-label="__('Remove')" :title="__('Remove')" - class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-top-4 gl-right-4" + 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="removeFrequentItem(itemId)" + @click.stop.prevent="removeFrequentItemTracked(itemId)" > <gl-icon name="close" /> </gl-button> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index 4a1b7e57749..023245f050b 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -28,12 +28,25 @@ export default { 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> @@ -43,6 +56,8 @@ export default { <gl-search-box-by-type v-model="searchQuery" :placeholder="translations.searchInputPlaceholder" + @focus="trackFocus" + @blur="trackBlur" /> </div> </template> diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 2b157fac878..f4008fe3cc9 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,5 +1,6 @@ import autosize from 'autosize'; import $ from 'jquery'; +import { isEmpty } from 'lodash'; import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import dropzoneInput from './dropzone_input'; @@ -12,14 +13,22 @@ export default class GLForm { * @param {jQuery} form Root element of the GLForm * @param {Object} enableGFM Which autocomplete features should be enabled? * @param {Boolean} forceNew If true, treat the element as a **new** form even if `gfm-form` class already exists. + * @param {Object} gfmDataSources The paths of the autocomplete data sources to use for GfmAutoComplete + * By default, the backend embeds these in the global object gl.GfmAutocomplete.dataSources. + * Use this param to override them. */ - constructor(form, enableGFM = {}, forceNew = false) { + constructor(form, enableGFM = {}, forceNew = false, gfmDataSources = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; // Disable autocomplete for keywords which do not have dataSources available - const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + let dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + + if (!isEmpty(gfmDataSources)) { + dataSources = gfmDataSources; + } + Object.keys(this.enableGFM).forEach((item) => { if (item !== 'emojis' && !dataSources[item]) { this.enableGFM[item] = false; @@ -29,7 +38,7 @@ export default class GLForm { // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form - this.setupForm(forceNew); + this.setupForm(dataSources, forceNew); this.form.data('glForm', this); } @@ -46,7 +55,7 @@ export default class GLForm { this.form.data('glForm', null); } - setupForm(forceNew = false) { + setupForm(dataSources, forceNew = false) { const isNewForm = this.form.is(':not(.gfm-form)') || forceNew; this.form.removeClass('js-new-note-form'); if (isNewForm) { @@ -57,7 +66,7 @@ export default class GLForm { this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'), ); - this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); + this.autoComplete = new GfmAutoComplete(dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 }); diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 0a4733de65f..ad339155a59 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -13,7 +13,7 @@ export default class GpgBadges { return Promise.resolve(); } - const badges = $('.js-loading-gpg-badge'); + const badges = $('.js-loading-signature-badge'); badges.html(loadingIconForLegacyJS()); badges.children().attr('aria-label', __('Loading')); diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 22fa2912881..3c4ca4c197e 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,23 +1,28 @@ export const MINIMUM_SEARCH_LENGTH = 3; -export const TYPE_BOARD = 'Board'; -export const TYPE_CI_RUNNER = 'Ci::Runner'; -export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; -export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization'; -export const TYPE_DISCUSSION = 'Discussion'; -export const TYPE_EPIC = 'Epic'; -export const TYPE_EPIC_BOARD = 'Boards::EpicBoard'; -export const TYPE_GROUP = 'Group'; -export const TYPE_ISSUE = 'Issue'; -export const TYPE_ITERATION = 'Iteration'; -export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; -export const TYPE_MERGE_REQUEST = 'MergeRequest'; -export const TYPE_MILESTONE = 'Milestone'; -export const TYPE_NOTE = 'Note'; -export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; -export const TYPE_PROJECT = 'Project'; -export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; -export const TYPE_SITE_PROFILE = 'DastSiteProfile'; -export const TYPE_USER = 'User'; -export const TYPE_VULNERABILITY = 'Vulnerability'; -export const TYPE_WORK_ITEM = 'WorkItem'; +export const TYPENAME_BOARD = 'Board'; +export const TYPENAME_CI_BUILD = 'Ci::Build'; +export const TYPENAME_CI_PIPELINE = 'Ci::Pipeline'; +export const TYPENAME_CI_RUNNER = 'Ci::Runner'; +export const TYPENAME_CI_VARIABLE = 'Ci::Variable'; +export const TYPENAME_COMMIT_STATUS = 'CommitStatus'; +export const TYPENAME_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPENAME_CRM_ORGANIZATION = 'CustomerRelations::Organization'; +export const TYPENAME_DISCUSSION = 'Discussion'; +export const TYPENAME_EPIC = 'Epic'; +export const TYPENAME_EPIC_BOARD = 'Boards::EpicBoard'; +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_MERGE_REQUEST = 'MergeRequest'; +export const TYPENAME_MILESTONE = 'Milestone'; +export const TYPENAME_NOTE = 'Note'; +export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package'; +export const TYPENAME_PROJECT = 'Project'; +export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile'; +export const TYPENAME_SITE_PROFILE = 'DastSiteProfile'; +export const TYPENAME_USER = 'User'; +export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; +export const TYPENAME_VULNERABILITY = 'Vulnerability'; +export const TYPENAME_WORK_ITEM = 'WorkItem'; diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 01cc2fc3018..316bc746051 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -6,6 +6,8 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import { findHierarchyWidgetChildren } from '~/work_items/utils'; export const config = { typeDefs, @@ -13,7 +15,9 @@ export const config = { // included temporarily until Vuex is removed from boards app dataIdFromObject: (object) => { // eslint-disable-next-line no-underscore-dangle - return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object); + return object.__typename === 'BoardList' && !window.gon?.features?.apolloBoards + ? object.iid + : defaultDataIdFromObject(object); }, typePolicies: { Project: { @@ -72,6 +76,7 @@ export const config = { }, }; } + return incomingWidget || existingWidget; }); }, @@ -83,12 +88,85 @@ export const config = { nodes: concatPagination(), }, }, + ...(window.gon?.features?.apolloBoards + ? { + BoardList: { + fields: { + issues: { + keyArgs: ['filters'], + }, + }, + }, + IssueConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + EpicList: { + fields: { + epics: { + keyArgs: ['filters'], + }, + }, + }, + EpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + BoardEpicConnection: { + merge(existing = { nodes: [] }, incoming, { args }) { + if (!args.after) { + return incoming; + } + return { + ...incoming, + nodes: [...existing.nodes, ...incoming.nodes], + }; + }, + }, + } + : {}), }, }, }; export const resolvers = { Mutation: { + addHierarchyChild: (_, { id, workItem }, { cache }) => { + const queryArgs = { query: getWorkItemLinksQuery, variables: { id } }; + const sourceData = cache.readQuery(queryArgs); + + const data = produce(sourceData, (draftState) => { + findHierarchyWidgetChildren(draftState.workItem).push(workItem); + }); + + cache.writeQuery({ ...queryArgs, data }); + }, + removeHierarchyChild: (_, { id, workItem }, { cache }) => { + const queryArgs = { query: getWorkItemLinksQuery, variables: { id } }; + const sourceData = cache.readQuery(queryArgs); + + const data = produce(sourceData, (draftState) => { + const hierarchyChildren = findHierarchyWidgetChildren(draftState.workItem); + const index = hierarchyChildren.findIndex((child) => child.id === workItem.id); + hierarchyChildren.splice(index, 1); + }); + + cache.writeQuery({ ...queryArgs, data }); + }, updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { const sourceData = cache.readQuery({ query: getIssueStateQuery }); const data = produce(sourceData, (draftData) => { diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index a622b342c0a..4a5536986bd 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -156,6 +156,7 @@ "WorkItemWidgetRequirementLegacy", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", + "WorkItemWidgetTestReports", "WorkItemWidgetWeight" ] -} +}
\ No newline at end of file diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 8fb70eb59bd..806e89d6e9f 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -104,3 +104,15 @@ export const convertNodeIdsFromGraphQLIds = (nodes) => { return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node)); }; + +/** + * This function takes a GraphQL query data as a required argument and + * the field name to resolve as an optional argument + * and returns resolved field's data or an empty array + * @param {Object} queryData + * @param {String} nodesField (in most cases it will be 'nodes') + * @returns {Array} + */ +export const getNodesOrDefault = (queryData, nodesField = 'nodes') => { + return queryData?.[nodesField] ?? []; +}; diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 46d5341ea97..148bf0a98ee 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/flash'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -225,7 +226,7 @@ export default { }) .catch((err) => { let message = COMMON_STR.FAILURE; - if (err.status === 403) { + if (err.status === HTTP_STATUS_FORBIDDEN) { message = COMMON_STR.LEAVE_FORBIDDEN; } createAlert({ message }); diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js index 503dad673dd..6eab284c066 100644 --- a/app/assets/javascripts/groups/init_transfer_group_form.js +++ b/app/assets/javascripts/groups/init_transfer_group_form.js @@ -17,6 +17,7 @@ export default () => { targetFormId = null, buttonText: confirmButtonText = '', groupName = '', + groupFullPath, groupId: resourceId, isPaidGroup, } = el.dataset; @@ -35,7 +36,7 @@ export default () => { props: { isPaidGroup: parseBoolean(isPaidGroup), confirmButtonText, - confirmationPhrase: groupName, + confirmationPhrase: groupFullPath, }, on: { confirm: () => { diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index e0c8ce36e3c..360af772a10 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -25,6 +25,7 @@ export const i18n = { 'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.', ), ALERT_DISMISS_LABEL: __('Dismiss'), + NO_RESULTS_TEXT: __('No results found.'), }; export default { @@ -90,6 +91,9 @@ export default { hasGroupTransferLocations() { return this.groupTransferLocations.length; }, + hasAdditionalDropdownItems() { + return this.filteredAdditionalDropdownItems.length; + }, selectedText() { return this.value?.humanName || this.label; }, @@ -99,6 +103,17 @@ export default { showAdditionalDropdownItems() { return !this.isLoading && this.filteredAdditionalDropdownItems.length; }, + hasNoResults() { + if (this.isLoading || this.isSearchLoading) { + return false; + } + + return ( + !this.hasAdditionalDropdownItems && + !this.hasUserTransferLocations && + !this.hasGroupTransferLocations + ); + }, }, watch: { searchTerm() { @@ -274,6 +289,9 @@ export default { >{{ item.humanName }}</gl-dropdown-item > </div> + <gl-dropdown-item v-if="hasNoResults" button-class="gl-text-gray-900!" disabled>{{ + $options.i18n.NO_RESULTS_TEXT + }}</gl-dropdown-item> <gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" /> <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="handleLoadMoreGroups" /> </gl-dropdown> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index f58781fa9ec..6c9354b663f 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -33,6 +33,13 @@ function initStatusTriggers() { 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' : undefined, + }); + import( /* webpackChunkName: 'statusModalBundle' */ './set_status_modal/set_status_modal_wrapper.vue' ) diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index bf5daf29b21..ace0d77c431 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -171,7 +171,7 @@ export default { Tracking.event(undefined, 'focus_input', { label: 'global_search', - property: 'top_navigation', + property: 'navigation_top', }); } }, @@ -190,7 +190,7 @@ export default { Tracking.event(undefined, 'blur_input', { label: 'global_search', - property: 'top_navigation', + property: 'navigation_top', }); }, 200); }, diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index cda3379309c..65e113e5084 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -77,3 +77,5 @@ export const DROPDOWN_ORDER = [ ]; export const FETCH_TYPES = ['generic', 'search']; + +export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index f6f5c6a14fa..f6963263725 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -1,36 +1,44 @@ import Vue from 'vue'; +import * as Sentry from '@sentry/browser'; 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'); - let navBarEl = null; + const headerEl = document.querySelector('.header-content'); - if (!el) { + if (!el && !headerEl) { return false; } + const searchContainer = headerEl.querySelector('.global-search-container'); + const newHeader = headerEl.querySelector('.header-search-new'); + const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset; let { searchContext } = el.dataset; - searchContext = JSON.parse(searchContext); + + 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 }), - mounted() { - navBarEl = document.querySelector('.header-content'); - }, render(createElement) { return createElement(HeaderSearchApp, { on: { expandSearchBar: () => { - navBarEl?.classList.add('header-search-is-active'); + searchContainer.style.flexGrow = '1'; }, collapseSearchBar: () => { - navBarEl?.classList.remove('header-search-is-active'); + searchContainer.style.flexGrow = '0'; }, }, }); diff --git a/app/assets/javascripts/helpers/init_simple_app_helper.js b/app/assets/javascripts/helpers/init_simple_app_helper.js new file mode 100644 index 00000000000..695fc455f13 --- /dev/null +++ b/app/assets/javascripts/helpers/init_simple_app_helper.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; + +/** + * Initializes a component as a simple vue app, passing the necessary props. If the element + * has a data attribute named `data-view-model`, the content of that attributed will be + * converted from json and passed on to the component as a prop. The root component is then + * responsible for setting up it's children, injections, and other desired features. + * + * @param {string} selector css selector for where to build + * @param {Vue.component} component The Vue compoment to be built as the root of the app + * + * @example + * ```html + * <div id='#mount-here' data-view-model="{'some': 'object'}" /> + * ``` + * + * ```javascript + * initSimpleApp('#mount-here', MyApp) + * ``` + * + * This will mount MyApp as root on '#mount-here'. It will receive {'some': 'object'} as it's + * view model prop. + */ +export const initSimpleApp = (selector, component) => { + const element = document.querySelector(selector); + + if (!element) { + return null; + } + + const props = element.dataset.viewModel ? JSON.parse(element.dataset.viewModel) : {}; + + return new Vue({ + el: element, + render(h) { + return h(component, { props }); + }, + }); +}; diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index da2d4fbe7f0..8342b3f428c 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,10 +1,9 @@ <script> -import { mapGetters, mapState } from 'vuex'; +import { mapState } from 'vuex'; import { __ } from '~/locale'; import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; import JobsDetail from '../jobs/detail.vue'; import PipelinesList from '../pipelines/list.vue'; -import Clientside from '../preview/clientside.vue'; import ResizablePanel from '../resizable_panel.vue'; import TerminalView from '../terminal/view.vue'; import CollapsibleSidebar from './collapsible_sidebar.vue'; @@ -20,12 +19,8 @@ export default { }, computed: { ...mapState('terminal', { isTerminalVisible: 'isVisible' }), - ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), - ...mapGetters(['packageJson']), + ...mapState(['currentMergeRequestId']), ...mapState('rightPane', ['isOpen']), - showLivePreview() { - return this.packageJson && this.clientsidePreviewEnabled; - }, rightExtensionTabs() { return [ { @@ -38,12 +33,6 @@ export default { icon: 'rocket', }, { - show: this.showLivePreview, - title: __('Live preview'), - views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], - icon: 'live-preview', - }, - { show: this.isTerminalVisible, title: __('Terminal'), views: [{ component: TerminalView, ...rightSidebarViews.terminal }], diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue deleted file mode 100644 index 70b881b6ff6..00000000000 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ /dev/null @@ -1,191 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { listen } from 'codesandbox-api'; -import { isEmpty, debounce } from 'lodash'; -import { SandpackClient } from '@codesandbox/sandpack-client'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { - packageJsonPath, - LIVE_PREVIEW_DEBOUNCE, - PING_USAGE_PREVIEW_KEY, - PING_USAGE_PREVIEW_SUCCESS_KEY, -} from '../../constants'; -import eventHub from '../../eventhub'; -import { createPathWithExt } from '../../utils'; -import Navigator from './navigator.vue'; - -export default { - components: { - Navigator, - GlLoadingIcon, - }, - data() { - return { - client: {}, - loading: false, - sandpackReady: false, - }; - }, - computed: { - ...mapState(['entries', 'promotionSvgPath', 'links', 'codesandboxBundlerUrl']), - ...mapGetters(['packageJson', 'currentProject']), - normalizedEntries() { - return Object.keys(this.entries).reduce((acc, path) => { - const file = this.entries[path]; - - if (file.type === 'tree' || !(file.raw || file.content)) return acc; - - return { - ...acc, - [`/${path}`]: { - code: file.content || file.raw, - }, - }; - }, {}); - }, - mainEntry() { - if (!this.packageJson.raw) return false; - - const parsedPackage = JSON.parse(this.packageJson.raw); - - return parsedPackage.main; - }, - showPreview() { - return this.mainEntry && !this.loading; - }, - showEmptyState() { - return !this.mainEntry && !this.loading; - }, - showOpenInCodeSandbox() { - return this.currentProject && this.currentProject.visibility === 'public'; - }, - sandboxOpts() { - return { - files: { ...this.normalizedEntries }, - entry: `/${this.mainEntry}`, - showOpenInCodeSandbox: this.showOpenInCodeSandbox, - }; - }, - }, - watch: { - sandpackReady: { - handler(val) { - if (val) { - this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY); - } - }, - }, - }, - mounted() { - this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE); - eventHub.$on('ide.files.change', this.onFilesChangeCallback); - - this.loading = true; - - return this.loadFileContent(packageJsonPath) - .then(() => { - this.loading = false; - }) - .then(() => this.$nextTick()) - .then(() => this.initPreview()); - }, - beforeDestroy() { - // Setting sandpackReady = false protects us form a phantom `update()` being called when `debounce` finishes. - this.sandpackReady = false; - eventHub.$off('ide.files.change', this.onFilesChangeCallback); - - if (!isEmpty(this.client)) { - this.client.cleanup(); - } - - this.client = {}; - - if (this.listener) { - this.listener(); - } - }, - methods: { - ...mapActions(['getFileData', 'getRawFileData']), - ...mapActions('clientside', ['pingUsage']), - loadFileContent(path) { - return this.getFileData({ path, makeFileActive: false }).then(() => - this.getRawFileData({ path }), - ); - }, - initPreview() { - if (!this.mainEntry) return null; - - this.pingUsage(PING_USAGE_PREVIEW_KEY); - - return this.loadFileContent(this.mainEntry) - .then(() => this.$nextTick()) - .then(() => { - this.initClient(); - - this.listener = listen((e) => { - switch (e.type) { - case 'done': - this.sandpackReady = true; - break; - default: - break; - } - }); - }); - }, - update() { - if (!this.sandpackReady) return; - - if (isEmpty(this.client)) { - this.initPreview(); - - return; - } - - this.client.updatePreview(this.sandboxOpts); - }, - initClient() { - const { codesandboxBundlerUrl: bundlerURL } = this; - - const settings = { - fileResolver: { - isFile: (p) => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])), - readFile: (p) => this.loadFileContent(createPathWithExt(p)).then((content) => content), - }, - ...(bundlerURL ? { bundlerURL } : {}), - }; - - this.client = new SandpackClient('#ide-preview', this.sandboxOpts, settings); - }, - }, -}; -</script> - -<template> - <div class="preview h-100 w-100 d-flex flex-column gl-bg-white"> - <template v-if="showPreview"> - <navigator :client="client" /> - <div id="ide-preview"></div> - </template> - <div - v-else-if="showEmptyState" - v-once - class="d-flex h-100 flex-column align-items-center justify-content-center svg-content" - > - <img :src="promotionSvgPath" :alt="s__('IDE|Live Preview')" width="130" height="100" /> - <h3>{{ s__('IDE|Live Preview') }}</h3> - <p class="text-center"> - {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }} - </p> - <a - :href="links.webIDEHelpPagePath" - class="btn gl-button btn-confirm" - target="_blank" - rel="noopener noreferrer" - > - {{ s__('IDE|Get started with Live Preview') }} - </a> - </div> - <gl-loading-icon v-else size="lg" class="align-self-center mt-auto mb-auto" /> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue deleted file mode 100644 index 852de16d508..00000000000 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ /dev/null @@ -1,136 +0,0 @@ -<script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { listen } from 'codesandbox-api'; - -export default { - components: { - GlIcon, - GlLoadingIcon, - }, - props: { - client: { - type: Object, - required: true, - }, - }, - data() { - return { - currentBrowsingIndex: null, - navigationStack: [], - forwardNavigationStack: [], - path: '', - loading: true, - }; - }, - computed: { - backButtonDisabled() { - return this.navigationStack.length <= 1; - }, - forwardButtonDisabled() { - return !this.forwardNavigationStack.length; - }, - }, - mounted() { - this.listener = listen((e) => { - switch (e.type) { - case 'urlchange': - this.onUrlChange(e); - break; - case 'done': - this.loading = false; - break; - default: - break; - } - }); - }, - beforeDestroy() { - this.listener(); - }, - methods: { - onUrlChange(e) { - const lastPath = this.path; - - this.path = e.url.replace(this.client.bundlerURL, '') || '/'; - - if (lastPath !== this.path) { - this.currentBrowsingIndex = - this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1; - this.navigationStack.push(this.path); - } - }, - back() { - const lastPath = this.path; - - this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]); - - this.forwardNavigationStack.push(lastPath); - - if (this.currentBrowsingIndex === 1) { - this.currentBrowsingIndex = null; - this.navigationStack = []; - } - }, - forward() { - this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]); - }, - refresh() { - this.visitPath(this.path); - }, - visitPath(path) { - // eslint-disable-next-line vue/no-mutating-props - this.client.iframe.src = `${this.client.bundlerURL}${path}`; - }, - }, -}; -</script> - -<template> - <header class="ide-preview-header d-flex align-items-center"> - <button - :aria-label="s__('IDE|Back')" - :disabled="backButtonDisabled" - :class="{ - 'disabled-content': backButtonDisabled, - }" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="back" - > - <gl-icon :size="24" name="chevron-left" class="m-auto" /> - </button> - <button - :aria-label="s__('IDE|Back')" - :disabled="forwardButtonDisabled" - :class="{ - 'disabled-content': forwardButtonDisabled, - }" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="forward" - > - <gl-icon :size="24" name="chevron-right" class="m-auto" /> - </button> - <button - :aria-label="s__('IDE|Refresh preview')" - type="button" - class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" - @click="refresh" - > - <gl-icon :size="16" name="retry" class="m-auto" /> - </button> - <div class="position-relative w-100 gl-ml-2"> - <input - :value="path || '/'" - type="text" - class="ide-navigator-location form-control bg-white" - readonly - /> - <gl-loading-icon - v-if="loading" - size="sm" - class="position-absolute ide-preview-loading-icon" - /> - </div> - </header> -</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 01ce5fa07ee..1aa64656c30 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -64,7 +64,6 @@ export const rightSidebarViews = { pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, - clientSidePreview: { name: 'clientside', keepAlive: false }, terminal: { name: 'terminal', keepAlive: true }, }; @@ -101,22 +100,13 @@ export const commitActionTypes = { update: 'update', }; -export const packageJsonPath = 'package.json'; - export const SIDE_LEFT = 'left'; export const SIDE_RIGHT = 'right'; -// Live Preview feature -export const LIVE_PREVIEW_DEBOUNCE = 2000; - // This is the maximum number of files to auto open when opening the Web IDE // from a merge request export const MAX_MR_FILES_AUTO_OPEN = 10; export const DEFAULT_BRANCH = 'main'; -// Ping Usage Metrics Keys -export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview'; -export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success'; - export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 1347d92b3b7..29c44d2f596 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -67,10 +67,8 @@ export const initLegacyWebIDE = (el, options = {}) => { forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null, }); this.init({ - clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME, - codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl, environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, userPreferencesPath: el.dataset.userPreferencesPath, diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index d3c64754e8a..4d3cefcb107 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -7,6 +7,7 @@ import csrf from '~/lib/utils/csrf'; import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config'; import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element'; import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; +import { handleTracking } from './lib/gitlab_web_ide/handle_tracking_event'; const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { const remotePath = cleanLeadingSeparator(remotePathArg); @@ -38,6 +39,9 @@ export const initGitlabWebIDE = async (el) => { filePath, mergeRequest: mrId, forkInfo: forkInfoJSON, + editorFontSrcUrl, + editorFontFormat, + editorFontFamily, } = el.dataset; const rootEl = setupRootElement(el); @@ -64,6 +68,12 @@ export const initGitlabWebIDE = async (el) => { feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, userPreferences: el.dataset.userPreferencesPath, }, + editorFont: { + srcUrl: editorFontSrcUrl, + fontFamily: editorFontFamily, + format: editorFontFormat, + }, + handleTracking, async handleStartRemote({ remoteHost, remotePath, connectionToken }) { const confirmed = await confirmAction( __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 289027c3054..7a516f5e3f5 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -1,12 +1,5 @@ -import { useNewFonts } from '~/lib/utils/common_utils'; import { getCssVariable } from '~/lib/utils/css_utils'; -const fontOptions = {}; - -if (useNewFonts()) { - fontOptions.fontFamily = getCssVariable('--code-editor-font'); -} - export const defaultEditorOptions = { model: null, readOnly: false, @@ -18,7 +11,7 @@ export const defaultEditorOptions = { wordWrap: 'on', glyphMargin: true, automaticLayout: true, - ...fontOptions, + fontFamily: getCssVariable('--code-editor-font'), }; export const defaultDiffOptions = { diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js new file mode 100644 index 00000000000..615dad02386 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js @@ -0,0 +1,20 @@ +import { snakeCase } from 'lodash'; +import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; + +export const handleTracking = ({ name, data }) => { + const snakeCaseEventName = snakeCase(name); + + if (data && Object.keys(data).length) { + Tracking.event(undefined, snakeCaseEventName, { + /* See GitLab snowplow schema for a definition of the extra field + * https://docs.gitlab.com/ee/development/snowplow/schemas.html#gitlab_standard. + */ + extra: convertObjectPropsToSnakeCase(data, { + deep: true, + }), + }); + } else { + Tracking.event(undefined, snakeCaseEventName); + } +}; diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js index 78990953beb..f437965b25a 100644 --- a/app/assets/javascripts/ide/lib/mirror.js +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -1,3 +1,4 @@ +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import createDiff from './create_diff'; @@ -26,7 +27,7 @@ const cancellableWait = (time) => { const isErrorResponse = (error) => error && error.code !== 0; -const isErrorPayload = (payload) => payload && payload.status_code !== 200; +const isErrorPayload = (payload) => payload && payload.status_code !== HTTP_STATUS_OK; const getErrorFromResponse = (data) => { if (isErrorResponse(data.error)) { diff --git a/app/assets/javascripts/ide/remote/index.js b/app/assets/javascripts/ide/remote/index.js index fb8db20c0c1..6966786ca4e 100644 --- a/app/assets/javascripts/ide/remote/index.js +++ b/app/assets/javascripts/ide/remote/index.js @@ -1,6 +1,7 @@ import { startRemote } from '@gitlab/web-ide'; import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide'; import { isSameOriginUrl, joinPaths } from '~/lib/utils/url_utility'; +import { handleTracking } from '~/ide/lib/gitlab_web_ide/handle_tracking_event'; /** * @param {Element} rootEl @@ -36,5 +37,6 @@ export const mountRemoteIDE = async (el) => { // TODO Handle error better handleError: visitReturnUrl, handleClose: visitReturnUrl, + handleTracking, }); }; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index dc0f3a1d7e9..b7445d3ad0a 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,7 @@ import { escape } from 'lodash'; import Vue from 'vue'; import { createAlert } from '~/flash'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { @@ -278,7 +279,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = resolve(data); }) .catch((e) => { - if (e.response.status === 404) { + if (e.response.status === HTTP_STATUS_NOT_FOUND) { reject(e); } else { createAlert({ diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 3c02b1d1da7..c0f666c6652 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -2,7 +2,6 @@ import Api from '~/api'; import { addNumericSuffix } from '~/ide/utils'; import { leftSidebarViews, - packageJsonPath, DEFAULT_PERMISSIONS, PERMISSION_READ_MR, PERMISSION_CREATE_MR, @@ -153,8 +152,6 @@ export const currentBranch = (state, getters) => export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; -export const packageJson = (state) => state.entries[packageJsonPath]; - export const isOnDefaultBranch = (_state, getters) => getters.currentProject && getters.currentProject.default_branch === getters.branchName; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index b660ff178a2..c2f7126159c 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -3,7 +3,6 @@ import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import branches from './modules/branches'; -import clientsideModule from './modules/clientside'; import commitModule from './modules/commit'; import editorModule from './modules/editor'; import { setupFileEditorsSync } from './modules/editor/setup'; @@ -29,7 +28,6 @@ export const createStoreOptions = () => ({ branches, fileTemplates: fileTemplates(), rightPane: paneModule(), - clientside: clientsideModule(), router: routerModule, editor: editorModule, }, diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js deleted file mode 100644 index 1a8e665867f..00000000000 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export const pingUsage = ({ rootGetters }, metricName) => { - const { web_url: projectUrl } = rootGetters.currentProject; - - const url = `${projectUrl}/service_ping/${metricName}`; - - return axios.post(url); -}; - -export default pingUsage; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js deleted file mode 100644 index b28f7b935a8..00000000000 --- a/app/assets/javascripts/ide/stores/modules/clientside/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as actions from './actions'; - -export default () => ({ - namespaced: true, - actions, -}); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index b89d9d38a1a..356bbf28a48 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -26,10 +26,8 @@ export default () => ({ path: '', entry: {}, }, - clientsidePreviewEnabled: false, renderWhitespaceInCode: false, editorTheme: DEFAULT_THEME, - codesandboxBundlerUrl: null, environmentsGuidanceAlertDismissed: false, environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index db677c574d1..6dc0b2cec24 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -65,7 +65,9 @@ const STATUS_MAP = { }; function isIncompleteImport(stats) { - return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]); + return Object.keys(stats?.fetched ?? []).some( + (key) => stats.fetched[key] !== stats.imported[key], + ); } export default { @@ -91,7 +93,9 @@ export default { computed: { knownStats() { const knownStatisticKeys = Object.keys(STATISTIC_ITEMS); - return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key)); + return Object.keys(this.stats?.fetched ?? []).filter((key) => + knownStatisticKeys.includes(key), + ); }, hasStats() { @@ -142,7 +146,13 @@ export default { <template> <div> <div class="gl-display-inline-block gl-w-13"> - <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2"> + <gl-badge + :icon="mappedStatus.icon" + :variant="mappedStatus.variant" + size="md" + icon-size="sm" + class="gl-mr-2" + > {{ mappedStatus.text }} </gl-badge> </div> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index 8d72942447c..ed7c9e7abe9 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -46,7 +46,7 @@ export default { <template> <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center"> <gl-dropdown - v-if="isProjectsImportEnabled && isAvailableForImport" + v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)" :text="isFinished ? __('Re-import with projects') : __('Import with projects')" :disabled="isInvalid" variant="confirm" @@ -60,7 +60,7 @@ export default { }}</gl-dropdown-item> </gl-dropdown> <gl-button - v-else-if="isAvailableForImport" + v-else-if="isAvailableForImport || isFinished" :disabled="isInvalid" variant="confirm" category="secondary" @@ -70,7 +70,7 @@ export default { {{ isFinished ? __('Re-import') : __('Import') }} </gl-button> <gl-icon - v-if="isAvailableForImport && isFinished" + v-if="isFinished" v-gl-tooltip :size="16" name="information-o" 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 c590d832568..7d2ddd2176b 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 @@ -17,6 +17,7 @@ import { import { debounce } from 'lodash'; import { createAlert } from '~/flash'; 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'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; @@ -102,8 +103,12 @@ export default { perPage: DEFAULT_PAGE_SIZE, selectedGroupsIds: [], pendingGroupsIds: [], + reimportRequests: [], importTargets: {}, unavailableFeaturesAlertVisible: true, + helpUrl: helpPagePath('ee/user/group/import', { + anchor: 'visibility-rules', + }), }; }, @@ -177,9 +182,14 @@ export default { const importTarget = this.importTargets[group.id]; const status = this.getStatus(group); + const isGroupAvailableForImport = isFinished(group) + ? this.reimportRequests.includes(group.id) + : isAvailableForImport(group) && status !== STATUSES.SCHEDULING; + const flags = { - isInvalid: importTarget.validationErrors?.length > 0, - isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING, + isInvalid: (importTarget.validationErrors ?? []).filter((e) => !e.nonBlocking).length > 0, + isAvailableForImport: isGroupAvailableForImport, + isAllowedForReimport: false, isFinished: isFinished(group), }; @@ -355,13 +365,9 @@ export default { this.validateImportTarget(newImportTarget); }, - async importGroups(importRequests) { + async requestGroupsImport(importRequests) { const newPendingGroupsIds = importRequests.map((request) => request.sourceGroupId); newPendingGroupsIds.forEach((id) => { - this.importTargets[id].validationErrors = [ - { field: NEW_NAME_FIELD, message: i18n.ERROR_IMPORT_COMPLETED }, - ]; - if (!this.pendingGroupsIds.includes(id)) { this.pendingGroupsIds.push(id); } @@ -373,11 +379,19 @@ export default { variables: { importRequests }, }); } catch (error) { - createAlert({ - message: i18n.ERROR_IMPORT, - captureError: true, - error, - }); + if (error.networkError?.response?.status === HTTP_STATUS_TOO_MANY_REQUESTS) { + newPendingGroupsIds.forEach((id) => { + this.importTargets[id].validationErrors = [ + { field: NEW_NAME_FIELD, message: i18n.ERROR_TOO_MANY_REQUESTS, nonBlocking: true }, + ]; + }); + } else { + createAlert({ + message: i18n.ERROR_IMPORT, + captureError: true, + error, + }); + } } finally { this.pendingGroupsIds = this.pendingGroupsIds.filter( (id) => !newPendingGroupsIds.includes(id), @@ -385,6 +399,26 @@ export default { } }, + importGroup({ group, extraArgs, index }) { + if (group.flags.isFinished && !this.reimportRequests.includes(group.id)) { + this.validateImportTarget(group.importTarget); + this.reimportRequests.push(group.id); + this.$nextTick(() => { + this.$refs[`importTargetCell-${index}`].focusNewName(); + }); + } else { + this.reimportRequests = this.reimportRequests.filter((id) => id !== group.id); + this.requestGroupsImport([ + { + sourceGroupId: group.id, + targetNamespace: group.importTarget.targetNamespace.fullPath, + newName: group.importTarget.newName, + ...extraArgs, + }, + ]); + } + }, + importSelectedGroups(extraArgs = {}) { const importRequests = this.groupsTableData .filter((group) => this.selectedGroupsIds.includes(group.id)) @@ -395,7 +429,7 @@ export default { ...extraArgs, })); - this.importGroups(importRequests); + this.requestGroupsImport(importRequests); }, setPageSize(size) { @@ -552,6 +586,7 @@ export default { </div> <gl-alert v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible" + data-testid="unavailable-features-alert" variant="warning" :title="unavailableFeaturesAlertTitle" @dismiss="unavailableFeaturesAlertVisible = false" @@ -582,6 +617,19 @@ export default { </template> </gl-sprintf> </gl-alert> + <gl-alert variant="warning" :dismissible="false" class="mt-3"> + <gl-sprintf + :message=" + s__( + 'BulkImport|Be aware of %{linkStart}visibility rules%{linkEnd} when importing groups.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="helpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > @@ -742,8 +790,9 @@ export default { <template #cell(webUrl)="{ item: group }"> <import-source-cell :group="group" /> </template> - <template #cell(importTarget)="{ item: group }"> + <template #cell(importTarget)="{ item: group, index }"> <import-target-cell + :ref="`importTargetCell-${index}`" :group="group" :group-path-regex="groupPathRegex" @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })" @@ -753,22 +802,13 @@ export default { <template #cell(progress)="{ item: group }"> <import-status-cell :status="group.visibleStatus" class="gl-line-height-32" /> </template> - <template #cell(actions)="{ item: group }"> + <template #cell(actions)="{ item: group, index }"> <import-actions-cell :is-projects-import-enabled="isProjectsImportEnabled" :is-finished="group.flags.isFinished" :is-available-for-import="group.flags.isAvailableForImport" :is-invalid="group.flags.isInvalid" - @import-group=" - importGroups([ - { - sourceGroupId: group.id, - targetNamespace: group.importTarget.targetNamespace.fullPath, - newName: group.importTarget.newName, - ...$event, - }, - ]) - " + @import-group="importGroup({ group, extraArgs: $event, index })" /> </template> </gl-table> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index 04a90d9c20c..807b084fefb 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -38,6 +38,15 @@ export default { // this will highlight field in green like "passed validation" return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null; }, + isPathSelectionAvailable() { + return this.group.flags.isAvailableForImport; + }, + }, + + methods: { + focusNewName() { + this.$refs.newName.$el.focus(); + }, }, }; </script> @@ -48,7 +57,7 @@ export default { <import-group-dropdown #default="{ namespaces }" :text="fullPath" - :disabled="!group.flags.isAvailableForImport" + :disabled="!isPathSelectionAvailable" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" @@ -76,23 +85,22 @@ export default { <div class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" :class="{ - 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, - 'gl-border-gray-200': group.flags.isAvailableForImport, + 'gl-text-gray-400 gl-border-gray-100': !isPathSelectionAvailable, + 'gl-border-gray-200': isPathSelectionAvailable, }" > / </div> <div class="gl-flex-grow-1"> <gl-form-input + ref="newName" class="gl-rounded-top-left-none gl-rounded-bottom-left-none" :class="{ - 'gl-inset-border-1-gray-200!': - group.flags.isAvailableForImport && !group.flags.isInvalid, - 'gl-inset-border-1-gray-100!': - !group.flags.isAvailableForImport && !group.flags.isInvalid, + 'gl-inset-border-1-gray-200!': isPathSelectionAvailable, + 'gl-inset-border-1-gray-100!': !isPathSelectionAvailable, }" debounce="500" - :disabled="!group.flags.isAvailableForImport" + :disabled="!isPathSelectionAvailable" :value="group.importTarget.newName" :aria-label="__('New name')" :state="validNameState" @@ -101,7 +109,7 @@ export default { </div> </div> <div - v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)" + v-if="isPathSelectionAvailable && (group.flags.isInvalid || validationMessage)" class="gl-text-red-500 gl-m-0 gl-mt-2" role="alert" > diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index 7e532dfec05..60938272d11 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -11,6 +11,9 @@ export const i18n = { ), ERROR_IMPORT: s__('BulkImport|Importing the group failed.'), ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'), + ERROR_TOO_MANY_REQUESTS: s__( + 'Bulkmport|Over six imports in one minute were attempted. Wait at least one minute and try again.', + ), NO_GROUPS_FOUND: s__('BulkImport|No groups found'), OWNER: __('Owner'), 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 63a36f1a79f..aaa37f145aa 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 @@ -182,16 +182,16 @@ export default { <div v-if="repositories.length" class="gl-w-full"> <table> <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100"> - <th class="import-jobs-from-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ fromHeaderText }} </th> - <th class="import-jobs-to-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ __('To GitLab') }} </th> - <th class="import-jobs-status-col gl-p-4 gl-vertical-align-top gl-border-b-1"> + <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"> {{ __('Status') }} </th> - <th class="import-jobs-cta-col gl-p-4 gl-vertical-align-top gl-border-b-1"></th> + <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th> </thead> <tbody> <template v-for="repo in repositories"> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index da5dcfa71e3..265cca9070e 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -9,6 +9,8 @@ import { GlDropdownDivider, GlDropdownSectionHeader, GlTooltip, + GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; @@ -32,6 +34,10 @@ export default { GlBadge, GlLink, GlTooltip, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { repo: { @@ -53,6 +59,12 @@ export default { }, }, + data() { + return { + isSelectedForReimport: false, + }; + }, + computed: { ...mapState(['ciCdOnly']), ...mapGetters(['getImportTarget']), @@ -94,7 +106,11 @@ export default { }, importButtonText() { - return this.ciCdOnly ? __('Connect') : __('Import'); + if (this.ciCdOnly) { + return __('Connect'); + } + + return this.isFinished ? __('Re-import') : __('Import'); }, newNameInput: { @@ -115,6 +131,22 @@ export default { importTarget: { ...this.importTarget, ...changedValues }, }); }, + + handleImportRepo() { + if (this.isFinished && !this.isSelectedForReimport) { + this.isSelectedForReimport = true; + this.$nextTick(() => { + this.$refs.newNameInput.$el.focus(); + }); + } else { + this.isSelectedForReimport = false; + + this.fetchImport({ + repoId: this.repo.importSource.id, + optionalStages: this.optionalStages, + }); + } + }, }, helpUrl: helpPagePath('/user/project/import/github.md'), @@ -132,6 +164,20 @@ export default { >{{ repo.importSource.fullName }} <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </gl-link> + <div v-if="isFinished" class="gl-font-sm"> + <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')"> + <template #link> + <gl-link + :href="repo.importedProject.fullPath" + class="gl-font-sm" + target="_blank" + data-qa-selector="go_to_project_link" + > + {{ displayFullPath }} + </gl-link> + </template> + </gl-sprintf> + </div> </td> <td class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" @@ -139,7 +185,7 @@ export default { data-qa-selector="project_path_content" > <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> - <template v-else-if="isImportNotStarted"> + <template v-else-if="isImportNotStarted || isSelectedForReimport"> <div class="gl-display-flex gl-align-items-stretch gl-w-full"> <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> <template v-if="namespaces.length"> @@ -166,6 +212,7 @@ export default { / </div> <gl-form-input + ref="newNameInput" v-model="newNameInput" class="gl-rounded-top-left-none gl-rounded-bottom-left-none" data-qa-selector="project_path_field" @@ -177,7 +224,7 @@ export default { <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator"> <import-status :status="importStatus" :stats="stats" /> </td> - <td data-testid="actions" class="gl-vertical-align-top gl-pt-4"> + <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap"> <gl-tooltip :target="() => $refs.cancelButton.$el"> <div class="gl-text-left"> <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> @@ -199,22 +246,26 @@ export default { @click="cancelImport({ repoId: repo.importSource.id })" /> <gl-button - v-if="isFinished" - class="btn btn-default" - :href="repo.importedProject.fullPath" - rel="noreferrer noopener" - target="_blank" - data-qa-selector="go_to_project_button" - >{{ __('Go to project') }} - </gl-button> - <gl-button - v-if="isImportNotStarted" + v-if="isImportNotStarted || isFinished" type="button" data-qa-selector="import_button" - @click="fetchImport({ repoId: repo.importSource.id, optionalStages })" + @click="handleImportRepo()" > {{ importButtonText }} </gl-button> + <gl-icon + v-if="isFinished" + v-gl-tooltip + :size="16" + name="information-o" + :title=" + s__( + 'ImportProjects|Re-import creates a new project. It does not sync with the existing project.', + ) + " + class="gl-ml-3" + /> + <gl-badge v-else-if="isIncompatible" variant="danger">{{ __('Incompatible project') }}</gl-badge> 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 8b2e0364d7a..734e7b10a77 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -2,16 +2,6 @@ import Vue from 'vue'; import { STATUSES } from '../../constants'; import * as types from './mutation_types'; -const makeNewImportedProject = (importedProject) => ({ - importSource: { - id: importedProject.id, - fullName: importedProject.importSource, - sanitizedName: importedProject.name, - providerLink: importedProject.providerLink, - }, - importedProject: { ...importedProject }, -}); - const makeNewIncompatibleProject = (project) => ({ importSource: { ...project, incompatible: true }, importedProject: null, @@ -55,14 +45,6 @@ export default { // Legacy code path, will be removed when all importers will be switched to new pagination format // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 - const newImportedProjects = processLegacyEntries({ - newRepositories: repositories.importedProjects.filter( - (p) => p.importStatus !== STATUSES.CANCELED, - ), - existingRepositories: state.repositories, - factory: makeNewImportedProject, - }); - const incompatibleRepos = repositories.incompatibleRepos ?? []; const newIncompatibleProjects = processLegacyEntries({ newRepositories: incompatibleRepos, @@ -70,16 +52,22 @@ export default { factory: makeNewIncompatibleProject, }); - const existingProjects = [...newImportedProjects, ...state.repositories]; - const existingProjectNames = new Set(existingProjects.map((p) => p.importSource.fullName)); + const existingProjectNames = new Set(state.repositories.map((p) => p.importSource.fullName)); + const importedProjects = [...(repositories.importedProjects ?? [])].reverse(); const newProjects = repositories.providerRepos .filter((project) => !existingProjectNames.has(project.fullName)) - .map((project) => ({ - importSource: project, - importedProject: null, - })); + .map((project) => { + const importedProject = importedProjects.find( + (p) => p.providerLink === project.providerLink, + ); + + return { + importSource: project, + importedProject, + }; + }); - state.repositories = [...existingProjects, ...newProjects, ...newIncompatibleProjects]; + state.repositories = [...state.repositories, ...newProjects, ...newIncompatibleProjects]; if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) { state.pageInfo.page -= 1; @@ -113,7 +101,7 @@ export default { [types.RECEIVE_IMPORT_ERROR](state, repoId) { const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); - existingRepo.importedProject = null; + existingRepo.importedProject.importStatus = STATUSES.FAILED; }, [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index c4c9e544c1e..08a96160ee3 100644 --- a/app/assets/javascripts/import_entities/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -11,7 +11,7 @@ export function getImportStatus(project) { export function isProjectImportable(project) { return ( !isIncompatible(project) && - [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project)) + [STATUSES.NONE, STATUSES.CANCELED, STATUSES.FAILED].includes(getImportStatus(project)) ); } diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 14ab7b2dc1e..f8e70fea7aa 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -402,6 +402,7 @@ export default { > <gl-link data-testid="incident-link" + data-qa-selector="incident_link" :href="showIncidentLink(item)" class="gl-min-w-0" > diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index b956bdf067d..5d08520bb5c 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -58,19 +58,21 @@ export const integrationTriggerEvents = { export const integrationTriggerEventTitles = { [integrationTriggerEvents.PUSH]: s__('IntegrationEvents|A push is made to the repository'), [integrationTriggerEvents.ISSUE]: s__( - 'IntegrationEvents|An issue is created, updated, or closed', + 'IntegrationEvents|An issue is created, closed, or reopened', ), [integrationTriggerEvents.CONFIDENTIAL_ISSUE]: s__( - 'IntegrationEvents|A confidential issue is created, updated, or closed', + 'IntegrationEvents|A confidential issue is created, closed, or reopened', ), [integrationTriggerEvents.MERGE_REQUEST]: s__( - 'IntegrationEvents|A merge request is created, updated, or merged', + 'IntegrationEvents|A merge request is created, merged, closed, or reopened', ), - [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added on an issue'), + [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added'), [integrationTriggerEvents.CONFIDENTIAL_NOTE]: s__( - 'IntegrationEvents|A comment is added on a confidential issue', + 'IntegrationEvents|An internal note or comment on a confidential issue is added', + ), + [integrationTriggerEvents.TAG_PUSH]: s__( + 'IntegrationEvents|A tag is pushed to the repository or removed', ), - [integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'), [integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'), [integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'), [integrationTriggerEvents.DEPLOYMENT]: s__( @@ -88,7 +90,7 @@ export const billingPlanNames = { [billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'), }; -const INTEGRATION_TYPE_SLACK = 'slack'; +export const INTEGRATION_TYPE_SLACK = 'slack'; const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application'; const INTEGRATION_TYPE_MATTERMOST = 'mattermost'; diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 1e58b604bf7..d671ec33bcb 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -208,6 +208,17 @@ export default { data-testid="redirect-to-field" /> + <div v-if="shouldUpgradeSlack" class="gl-mb-6"> + <gl-alert + :dismissible="false" + :title="$options.slackUpgradeInfo.title" + :primary-button-link="customState.upgradeSlackUrl" + :primary-button-text="$options.slackUpgradeInfo.btnText" + class="gl-mb-5" + >{{ $options.slackUpgradeInfo.text }}</gl-alert + > + </div> + <override-dropdown v-if="defaultState !== null" :inherit-from-id="defaultState.id" @@ -241,17 +252,6 @@ export default { </div> </section> - <div v-if="shouldUpgradeSlack" class="gl-border-t"> - <gl-alert - :dismissible="false" - :title="$options.slackUpgradeInfo.title" - :primary-button-link="customState.upgradeSlackUrl" - :primary-button-text="$options.slackUpgradeInfo.btnText" - class="gl-mb-8 gl-mt-5" - >{{ $options.slackUpgradeInfo.text }}</gl-alert - > - </div> - <template v-if="hasSections"> <integration-form-section v-for="(section, index) in customState.sections" diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue index 439c243f418..62f0fe4d6bf 100644 --- a/app/assets/javascripts/integrations/index/components/integrations_table.vue +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -1,7 +1,9 @@ <script> import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants'; import { sprintf, s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -13,6 +15,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, @@ -55,6 +58,15 @@ export default { }, ]; }, + filteredIntegrations() { + if (this.glFeatures.integrationSlackAppNotifications) { + return this.integrations.filter( + (integration) => + !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false), + ); + } + return this.integrations; + }, }, methods: { getStatusTooltipTitle(integration) { @@ -67,7 +79,7 @@ export default { </script> <template> - <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed> + <gl-table :items="filteredIntegrations" :fields="fields" :empty-text="emptyText" show-empty fixed> <template #cell(active)="{ item }"> <gl-icon v-if="item.active" 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 fa1aa6b0d88..607c888b85a 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,8 +1,7 @@ <script> import { GlAlert, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlLink, GlSprintf, GlFormCheckboxGroup, @@ -13,6 +12,7 @@ import { import { partition, isString, uniqueId, isEmpty } from 'lodash'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; +import Tracking from '~/tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; @@ -22,6 +22,7 @@ import { INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, LEARN_GITLAB, + INVITE_MEMBER_MODAL_TRACKING_CATEGORY, } from '../constants'; import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; @@ -40,8 +41,7 @@ export default { components: { GlAlert, GlLink, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlSprintf, GlFormCheckboxGroup, GlButton, @@ -52,6 +52,7 @@ export default { ModalConfetti, UserLimitNotification, }, + mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })], inject: ['newProjectPath'], props: { id: { @@ -109,6 +110,11 @@ export default { required: false, default: () => ({}), }, + activeTrialDataset: { + type: Object, + required: false, + default: () => ({}), + }, reloadPageOnSubmit: { type: Boolean, required: false, @@ -124,6 +130,7 @@ export default { invalidMembers: {}, selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], + selectedTaskProjectId: this.projects[0]?.id, source: 'unknown', mode: 'default', // Kept in sync with "base" @@ -131,6 +138,7 @@ export default { errorsLimit: 2, isErrorsSectionExpanded: false, shouldShowEmptyInvitesAlert: false, + projectsForDropdown: this.projects.map((p) => ({ value: p.id, text: p.title, ...p })), }; }, computed: { @@ -263,11 +271,12 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ mode = 'default', source }) { + openModal({ mode = 'default', source = 'unknown' }) { this.mode = mode; this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); + this.track('render', { label: this.source }); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); @@ -339,6 +348,12 @@ export default { const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); tracking.event(INVITE_MEMBERS_FOR_TASK.submit); }, + onCancel() { + this.track('click_cancel', { label: this.source }); + }, + onClose() { + this.track('click_x', { label: this.source }); + }, resetFields() { this.clearValidation(); this.isLoading = false; @@ -347,10 +362,12 @@ export default { this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; }, - changeSelectedTaskProject(project) { - this.selectedTaskProject = project; + changeSelectedTaskProject(projectId) { + this.selectedTaskProject = this.projects.find((project) => project.id === projectId); }, onInviteSuccess() { + this.track('invite_successful', { label: this.source }); + if (this.reloadPageOnSubmit) { reloadOnInvitationSuccess(); } else { @@ -404,7 +421,10 @@ export default { :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" :users-limit-dataset="usersLimitDataset" + :active-trial-dataset="activeTrialDataset" :full-path="fullPath" + @close="onClose" + @cancel="onCancel" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -513,23 +533,14 @@ export default { <label class="gl-mt-5 gl-display-block"> {{ $options.labels.tasksProject.title }} </label> - <gl-dropdown + <gl-collapsible-listbox + v-model="selectedTaskProjectId" + :items="projectsForDropdown" + :block="true" class="gl-w-half gl-xs-w-full" - :text="selectedTaskProject.title" data-testid="invite-members-modal-project-select" - > - <template v-for="project in projects"> - <gl-dropdown-item - :key="project.id" - active-class="is-active" - is-check-item - :is-checked="project.id === selectedTaskProject.id" - @click="changeSelectedTaskProject(project)" - > - {{ project.title }} - </gl-dropdown-item> - </template> - </gl-dropdown> + @select="changeSelectedTaskProject" + /> </template> </template> <gl-alert 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 2cbd681c67d..1e3b6093f0b 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -206,7 +206,7 @@ export default { this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL }); } }, - onCloseModal(e) { + onCancel(e) { if (this.preventCancelDefault) { e.preventDefault(); } else { @@ -225,6 +225,9 @@ export default { expiresAt: this.selectedDate, }); }, + onClose() { + this.$emit('close'); + }, }, HEADER_CLOSE_LABEL, ACCESS_EXPIRE_DATE, @@ -249,7 +252,8 @@ export default { :action-cancel="actionCancel" @shown="onShowModal" @primary="onSubmit" - @cancel="onCloseModal" + @cancel="onCancel" + @close="onClose" @hidden="onReset" > <content-transition @@ -267,11 +271,12 @@ export default { <strong>{{ content }}</strong> </template> </gl-sprintf> + <slot name="intro-text-after"></slot> </p> - <slot name="intro-text-after"></slot> </div> <slot name="alert"></slot> + <slot name="active-trial-alert"></slot> <gl-form-group :label="labelSearchField" diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue index b7a3918813b..c1114c240b9 100644 --- a/app/assets/javascripts/invite_members/components/project_select.vue +++ b/app/assets/javascripts/invite_members/components/project_select.vue @@ -1,28 +1,23 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { getProjects } from '~/rest_api'; import { SEARCH_DELAY, GROUP_FILTERS } from '../constants'; +// We can have GlCollapsibleListbox dropdown panel with full +// width once we implement +// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2133 +// https://gitlab.com/gitlab-org/gitlab/-/issues/390411 export default { name: 'ProjectSelect', components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, model: { - prop: 'selectedProject', + prop: 'selectedProjectId', }, props: { groupsFilter: { @@ -41,18 +36,21 @@ export default { return { isFetching: false, projects: [], - selectedProject: {}, + selectedProjectId: '', searchTerm: '', errorMessage: '', }; }, computed: { selectedProjectName() { - return this.selectedProject.name || this.$options.i18n.dropdownText; + return this.selectedProject.nameWithNamespace || this.$options.i18n.dropdownText; }, isFetchResultEmpty() { return this.projects.length === 0 && !this.isFetching; }, + selectedProject() { + return this.projects.find((prj) => prj.id === this.selectedProjectId) || {}; + }, }, watch: { searchTerm() { @@ -70,10 +68,14 @@ export default { .then((response) => { this.projects = response.data.map((project) => ({ ...convertObjectPropsToCamelCase(project), - name: project.name_with_namespace, + text: project.name_with_namespace, + value: project.id, })); }) .catch(() => { + // To be displayed in GlCollapsibleListbox once we implement + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2132 + // https://gitlab.com/gitlab-org/gitlab/-/issues/389974 this.errorMessage = this.$options.i18n.errorFetchingProjects; }) .finally(() => { @@ -83,9 +85,7 @@ export default { fetchProjects() { return getProjects(this.searchTerm, this.$options.defaultFetchOptions); }, - selectProject(project) { - this.selectedProject = project; - + selectProject() { this.$emit('input', this.selectedProject); }, }, @@ -104,40 +104,28 @@ export default { }; </script> <template> - <div> - <gl-dropdown - data-testid="project-select-dropdown" - :text="selectedProjectName" - toggle-class="gl-mb-2" - block - menu-class="gl-w-full!" - > - <gl-search-box-by-type - v-model="searchTerm" - :is-loading="isFetching" - :placeholder="$options.i18n.searchPlaceholder" - data-qa-selector="project_select_dropdown_search_field" + <gl-collapsible-listbox + v-model="selectedProjectId" + searchable + :items="projects" + :searching="isFetching" + :toggle-text="selectedProjectName" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + data-testid="project-select-dropdown" + data-qa-selector="project_select_dropdown" + class="gl-collapsible-listbox-w-full" + @search="searchTerm = $event" + @select="selectProject" + > + <template #list-item="{ item }"> + <gl-avatar-labeled + :label="item.text" + :src="item.avatarUrl" + :entity-id="item.id" + :entity-name="item.name" + :size="32" /> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - :name="project.name" - @click="selectProject(project)" - > - <gl-avatar-labeled - :label="project.name" - :src="project.avatarUrl" - :entity-id="project.id" - :entity-name="project.name" - :size="32" - /> - </gl-dropdown-item> - <gl-dropdown-text v-if="errorMessage" data-testid="error-message"> - <span class="gl-text-gray-500">{{ errorMessage }}</span> - </gl-dropdown-text> - <gl-dropdown-text v-else-if="isFetchResultEmpty" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> - </div> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index edc0ebff083..ac0b708c55e 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -20,6 +20,7 @@ export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; export const TRIGGER_ELEMENT_BUTTON = 'button'; export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav'; +export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal'; export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button'; export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); export const MEMBERS_MODAL_CELEBRATE_TITLE = s__( @@ -138,6 +139,7 @@ export const GROUP_MODAL_LABELS = { export const LEARN_GITLAB = 'learn_gitlab'; export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; +export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal'; export const INFO_ALERT_TITLE = s__( 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.', 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 842ab07f368..4f539cd8756 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -41,6 +41,9 @@ export default (function initInviteMembersModal() { usersLimitDataset: convertObjectPropsToCamelCase( JSON.parse(el.dataset.usersLimitDataset || '{}'), ), + activeTrialDataset: convertObjectPropsToCamelCase( + JSON.parse(el.dataset.activeTrialDataset || '{}'), + ), reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index fcebae3af71..71ec5544c34 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -9,6 +9,7 @@ import { GlFormInputGroup, GlIcon, } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; import { sprintf, __ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -36,7 +37,7 @@ export default { default: null, }, issuableType: { - default: 'issue', + default: TYPE_ISSUE, }, emailsHelpPagePath: { default: '', @@ -54,7 +55,7 @@ export default { data() { return { email: this.initialEmail, - issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'), + issuableName: this.issuableType === TYPE_ISSUE ? __('issue') : __('merge request'), }; }, computed: { diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 14325d6b64e..0e58f3793bc 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -2,7 +2,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { sprintf, __ } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; @@ -12,8 +12,8 @@ const NoteableTypeText = { }; export default { + TYPE_ISSUE, WorkspaceType, - IssuableType, components: { GlIcon, ConfidentialityBadge, @@ -61,7 +61,7 @@ export default { v-if="isConfidential" data-testid="confidential" :workspace-type="$options.WorkspaceType.project" - :issuable-type="$options.IssuableType.Issue" + :issuable-type="$options.TYPE_ISSUE" /> <template v-for="meta in warningIconsMeta"> <div diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 4f1001e8c3b..c7da3e59098 100644 --- a/app/assets/javascripts/issuable/components/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue @@ -82,7 +82,7 @@ export default { <span v-if="milestoneStart || milestoneDue" :class="{ - 'text-danger-muted': isMilestonePastDue, + 'gl-text-red-300': isMilestonePastDue, 'text-tertiary': !isMilestonePastDue, }" ><span>{{ milestoneDatesHuman }}</span diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index c815c7aaba9..608c1deac64 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -3,7 +3,7 @@ import '~/commons/bootstrap'; import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -69,7 +69,7 @@ export default { return `${this.iconClass} ic-${this.iconName}`; }, workItemId() { - return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey); + return convertToGraphQLId(TYPENAME_WORK_ITEM, this.idKey); }, }, methods: { diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index 6c4ffc44444..0c75e44443d 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -4,7 +4,7 @@ import Vue from 'vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { fetchPolicies } from '~/lib/graphql'; import { __ } from '~/locale'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; export const badgeState = Vue.observable({ @@ -92,7 +92,7 @@ export default { return STATUS[this.state]; }, badgeIcon() { - if (this.issuableType === IssuableType.Issue) { + if (this.issuableType === TYPE_ISSUE) { return ISSUE_ICONS[this.state]; } return MERGE_REQUEST_ICONS[this.state]; diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js index c386267501a..201782a201a 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js @@ -43,10 +43,10 @@ export default { */ getFormDataAsObject() { + const assigneeIds = this.form.find('input[name="update[assignee_ids][]"]').val(); const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), - assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), @@ -57,6 +57,9 @@ export default { remove_label_ids: [], }, }; + if (assigneeIds) { + formData.update.assignee_ids = [assigneeIds]; + } if (this.willUpdateLabels) { formData.update.add_label_ids = this.$labelDropdown.data('user-checked'); formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked'); diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js index 095da60a583..9c891bcfc9e 100644 --- a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js @@ -1,9 +1,9 @@ /* eslint-disable class-methods-use-this, no-new */ - import $ from 'jquery'; import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; import { + mountAssigneesDropdown, mountMilestoneDropdown, mountMoveIssuesButton, mountStatusDropdown, @@ -64,6 +64,7 @@ export default class IssuableBulkUpdateSidebar { mountMoveIssuesButton(); mountStatusDropdown(); mountSubscriptionsDropdown(); + mountAssigneesDropdown(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index 99a3f76ca76..8a094d5d688 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -60,8 +60,6 @@ export default class IssuableForm { return; } this.form = form; - this.toggleWip = this.toggleWip.bind(this); - this.renderWipExplanation = this.renderWipExplanation.bind(this); this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); // prettier-ignore @@ -86,6 +84,7 @@ export default class IssuableForm { this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); this.descriptionField = this.form.find('textarea[name*="[description]"]'); + this.draftCheck = document.querySelector('input.js-toggle-draft'); if (!(this.titleField.length && this.descriptionField.length)) { return; } @@ -93,8 +92,7 @@ export default class IssuableForm { this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave); - this.form.find('.js-unwrap-on-load').unwrap(); - this.initWip(); + this.initDraft(); const $issuableDueDate = $('#issuable-due-date'); @@ -160,48 +158,34 @@ export default class IssuableForm { }); } - initWip() { - this.$wipExplanation = this.form.find('.js-wip-explanation'); - this.$noWipExplanation = this.form.find('.js-no-wip-explanation'); - if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) { - return undefined; + initDraft() { + if (this.draftCheck) { + this.draftCheck.addEventListener('click', () => this.writeDraftStatus()); + this.titleField.on('keyup blur', () => this.readDraftStatus()); + + this.readDraftStatus(); } - this.form.on('click', '.js-toggle-wip', this.toggleWip); - this.titleField.on('keyup blur', this.renderWipExplanation); - return this.renderWipExplanation(); } - workInProgress() { + isMarkedDraft() { return this.draftRegex.test(this.titleField.val()); } - - renderWipExplanation() { - if (this.workInProgress()) { - // These strings are not "translatable" (the code is hard-coded to look for them) - this.$wipExplanation.find('code')[0].textContent = - 'Draft'; /* eslint-disable-line @gitlab/require-i18n-strings */ - this.$wipExplanation.show(); - return this.$noWipExplanation.hide(); - } - this.$wipExplanation.hide(); - return this.$noWipExplanation.show(); + readDraftStatus() { + this.draftCheck.checked = this.isMarkedDraft(); } - - toggleWip(event) { - event.preventDefault(); - if (this.workInProgress()) { - this.removeWip(); + writeDraftStatus() { + if (this.draftCheck.checked) { + this.addDraft(); } else { - this.addWip(); + this.removeDraft(); } - return this.renderWipExplanation(); } - removeWip() { + removeDraft() { return this.titleField.val(this.titleField.val().replace(this.draftRegex, '')); } - addWip() { + addDraft() { this.titleField.val(`Draft: ${this.titleField.val()}`); } } diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue index 945a3782642..55fb3958e82 100644 --- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue @@ -4,7 +4,7 @@ import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import IssueMilestone from '~/issuable/components/issue_milestone.vue'; import StatusBox from '~/issuable/components/status_box.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -57,7 +57,7 @@ export default { return Object.keys(this.issue).length > 0; }, isIssueClosed() { - return this.issue?.state === IssuableStatus.Closed; + return this.issue?.state === STATUS_CLOSED; }, }, apollo: { diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 4b9a42da178..ba05dd731f7 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -1,22 +1,27 @@ import { __ } from '~/locale'; -export const IssuableStatus = { - Closed: 'closed', - Open: 'opened', - Reopened: 'reopened', -}; +export const STATUS_CLOSED = 'closed'; +export const STATUS_OPEN = 'opened'; +export const STATUS_REOPENED = 'reopened'; + +export const TITLE_LENGTH_MAX = 255; + +export const TYPE_EPIC = 'epic'; +export const TYPE_ISSUE = 'issue'; export const IssuableStatusText = { - [IssuableStatus.Closed]: __('Closed'), - [IssuableStatus.Open]: __('Open'), - [IssuableStatus.Reopened]: __('Open'), + [STATUS_CLOSED]: __('Closed'), + [STATUS_OPEN]: __('Open'), + [STATUS_REOPENED]: __('Open'), }; +// Deprecated - use individual constants instead like `TYPE_ISSUE` above export const IssuableType = { Issue: 'issue', Epic: 'epic', MergeRequest: 'merge_request', Alert: 'alert', + TestCase: 'test_case', }; export const IssueType = { diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 8edc9a08c9e..a4a2feba716 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -1,12 +1,14 @@ <script> -import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import { CREATED_DESC, + defaultTypeTokenOptions, + i18n, PAGE_SIZE, PARAM_STATE, UPDATED_DESC, @@ -26,21 +28,29 @@ import { import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import { + OPERATORS_IS, + OPERATORS_IS_NOT_OR, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_SEARCH_WITHIN, + TOKEN_TITLE_TYPE, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import getIssuesCountsQuery from '../queries/get_issues_counts.query.graphql'; import { AutocompleteCache } from '../utils'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); @@ -52,17 +62,7 @@ const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); export default { - i18n: { - calendarButtonText: __('Subscribe to calendar'), - closed: __('CLOSED'), - closedMoved: __('CLOSED (MOVED)'), - emptyStateWithFilterTitle: __('Sorry, your filter produced no results'), - emptyStateWithFilterDescription: __('To widen your search, change or remove filters above'), - emptyStateWithoutFilterTitle: __('Please select at least one filter to see results'), - errorFetchingIssues: __('An error occurred while loading issues'), - rssButtonText: __('Subscribe to RSS feed'), - searchInputPlaceholder: __('Search or filter results...'), - }, + i18n, IssuableListTabs, components: { GlButton, @@ -105,6 +105,7 @@ export default { return { filterTokens: getFilterTokens(window.location.search), issues: [], + issuesCounts: {}, issuesError: null, pageInfo: {}, pageParams: getInitialPageParams(), @@ -116,15 +117,7 @@ export default { issues: { query: getIssuesQuery, variables() { - return { - hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, - isSignedIn: this.isSignedIn, - search: this.searchQuery, - sort: this.sortKey, - state: this.state, - ...this.pageParams, - ...this.apiFilterParams, - }; + return this.queryVariables; }, update(data) { return data.issues.nodes ?? []; @@ -141,13 +134,33 @@ export default { }, debounce: 200, }, + issuesCounts: { + query: getIssuesCountsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + skip() { + return !this.hasSearch; + }, + debounce: 200, + context: { + isSingleRequest: true, + }, + }, }, computed: { apiFilterParams() { return convertToApiParams(this.filterTokens); }, emptyStateDescription() { - return this.hasSearch ? this.$options.i18n.emptyStateWithFilterDescription : undefined; + return this.hasSearch ? this.$options.i18n.noSearchResultsDescription : undefined; }, emptyStateSvgPath() { return this.hasSearch @@ -156,12 +169,23 @@ export default { }, emptyStateTitle() { return this.hasSearch - ? this.$options.i18n.emptyStateWithFilterTitle - : this.$options.i18n.emptyStateWithoutFilterTitle; + ? this.$options.i18n.noSearchResultsTitle + : this.$options.i18n.noSearchNoFilterTitle; }, hasSearch() { return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length); }, + queryVariables() { + return { + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, renderedIssues() { return this.hasSearch ? this.issues : []; }, @@ -186,6 +210,7 @@ export default { title: TOKEN_TITLE_ASSIGNEE, icon: 'user', token: UserToken, + operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, preloadedUsers, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee', @@ -195,6 +220,7 @@ export default { title: TOKEN_TITLE_AUTHOR, icon: 'pencil', token: UserToken, + operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, defaultUsers: [], preloadedUsers, @@ -205,6 +231,7 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, + operators: OPERATORS_IS_NOT_OR, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-label', }, @@ -217,10 +244,46 @@ export default { recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-milestone', shouldSkipSort: true, }, + { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: this.$options.i18n.descriptions, + }, + ], + }, + { + type: TOKEN_TYPE_TYPE, + title: TOKEN_TITLE_TYPE, + icon: 'issues', + token: GlFilteredSearchToken, + options: defaultTypeTokenOptions, + }, ]; if (this.isSignedIn) { tokens.push({ + type: TOKEN_TYPE_CONFIDENTIAL, + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, + { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, + ], + }); + + tokens.push({ type: TOKEN_TYPE_MY_REACTION, title: TOKEN_TITLE_MY_REACTION, icon: 'thumb-up', @@ -248,6 +311,14 @@ export default { hasIssueWeightsFeature: this.hasIssueWeightsFeature, }); }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.issuesCounts; + return { + [IssuableStates.Opened]: openedIssues?.count, + [IssuableStates.Closed]: closedIssues?.count, + [IssuableStates.All]: allIssues?.count, + }; + }, urlFilterParams() { return convertToUrlParams(this.filterTokens); }, @@ -292,10 +363,10 @@ export default { return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); }, getStatus(issue) { - if (issue.state === IssuableStatus.Closed && issue.moved) { + if (issue.state === STATUS_CLOSED && issue.moved) { return this.$options.i18n.closedMoved; } - if (issue.state === IssuableStatus.Closed) { + if (issue.state === STATUS_CLOSED) { return this.$options.i18n.closed; } return undefined; @@ -372,12 +443,14 @@ export default { :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" - :search-input-placeholder="$options.i18n.searchInputPlaceholder" + :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" :show-pagination-controls="showPaginationControls" show-work-item-type-icon :sort-options="sortOptions" + :tab-counts="tabCounts" :tabs="$options.IssuableListTabs" + truncate-counts :url-params="urlParams" use-keyset-pagination @click-tab="handleClickTab" @@ -389,10 +462,10 @@ export default { > <template #nav-actions> <gl-button :href="rssPath" icon="rss"> - {{ $options.i18n.rssButtonText }} + {{ $options.i18n.rssLabel }} </gl-button> <gl-button :href="calendarPath" icon="calendar"> - {{ $options.i18n.calendarButtonText }} + {{ $options.i18n.calendarLabel }} </gl-button> </template> diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 43b8804108c..5625e6afad3 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/issues/list/queries/issue.fragment.graphql" +#import "./issue.fragment.graphql" query getDashboardIssues( $hideUsers: Boolean = false @@ -10,11 +10,15 @@ query getDashboardIssues( $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String + $confidential: Boolean $labelName: [String] $milestoneTitle: [String] $milestoneWildcardId: MilestoneWildcardId $myReactionEmoji: String + $types: [IssueType!] + $in: [IssuableSearchableField!] $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput $afterCursor: String $beforeCursor: String $firstPageSize: Int @@ -27,11 +31,15 @@ query getDashboardIssues( assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + confidential: $confidential labelName: $labelName milestoneTitle: $milestoneTitle milestoneWildcardId: $milestoneWildcardId myReactionEmoji: $myReactionEmoji + types: $types + in: $in not: $not + or: $or after: $afterCursor before: $beforeCursor first: $firstPageSize diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql new file mode 100644 index 00000000000..b36f546e4ab --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql @@ -0,0 +1,70 @@ +query getDashboardIssuesCount( + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $types: [IssueType!] + $in: [IssuableSearchableField!] + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput +) { + openedIssues: issues( + state: opened + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } + closedIssues: issues( + state: closed + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } + allIssues: issues( + state: all + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + types: $types + in: $in + not: $not + or: $or + ) { + count + } +} diff --git a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql new file mode 100644 index 00000000000..040763f2ba4 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql @@ -0,0 +1,56 @@ +fragment IssueFragment on Issue { + id + iid + confidential + createdAt + downvotes + dueDate + hidden + humanTimeEstimate + mergeRequestsCount + moved + state + title + updatedAt + closedAt + upvotes + userDiscussionsCount @include(if: $isSignedIn) + webPath + webUrl + type + assignees @skip(if: $hideUsers) { + nodes { + id + avatarUrl + name + username + webUrl + } + } + author @skip(if: $hideUsers) { + id + avatarUrl + name + username + webUrl + } + labels { + nodes { + id + color + title + description + } + } + milestone { + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index e3716d0e111..5b5f1d273d0 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -60,7 +60,7 @@ export function initShow() { const { issueType, ...issuableData } = parseIssuableData(el); if (issueType === IssueType.Incident) { - initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }); + initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store); initHeaderActions(store, IssueType.Incident); initLinkedResources(); initRelatedIssues(IssueType.Incident); diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue index 5a37751410a..652d4e0fb42 100644 --- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -2,8 +2,9 @@ import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; import { i18n } from '../constants'; -import NewIssueDropdown from './new_issue_dropdown.vue'; +import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; export default { i18n, @@ -14,8 +15,9 @@ export default { GlEmptyState, GlLink, GlSprintf, - NewIssueDropdown, + NewResourceDropdown, }, + mixins: [hasNewIssueDropdown()], inject: [ 'canCreateProjects', 'emptyStateSvgPath', @@ -75,7 +77,13 @@ export default { :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> - <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> + <new-resource-dropdown + v-if="showNewIssueDropdown" + class="gl-align-self-center" + :query="$options.searchProjectsQuery" + :query-variables="newIssueDropdownQueryVariables" + :extract-projects="extractProjects" + /> </template> </gl-empty-state> <hr /> diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index 1139861ae78..d11540ad3dd 100644 --- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import { dateInWords, getTimeRemainingInWords, @@ -43,8 +43,7 @@ export default { }, showDueDateInRed() { return ( - isInPast(newDateAsLocaleTime(this.issue.dueDate)) && - this.issue.state !== IssuableStatus.Closed + isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED ); }, timeEstimate() { 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 e4000184f41..6c46013e4f9 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -7,18 +7,18 @@ import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; import { createAlert, VARIANT_INFO } from '~/flash'; -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; -import { IssuableStatus } from '~/issues/constants'; +import { STATUS_CLOSED } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; +import { fetchPolicies } from '~/lib/graphql'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; import { - FILTERED_SEARCH_TERM, OPERATORS_IS, OPERATORS_IS_NOT, OPERATORS_IS_NOT_OR, @@ -48,6 +48,7 @@ import { import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; import { CREATED_DESC, defaultTypeTokenOptions, @@ -82,9 +83,9 @@ import { getSortOptions, isSortKey, } from '../utils'; +import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin'; import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; -import NewIssueDropdown from './new_issue_dropdown.vue'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => @@ -112,12 +113,12 @@ export default { IssuableList, IssueCardStatistics, IssueCardTimeInfo, - NewIssueDropdown, + NewResourceDropdown, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin(), hasNewIssueDropdown()], inject: [ 'autocompleteAwardEmojisPath', 'calendarPath', @@ -134,7 +135,6 @@ export default { 'hasScopedLabelsFeature', 'initialEmail', 'initialSort', - 'isAnonymousSearchDisabled', 'isIssueRepositioningDisabled', 'isProject', 'isPublicVisibilityRestricted', @@ -190,8 +190,15 @@ export default { update(data) { return data[this.namespace]?.issues.nodes ?? []; }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + // We need this for handling loading state when using frontend cache + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details + notifyOnNetworkStatusChange: true, result({ data }) { - this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {}; + if (!data) { + return; + } + this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -293,7 +300,7 @@ export default { if (gon.current_user_id) { preloadedUsers.push({ - id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, avatar_url: gon.current_user_avatar_url, @@ -354,6 +361,7 @@ export default { token: LabelToken, operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, fetchLabels: this.fetchLabels, + fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, { @@ -473,8 +481,16 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, - shouldDisableTextSearch() { - return this.isAnonymousSearchDisabled && !this.isSignedIn; + // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache. + // if we have cached data, we disregard the loading state + isLoading() { + return ( + this.$apollo.queries.issues.loading && + !this.$apollo.provider.clients.defaultClient.readQuery({ + query: getIssuesQuery, + variables: this.queryVariables, + }) + ); }, }, watch: { @@ -514,11 +530,12 @@ export default { fetchReleases(search) { return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search); }, - fetchLabels(search) { + fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) { return this.$apollo .query({ query: searchLabelsQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, + fetchPolicy, }) .then(({ data }) => data[this.namespace]?.labels.nodes) .then((labels) => @@ -527,6 +544,12 @@ export default { labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), ); }, + fetchLabels(search) { + return this.fetchLabelsWithFetchPolicy(search); + }, + fetchLatestLabels(search) { + return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); + }, fetchMilestones(search) { return this.$apollo .query({ @@ -549,10 +572,10 @@ export default { return `${this.exportCsvPath}${window.location.search}`; }, getStatus(issue) { - if (issue.state === IssuableStatus.Closed && issue.moved) { + if (issue.state === STATUS_CLOSED && issue.moved) { return this.$options.i18n.closedMoved; } - if (issue.state === IssuableStatus.Closed) { + if (issue.state === STATUS_CLOSED) { return this.$options.i18n.closed; } return undefined; @@ -569,9 +592,6 @@ export default { const bulkUpdateSidebar = await import('~/issuable'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - const UsersSelect = (await import('~/users_select')).default; - new UsersSelect(); // eslint-disable-line no-new - this.hasInitBulkEdit = true; } @@ -591,7 +611,7 @@ export default { this.issuesError = null; }, handleFilter(tokens) { - this.setFilterTokens(tokens); + this.filterTokens = tokens; this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); @@ -684,24 +704,6 @@ export default { Sentry.captureException(error); }); }, - setFilterTokens(tokens) { - this.filterTokens = this.removeDisabledSearchTerms(tokens); - - if (this.filterTokens.length < tokens.length) { - this.showAnonymousSearchingMessage(); - } - }, - removeDisabledSearchTerms(filters) { - return this.shouldDisableTextSearch - ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data)) - : filters; - }, - showAnonymousSearchingMessage() { - createAlert({ - message: this.$options.i18n.anonymousSearchingMessage, - variant: VARIANT_INFO, - }); - }, showIssueRepositioningMessage() { createAlert({ message: this.$options.i18n.issueRepositioningMessage, @@ -737,7 +739,7 @@ export default { sortKey = defaultSortKey; } - this.setFilterTokens(getFilterTokens(window.location.search)); + this.filterTokens = getFilterTokens(window.location.search); this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( @@ -773,7 +775,7 @@ export default { :current-tab="state" :tab-counts="tabCounts" :truncate-counts="!isProject" - :issuables-loading="$apollo.queries.issues.loading" + :issuables-loading="isLoading" :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" @@ -831,7 +833,12 @@ export default { {{ $options.i18n.newIssueLabel }} </gl-button> <slot name="new-objective-button"></slot> - <new-issue-dropdown v-if="showNewIssueDropdown" /> + <new-resource-dropdown + v-if="showNewIssueDropdown" + :query="$options.searchProjectsQuery" + :query-variables="newIssueDropdownQueryVariables" + :extract-projects="extractProjects" + /> </template> <template #timeframe="{ issuable = {} }"> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue deleted file mode 100644 index e420c21a11f..00000000000 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ /dev/null @@ -1,127 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { createAlert } from '~/flash'; -import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; -import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; -import searchProjectsQuery from '../queries/search_projects.query.graphql'; - -export default { - i18n: { - defaultDropdownText: __('Select project to create issue'), - noMatchesFound: __('No matches found'), - toggleButtonLabel: __('Toggle project select'), - }, - components: { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, - GlSearchBoxByType, - }, - inject: ['fullPath'], - data() { - return { - projects: [], - search: '', - selectedProject: {}, - shouldSkipQuery: true, - }; - }, - apollo: { - projects: { - query: searchProjectsQuery, - variables() { - return { - fullPath: this.fullPath, - search: this.search, - }; - }, - update: ({ group }) => group.projects.nodes ?? [], - error(error) { - createAlert({ - message: __('An error occurred while loading projects.'), - captureError: true, - error, - }); - }, - skip() { - return this.shouldSkipQuery; - }, - debounce: DEBOUNCE_DELAY, - }, - }, - computed: { - dropdownHref() { - return this.hasSelectedProject - ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new') - : undefined; - }, - dropdownText() { - return this.hasSelectedProject - ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name }) - : this.$options.i18n.defaultDropdownText; - }, - hasSelectedProject() { - return this.selectedProject.id; - }, - projectsWithIssuesEnabled() { - return this.projects.filter((project) => project.issuesEnabled); - }, - showNoSearchResultsText() { - return !this.projectsWithIssuesEnabled.length && this.search; - }, - }, - methods: { - handleDropdownClick() { - if (!this.dropdownHref) { - this.$refs.dropdown.show(); - } - }, - handleDropdownShown() { - if (this.shouldSkipQuery) { - this.shouldSkipQuery = false; - } - this.$refs.search.focusInput(); - }, - selectProject(project) { - this.selectedProject = project; - }, - }, -}; -</script> - -<template> - <gl-dropdown - ref="dropdown" - right - split - :split-href="dropdownHref" - :text="dropdownText" - :toggle-text="$options.i18n.toggleButtonLabel" - variant="confirm" - @click="handleDropdownClick" - @shown="handleDropdownShown" - > - <gl-search-box-by-type ref="search" v-model.trim="search" /> - <gl-loading-icon v-if="$apollo.queries.projects.loading" /> - <template v-else> - <gl-dropdown-item - v-for="project of projectsWithIssuesEnabled" - :key="project.id" - @click="selectProject(project)" - > - {{ project.nameWithNamespace }} - </gl-dropdown-item> - <gl-dropdown-text v-if="showNoSearchResultsText"> - {{ $options.i18n.noMatchesFound }} - </gl-dropdown-text> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 87184799d5f..31a43c95f5e 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -76,7 +76,6 @@ export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; export const i18n = { - anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), closed: __('CLOSED'), closedMoved: __('CLOSED (MOVED)'), @@ -105,6 +104,7 @@ export const i18n = { noIssuesDescription: __('Learn more about issues.'), noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), + noSearchNoFilterTitle: __('Please select at least one filter to see results'), noSearchResultsDescription: __('To widen your search, change or remove filters above'), noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js index 5ef61727a3d..b590006929a 100644 --- a/app/assets/javascripts/issues/list/graphql.js +++ b/app/assets/javascripts/issues/list/graphql.js @@ -22,4 +22,6 @@ const resolvers = { }, }; -export const gqlClient = createDefaultClient(resolvers); +export const gqlClient = gon.features?.frontendCaching + ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' }) + : createDefaultClient(resolvers); diff --git a/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js new file mode 100644 index 00000000000..510edf9b78c --- /dev/null +++ b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js @@ -0,0 +1,18 @@ +import searchProjectsQuery from './queries/search_projects.query.graphql'; + +export const hasNewIssueDropdown = () => ({ + inject: ['fullPath'], + computed: { + newIssueDropdownQueryVariables() { + return { + fullPath: this.fullPath, + }; + }, + }, + methods: { + extractProjects(data) { + return data?.group?.projects?.nodes; + }, + }, + searchProjectsQuery, +}); diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 7b68b7432c9..aca894549e4 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -78,7 +78,6 @@ export function mountIssuesListApp() { importCsvIssuesPath, initialEmail, initialSort, - isAnonymousSearchDisabled, isIssueRepositioningDisabled, isProject, isPublicVisibilityRestricted, @@ -127,7 +126,6 @@ export function mountIssuesListApp() { hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, - isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index ee97fb6edca..1018848fb53 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -31,7 +31,7 @@ query getIssues( $firstPageSize: Int $lastPageSize: Int ) { - group(fullPath: $fullPath) @skip(if: $isProject) { + group(fullPath: $fullPath) @skip(if: $isProject) @persist { id issues( includeSubgroups: true @@ -58,16 +58,18 @@ query getIssues( first: $firstPageSize last: $lastPageSize ) { + __persist pageInfo { ...PageInfo } nodes { + __persist ...IssueFragment reference(full: true) } } } - project(fullPath: $fullPath) @include(if: $isProject) { + project(fullPath: $fullPath) @include(if: $isProject) @persist { id issues( iid: $iid @@ -95,10 +97,12 @@ query getIssues( first: $firstPageSize last: $lastPageSize ) { + __persist pageInfo { ...PageInfo } nodes { + __persist ...IssueFragment } } diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 040763f2ba4..3b49c0efb14 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -20,6 +20,7 @@ fragment IssueFragment on Issue { type assignees @skip(if: $hideUsers) { nodes { + __persist id avatarUrl name @@ -28,6 +29,7 @@ fragment IssueFragment on Issue { } } author @skip(if: $hideUsers) { + __persist id avatarUrl name @@ -36,6 +38,7 @@ fragment IssueFragment on Issue { } labels { nodes { + __persist id color title @@ -43,6 +46,7 @@ fragment IssueFragment on Issue { } } milestone { + __persist id dueDate startDate diff --git a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql index 44b57317161..7c2aa19046c 100644 --- a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql @@ -1,18 +1,22 @@ #import "./label.fragment.graphql" query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { + group(fullPath: $fullPath) @skip(if: $isProject) @persist { id labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { + __persist nodes { + __persist ...Label } } } - project(fullPath: $fullPath) @include(if: $isProject) { + project(fullPath: $fullPath) @include(if: $isProject) @persist { id labels(searchTerm: $search, includeAncestorGroups: true) { + __persist nodes { + __persist ...Label } } diff --git a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql index bd2f9bc2340..2fd37489234 100644 --- a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql @@ -1,10 +1,9 @@ query searchProjects($fullPath: ID!, $search: String) { group(fullPath: $fullPath) { id - projects(search: $search, includeSubgroups: true) { + projects(search: $search, withIssuesEnabled: true, includeSubgroups: true) { nodes { id - issuesEnabled name nameWithNamespace webUrl diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index e5428f87095..decb559ee81 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -3,10 +3,11 @@ import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gi import Visibility from 'visibilityjs'; import { createAlert } from '~/flash'; import { - IssuableStatus, IssuableStatusText, + STATUS_CLOSED, + TYPE_EPIC, + TYPE_ISSUE, WorkspaceType, - IssuableType, } from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -156,7 +157,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, canAttachFile: { type: Boolean, @@ -190,6 +191,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -251,7 +257,7 @@ export default { return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, isClosed() { - return this.issuableStatus === IssuableStatus.Closed; + return this.issuableStatus === STATUS_CLOSED; }, pinnedLinkClasses() { return this.showTitleBorder @@ -259,7 +265,7 @@ export default { : ''; }, statusIcon() { - if (this.issuableType === IssuableType.Issue) { + if (this.issuableType === TYPE_ISSUE) { return this.isClosed ? 'issue-closed' : 'issues'; } return this.isClosed ? 'epic-closed' : 'epic'; @@ -271,7 +277,7 @@ export default { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType); + return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType); }, }, created() { @@ -453,7 +459,7 @@ export default { } }, - handleListItemReorder(description) { + handleSaveDescription(description) { this.updateFormState(); this.setFormState({ description }); this.updateIssuable(); @@ -564,6 +570,7 @@ export default { <component :is="descriptionComponent" :issue-id="issueId" + :issue-iid="issueIid" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" @@ -573,7 +580,7 @@ export default { :update-url="updateEndpoint" :lock-version="state.lock_version" :is-updating="formState.updateLoading" - @listItemReorder="handleListItemReorder" + @saveDescription="handleSaveDescription" @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 78e729b97da..188a6f6b15e 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,13 +1,15 @@ <script> -import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; +import { GlModalDirective, GlToast } from '@gitlab/ui'; import $ from 'jquery'; +import { uniqueId } from 'lodash'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -15,22 +17,30 @@ import { __, s__, sprintf } from '~/locale'; import { getSortableDefaultOptions, isDragging } from '~/sortable/utils'; import TaskList from '~/task_list'; import Tracking from '~/tracking'; +import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql'; +import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql'; +import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; +import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; -import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; - import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING, + I18N_WORK_ITEM_ERROR_DELETING, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME, - WIDGET_TYPE_DESCRIPTION, } from '~/work_items/constants'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; -import { convertDescriptionWithNewSort } from '../utils'; +import { + deleteTaskListItem, + convertDescriptionWithNewSort, + extractTaskTitleAndDescription, +} from '../utils'; +import TaskListItemActions from './task_list_item_actions.vue'; Vue.use(GlToast); @@ -44,11 +54,10 @@ export default { GlModal: GlModalDirective, }, components: { - GlTooltip, WorkItemDetailModal, }, mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()], - inject: ['fullPath'], + inject: ['fullPath', 'hasIterationsFeature'], props: { canUpdate: { type: Boolean, @@ -71,7 +80,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, updateUrl: { type: String, @@ -88,6 +97,11 @@ export default { required: false, default: null, }, + issueIid: { + type: Number, + required: false, + default: null, + }, isUpdating: { type: Boolean, required: false, @@ -98,18 +112,29 @@ export default { const workItemId = getParameterByName('work_item_id'); return { + hasTaskListItemActions: false, preAnimation: false, pulseAnimation: false, initialUpdate: true, - taskButtons: [], + issueDetails: {}, activeTask: {}, workItemId: isPositiveInteger(workItemId) - ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId) : undefined, workItemTypes: [], }; }, apollo: { + issueDetails: { + query: getIssueDetailsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.issueIid), + }; + }, + update: (data) => data.workspace?.issuable, + }, workItem: { query: workItemQuery, variables() { @@ -118,7 +143,7 @@ export default { }; }, skip() { - return !this.workItemId || !this.workItemsEnabled; + return !this.workItemId || !this.workItemsMvcEnabled; }, }, workItemTypes: { @@ -132,19 +157,19 @@ export default { return data.workspace?.workItemTypes?.nodes; }, skip() { - return !this.workItemsEnabled; + return !this.workItemsMvcEnabled; }, }, }, computed: { - workItemsEnabled() { - return this.glFeatures.workItemsCreateFromMarkdown; + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; }, taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; }, issueGid() { - return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null; + return this.issueId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issueId) : null; }, }, watch: { @@ -164,10 +189,13 @@ export default { }, }, mounted() { + eventHub.$on('convert-task-list-item', this.convertTaskListItem); + eventHub.$on('delete-task-list-item', this.deleteTaskListItem); + this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemId && this.workItemsEnabled) { + if (this.workItemId && this.workItemsMvcEnabled) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, ); @@ -175,6 +203,9 @@ export default { } }, beforeDestroy() { + eventHub.$off('convert-task-list-item', this.convertTaskListItem); + eventHub.$off('delete-task-list-item', this.deleteTaskListItem); + this.removeAllPointerEventListeners(); }, methods: { @@ -197,8 +228,8 @@ export default { this.renderSortableLists(); - if (this.workItemsEnabled) { - this.renderTaskActions(); + if (this.workItemsMvcEnabled) { + this.renderTaskListItemActions(); } } }, @@ -223,7 +254,7 @@ export default { handle: '.drag-icon', onUpdate: (event) => { const description = convertDescriptionWithNewSort(this.descriptionText, event.to); - this.$emit('listItemReorder', description); + this.$emit('saveDescription', description); }, }), ); @@ -232,29 +263,29 @@ export default { createDragIconElement() { const container = document.createElement('div'); // eslint-disable-next-line no-unsanitized/property - container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true"> - <use href="${gon.sprite_icons}#drag-vertical"></use> + container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-opacity-0" role="img" aria-hidden="true"> + <use href="${gon.sprite_icons}#grip"></use> </svg>`; return container.firstChild; }, - addPointerEventListeners(listItem, iconSelector) { + addPointerEventListeners(listItem, elementSelector) { const pointeroverListener = (event) => { - const icon = event.target.closest('li').querySelector(iconSelector); - if (!icon || isDragging() || this.isUpdating) { + const element = event.target.closest('li').querySelector(elementSelector); + if (!element || isDragging() || this.isUpdating) { return; } - icon.style.visibility = 'visible'; + element.classList.add('gl-opacity-10'); }; const pointeroutListener = (event) => { - const icon = event.target.closest('li').querySelector(iconSelector); - if (!icon) { + const element = event.target.closest('li').querySelector(elementSelector); + if (!element) { return; } - icon.style.visibility = 'hidden'; + element.classList.remove('gl-opacity-10'); }; // We use pointerover/pointerout instead of CSS so that when we hover over a - // list item with children, the drag icons of its children do not become visible. + // list item with children, the grip icons of its children do not become visible. listItem.addEventListener('pointerover', pointeroverListener); listItem.addEventListener('pointerout', pointeroutListener); @@ -279,11 +310,9 @@ export default { taskListUpdateStarted() { this.$emit('taskListUpdateStarted'); }, - taskListUpdateSuccess() { this.$emit('taskListUpdateSucceeded'); }, - taskListUpdateError() { createAlert({ message: sprintf( @@ -298,7 +327,6 @@ export default { this.$emit('taskListUpdateFailed'); }, - updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); @@ -317,22 +345,42 @@ export default { $tasksShort.text(''); } }, - renderTaskActions() { + createTaskListItemActions(provide) { + const app = new Vue({ + el: document.createElement('div'), + provide, + render: (createElement) => createElement(TaskListItemActions), + }); + return app.$el; + }, + convertTaskListItem(sourcepos) { + const oldDescription = this.descriptionText; + const { newDescription, taskDescription, taskTitle } = deleteTaskListItem( + oldDescription, + sourcepos, + ); + this.$emit('saveDescription', newDescription); + this.createTask({ taskTitle, taskDescription, oldDescription }); + }, + deleteTaskListItem(sourcepos) { + const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos); + this.$emit('saveDescription', newDescription); + }, + renderTaskListItemActions() { if (!this.$el?.querySelectorAll) { return; } - this.taskButtons = []; const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)'); - taskListFields.forEach((item, index) => { + taskListFields.forEach((item) => { const taskLink = item.querySelector('.gfm-issue'); if (taskLink) { const { issue, referenceType, issueType } = taskLink.dataset; if (issueType !== workItemTypes.TASK) { return; } - const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue); this.addHoverListeners(taskLink, workItemId); taskLink.classList.add('gl-link'); taskLink.addEventListener('click', (e) => { @@ -351,31 +399,12 @@ export default { }); return; } - this.addPointerEventListeners(item, '.js-add-task'); - const button = document.createElement('button'); - button.classList.add( - 'btn', - 'btn-default', - 'btn-md', - 'gl-button', - 'btn-default-tertiary', - 'gl-visibility-hidden', - 'gl-p-0!', - 'gl-mt-n1', - 'gl-ml-3', - 'js-add-task', - ); - button.id = `js-task-button-${index}`; - this.taskButtons.push(button.id); - // eslint-disable-next-line no-unsanitized/property - button.innerHTML = ` - <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> - <use href="${gon.sprite_icons}#doc-new"></use> - </svg> - `; - button.setAttribute('aria-label', s__('WorkItem|Create task')); - button.addEventListener('click', () => this.handleCreateTask(button)); - this.insertButtonNextToTaskText(item, button); + + const toggleClass = uniqueId('task-list-item-actions-'); + const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass }); + this.addPointerEventListeners(item, `.${toggleClass}`); + this.insertNextToTaskListItemText(dropdown, item); + this.hasTaskListItemActions = true; }); }, addHoverListeners(taskLink, id) { @@ -391,19 +420,20 @@ export default { } }); }, - insertButtonNextToTaskText(listItem, button) { - const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P'); - const lastChild = listItem.lastElementChild; + insertNextToTaskListItemText(element, listItem) { + const children = Array.from(listItem.children); + const paragraph = children.find((el) => el.tagName === 'P'); + const list = children.find((el) => el.classList.contains('task-list')); if (paragraph) { // If there's a `p` element, then it's a multi-paragraph task item // and the task text exists within the `p` element as the last child - paragraph.append(button); - } else if (lastChild.tagName === 'OL' || lastChild.tagName === 'UL') { + paragraph.append(element); + } else if (list) { // Otherwise, the task item can have a child list which exists directly after the task text - lastChild.insertAdjacentElement('beforebegin', button); + list.insertAdjacentElement('beforebegin', element); } else { // Otherwise, the task item is a simple one where the task text exists as the last child - listItem.append(button); + listItem.append(element); } }, setActiveTask(el) { @@ -427,55 +457,90 @@ export default { this.workItemId = undefined; this.updateWorkItemIdUrlQuery(undefined); }, - async handleCreateTask(el) { - this.setActiveTask(el); + async createTask({ taskTitle, taskDescription, oldDescription }) { try { - const { data } = await this.$apollo.mutate({ - mutation: createWorkItemFromTaskMutation, - variables: { - input: { - id: this.issueGid, - workItemData: { - lockVersion: this.lockVersion, - title: this.activeTask.title, - lineNumberStart: Number(this.activeTask.lineNumberStart), - lineNumberEnd: Number(this.activeTask.lineNumberEnd), - workItemTypeId: this.taskWorkItemType, - }, - }, + const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription); + const iterationInput = { + iterationWidget: { + iterationId: this.issueDetails.iteration?.id ?? null, }, - update(store, { data: { workItemCreateFromTask } }) { - const { newWorkItem } = workItemCreateFromTask; - - store.writeQuery({ - query: workItemQuery, - variables: { - id: newWorkItem.id, - }, - data: { - workItem: newWorkItem, - }, - }); + }; + const input = { + confidential: this.issueDetails.confidential, + description, + hierarchyWidget: { + parentId: this.issueGid, + }, + ...(this.hasIterationsFeature && iterationInput), + milestoneWidget: { + milestoneId: this.issueDetails.milestone?.id ?? null, }, + projectPath: this.fullPath, + title, + workItemTypeId: this.taskWorkItemType, + }; + + const { data } = await this.$apollo.mutate({ + mutation: createWorkItemMutation, + variables: { input }, }); - const { workItem, newWorkItem } = data.workItemCreateFromTask; + const { workItem, errors } = data.workItemCreate; + + if (errors?.length) { + throw new Error(errors); + } - const updatedDescription = workItem?.widgets?.find( - (widget) => widget.type === WIDGET_TYPE_DESCRIPTION, - )?.descriptionHtml; + await this.$apollo.mutate({ + mutation: addHierarchyChildMutation, + variables: { id: this.issueGid, workItem }, + }); - this.$emit('updateDescription', updatedDescription); - this.workItemId = newWorkItem.id; - this.openWorkItemDetailModal(el); + this.$toast.show(s__('WorkItem|Converted to task'), { + action: { + text: s__('WorkItem|Undo'), + onClick: (_, toast) => { + this.undoCreateTask(oldDescription, workItem.id); + toast.hide(); + }, + }, + }); } catch (error) { - createAlert({ - message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), - error, - captureError: true, + this.showAlert(I18N_WORK_ITEM_ERROR_CREATING, error); + } + }, + async undoCreateTask(oldDescription, id) { + this.$emit('saveDescription', oldDescription); + + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id } }, + }); + + const { errors } = data.workItemDelete; + + if (errors?.length) { + throw new Error(errors); + } + + await this.$apollo.mutate({ + mutation: removeHierarchyChildMutation, + variables: { id: this.issueGid, workItem: { id } }, }); + + this.$toast.show(s__('WorkItem|Task reverted')); + } catch (error) { + this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error); } }, + showAlert(message, error) { + createAlert({ + message: sprintfWorkItem(message, workItemTypes.TASK), + error, + captureError: true, + }); + }, handleDeleteTask(description) { this.$emit('updateDescription', description); this.$toast.show(s__('WorkItem|Task deleted')); @@ -492,14 +557,7 @@ export default { </script> <template> - <div - v-if="descriptionHtml" - :class="{ - 'js-task-list-container': canUpdate, - 'work-items-enabled': workItemsEnabled, - }" - class="description" - > + <div v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate }" class="description"> <div ref="gfm-content" v-safe-html:[$options.safeHtmlConfig]="descriptionHtml" @@ -507,10 +565,10 @@ export default { :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation, + 'has-task-list-item-actions': hasTaskListItemActions, }" class="md" ></div> - <textarea v-if="descriptionText" :value="descriptionText" @@ -531,10 +589,5 @@ export default { @workItemDeleted="handleDeleteTask" @close="closeWorkItemDetailModal" /> - <template v-if="workItemsEnabled"> - <gl-tooltip v-for="item in taskButtons" :key="item" :target="item"> - {{ s__('WorkItem|Create task') }} - </gl-tooltip> - </template> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 04c5007dbec..3bc24e8ce01 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; @@ -35,6 +36,16 @@ export default { default: true, }, }, + data() { + return { + formFieldProps: { + id: 'issue-description', + name: 'issue-description', + placeholder: __('Write a comment or drag your files here…'), + 'aria-label': __('Description'), + }, + }; + }, computed: { quickActionsDocsPath() { return helpPagePath('user/project/quick_actions'); @@ -60,10 +71,7 @@ export default { :value="value" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="issue-description" - form-field-name="issue-description" + :form-field-props="formFieldProps" :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions @@ -84,15 +92,13 @@ export default { > <template #textarea> <textarea - id="issue-description" + v-bind="formFieldProps" ref="textarea" :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" data-qa-selector="description_field" dir="auto" data-supports-quick-actions="true" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 5695efd7114..5ade1a86d30 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -1,6 +1,6 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { capitalize } from 'lodash'; +import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { issuableTypes, INCIDENT_TYPE } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; @@ -16,34 +16,35 @@ export default { components: { GlFormGroup, GlIcon, - GlDropdown, - GlDropdownItem, + GlListbox, }, inject: { canCreateIncident: { default: false, }, issueType: { - default: 'issue', + default: TYPE_ISSUE, }, }, data() { return { issueState: {}, + selectedIssueType: '', }; }, apollo: { issueState: { query: getIssueStateQuery, + result({ + data: { + issueState: { issueType }, + }, + }) { + this.selectedIssueType = issueType; + }, }, }, computed: { - dropdownText() { - const { - issueState: { issueType }, - } = this; - return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType); - }, shouldShowIncident() { return this.issueType === INCIDENT_TYPE || this.canCreateIncident; }, @@ -72,25 +73,21 @@ export default { label-for="issuable-type" class="mb-2 mb-md-0" > - <gl-dropdown - id="issuable-type" - :aria-labelledby="$options.i18n.label" - :text="dropdownText" + <gl-listbox + v-model="selectedIssueType" + toggle-class="gl-mb-0" + :items="$options.issuableTypes" :header-text="$options.i18n.label" - class="gl-w-full" - toggle-class="dropdown-menu-toggle" + :list-aria-labelled-by="$options.i18n.label" + block + @select="updateIssueType" > - <gl-dropdown-item - v-for="type in $options.issuableTypes" - v-show="isShown(type)" - :key="type.value" - :is-checked="issueState.issueType === type.value" - is-check-item - @click="updateIssueType(type.value)" - > - <gl-icon :name="type.icon" /> - {{ type.text }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <span v-show="isShown(item)" data-testid="issue-type-list-item"> + <gl-icon :name="item.icon" /> + {{ item.text }} + </span> + </template> + </gl-listbox> </gl-form-group> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index b56c91d7983..bcea9cf57a7 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,7 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -98,7 +98,7 @@ export default { return this.formState.lockedWarningVisible && !this.formState.updateLoading; }, isIssueType() { - return this.issuableType === IssuableType.Issue; + return this.issuableType === TYPE_ISSUE; }, }, watch: { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 56e360c75e3..9d92b5cf954 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -12,7 +12,7 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableStatus, IssueType } from '~/issues/constants'; +import { IssueType, STATUS_CLOSED } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -98,6 +98,12 @@ export default { submitAsSpamPath: { default: '', }, + reportedUserId: { + default: '', + }, + reportedFromUrl: { + default: '', + }, }, data() { return { @@ -108,7 +114,7 @@ export default { ...mapState(['isToggleStateButtonLoading']), ...mapGetters(['openState', 'getBlockedByIssues']), isClosed() { - return this.openState === IssuableStatus.Closed; + return this.openState === STATUS_CLOSED; }, issueTypeText() { const issueTypeTexts = { @@ -368,7 +374,12 @@ export default { :title="deleteButtonText" /> + <!-- IMPORTANT: show this component lazily because it causes layout thrashing --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 --> <abuse-category-selector + v-if="isReportAbuseDrawerOpen" + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" :show-drawer="isReportAbuseDrawerOpen" @close-drawer="toggleReportAbuseDrawer(false)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 2fdae538902..c0aadf9c14e 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -47,9 +47,21 @@ export const timelineItemI18n = Object.freeze({ export const timelineEventTagsI18n = Object.freeze({ startTime: __('Start time'), + impactDetected: __('Impact detected'), + responseInitiated: __('Response initiated'), + impactMitigated: __('Impact mitigated'), + causeIdentified: __('Cause identified'), endTime: __('End time'), }); +export const timelineEventTagsPopover = Object.freeze({ + title: s__('Incident|Event tag'), + message: s__( + 'Incident|Adding an event tag associates the timeline comment with specific incident metrics.', + ), + link: __('Learn more'), +}); + export const MAX_TEXT_LENGTH = 280; export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({ diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index 81111d42b39..40cb7fbb0ff 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -5,7 +5,7 @@ import { GlIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { timelineFormI18n } from './constants'; import TimelineEventsForm from './timeline_events_form.vue'; @@ -41,7 +41,7 @@ export default { } const variables = { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), fullPath: this.fullPath, }; @@ -71,7 +71,7 @@ export default { mutation: CreateTimelineEvent, variables: { input: { - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), note: eventDetails.note, occurredAt: eventDetails.occurredAt, timelineEventTagNames: eventDetails.timelineEventTags, @@ -113,13 +113,13 @@ export default { > <div v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> <timeline-events-form ref="eventForm" - :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }" + :class="{ 'gl-border-gray-50 gl-border-t gl-pt-3': hasTimelineEvents }" :is-event-processed="createTimelineEventActive" show-save-and-add @save-event="createIncidentTimelineEvent" diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 4ef9b9c5a99..c2fb8b6f683 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -28,9 +28,9 @@ export default { </script> <template> - <div class="gl-relative gl-display-flex gl-align-items-center"> + <div class="edit-timeline-event gl-relative gl-display-flex gl-align-items-center"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1" > <gl-icon name="comment" class="note-icon" /> </div> @@ -40,6 +40,7 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + :previous-tags="event.timelineEventTags.nodes" is-editing @save-event="saveEvent" @cancel="$emit('hide-edit')" diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql index 54f036268cc..77f955c08dc 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } 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 53956fcb4b2..997fadec602 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -125,8 +125,8 @@ 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-display-none', !isSummaryTab); + editButton?.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab); } }, }, diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 6648e20865d..7944362a40f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,10 +1,11 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf } from '~/locale'; +import TimelineEventsTagsPopover from './timeline_events_tags_popover.vue'; import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants'; -import { getUtcShiftedDate } from './utils'; +import { getUtcShiftedDate, getPreviousEventTags } from './utils'; export default { name: 'TimelineEventsForm', @@ -21,11 +22,12 @@ export default { ], components: { MarkdownField, + TimelineEventsTagsPopover, GlDatepicker, GlFormInput, GlFormGroup, GlButton, - GlListbox, + GlCollapsibleListbox, }, mixins: [glFeatureFlagsMixin()], i18n: timelineFormI18n, @@ -77,7 +79,7 @@ export default { hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), datePickerInput: placeholderDate, - selectedTags: [...this.previousTags], + selectedTags: getPreviousEventTags(this.previousTags), }; }, computed: { @@ -101,19 +103,19 @@ export default { timelineTextCount() { return this.timelineText.length; }, - dropdownText() { + listboxText() { if (!this.selectedTags.length) { return timelineFormI18n.selectTags; } - const dropdownText = + const listboxText = this.selectedTags.length === 1 ? this.selectedTags[0] : sprintf(__('%{numberOfSelectedTags} tags'), { numberOfSelectedTags: this.selectedTags.length, }); - return dropdownText; + return listboxText; }, }, mounted() { @@ -164,11 +166,11 @@ export default { <template> <form class="gl-flex-grow-1 gl-border-gray-50"> - <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> - <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mt-3"> + <gl-form-group :label="__('Date')" class="gl-mr-5"> <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" /> </gl-form-group> - <div class="gl-display-flex gl-mt-5"> + <div class="gl-display-flex"> <gl-form-group :label="__('Time')"> <div class="gl-display-flex"> <label label-for="timeline-input-hours" class="sr-only"></label> @@ -197,10 +199,15 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> - <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel"> - <gl-listbox + <gl-form-group v-if="glFeatures.incidentEventTags"> + <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags"> + {{ $options.i18n.tagsLabel }} + <timeline-events-tags-popover /> + </label> + <gl-collapsible-listbox + id="timeline-input-tags" :selected="selectedTags" - :toggle-text="dropdownText" + :toggle-text="listboxText" :items="tags" :is-check-centered="true" :multiple="true" diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 90ee4351e39..d33f3146d64 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -32,16 +32,19 @@ export default { type: String, required: true, }, - eventTag: { - type: String, + eventTags: { + type: Array, required: false, - default: null, + default: () => [], }, }, computed: { time() { return formatDate(this.occurredAt, 'HH:MM', true); }, + canEditEvent() { + return this.action === 'comment'; + }, }, methods: { getEventIcon, @@ -51,19 +54,24 @@ export default { <template> <div class="timeline-event gl-display-grid"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> - <div class="gl-display-flex gl-align-items-center gl-mb-3"> - <strong class="gl-font-lg" data-testid="event-time"> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2"> + <h3 + class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0" + data-testid="event-time" + > <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> + <template #time> + <span class="gl-font-lg">{{ time }}</span> + </template> </gl-sprintf> - </strong> - <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> - {{ eventTag }} + </h3> + <gl-badge v-for="tag in eventTags" :key="tag.key" variant="muted" icon="tag"> + {{ tag.name }} </gl-badge> </div> <div v-safe-html="noteHtml" class="md"></div> @@ -78,7 +86,7 @@ export default { category="tertiary" no-caret > - <gl-dropdown-item @click="$emit('edit')"> + <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')"> {{ $options.i18n.edit }} </gl-dropdown-item> <gl-dropdown-item @click="$emit('delete')"> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index c6b93201c97..10b80529a66 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,9 +50,6 @@ export default { }, }, methods: { - getFirstTag(eventTag) { - return eventTag.nodes?.[0]?.name; - }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -105,6 +102,7 @@ export default { id: eventDetails.id, note: eventDetails.note, occurredAt: eventDetails.occurredAt, + timelineEventTagNames: eventDetails.timelineEventTags, }, }, }) @@ -132,21 +130,25 @@ export default { </script> <template> - <div class="issuable-discussion incident-timeline-events"> + <div class="issuable-discussion incident-timeline-events gl-mt-n3"> <div v-for="[eventDate, events] in dateGroupedEvents" :key="eventDate" data-testid="timeline-group" class="timeline-group" > - <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> - <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> - </div> + <h2 + class="gl-font-size-h2 gl-my-0 gl-py-5 gl-border-gray-50 gl-border-1 gl-border-b-solid" + data-testid="event-date" + > + {{ eventDate }} + </h2> + <ul class="notes main-notes-list"> <li v-for="(event, eventIndex) in events" :key="eventIndex" - class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!" + class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-0! gl-pr-0!" > <edit-timeline-event v-if="eventToEdit === event.id" @@ -164,7 +166,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" - :event-tag="getFirstTag(event.timelineEventTags)" + :event-tags="event.timelineEventTags.nodes" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index c8237766505..cb18d34b70b 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; @@ -33,7 +33,7 @@ export default { variables() { return { fullPath: this.fullPath, - incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), + incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId), }; }, update(data) { diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue new file mode 100644 index 00000000000..772a16e9ba2 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue @@ -0,0 +1,42 @@ +<script> +import { GlIcon, GlPopover, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timelineEventTagsPopover } from './constants'; + +export default { + name: 'TimelineEventsTagsPopover', + components: { + GlIcon, + GlPopover, + GlLink, + }, + i18n: timelineEventTagsPopover, + learnMoreLink: helpPagePath('ee/operations/incident_management/incident_timeline_events', { + anchor: 'incident-tags', + }), +}; +</script> + +<template> + <span> + <gl-icon id="timeline-events-tag-question" name="question-o" class="gl-text-blue-600" /> + + <gl-popover + target="timeline-events-tag-question" + triggers="hover focus" + placement="top" + container="viewport" + :title="$options.i18n.title" + > + <div> + <p class="gl-mb-0"> + {{ $options.i18n.message }} + </p> + <gl-link target="_blank" class="gl-font-sm" :href="$options.learnMoreLink">{{ + $options.i18n.link + }}</gl-link + >. + </div> + </gl-popover> + </span> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index 5a009debd75..ce33e91c3b8 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -32,3 +32,11 @@ export const getUtcShiftedDate = (ISOString = null) => { return date; }; + +/** + * Returns an array of previously set event tags + * @param {array} timelineEventTagsNodes + * @returns {array} + */ +export const getPreviousEventTags = (timelineEventTagsNodes = []) => + timelineEventTagsNodes.map(({ name }) => name); diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue new file mode 100644 index 00000000000..d0beb0f39b3 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -0,0 +1,47 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + i18n: { + convertToTask: s__('WorkItem|Convert to task'), + delete: __('Delete'), + taskActions: s__('WorkItem|Task actions'), + }, + components: { + GlDropdown, + GlDropdownItem, + }, + inject: ['canUpdate', 'toggleClass'], + methods: { + convertToTask() { + eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + deleteTaskListItem() { + eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos); + }, + }, +}; +</script> + +<template> + <gl-dropdown + class="task-list-item-actions-wrapper" + category="tertiary" + icon="ellipsis_v" + lazy + no-caret + right + :text="$options.i18n.taskActions" + text-sr-only + :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`" + > + <gl-dropdown-item v-if="canUpdate" @click="convertToTask"> + {{ $options.i18n.convertToTask }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js deleted file mode 100644 index deee034f9d1..00000000000 --- a/app/assets/javascripts/issues/show/graphql.js +++ /dev/null @@ -1,9 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { defaultClient } from '~/graphql_shared/issuable_client'; - -Vue.use(VueApollo); - -export default new VueApollo({ - defaultClient, -}); diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 21d877c5fe6..1793ce66ad4 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { mapGetters } from 'vuex'; import errorTrackingStore from '~/error_tracking/store'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import IssueApp from './components/app.vue'; @@ -8,7 +9,6 @@ import HeaderActions from './components/header_actions.vue'; import IncidentTabs from './components/incidents/incident_tabs.vue'; import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; import { INCIDENT_TYPE, issueState } from './constants'; -import apolloProvider from './graphql'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; const bootstrapApollo = (state = {}) => { @@ -20,7 +20,7 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issueData = {}) { +export function initIncidentApp(issueData = {}, store) { const el = document.getElementById('js-issuable-app'); if (!el) { @@ -49,6 +49,7 @@ export function initIncidentApp(issueData = {}) { el, name: 'DescriptionRoot', apolloProvider, + store, provide: { issueType: INCIDENT_TYPE, canCreateIncident, @@ -62,6 +63,9 @@ export function initIncidentApp(issueData = {}) { uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), contentEditorOnIssues: gon.features.contentEditorOnIssues, }, + computed: { + ...mapGetters(['getNoteableData']), + }, render(createElement) { return createElement(IssueApp, { props: { @@ -70,6 +74,7 @@ export function initIncidentApp(issueData = {}) { issuableStatus: state, descriptionComponent: IncidentTabs, showTitleBorder: false, + isConfidential: this.getNoteableData?.confidential, }, }); }, @@ -89,7 +94,12 @@ export function initIssueApp(issueData, store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData; + const { + canCreateIncident, + hasIssueWeightsFeature, + hasIterationsFeature, + ...issueProps + } = issueData; return new Vue({ el, @@ -102,6 +112,7 @@ export function initIssueApp(issueData, store) { registerPath, signInPath, hasIssueWeightsFeature, + hasIterationsFeature, }, computed: { ...mapGetters(['getNoteableData']), @@ -114,6 +125,7 @@ export function initIssueApp(issueData, store) { isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, issueId: this.getNoteableData?.id, + issueIid: this.getNoteableData?.iid, }, }); }, @@ -152,7 +164,7 @@ export function initHeaderActions(store, type = '') { projectPath: el.dataset.projectPath, projectId: el.dataset.projectId, reportAbusePath: el.dataset.reportAbusePath, - reportedUserId: el.dataset.reportedUserId, + reportedUserId: parseInt(el.dataset.reportedUserId, 10), reportedFromUrl: el.dataset.reportedFromUrl, submitAsSpamPath: el.dataset.submitAsSpamPath, }, diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js index 05b06586362..7742a015836 100644 --- a/app/assets/javascripts/issues/show/utils.js +++ b/app/assets/javascripts/issues/show/utils.js @@ -1,4 +1,6 @@ +import { TITLE_LENGTH_MAX } from '~/issues/constants'; import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; /** * Returns the start and end `sourcepos` rows, converted to zero-based numbering. @@ -93,3 +95,136 @@ export const convertDescriptionWithNewSort = (description, list) => { return descriptionLines.join(NEWLINE); }; + +const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]\s+/; +const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]\s+/; +const codeMarkdownRegex = /^\s*`.*`\s*$/; +const imageOrLinkMarkdownRegex = /^\s*!?\[.*\)\s*$/; + +/** + * Checks whether the line of markdown contains a task list item, + * i.e. `- [ ]`, `* [ ]`, or `1. [ ]`. + * + * @param {String} line A line of markdown + * @returns {boolean} `true` if the line contains a task list item, otherwise `false` + */ +const containsTaskListItem = (line) => + bulletTaskListItemRegex.test(line) || numericalTaskListItemRegex.test(line); + +/** + * Deletes a task list item from the description. + * + * Starting from the task list item, it deletes each line until it hits a nested + * task list item and reduces the indentation of each line from this line onwards. + * + * For example, for a given description like: + * + * <pre> + * 1. [ ] item 1 + * + * paragraph text + * + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * Then when prompted to delete item 1, this function will return: + * + * <pre> + * 1. [ ] item 2 + * + * paragraph text + * + * 1. [ ] item 3 + * </pre> + * + * @param {String} description Description in markdown format + * @param {String} sourcepos Source position in format `23:3-23:14` + * @returns {{newDescription: String, taskDescription: String, taskTitle: String}} Object with: + * + * - `newDescription` property that contains markdown with the deleted task list item omitted + * - `taskDescription` property that contains the description of the deleted task list item + * - `taskTitle` property that contains the title of the deleted task list item + */ +export const deleteTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const [startIndex, endIndex] = getSourceposRows(sourcepos); + + const firstLine = descriptionLines[startIndex]; + const firstLineIndentation = firstLine.length - firstLine.trimStart().length; + + const taskTitle = firstLine + .replace(bulletTaskListItemRegex, '') + .replace(numericalTaskListItemRegex, ''); + const taskDescription = []; + + let indentation = 0; + let linesToDelete = 1; + let reduceIndentation = false; + + for (let i = startIndex + 1; i <= endIndex; i += 1) { + if (reduceIndentation) { + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else if (containsTaskListItem(descriptionLines[i])) { + reduceIndentation = true; + const currentLine = descriptionLines[i]; + const currentLineIndentation = currentLine.length - currentLine.trimStart().length; + indentation = currentLineIndentation - firstLineIndentation; + descriptionLines[i] = descriptionLines[i].slice(indentation); + } else { + taskDescription.push(descriptionLines[i].trimStart()); + linesToDelete += 1; + } + } + + descriptionLines.splice(startIndex, linesToDelete); + + return { + newDescription: descriptionLines.join(NEWLINE), + taskDescription: taskDescription.join(NEWLINE) || undefined, + taskTitle, + }; +}; + +/** + * Given a title and description for a task: + * + * - Moves characters beyond the 255 character limit from the title to the description + * - Moves a pure markdown title to the description and gives the title the value `Untitled` + * + * @param {String} taskTitle The task title + * @param {String} taskDescription The task description + * @returns {{description: String, title: String}} An object with the formatted task title and description + */ +export const extractTaskTitleAndDescription = (taskTitle, taskDescription) => { + const isTitleOnlyMarkdown = + codeMarkdownRegex.test(taskTitle) || imageOrLinkMarkdownRegex.test(taskTitle); + + if (isTitleOnlyMarkdown) { + return { + title: __('Untitled'), + description: taskDescription + ? taskTitle.concat(NEWLINE, NEWLINE, taskDescription) + : taskTitle, + }; + } + + const isTitleTooLong = taskTitle.length > TITLE_LENGTH_MAX; + + if (isTitleTooLong) { + return { + title: taskTitle.slice(0, TITLE_LENGTH_MAX), + description: taskDescription + ? taskTitle.slice(TITLE_LENGTH_MAX).concat(NEWLINE, NEWLINE, taskDescription) + : taskTitle.slice(TITLE_LENGTH_MAX), + }; + } + + return { + title: taskTitle, + description: taskDescription, + }; +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index c79d7002111..8c5dc88f183 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -35,13 +35,16 @@ export const removeSubscription = async (removePath) => { }); }; -export const fetchGroups = async (groupsPath, { page, perPage, search }) => { +export const fetchGroups = async (groupsPath, { page, perPage, search }, accessToken = null) => { return axiosInstance.get(groupsPath, { params: { page, per_page: perPage, search, }, + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, }); }; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue index a9ec7bd971e..a4b728335c5 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/subscriptions/api'; import { @@ -38,6 +39,7 @@ export default { showPagination() { return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0; }, + ...mapState(['accessToken']), }, mounted() { return this.loadGroups().finally(() => { @@ -47,11 +49,15 @@ export default { methods: { loadGroups() { this.isLoadingMore = true; - return fetchGroups(this.groupsPath, { - page: this.page, - perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, - search: this.searchValue, - }) + return fetchGroups( + this.groupsPath, + { + page: this.page, + perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, + search: this.searchValue, + }, + this.accessToken, + ) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); this.page = page; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 44575455a34..ec42b533dd4 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -31,6 +31,9 @@ export default { subscriptionsPath: { default: '', }, + publicKeyStorageEnabled: { + default: false, + }, }, computed: { ...mapState(['currentUser']), @@ -144,6 +147,7 @@ export default { <sign-in-page v-show="!userSignedIn" :has-subscriptions="hasSubscriptions" + :public-key-storage-enabled="publicKeyStorageEnabled" @sign-in-oauth="onSignInOauth" @error="onSignInError" /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 01bc5dfc66b..bb22a4ef252 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -38,7 +38,7 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_ anchor: 'use-the-integration', }); export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'connect-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', + anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances', }); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 8e9f73538b9..21ff85e58e2 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -27,6 +27,7 @@ export function initJiraConnect() { usersPath, gitlabUserPath, oauthMetadata, + publicKeyStorageEnabled, } = el.dataset; sizeToParent(); @@ -42,6 +43,7 @@ export function initJiraConnect() { usersPath, gitlabUserPath, oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null, + publicKeyStorageEnabled, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue index 782e8a625a9..6de3f507a39 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue @@ -27,7 +27,7 @@ export default { }, i18n: { signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), - signInText: s__('JiraService|Sign in to GitLab.com to get started.'), + signInText: s__('JiraService|Sign in to GitLab to get started.'), }, GITLAB_COM_BASE_PATH, methods: { diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue index f4c59b2184e..e6a94ffbaa4 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue @@ -12,10 +12,14 @@ export default { type: Boolean, required: true, }, + publicKeyStorageEnabled: { + type: Boolean, + required: true, + }, }, computed: { isOauthSelfManagedEnabled() { - return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged; + return this.glFeatures.jiraConnectOauth && this.publicKeyStorageEnabled; }, }, }; diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index c6d900ef13e..d93b8a8de29 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -9,6 +9,7 @@ import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import Log from '~/jobs/components/log/log.vue'; +import { MANUAL_STATUS } from '~/jobs/constants'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; @@ -144,6 +145,12 @@ export default { this.fetchJobsForStage(defaultStage); } } + + // Only poll for job log if we are not in the manual variables form empty state. + // This will be handled more elegantly in the future with GraphQL in https://gitlab.com/gitlab-org/gitlab/-/issues/389597 + if (newVal?.status?.group !== MANUAL_STATUS && !this.showUpdateVariablesState) { + this.fetchJobLog(); + } }, }, created() { @@ -163,6 +170,7 @@ export default { }, methods: { ...mapActions([ + 'fetchJobLog', 'fetchJobsForStage', 'hideSidebar', 'showSidebar', diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index 734d3ca0d49..763eb6705aa 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -13,8 +13,9 @@ import { cloneDeep, uniqueId } from 'lodash'; import { mapActions } from 'vuex'; import { fetchPolicies } from '~/lib/graphql'; import { createAlert } from '~/flash'; +import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants'; +import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -45,7 +46,7 @@ export default { variables() { return { fullPath: this.projectPath, - id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), }; }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, @@ -76,13 +77,16 @@ export default { i18n: { clearInputs: s__('CiVariables|Clear inputs'), formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + 'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', ), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), keyPlaceholder: s__('CiVariables|Input variable key'), runAgainButtonText: s__('CiVariables|Run job again'), - triggerButtonText: s__('CiVariables|Trigger this manual action'), + triggerButtonText: s__('CiVariables|Run job'), valueLabel: s__('CiVariables|Value'), valuePlaceholder: s__('CiVariables|Input variable value'), }, @@ -157,7 +161,7 @@ export default { const { data } = await this.$apollo.mutate({ mutation: retryJobWithVariablesMutation, variables: { - id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId), + id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId), // we need to ensure no empty variables are passed to the API variables: this.preparedVariables, }, @@ -258,6 +262,15 @@ export default { </template> </gl-sprintf> </div> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-button class="gl-mt-5" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index 40aec0b0536..8100bc2d87a 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -2,11 +2,11 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { createAlert } from '~/flash'; +import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_GRAPHQL_ERRORS, - GRAPHQL_ID_TYPES, JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId, PASSED_STATUS, @@ -35,7 +35,7 @@ export default { variables() { return { fullPath: this.projectPath, - id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), }; }, update(data) { diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 6f351d91165..17766b4d162 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -8,9 +8,11 @@ import { ACTIONS_UNSCHEDULE, ACTIONS_PLAY, ACTIONS_RETRY, + ACTIONS_RUN_AGAIN, CANCEL, GENERIC_ERROR, JOB_SCHEDULED, + JOB_SUCCESS, PLAY_JOB_CONFIRMATION_MESSAGE, RUN_JOB_NOW_HEADER_TITLE, FILE_TYPE_ARCHIVE, @@ -107,6 +109,9 @@ export default { shouldDisplayArtifacts() { return this.canReadArtifacts && this.hasArtifacts; }, + retryButtonTitle() { + return this.job.status === JOB_SUCCESS ? ACTIONS_RUN_AGAIN : ACTIONS_RETRY; + }, }, methods: { async postJobAction(name, mutation, redirect = false) { @@ -223,8 +228,8 @@ export default { <gl-button v-else-if="isRetryable" icon="retry" - :title="$options.ACTIONS_RETRY" - :aria-label="$options.ACTIONS_RETRY" + :title="retryButtonTitle" + :aria-label="retryButtonTitle" :method="currentJobMethod" :disabled="retryBtnDisabled" data-testid="retry" diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index f73241aed6b..41ce6e4d64d 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -9,6 +9,7 @@ export const RAW_TEXT_WARNING = s__( /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; +export const JOB_SUCCESS = 'SUCCESS'; /* Artifact file types */ export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; @@ -19,6 +20,7 @@ export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule'); export const ACTIONS_PLAY = __('Play'); export const ACTIONS_RETRY = __('Retry'); +export const ACTIONS_RUN_AGAIN = __('Run again'); export const CANCEL = __('Cancel'); export const GENERIC_ERROR = __('An error occurred while making the request.'); diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index 405aea11181..027d896ba0e 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -5,11 +5,6 @@ const moreInfo = __('More information'); export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; -export const GRAPHQL_ID_TYPES = { - commitStatus: 'CommitStatus', - ciBuild: 'Ci::Build', -}; - export const JOB_SIDEBAR_COPY = { cancel, cancelJobButtonLabel: s__('Job|Cancel'), @@ -42,3 +37,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { export const SUCCESS_STATUS = 'SUCCESS'; export const PASSED_STATUS = 'passed'; +export const MANUAL_STATUS = 'manual'; diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index a81edb240ad..af2d720643f 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -8,7 +8,6 @@ import { canScroll, isScrolledToBottom, isScrolledToTop, - isScrolledToMiddle, scrollDown, scrollUp, } from '~/lib/utils/scroll_utils'; @@ -23,7 +22,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { pagePath, }); - return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]); + return Promise.all([dispatch('fetchJob')]); }; export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); @@ -124,15 +123,15 @@ export const scrollBottom = ({ dispatch }) => { */ export const toggleScrollButtons = ({ dispatch }) => { if (canScroll()) { - if (isScrolledToMiddle()) { - dispatch('enableScrollTop'); - dispatch('enableScrollBottom'); - } else if (isScrolledToTop()) { + if (isScrolledToTop()) { dispatch('disableScrollTop'); dispatch('enableScrollBottom'); } else if (isScrolledToBottom()) { dispatch('disableScrollBottom'); dispatch('enableScrollTop'); + } else { + dispatch('enableScrollTop'); + dispatch('enableScrollBottom'); } } else { dispatch('disableScrollBottom'); diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue index 4d3fe22e247..a2012f95fd6 100644 --- a/app/assets/javascripts/language_switcher/components/app.vue +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -45,7 +45,7 @@ export default { :toggle-text="preferredLocale.text" :items="locales" category="tertiary" - right + placement="right" icon="earth" size="small" toggle-class="py-0 gl-h-6" diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 90c1b31286a..b8138f34d45 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -56,7 +56,11 @@ function initDeferred() { if (!appEl) return; setNotification(appEl); - document.querySelector('.js-whats-new-trigger').addEventListener('click', () => { + + const triggerEl = document.querySelector('.js-whats-new-trigger'); + if (!triggerEl) return; + + triggerEl.addEventListener('click', () => { import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') .then(({ default: initWhatsNew }) => { initWhatsNew(appEl); diff --git a/app/assets/javascripts/lib/apollo/persist_link.js b/app/assets/javascripts/lib/apollo/persist_link.js new file mode 100644 index 00000000000..9d95409d96c --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persist_link.js @@ -0,0 +1,141 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistLink.ts +// with some heavy refactororing + +/* eslint-disable consistent-return */ +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-param-reassign */ +import { visit } from 'graphql'; +import { ApolloLink } from '@apollo/client/core'; +import traverse from 'traverse'; + +const extractPersistDirectivePaths = (originalQuery, directive = 'persist') => { + const paths = []; + const fragmentPaths = {}; + const fragmentPersistPaths = {}; + + const query = visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + const root = ancestors.find( + ({ kind }) => kind === 'OperationDefinition' || kind === 'FragmentDefinition', + ); + + const rootKey = root.kind === 'FragmentDefinition' ? root.name.value : '$ROOT'; + + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fragmentPaths[name] = [rootKey].concat(fieldPath); + }, + Directive: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (name === directive) { + const fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + const fragmentDefinition = ancestors.find(({ kind }) => kind === 'FragmentDefinition'); + + // If we are inside a fragment, we must save the reference. + if (fragmentDefinition) { + fragmentPersistPaths[fragmentDefinition.name.value] = fieldPath; + } else if (fieldPath.length) { + paths.push(fieldPath); + } + return null; + } + }, + }); + + // In case there are any FragmentDefinition items, we need to combine paths. + if (Object.keys(fragmentPersistPaths).length) { + visit(originalQuery, { + FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => { + if (fragmentPersistPaths[name]) { + let fieldPath = ancestors + .filter(({ kind }) => kind === 'Field') + .map(({ name: { value } }) => value); + + fieldPath = fieldPath.concat(fragmentPersistPaths[name]); + + const fragment = name; + let parent = fragmentPaths[fragment][0]; + + while (parent && parent !== '$ROOT' && fragmentPaths[parent]) { + fieldPath = fragmentPaths[parent].slice(1).concat(fieldPath); + // eslint-disable-next-line prefer-destructuring + parent = fragmentPaths[parent][0]; + } + + paths.push(fieldPath); + } + }, + }); + } + + return { query, paths }; +}; + +/** + * Given a data result object path, return the equivalent query selection path. + * + * @param {Array} path The data result object path. i.e.: ["a", 0, "b"] + * @return {String} the query selection path. i.e.: "a.b" + */ +const toQueryPath = (path) => path.filter((key) => Number.isNaN(Number(key))).join('.'); + +const attachPersists = (paths, object) => { + const queryPaths = paths.map(toQueryPath); + function mapperFunction() { + if ( + !this.isRoot && + this.node && + typeof this.node === 'object' && + Object.keys(this.node).length && + !Array.isArray(this.node) + ) { + const path = toQueryPath(this.path); + + this.update({ + __persist: Boolean( + queryPaths.find( + (queryPath) => queryPath.indexOf(path) === 0 || path.indexOf(queryPath) === 0, + ), + ), + ...this.node, + }); + } + } + + return traverse(object).map(mapperFunction); +}; + +export const getPersistLink = () => { + return new ApolloLink((operation, forward) => { + const { query, paths } = extractPersistDirectivePaths(operation.query); + + // Noop if not a persist query + if (!paths.length) { + return forward(operation); + } + + // Replace query with one without @persist directives. + operation.query = query; + + // Remove requesting __persist fields. + operation.query = visit(operation.query, { + Field: ({ name: { value: name } }) => { + if (name === '__persist') { + return null; + } + }, + }); + + return forward(operation).map((result) => { + if (result.data) { + result.data = attachPersists(paths, result.data); + } + + return result; + }); + }); +}; diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js new file mode 100644 index 00000000000..8fc7c69c79d --- /dev/null +++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js @@ -0,0 +1,67 @@ +// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistenceMapper.ts +// with some heavy refactororing + +/* eslint-disable @gitlab/require-i18n-strings */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-param-reassign */ +/* eslint-disable dot-notation */ +export const persistenceMapper = async (data) => { + const parsed = JSON.parse(data); + + const mapped = {}; + const persistEntities = []; + const rootQuery = parsed['ROOT_QUERY']; + + // cache entities that have `__persist: true` + Object.keys(parsed).forEach((key) => { + if (parsed[key]['__persist']) { + persistEntities.push(key); + } + }); + + // cache root queries that have `@persist` directive + mapped['ROOT_QUERY'] = Object.keys(rootQuery).reduce( + (obj, key) => { + if (key === '__typename') return obj; + + if (/@persist$/.test(key)) { + obj[key] = rootQuery[key]; + + if (Array.isArray(rootQuery[key])) { + const entities = rootQuery[key].map((item) => item.__ref); + persistEntities.push(...entities); + } else { + const entity = rootQuery[key].__ref; + persistEntities.push(entity); + } + } + + return obj; + }, + { __typename: 'Query' }, + ); + + persistEntities.reduce((obj, key) => { + const parsedEntity = parsed[key]; + + // check for root queries and only cache root query properties that have `__persist: true` + // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once + // with different set of fields + + if (Object.values(rootQuery).some((value) => value.__ref === key)) { + const mappedEntity = {}; + Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => { + if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) { + mappedEntity[parsedKey] = parsedValue; + } + }); + obj[key] = mappedEntity; + } else { + obj[key] = parsed[key]; + } + + return obj; + }, mapped); + + return JSON.stringify(mapped); +}; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 98e45f95b38..c0e923b2670 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,6 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; +import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -10,6 +11,8 @@ import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; import { getInstrumentationLink } from './apollo/instrumentation_link'; import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link'; +import { getPersistLink } from './apollo/persist_link'; +import { persistenceMapper } from './apollo/persistence_mapper'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -110,6 +113,7 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, + localCacheKey = null, } = config; let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -201,6 +205,8 @@ export default (resolvers = {}, config = {}) => { }); }); + const persistLink = getPersistLink(); + const appLink = ApolloLink.split( hasSubscriptionOperation, new ActionCableLink(), @@ -212,27 +218,40 @@ export default (resolvers = {}, config = {}) => { performanceBarLink, new StartupJSLink(), apolloCaptchaLink, + persistLink, uploadsLink, requestLink, ].filter(Boolean), ), ); + const newCache = new InMemoryCache({ + ...cacheConfig, + typePolicies: { + ...typePolicies, + ...cacheConfig.typePolicies, + }, + possibleTypes: { + ...possibleTypes, + ...cacheConfig.possibleTypes, + }, + }); + + if (localCacheKey) { + persistCacheSync({ + cache: newCache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage: new LocalStorageWrapper(window.localStorage), + persistenceMapper, + }); + } + ac = new ApolloClient({ typeDefs, link: appLink, connectToDevTools: process.env.NODE_ENV !== 'production', - cache: new InMemoryCache({ - ...cacheConfig, - typePolicies: { - ...typePolicies, - ...cacheConfig.typePolicies, - }, - possibleTypes: { - ...possibleTypes, - ...cacheConfig.possibleTypes, - }, - }), + cache: newCache, resolvers, defaultOptions: { query: { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 241488c8039..9bf382c41e7 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,7 +4,7 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { isFunction, defer, escape } from 'lodash'; +import { isFunction, defer, escape, partial, toLower } from 'lodash'; import Cookies from '~/lib/utils/cookies'; import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; @@ -552,6 +552,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => convertObjectProps(convertToCamelCase, obj, options); /** + * This method returns a new object with lowerCase property names + * + * Reasoning for this method is to ensure consistent access for some + * sort of objects + * + * This method also supports additional params in `options` object + * + * @param {Object} obj - Object to be converted. + * @param {Object} options - Object containing additional options. + * @param {boolean} options.deep - FLag to allow deep object converting + * @param {Array[]} options.dropKeys - List of properties to discard while building new object + * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact while building new object + */ +export const convertObjectPropsToLowerCase = partial(convertObjectProps, toLower); + +/** * Converts all the object keys to snake case * * This method also supports additional params in `options` object @@ -717,16 +733,3 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; - -// TODO: remove when FF `new_fonts` is removed https://gitlab.com/gitlab-org/gitlab/-/issues/379147 -/** - * This method checks the FF `new_fonts` - * as well as a query parameter `new_fonts`. - * If either of them is enabled, new fonts will be applied. - * - * @returns Boolean Whether to apply new fonts - */ -export const useNewFonts = () => { - const hasQueryParam = new URLSearchParams(window.location.search).has('new_fonts'); - return window?.gon.features?.newFonts || hasQueryParam; -}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 678ebc35565..61c2ecfecd9 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -9,16 +9,17 @@ export const HTTP_STATUS_PARTIAL_CONTENT = 206; export const HTTP_STATUS_MULTI_STATUS = 207; export const HTTP_STATUS_ALREADY_REPORTED = 208; export const HTTP_STATUS_IM_USED = 226; +export const HTTP_STATUS_BAD_REQUEST = 400; +export const HTTP_STATUS_UNAUTHORIZED = 401; +export const HTTP_STATUS_FORBIDDEN = 403; +export const HTTP_STATUS_NOT_FOUND = 404; export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405; export const HTTP_STATUS_CONFLICT = 409; export const HTTP_STATUS_GONE = 410; export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413; +export const HTTP_STATUS_IM_A_TEAPOT = 418; export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; -export const HTTP_STATUS_BAD_REQUEST = 400; -export const HTTP_STATUS_UNAUTHORIZED = 401; -export const HTTP_STATUS_FORBIDDEN = 403; -export const HTTP_STATUS_NOT_FOUND = 404; export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503; diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js index 01e43fd3b93..bab84448657 100644 --- a/app/assets/javascripts/lib/utils/scroll_utils.js +++ b/app/assets/javascripts/lib/utils/scroll_utils.js @@ -7,14 +7,11 @@ export const canScroll = () => $(document).height() > $(window).height(); * @returns {Boolean} */ export const isScrolledToBottom = () => { - const $document = $(document); - - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - - const windowHeight = $(window).height(); + // Use clientHeight to account for any horizontal scrollbar. + const { scrollHeight, scrollTop, clientHeight } = document.documentElement; - return scrollHeight - currentPosition === windowHeight; + // scrollTop can be a float, so round up to next integer. + return Math.ceil(scrollTop + clientHeight) >= scrollHeight; }; /** @@ -31,21 +28,3 @@ export const scrollDown = () => { export const scrollUp = () => { $(document).scrollTop(0); }; - -/** - * Checks if scroll position is in the middle of the page - * @returns {Boolean} - */ -export const isScrolledToMiddle = () => { - const $document = $(document); - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - const windowHeight = $(window).height(); - - return currentPosition > 0 && scrollHeight - currentPosition !== windowHeight; -}; - -export const toggleDisableButton = ($button, disable) => { - if (disable && $button.prop('disabled')) return; - $button.prop('disabled', disable); -}; diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js deleted file mode 100644 index 03c0e608b79..00000000000 --- a/app/assets/javascripts/lib/utils/select2_utils.js +++ /dev/null @@ -1,25 +0,0 @@ -import axios from './axios_utils'; -import { normalizeHeaders, parseIntPagination } from './common_utils'; - -// This is used in the select2 config to replace jQuery.ajax with axios -export const select2AxiosTransport = (params) => { - axios({ - method: params.type?.toLowerCase() || 'get', - url: params.url, - params: params.data, - }) - .then((res) => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const pagination = parseIntPagination(headers); - const more = pagination.nextPage > pagination.page; - - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); -}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 3894ec36a0b..05ed08931bb 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -522,15 +522,23 @@ function handleContinueList(e, textArea) { if (!(e.key === 'Enter')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (textArea.selectionStart !== textArea.selectionEnd) return; + // prevent unintended line breaks inserted using Japanese IME on MacOS if (compositioningNoteText) return; - const firstSelectedLine = linesFromSelection(textArea).lines[0]; + const selectedLines = linesFromSelection(textArea); + const firstSelectedLine = selectedLines.lines[0]; const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN); if (listLineMatch) { const { leader, indent, content, isOl } = listLineMatch.groups; const emptyListItem = !content; + const prefixLength = leader.length + indent.length; + + if (selectedLines.selectionStart - selectedLines.startPos < prefixLength) { + // cursor in the indent/leader area, allow the natural line feed to be added + return; + } if (emptyListItem) { // erase empty list item - select the text and allow the diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index 7e8fc4b637b..e3d26d1464e 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,22 +1,20 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; export function parseAttributes(el) { - const { items: itemsString, selected, right: rightString } = el.dataset; + const { items: itemsString, selected, placement } = el.dataset; const items = JSON.parse(itemsString); - const right = parseBoolean(rightString); const { className } = el; - return { items, selected, right, className }; + return { items, selected, placement, className }; } export function initListbox(el, { onChange } = {}) { if (!el) return null; - const { items, selected, right, className } = parseAttributes(el); + const { items, selected, placement, className } = parseAttributes(el); return new Vue({ el, @@ -34,7 +32,7 @@ export function initListbox(el, { onChange } = {}) { return h(GlCollapsibleListbox, { props: { items, - right, + placement, selected: this.selected, toggleText: this.text, }, diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index c1afabf1e35..600654794a5 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -6,8 +6,17 @@ const GITLAB_FALLBACK_LANGUAGE = 'en'; const languageCode = () => document.querySelector('html').getAttribute('lang') || GITLAB_FALLBACK_LANGUAGE; -const locale = new Jed(window.translations || {}); -delete window.translations; + +/** + * This file might be imported into a web worker indirectly, the `window` object + * won't be defined in the web worker context so we need to check if it is defined + * before we access the `translations` property. + */ +const hasTranslations = typeof window !== 'undefined' && window.translations; +const locale = new Jed(hasTranslations ? window.translations : {}); +if (hasTranslations) { + delete window.translations; +} /** Translates `text` diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index fd5c4abe729..4c715c4993f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -16,7 +16,6 @@ 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 { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; @@ -253,16 +252,6 @@ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { visitUrl(action); }); -const flashContainer = document.querySelector('.flash-container'); - -if (flashContainer && flashContainer.children.length) { - flashContainer - .querySelectorAll('.flash-alert, .flash-notice, .flash-success') - .forEach((flashEl) => { - addDismissFlashClickListener(flashEl); - }); -} - // initialize field errors $('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form)); diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 90034f46e7c..88d5384c9d5 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -39,6 +39,7 @@ export default { v-gl-tooltip.hover :title="$options.title" :aria-label="$options.title" + data-qa-selector="approve_access_request_button" icon="check" type="submit" /> 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 24500fbe44d..3b4b7516934 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 @@ -35,7 +35,7 @@ export default { :title="$options.i18n.buttonTitle" :aria-label="$options.i18n.buttonTitle" icon="remove" - data-qa-selector="delete_group_access_link" + data-qa-selector="remove_group_link_button" @click="showRemoveGroupLinkModal(groupLink)" /> </template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js index 8ccfc57dc28..ce6865a8f0a 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/constants.js +++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js @@ -19,4 +19,5 @@ export const I18N = { lastGroupOwnerCannotBeRemoved: s__( 'Members|A group must have at least one owner. To remove the member, assign a new owner.', ), + banMember: s__('Members|Ban member'), }; diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue index 8f5c32956a2..c82ebadea6e 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue @@ -20,9 +20,11 @@ export default { 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue' ), LdapOverrideDropdownItem: () => - import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'), + import('ee_component/members/components/action_dropdowns/ldap_override_dropdown_item.vue'), LeaveGroupDropdownItem, RemoveMemberDropdownItem, + BanMemberDropdownItem: () => + import('ee_component/members/components/action_dropdowns/ban_member_dropdown_item.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -77,7 +79,10 @@ export default { }, showDropdown() { return ( - this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride + this.permissions.canDisableTwoFactor || + this.showLeaveOrRemove || + this.showLdapOverride || + this.showBan ); }, showLeaveOrRemove() { @@ -86,6 +91,9 @@ export default { showLdapOverride() { return this.permissions.canOverride && !this.member.isOverridden; }, + showBan() { + return !this.isCurrentUser && this.permissions.canBan; + }, }, }; </script> @@ -130,5 +138,8 @@ export default { <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{ $options.i18n.editPermissions }}</ldap-override-dropdown-item> + <ban-member-dropdown-item v-if="showBan" :member="member">{{ + $options.i18n.banMember + }}</ban-member-dropdown-item> </gl-dropdown> </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 b179ced46e1..b28ca6e385b 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 @@ -14,6 +14,7 @@ export default { text: s__('Members|Remove group'), attributes: { variant: 'danger', + 'data-qa-selector': 'remove_group_button', }, }, csrf, 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 337379d8b4e..f1da1cd8ffc 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -70,6 +70,7 @@ export default { text: this.actionText, attributes: { variant: 'danger', + 'data-qa-selector': 'remove_member_button', }, }; }, diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_actions.vue index 6ec7be608ba..61a6f37687a 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_actions.vue @@ -6,7 +6,7 @@ import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue'; export default { - name: 'MemberActionButtons', + name: 'MemberActions', components: { UserActionDropdown, GroupActionButtons, diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 8f03a298e63..c973d58fcd2 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -26,7 +26,7 @@ import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; import RemoveMemberModal from '../modals/remove_member_modal.vue'; import CreatedAt from './created_at.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; -import MemberActionButtons from './member_action_buttons.vue'; +import MemberActions from './member_actions.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; import MemberActivity from './member_activity.vue'; @@ -42,7 +42,7 @@ export default { CreatedAt, MembersTableCell, MemberSource, - MemberActionButtons, + MemberActions, RoleDropdown, RemoveGroupLinkModal, RemoveMemberModal, @@ -51,7 +51,7 @@ export default { DisableTwoFactorModal: () => import('ee_component/members/components/modals/disable_two_factor_modal.vue'), LdapOverrideConfirmationModal: () => - import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), + import('ee_component/members/components/modals/ldap_override_confirmation_modal.vue'), }, inject: ['namespace', 'currentUserId', 'canManageMembers'], props: { @@ -135,7 +135,10 @@ export default { tbodyTrAttr(member) { return { ...this.tableAttrs.tr, - ...(member?.id && { 'data-testid': `members-table-row-${member.id}` }), + ...(member?.id && { + 'data-testid': `members-table-row-${member.id}`, + 'data-qa-selector': 'member_row', + }), }; }, paginationLinkGenerator(page) { @@ -299,7 +302,7 @@ export default { <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> - <member-action-buttons + <member-actions :member-type="memberType" :is-current-user="isCurrentUser" :permissions="permissions" diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 70808587d56..e066b023fbb 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -11,7 +11,8 @@ export default { components: { GlDropdown, GlDropdownItem, - LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), + LdapDropdownItem: () => + import('ee_component/members/components/action_dropdowns/ldap_dropdown_item.vue'), }, inject: ['namespace', 'group'], props: { diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 80eb94a5364..61abdca0a5b 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -95,10 +95,6 @@ MergeRequest.prototype.initMRBtnListeners = function () { .then(({ data }) => { draftToggle.removeAttribute('disabled'); - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - } - MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready'); }) .catch(() => { @@ -155,6 +151,10 @@ MergeRequest.hideCloseButton = function () { }; MergeRequest.toggleDraftStatus = function (title, isReady) { + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } + if (isReady) { toast(__('Marked as ready. Merging is now allowed.')); } else { diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue new file mode 100644 index 00000000000..8e02048f494 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/compare_app.vue @@ -0,0 +1,134 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import axios from '~/lib/utils/axios_utils'; +import CompareDropdown from '~/merge_requests/components/compare_dropdown.vue'; + +export default { + components: { + GlIcon, + GlLoadingIcon, + CompareDropdown, + }, + directives: { + SafeHtml, + }, + inject: { + projectsPath: { + default: '', + }, + branchCommitPath: { + default: '', + }, + currentProject: { + default: () => ({}), + }, + currentBranch: { + default: () => ({}), + }, + inputs: { + default: () => ({}), + }, + i18n: { + default: () => ({}), + }, + toggleClass: { + default: () => ({}), + }, + branchQaSelector: { + default: '', + }, + }, + data() { + return { + selectedProject: this.currentProject, + selectedBranch: this.currentBranch, + loading: false, + commitHtml: null, + }; + }, + computed: { + staticProjectData() { + if (this.projectsPath) return undefined; + + return [this.currentProject]; + }, + showCommitBox() { + return this.commitHtml || this.loading || !this.selectedBranch.value; + }, + }, + mounted() { + this.fetchCommit(); + }, + methods: { + selectProject(p) { + this.selectedProject = p; + }, + selectBranch(branch) { + this.selectedBranch = branch; + this.fetchCommit(); + }, + async fetchCommit() { + if (!this.selectedBranch.value) return; + + this.loading = true; + + const { data } = await axios.get(this.branchCommitPath, { + params: { target_project_id: this.selectedProject.value, ref: this.selectedBranch.value }, + }); + + this.loading = false; + this.commitHtml = data; + }, + }, +}; +</script> + +<template> + <div> + <div class="clearfix"> + <div class="merge-request-select gl-pl-0"> + <compare-dropdown + :static-data="staticProjectData" + :endpoint="projectsPath" + :default="currentProject" + :dropdown-header="i18n.projectHeaderText" + :input-id="inputs.project.id" + :input-name="inputs.project.name" + :toggle-class="toggleClass.project" + is-project + @selected="selectProject" + /> + </div> + <div class="merge-request-select merge-request-branch-select gl-pr-0"> + <compare-dropdown + :endpoint="selectedProject.refsUrl" + :dropdown-header="i18n.branchHeaderText" + :input-id="inputs.branch.id" + :input-name="inputs.branch.name" + :default="currentBranch" + :toggle-class="toggleClass.branch" + :qa-selector="branchQaSelector" + @selected="selectBranch" + /> + </div> + </div> + <div + v-if="showCommitBox" + class="gl-bg-gray-50 gl-rounded-base gl-my-4" + data-testid="commit-box" + > + <gl-loading-icon v-if="loading" class="gl-py-3" /> + <template v-else> + <div + v-if="!selectedBranch.value" + class="compare-commit-empty gl-display-flex gl-align-items-center gl-p-5" + > + <gl-icon name="branch" class="gl-mr-3" /> + {{ __('Select a branch to compare') }} + </div> + <ul v-safe-html="commitHtml" class="list-unstyled mr_source_commit"></ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue new file mode 100644 index 00000000000..1590e693c07 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue @@ -0,0 +1,145 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlListbox, + }, + props: { + staticData: { + type: Array, + required: false, + default: () => [], + }, + endpoint: { + type: String, + required: false, + default: '', + }, + default: { + type: Object, + required: true, + }, + dropdownHeader: { + type: String, + required: true, + }, + isProject: { + type: Boolean, + required: false, + default: false, + }, + inputId: { + type: String, + required: true, + }, + inputName: { + type: String, + required: true, + }, + toggleClass: { + type: String, + required: false, + default: '', + }, + qaSelector: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + current: this.default, + selected: this.default.value, + isLoading: false, + data: this.staticData, + searchStr: '', + }; + }, + computed: { + filteredData() { + if (this.endpoint) return this.data; + + return this.data.filter( + (d) => d.text.toLowerCase().indexOf(this.searchStr.toLowerCase()) >= 0, + ); + }, + }, + methods: { + async fetchData() { + if (!this.endpoint) return; + + this.isLoading = true; + + try { + const { data } = await axios.get(this.endpoint, { + params: { search: this.searchStr }, + }); + + if (this.isProject) { + this.data = data.map((p) => ({ + value: `${p.id}`, + text: p.full_path.replace(/^\//, ''), + refsUrl: p.refs_url, + })); + } else { + this.data = data.Branches.map((d) => ({ + value: d, + text: d, + })); + } + + this.isLoading = false; + } catch { + createAlert({ + message: __('Error fetching data. Please try again.'), + primaryButton: { text: __('Try again'), clickHandler: () => this.fetchData() }, + }); + } + }, + searchData: debounce(function searchData(search) { + this.searchStr = search; + this.fetchData(); + }, 500), + selectItem(id) { + this.current = this.data.find((d) => d.value === id); + + this.$emit('selected', this.current); + }, + }, +}; +</script> + +<template> + <div> + <input + :id="inputId" + type="hidden" + :value="current.value" + :name="inputName" + data-testid="target-project-input" + /> + <gl-listbox + v-model="selected" + :items="filteredData" + :toggle-text="current.text || dropdownHeader" + :header-text="dropdownHeader" + :searching="isLoading" + searchable + class="gl-w-full dropdown-target-project" + :toggle-class="[ + 'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown', + toggleClass, + ]" + :data-qa-selector="qaSelector" + @shown="fetchData" + @search="searchData" + @select="selectItem" + /> + </div> +</template> diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 6af1baaa37e..525094271d9 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -2,7 +2,7 @@ import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isLoggedIn } from '~/lib/utils/common_utils'; @@ -45,7 +45,7 @@ export default { doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions, }), issuableId() { - return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id); + return convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.getNoteableData.id); }, issuableIid() { return `${this.getNoteableData.iid}`; @@ -77,7 +77,7 @@ export default { <template> <gl-intersection-observer - class="gl-relative gl-top-2" + class="gl-relative gl-top-n5" @appear="setStickyHeaderVisible(false)" @disappear="setStickyHeaderVisible(true)" > diff --git a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue deleted file mode 100644 index cd2e25793f4..00000000000 --- a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { GlListbox } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; - -export default { - components: { - GlListbox, - }, - inject: { - targetProjectsPath: { - type: String, - required: true, - }, - currentProject: { - type: Object, - required: true, - }, - }, - data() { - return { - currentProject: this.currentProject, - selected: this.currentProject.value, - isLoading: false, - projects: [], - }; - }, - methods: { - async fetchProjects(search = '') { - this.isLoading = true; - - try { - const { data } = await axios.get(this.targetProjectsPath, { - params: { search }, - }); - - this.projects = data.map((p) => ({ - value: `${p.id}`, - text: p.full_path.replace(/^\//, ''), - refsUrl: p.refs_url, - })); - this.isLoading = false; - } catch { - createAlert({ - message: __('Error fetching target projects. Please try again.'), - primaryButton: { text: __('Try again'), clickHandler: () => this.fetchProjects(search) }, - }); - } - }, - searchProjects: debounce(function searchProjects(search) { - this.fetchProjects(search); - }, 500), - selectProject(projectId) { - this.currentProject = this.projects.find((p) => p.value === projectId); - - this.$emit('project-selected', this.currentProject.refsUrl); - }, - }, -}; -</script> - -<template> - <div> - <input - id="merge_request_target_project_id" - type="hidden" - :value="currentProject.value" - name="merge_request[target_project_id]" - data-testid="target-project-input" - /> - <gl-listbox - v-model="selected" - :items="projects" - :toggle-text="currentProject.text" - :header-text="__('Select target project')" - :searching="isLoading" - searchable - class="gl-w-full dropdown-target-project" - toggle-class="gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown js-target-project" - @shown="fetchProjects" - @search="searchProjects" - @select="selectProject" - /> - </div> -</template> diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index 3a13c123d77..4b3c1bd7d10 100644 --- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue @@ -2,7 +2,7 @@ import { GlSprintf, GlModal } from '@gitlab/ui'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; - +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { __, n__, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; @@ -84,7 +84,7 @@ Once deleted, it cannot be undone or recovered.`), successful: false, }); - if (error.response && error.response.status === 404) { + if (error.response && error.response.status === HTTP_STATUS_NOT_FOUND) { createAlert({ message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle, diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 3b7e5a5f2ee..037120a0d81 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -3,6 +3,7 @@ import { escape } from 'lodash'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import { __ } from '~/locale'; import AUTH_METHOD from './constants'; @@ -87,7 +88,7 @@ export default class SSHMirror { )}`, ) .then(({ data, status }) => { - if (status === 204) { + if (status === HTTP_STATUS_NO_CONTENT) { this.backOffRequestCounter += 1; if (this.backOffRequestCounter < 3) { next(); diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue deleted file mode 100644 index 42f6394ed68..00000000000 --- a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlAlert, GlLink } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - i18n: { - titleLabel: __('Machine Learning Experiment Tracking is in Incubating Phase'), - contentLabel: __( - 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited', - ), - learnMoreLabel: __('Learn more'), - feedbackLabel: __('Feedback'), - }, - name: 'MlopsIncubationAlert', - components: { GlAlert, GlLink }, - data() { - return { - isAlertDismissed: false, - }; - }, - computed: { - shouldShowAlert() { - return !this.isAlertDismissed; - }, - }, - methods: { - dismissAlert() { - this.isAlertDismissed = true; - }, - }, -}; -</script> - -<template> - <gl-alert - v-if="shouldShowAlert" - :title="$options.i18n.titleLabel" - variant="warning" - :primary-button-text="$options.i18n.feedbackLabel" - primary-button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" - @dismiss="dismissAlert" - > - {{ $options.i18n.contentLabel }} - <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{ - $options.i18n.learnMoreLabel - }}</gl-link> - </gl-alert> -</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue index 0bb2a913dec..d0c42905ee2 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue @@ -1,7 +1,8 @@ <script> import { GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; -import IncubationAlert from './incubation_alert.vue'; +import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; export default { name: 'MlCandidate', @@ -9,7 +10,12 @@ export default { IncubationAlert, GlLink, }, - inject: ['candidate'], + props: { + candidate: { + type: Object, + required: true, + }, + }, i18n: { titleLabel: __('Model candidate details'), infoLabel: __('Info'), @@ -39,12 +45,17 @@ export default { ]; }, }, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, }; </script> <template> <div> - <incubation-alert /> + <incubation-alert + :feature-name="$options.FEATURE_NAME" + :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" + /> <h3> {{ $options.i18n.titleLabel }} diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue index 5d13122765a..c09aabb0d40 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -1,9 +1,20 @@ <script> -import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility'; +import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import IncubationAlert from './incubation_alert.vue'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + LIST_KEY_CREATED_AT, + BASE_SORT_FIELDS, + METRIC_KEY_PREFIX, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, +} from '~/ml/experiment_tracking/constants'; +import { s__ } from '~/locale'; +import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; export default { name: 'MlExperiment', @@ -12,19 +23,36 @@ export default { GlLink, TimeAgo, IncubationAlert, - GlPagination, + RegistrySearch, + KeysetPagination, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['candidates', 'metricNames', 'paramNames', 'pagination'], + inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'], data() { + const query = queryToObject(window.location.search); + + const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : []; + + let orderBy = query.orderBy || LIST_KEY_CREATED_AT; + + if (query.orderByType === 'metric') { + orderBy = `${METRIC_KEY_PREFIX}${orderBy}`; + } + return { - page: parseInt(getParameterValues('page')[0], 10) || 1, + filters: filter, + sorting: { + orderBy, + sort: (query.sort || 'desc').toLowerCase(), + }, }; }, computed: { fields() { + if (this.candidates.length === 0) return []; + return [ { key: 'name', label: this.$options.i18n.nameLabel }, { key: 'created_at', label: this.$options.i18n.createdAtLabel }, @@ -38,39 +66,87 @@ export default { displayPagination() { return this.candidates.length > 0; }, - prevPage() { - return this.pagination.page > 1 ? this.pagination.page - 1 : null; + sortableFields() { + return [ + ...BASE_SORT_FIELDS, + ...this.metricNames.map((name) => ({ + orderBy: `${METRIC_KEY_PREFIX}${name}`, + label: capitalizeFirstCharacter(name), + })), + ]; }, - nextPage() { - return !this.pagination.isLastPage ? this.pagination.page + 1 : null; + parsedQuery() { + const name = this.filters + .map((f) => f.value.data) + .join(' ') + .trim(); + + const filterByQuery = name === '' ? {} : { name }; + + let orderByType = 'column'; + let { orderBy } = this.sorting; + const { sort } = this.sorting; + + if (orderBy.startsWith(METRIC_KEY_PREFIX)) { + orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length); + orderByType = 'metric'; + } + + return { ...filterByQuery, orderBy, orderByType, sort }; }, }, methods: { - generateLink(page) { - return setUrlParams({ page }); + submitFilters() { + return visitUrl(setUrlParams({ ...this.parsedQuery })); + }, + updateFilters(newValue) { + this.filters = newValue; + }, + updateSorting(newValue) { + this.sorting = { ...this.sorting, ...newValue }; + }, + updateSortingAndEmitUpdate(newValue) { + this.updateSorting(newValue); + this.submitFilters(); }, }, i18n: { - titleLabel: __('Experiment candidates'), - emptyStateLabel: __('This experiment has no logged candidates'), - artifactsLabel: __('Artifacts'), - detailsLabel: __('Details'), - userLabel: __('User'), - createdAtLabel: __('Created at'), - nameLabel: __('Name'), - noDataContent: __('-'), + titleLabel: s__('MlExperimentTracking|Experiment candidates'), + emptyStateLabel: s__('MlExperimentTracking|No candidates to display'), + artifactsLabel: s__('MlExperimentTracking|Artifacts'), + detailsLabel: s__('MlExperimentTracking|Details'), + userLabel: s__('MlExperimentTracking|User'), + createdAtLabel: s__('MlExperimentTracking|Created at'), + nameLabel: s__('MlExperimentTracking|Name'), + noDataContent: s__('MlExperimentTracking|-'), + filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'), }, + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, }; </script> <template> <div> - <incubation-alert /> + <incubation-alert + :feature-name="$options.FEATURE_NAME" + :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" + /> <h3> {{ $options.i18n.titleLabel }} </h3> + <registry-search + :filters="filters" + :sorting="sorting" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="submitFilters" + @filter:clear="filters = []" + /> + <gl-table :fields="fields" :items="candidates" @@ -119,16 +195,6 @@ export default { </template> </gl-table> - <gl-pagination - v-if="displayPagination" - v-model="pagination.page" - :prev-page="prevPage" - :next-page="nextPage" - :total-items="pagination.totalItems" - :per-page="pagination.perPage" - :link-gen="generateLink" - align="center" - class="w-100" - /> + <keyset-pagination v-if="displayPagination" v-bind="pageInfo" /> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js new file mode 100644 index 00000000000..15462b519e1 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/constants.js @@ -0,0 +1,22 @@ +import { __, s__ } from '~/locale'; + +export const METRIC_KEY_PREFIX = 'metric.'; + +export const LIST_KEY_CREATED_AT = 'created_at'; + +export const BASE_SORT_FIELDS = Object.freeze([ + { + orderBy: 'name', + label: __('Name'), + }, + { + orderBy: LIST_KEY_CREATED_AT, + label: __('Created at'), + }, +]); + +export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg'; + +export const FEATURE_NAME = s__('MlExperimentTracking|Machine learning experiment tracking'); + +export const FEATURE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/381660'; diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue new file mode 100644 index 00000000000..4f2b8db3c00 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue @@ -0,0 +1,85 @@ +<script> +import { GlTableLite, GlEmptyState, GlLink } from '@gitlab/ui'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; +import { + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, + EMPTY_STATE_SVG, +} from '~/ml/experiment_tracking/constants'; +import * as constants from '~/ml/experiment_tracking/routes/experiments/index/constants'; +import * as translations from '~/ml/experiment_tracking/routes/experiments/index/translations'; + +export default { + name: 'MlExperimentsIndexApp', + components: { + Pagination, + IncubationAlert, + GlTableLite, + GlEmptyState, + GlLink, + }, + props: { + experiments: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + }, + tableFields: constants.EXPERIMENTS_TABLE_FIELDS, + i18n: translations, + computed: { + hasExperiments() { + return this.experiments.length > 0; + }, + tableItems() { + return this.experiments.map((exp) => ({ + nameColumn: { name: exp.name, path: exp.path }, + candidateCountColumn: exp.candidate_count, + })); + }, + }, + constants: { + FEATURE_NAME, + FEATURE_FEEDBACK_ISSUE, + EMPTY_STATE_SVG, + ...constants, + }, +}; +</script> + +<template> + <div v-if="hasExperiments"> + <h1 class="page-title gl-font-size-h-display"> + {{ $options.i18n.TITLE_LABEL }} + </h1> + + <incubation-alert + :feature-name="$options.constants.FEATURE_NAME" + :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE" + /> + + <gl-table-lite :items="tableItems" :fields="$options.tableFields"> + <template #cell(nameColumn)="data"> + <gl-link :href="data.value.path"> + {{ data.value.name }} + </gl-link> + </template> + </gl-table-lite> + + <pagination v-if="hasExperiments" v-bind="pageInfo" /> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" + :primary-button-text="$options.i18n.CREATE_NEW_LABEL" + :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH" + :svg-path="$options.constants.EMPTY_STATE_SVG" + :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" + class="gl-py-8" + /> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js new file mode 100644 index 00000000000..3026bce0972 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js @@ -0,0 +1,17 @@ +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const CREATE_EXPERIMENT_HELP_PATH = helpPagePath( + 'user/project/ml/experiment_tracking/index.md', + { + anchor: 'tracking-new-experiments-and-trials', + }, +); + +export const EXPERIMENTS_TABLE_FIELDS = Object.freeze([ + { key: 'nameColumn', label: s__('MlExperimentTracking|Experiment') }, + { + key: 'candidateCountColumn', + label: s__('MlExperimentTracking|Logged candidates for experiment'), + }, +]); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js new file mode 100644 index 00000000000..b40735ebe22 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js @@ -0,0 +1,3 @@ +import MlExperimentsIndex from './components/ml_experiments_index.vue'; + +export default MlExperimentsIndex; diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js new file mode 100644 index 00000000000..e954c054cf5 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const TITLE_LABEL = s__('MlExperimentTracking|Model experiments'); + +export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create a new experiment'); + +export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No experiments'); + +export const EMPTY_STATE_DESCRIPTION_LABEL = s__( + 'MlExperimentTracking|There are no logged experiments for this project. Create a new experiment using the MLflow client.', +); diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js index aab3c41b4cf..79447bc115d 100644 --- a/app/assets/javascripts/mr_notes/init.js +++ b/app/assets/javascripts/mr_notes/init.js @@ -20,7 +20,6 @@ function setupMrNotesState(notesDataset) { store.dispatch('setUserData', currentUserData); store.dispatch('setTargetNoteHash', getLocationHash()); store.dispatch('setEndpoints', endpoints); - eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); } export function initMrStateLazyLoad() { @@ -35,10 +34,13 @@ export function initMrStateLazyLoad() { stop = store.watch( (state) => state.page.activeTab, (activeTab) => { + setupMrNotesState(notesDataset); + // prevent loading MR state on commits and pipelines pages // this is due to them having a shared controller with the Overview page if (['diffs', 'show'].includes(activeTab)) { - setupMrNotesState(notesDataset); + eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); + requestIdleCallback(() => { initReviewBar(); initOverviewTabCounter(); diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index f5f10aa4a9b..d968c125068 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -23,6 +23,7 @@ export default () => { } const notesFilterProps = getNotesFilterData(el); + const notesDataset = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -32,8 +33,10 @@ export default () => { NotesApp, }, store, + provide: { + reportAbusePath: notesDataset.reportAbusePath, + }, data() { - const notesDataset = el.dataset; const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index 7b0076cc5d4..da22a8d2fb7 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -45,7 +45,7 @@ export default { Tracking.event(undefined, 'click_toggle', { label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', - property: 'navigation', + property: this.enabled ? 'navigation' : 'navigation_top', }); window.location.reload(); diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index e55bf25a60c..ab9313f7041 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -24,7 +24,7 @@ export default { trackToggleEvent() { Tracking.event(undefined, 'click_nav', { label: 'hamburger_menu', - property: 'top_navigation', + property: 'navigation_top', }); }, }, diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue index 97856eaf256..0f069670d09 100644 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -76,7 +76,11 @@ export default { :class="menuClass" data-testid="menu-sidebar" > - <top-nav-menu-sections :sections="menuSections" @menu-item-click="onMenuItemClick" /> + <top-nav-menu-sections + :sections="menuSections" + :is-primary-section="true" + @menu-item-click="onMenuItemClick" + /> </div> <keep-alive-slots v-show="activeView" diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue index 97e63c7324e..1f3f11dc624 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue @@ -1,7 +1,7 @@ <script> import TopNavMenuItem from './top_nav_menu_item.vue'; -const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-50'; +const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid'; export default { components: { @@ -17,6 +17,11 @@ export default { required: false, default: false, }, + isPrimarySection: { + type: Boolean, + required: false, + default: false, + }, }, methods: { onClick(menuItem) { @@ -30,8 +35,11 @@ export default { 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]: this.withTopBorder || index > 0, + [BORDER_CLASSES]: hasBorder, + 'gl-border-gray-100': hasBorder && this.isPrimarySection, + 'gl-border-gray-50': hasBorder && !this.isPrimarySection, 'gl-mt-3': index > 0, }; }, diff --git a/app/assets/javascripts/notes/components/attachments_warning.vue b/app/assets/javascripts/notes/components/attachments_warning.vue new file mode 100644 index 00000000000..aaa4b0d92b9 --- /dev/null +++ b/app/assets/javascripts/notes/components/attachments_warning.vue @@ -0,0 +1,18 @@ +<script> +import { COMMENT_FORM } from '../i18n'; + +export default { + i18n: COMMENT_FORM.attachmentMsg, + data() { + return { + message: this.$options.i18n, + }; + }, +}; +</script> + +<template> + <div class="issuable-note-warning" data-testid="attachment-warning"> + {{ message }} + </div> +</template> diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index 84bda1b0b5c..cc372520c70 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -1,14 +1,18 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; import EmailParticipantsWarning from './email_participants_warning.vue'; +import AttachmentsWarning from './attachments_warning.vue'; const DEFAULT_NOTEABLE_TYPE = 'Issue'; export default { components: { + AttachmentsWarning, EmailParticipantsWarning, NoteableWarning, }, + mixins: [glFeatureFlagsMixin()], props: { noteableData: { type: Object, @@ -29,6 +33,11 @@ export default { required: false, default: false, }, + containsLink: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isLocked() { @@ -46,6 +55,13 @@ export default { showEmailParticipantsWarning() { return this.emailParticipants.length && !this.isInternalNote; }, + showAttachmentWarning() { + return ( + this.glFeatures.serviceDeskNewNoteEmailNativeAttachments && + this.showEmailParticipantsWarning && + this.containsLink + ); + }, }, }; </script> @@ -68,6 +84,7 @@ export default { :confidential-noteable-docs-path="noteableData.confidential_issues_docs_path" /> <slot></slot> + <attachments-warning v-if="showAttachmentWarning" /> <email-participants-warning v-if="showEmailParticipantsWarning" class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!" diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index c6e7117cf2e..4f7256d0b0e 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -28,6 +28,7 @@ import CommentTypeDropdown from './comment_type_dropdown.vue'; import DiscussionLockedWidget from './discussion_locked_widget.vue'; import NoteSignedOutWidget from './note_signed_out_widget.vue'; +const ATTACHMENT_REGEXP = /!?\[.*?\]\(\/uploads\/[0-9a-f]{32}\/.*?\)/; export default { name: 'CommentForm', i18n: COMMENT_FORM, @@ -176,6 +177,9 @@ export default { disableSubmitButton() { return this.note.length === 0 || this.isSubmitting; }, + containsLink() { + return ATTACHMENT_REGEXP.test(this.note); + }, }, mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. @@ -356,6 +360,7 @@ export default { :noteable-data="getNoteableData" :is-internal-note="noteIsInternal" :noteable-type="noteableType" + :contains-link="containsLink" > <markdown-field ref="markdownField" diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c15c11ed9db..abed95a9706 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -4,12 +4,14 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '~/lib/utils/text_utility'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import ReplyButton from './note_actions/reply_button.vue'; import TimelineEventButton from './note_actions/timeline_event_button.vue'; @@ -30,6 +32,7 @@ export default { GlDropdownItem, UserAccessRoleBadge, EmojiPicker: () => import('~/emoji/components/picker.vue'), + AbuseCategorySelector, }, directives: { GlTooltip: GlTooltipDirective, @@ -58,11 +61,6 @@ export default { required: false, default: '', }, - reportAbusePath: { - type: String, - required: false, - default: null, - }, isAuthor: { type: Boolean, required: false, @@ -135,11 +133,16 @@ export default { default: '', }, }, + data() { + return { + isReportAbuseDrawerOpen: false, + }; + }, computed: { ...mapState(['isPromoteCommentToTimelineEventInProgress']), ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']), shouldShowActionsDropdown() { - return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + return this.currentUserId; }, showDeleteAction() { return this.canDelete && !this.canReportAsAbuse && !this.noteUrl; @@ -171,7 +174,7 @@ export default { return this.getNoteableData.assignees || []; }, isIssue() { - return this.targetType === 'issue'; + return this.targetType === TYPE_ISSUE; }, canAssign() { return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue; @@ -233,7 +236,7 @@ export default { assignees.push({ id: this.author.id }); } - if (this.targetType === 'issue') { + if (this.targetType === TYPE_ISSUE) { Api.updateIssue(project_id, iid, { assignee_ids: assignees.map((assignee) => assignee.id), }) @@ -252,6 +255,9 @@ export default { awardName, }); }, + toggleReportAbuseDrawer(isOpen) { + this.isReportAbuseDrawerOpen = isOpen; + }, }, }; </script> @@ -261,7 +267,7 @@ export default { <user-access-role-badge v-if="isAuthor" v-gl-tooltip - class="gl-mr-3 d-none d-md-inline-block" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayAuthorBadgeText" > {{ __('Author') }} @@ -269,7 +275,7 @@ export default { <user-access-role-badge v-if="accessLevel" v-gl-tooltip - class="gl-mr-3" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayMemberBadgeText" > {{ accessLevel }} @@ -277,7 +283,7 @@ export default { <user-access-role-badge v-else-if="isContributor" v-gl-tooltip - class="gl-mr-3" + class="gl-mr-3 gl-display-none gl-sm-display-block" :title="displayContributorBadgeText" > {{ __('Contributor') }} @@ -334,7 +340,7 @@ export default { :aria-label="$options.i18n.editCommentLabel" icon="pencil" category="tertiary" - class="note-action-button js-note-edit" + class="note-action-button js-note-edit gl-display-none gl-sm-display-block" data-qa-selector="note_edit_button" @click="onEdit" /> @@ -362,7 +368,18 @@ export default { /> <!-- eslint-enable @gitlab/vue-no-data-toggle --> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> - <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> + <gl-dropdown-item + v-if="canEdit" + class="js-note-edit gl-sm-display-none!" + @click.prevent="onEdit" + > + {{ __('Edit comment') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportAsAbuse" + data-testid="report-abuse-button" + @click="toggleReportAbuseDrawer(true)" + > {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item @@ -380,5 +397,14 @@ export default { </gl-dropdown-item> </ul> </div> + <!-- IMPORTANT: show this component lazily because it causes layout thrashing --> + <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 --> + <abuse-category-selector + v-if="canReportAsAbuse && isReportAbuseDrawerOpen" + :reported-user-id="authorId" + :reported-from-url="noteUrl" + :show-drawer="isReportAbuseDrawerOpen" + @close-drawer="toggleReportAbuseDrawer(false)" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 79b6139d4b1..c83b3d870d7 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -96,6 +96,8 @@ export default { 'text-underline': this.isUsernameLinkHovered, 'author-name-link': true, 'js-user-link': true, + 'gl-overflow-hidden': true, + 'gl-overflow-wrap-break': true, }; }, authorName() { diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 826e7e5a3d0..93575ad57ff 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -43,6 +43,11 @@ export default { SafeHtml, }, mixins: [noteable, resolvable], + inject: { + reportAbusePath: { + default: '', + }, + }, props: { note: { type: Object, @@ -129,7 +134,7 @@ export default { }; }, canReportAsAbuse() { - return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id; + return Boolean(this.reportAbusePath) && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; @@ -488,7 +493,6 @@ export default { :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" :can-resolve="canResolve" - :report-abuse-path="note.report_abuse_path" :resolvable="note.resolvable || note.isDraft" :is-resolved="note.resolved || note.resolve_discussion" :is-resolving="isResolving" diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 9fc11ff65d5..2a0a3d5414f 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; @@ -28,7 +28,7 @@ export default { }, }, created() { - if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) { + if (this.issuableType !== TYPE_ISSUE && this.issuableType !== TYPE_EPIC) { return; } diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 734e08dd586..4437d461308 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -79,7 +79,7 @@ export default { :link-href="author.path" :img-alt="author.name" img-css-classes="gl-mr-0!" - :img-src="author.avatar_url" + :img-src="author.avatar_url || author.avatarUrl" :img-size="24" :tooltip-text="author.name" tooltip-placement="bottom" @@ -102,7 +102,10 @@ export default { </gl-link> </template> </gl-sprintf> - <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> + <time-ago-tooltip + :time="lastReply.created_at || lastReply.createdAt" + tooltip-placement="bottom" + /> </template> <gl-button v-else diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 9b5fd69f816..a758a55014a 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -45,4 +45,7 @@ export const COMMENT_FORM = { commentHelp: __('Add a general comment to this %{noteableDisplayName}.'), internalCommentHelp: __('Add a confidential internal note to this %{noteableDisplayName}.'), }, + attachmentMsg: s__( + 'Notes|Attachments are sent by email. Attachments over 10 MB are sent as links to your GitLab instance, and only accessible to project members.', + ), }; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 95263e666b2..2e09c9f2288 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -52,6 +52,7 @@ export default () => { store, provide: { showTimelineViewToggle, + reportAbusePath: notesDataset.reportAbusePath, }, data() { return { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5cad091ce2c..f6b9be6ee9b 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -4,6 +4,7 @@ import Vue from 'vue'; import Api from '~/api'; import { createAlert, VARIANT_INFO } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; @@ -20,7 +21,7 @@ import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_NOTE } from '~/graphql_shared/constants'; +import { TYPENAME_NOTE } from '~/graphql_shared/constants'; import notesEventHub from '../event_hub'; import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql'; @@ -37,7 +38,8 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) return utils.gqClient .mutate({ - mutation: targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation, + mutation: + targetType === TYPE_ISSUE ? updateIssueLockMutation : updateMergeRequestLockMutation, variables: { input: { projectPath: fullPath, @@ -48,7 +50,7 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) }) .then(({ data }) => { const discussionLocked = - targetType === 'issue' + targetType === TYPE_ISSUE ? data.issueSetLocked.issue.discussionLocked : data.mergeRequestSetLocked.mergeRequest.discussionLocked; @@ -276,7 +278,7 @@ export const promoteCommentToTimelineEvent = ( mutation: promoteTimelineEvent, variables: { input: { - noteId: convertToGraphQLId(TYPE_NOTE, noteId), + noteId: convertToGraphQLId(TYPENAME_NOTE, noteId), }, }, }) diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 787f21d9419..d982df4f984 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,15 +1,30 @@ <script> +import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; +import { + CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, +} from '~/packages_and_registries/package_registry/constants'; +import Tracking from '~/tracking'; export default { components: { + DeleteModal, VersionRow, PackagesListLoader, RegistryList, }, + mixins: [Tracking.mixin()], props: { + canDestroy: { + type: Boolean, + required: false, + default: false, + }, versions: { type: Array, required: true, @@ -25,11 +40,35 @@ export default { default: false, }, }, + data() { + return { + itemsToBeDeleted: [], + }; + }, computed: { + listTitle() { + return n__('%d version', '%d versions', this.versions.length); + }, isListEmpty() { return this.versions.length === 0; }, }, + methods: { + deleteItemsCanceled() { + this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + deleteItemsConfirmation() { + this.$emit('delete', this.itemsToBeDeleted); + this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + setItemsToBeDeleted(items) { + this.itemsToBeDeleted = items; + this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION); + this.$refs.deletePackagesModal.show(); + }, + }, }; </script> <template> @@ -40,17 +79,34 @@ export default { <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> <registry-list - :hidden-delete="true" + :hidden-delete="!canDestroy" :is-loading="isLoading" :items="versions" :pagination="pageInfo" + :title="listTitle" + @delete="setItemsToBeDeleted" @prev-page="$emit('prev-page')" @next-page="$emit('next-page')" > - <template #default="{ item }"> - <version-row :package-entity="item" /> + <template #default="{ first, item, isSelected, selectItem }"> + <!-- `first` prop is used to decide whether to show the top border + for the first element. We want to show the top border only when + user has permission to bulk delete versions. --> + <version-row + :first="canDestroy && first" + :package-entity="item" + :selected="isSelected(item)" + @select="selectItem(item)" + /> </template> </registry-list> + + <delete-modal + ref="deletePackagesModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirm="deleteItemsConfirmation" + @cancel="deleteItemsCanceled" + /> </div> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index 57ff3cd2a83..9f8f6328970 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -1,15 +1,29 @@ <script> -import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, + GlTruncate, +} from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { PACKAGE_DEFAULT_STATUS } from '../../constants'; +import { + ERRORED_PACKAGE_TEXT, + ERROR_PUBLISHING, + PACKAGE_ERROR_STATUS, + WARNING_TEXT, +} from '../../constants'; export default { - name: 'PackageListRow', + name: 'PackageVersionRow', components: { + GlFormCheckbox, + GlIcon, GlLink, GlSprintf, GlTruncate, @@ -18,30 +32,55 @@ export default { ListItem, TimeAgoTooltip, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { packageEntity: { type: Object, required: true, }, + selected: { + type: Boolean, + default: false, + required: false, + }, }, computed: { + containsWebPathLink() { + return Boolean(this.packageEntity?._links?.webPath); + }, packageLink() { return `${getIdFromGraphQLId(this.packageEntity.id)}`; }, - disabledRow() { - return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; + errorStatusRow() { + return this.packageEntity?.status === PACKAGE_ERROR_STATUS; }, }, + i18n: { + erroredPackageText: ERRORED_PACKAGE_TEXT, + errorPublishing: ERROR_PUBLISHING, + warningText: WARNING_TEXT, + }, }; </script> <template> - <list-item :disabled="disabledRow"> + <list-item :selected="selected" v-bind="$attrs"> + <template #left-action> + <gl-form-checkbox + v-if="packageEntity.canDestroy" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> - <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" :disabled="disabledRow"> + <gl-link v-if="containsWebPathLink" class="gl-text-body gl-min-w-0" :href="packageLink"> <gl-truncate :text="packageEntity.name" /> </gl-link> + <gl-truncate v-else :text="packageEntity.name" /> <package-tags v-if="packageEntity.tags.nodes && packageEntity.tags.nodes.length" @@ -53,7 +92,20 @@ export default { </div> </template> <template #left-secondary> - {{ packageEntity.version }} + <div v-if="errorStatusRow" class="gl-text-red-500"> + <gl-icon + v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" + name="warning" + :aria-label="$options.i18n.warningText" + /> + <span>{{ $options.i18n.errorPublishing }}</span> + </div> + <gl-truncate + v-else + class="gl-max-w-15 gl-md-max-w-26" + :text="packageEntity.version" + :with-tooltip="true" + /> </template> <template #right-primary> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue deleted file mode 100644 index e1cf4883029..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; -import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; -import { s__ } from '~/locale'; - -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants'; - -export default { - props: { - refetchQueries: { - type: Array, - required: false, - default: null, - }, - showSuccessAlert: { - type: Boolean, - required: false, - default: false, - }, - }, - i18n: { - errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'), - successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE, - }, - methods: { - async deletePackage(packageEntity) { - try { - this.$emit('start'); - const { data } = await this.$apollo.mutate({ - mutation: destroyPackageMutation, - variables: { - id: packageEntity.id, - }, - awaitRefetchQueries: Boolean(this.refetchQueries), - refetchQueries: this.refetchQueries, - }); - - if (data?.destroyPackage?.errors[0]) { - throw data.destroyPackage.errors[0]; - } - if (this.showSuccessAlert) { - createAlert({ - message: this.$options.i18n.successMessage, - variant: VARIANT_SUCCESS, - }); - } - } catch (error) { - createAlert({ - message: this.$options.i18n.errorMessage, - variant: VARIANT_WARNING, - captureError: true, - error, - }); - } - this.$emit('end'); - }, - }, - render() { - return this.$scopedSlots.default({ deletePackage: this.deletePackage }); - }, -}; -</script> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue new file mode 100644 index 00000000000..0914c013108 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue @@ -0,0 +1,76 @@ +<script> +import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; + +import { + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_SUCCESS_MESSAGE, + DELETE_PACKAGES_ERROR_MESSAGE, + DELETE_PACKAGES_SUCCESS_MESSAGE, +} from '~/packages_and_registries/package_registry/constants'; + +export default { + name: 'DeletePackages', + props: { + refetchQueries: { + type: Array, + required: false, + default: null, + }, + showSuccessAlert: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + errorMessage: DELETE_PACKAGE_ERROR_MESSAGE, + errorMessageMultiple: DELETE_PACKAGES_ERROR_MESSAGE, + successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE, + successMessageMultiple: DELETE_PACKAGES_SUCCESS_MESSAGE, + }, + methods: { + async deletePackages(packageEntities) { + const isSinglePackage = packageEntities.length === 1; + try { + this.$emit('start'); + const ids = packageEntities.map((packageEntity) => packageEntity.id); + const { data } = await this.$apollo.mutate({ + mutation: destroyPackagesMutation, + variables: { + ids, + }, + awaitRefetchQueries: Boolean(this.refetchQueries), + refetchQueries: this.refetchQueries, + }); + + if (data?.destroyPackages?.errors[0]) { + throw data.destroyPackages.errors[0]; + } + + if (this.showSuccessAlert) { + createAlert({ + message: isSinglePackage + ? this.$options.i18n.successMessage + : this.$options.i18n.successMessageMultiple, + variant: VARIANT_SUCCESS, + }); + } + } catch (error) { + createAlert({ + message: isSinglePackage + ? this.$options.i18n.errorMessage + : this.$options.i18n.errorMessageMultiple, + variant: VARIANT_WARNING, + captureError: true, + error, + }); + } + this.$emit('end'); + }, + }, + render() { + return this.$scopedSlots.default({ deletePackages: this.deletePackages }); + }, +}; +</script> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 7ad1ebac11e..16f21bfe61d 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -11,8 +11,11 @@ import { import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { + ERRORED_PACKAGE_TEXT, + ERROR_PUBLISHING, PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS, + WARNING_TEXT, } from '~/packages_and_registries/package_registry/constants'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; @@ -78,9 +81,6 @@ export default { nonDefaultRow() { return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; }, - routerLinkEvent() { - return this.nonDefaultRow ? '' : 'click'; - }, errorPackageStyle() { return { 'gl-text-red-500': this.errorStatusRow, @@ -89,18 +89,18 @@ export default { }, }, i18n: { - erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), + erroredPackageText: ERRORED_PACKAGE_TEXT, createdAt: __('Created %{timestamp}'), deletePackage: s__('PackageRegistry|Delete package'), - errorPublishing: s__('PackageRegistry|Error publishing'), - warning: __('Warning'), + errorPublishing: ERROR_PUBLISHING, + warning: WARNING_TEXT, moreActions: __('More actions'), }, }; </script> <template> - <list-item data-testid="package-row" v-bind="$attrs"> + <list-item data-testid="package-row" :selected="selected" v-bind="$attrs"> <template #left-action> <gl-form-checkbox v-if="packageEntity.canDestroy" @@ -117,7 +117,6 @@ export default { class="gl-text-body gl-min-w-0" data-testid="details-link" data-qa-selector="package_link" - :event="routerLinkEvent" :to="{ name: 'details', params: { id: packageId } }" > <gl-truncate :text="packageEntity.name" /> @@ -134,8 +133,16 @@ export default { </div> </template> <template #left-secondary> - <div v-if="!errorStatusRow" class="gl-display-flex" data-testid="left-secondary-infos"> - <span>{{ packageEntity.version }}</span> + <div + v-if="!errorStatusRow" + class="gl-display-flex gl-align-items-center" + data-testid="left-secondary-infos" + > + <gl-truncate + class="gl-max-w-15 gl-md-max-w-26" + :text="packageEntity.version" + :with-tooltip="true" + /> <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2"> <gl-sprintf :message="s__('PackageRegistry|published by %{author}')"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 40bf7b7e143..486ab4fdc99 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -112,7 +112,7 @@ export default { this.itemsToBeDeleted = []; }, deleteItemConfirmation() { - this.$emit('package:delete', this.itemToBeDeleted); + this.$emit('delete', [this.itemToBeDeleted]); this.track(DELETE_PACKAGE_TRACKING_ACTION); this.itemToBeDeleted = null; }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 539b12bd6db..d979ae5c08c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -115,6 +115,10 @@ export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages'; export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages'; export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages'; +export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'; +export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions'; +export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions'; + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); @@ -124,6 +128,16 @@ export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages' export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); +export const DELETE_PACKAGE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package.', +); + +export const ERRORED_PACKAGE_TEXT = s__( + 'PackageRegistry|Invalid Package: failed metadata extraction', +); +export const ERROR_PUBLISHING = s__('PackageRegistry|Error publishing'); +export const WARNING_TEXT = __('Warning'); + export const PACKAGE_REGISTRY_TITLE = __('Package Registry'); export const PACKAGE_ERROR_STATUS = 'ERROR'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql deleted file mode 100644 index 884980f24a9..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation destroyPackage($id: PackagesPackageID!) { - destroyPackage(input: { id: $id }) { - errors - } -} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 9153906a38c..109d535469b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -66,9 +66,13 @@ query getPackageDetails( nodes { id name + canDestroy createdAt version status + _links { + webPath + } tags(first: 1) { nodes { id diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index 336eb0ca079..15ed98122a0 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -36,7 +36,7 @@ export default () => { const attachMainComponent = () => new Vue({ el, - name: 'PackageRegistery', + name: 'PackageRegistry', router, apolloProvider, provide: { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index 03352f01aca..4591c2eca87 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -11,6 +11,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash'; +import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -23,7 +24,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/ import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; -import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; +import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue'; import { PACKAGE_TYPE_NUGET, PACKAGE_TYPE_COMPOSER, @@ -71,7 +72,7 @@ export default { AdditionalMetadata, InstallationCommands, PackageFiles, - DeletePackage, + DeletePackages, PackageVersionsList, }, directives: { @@ -94,6 +95,7 @@ export default { deletePackageModalContent: DELETE_MODAL_CONTENT, filesToDelete: [], mutationLoading: false, + versionsMutationLoading: false, packageEntity: {}, }; }, @@ -132,7 +134,7 @@ export default { }, queryVariables() { return { - id: convertToGraphQLId('Packages::Package', this.packageId), + id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId), first: GRAPHQL_PAGE_SIZE, }; }, @@ -145,6 +147,9 @@ export default { isLoading() { return this.$apollo.queries.packageEntity.loading; }, + isVersionsLoading() { + return this.isLoading || this.versionsMutationLoading; + }, packageFilesLoading() { return this.isLoading || this.mutationLoading; }, @@ -156,9 +161,6 @@ export default { category: packageTypeToTrackCategory(this.packageType), }; }, - hasVersions() { - return this.packageEntity.versions?.nodes?.length > 0; - }, versionPageInfo() { return this.packageEntity?.versions?.pageInfo ?? {}; }, @@ -180,6 +182,14 @@ export default { PACKAGE_TYPE_PYPI, ].includes(this.packageType); }, + refetchQueriesData() { + return [ + { + query: getPackageDetails, + variables: this.queryVariables, + }, + ]; + }, }, methods: { formatSize(size) { @@ -205,12 +215,7 @@ export default { ids, }, awaitRefetchQueries: true, - refetchQueries: [ - { - query: getPackageDetails, - variables: this.queryVariables, - }, - ], + refetchQueries: this.refetchQueriesData, }); if (data?.destroyPackageFiles?.errors[0]) { throw data.destroyPackageFiles.errors[0]; @@ -402,27 +407,38 @@ export default { }}</gl-badge> </template> - <package-versions-list - :is-loading="isLoading" - :page-info="versionPageInfo" - :versions="packageEntity.versions.nodes" - @prev-page="fetchPreviousVersionsPage" - @next-page="fetchNextVersionsPage" + <delete-packages + :refetch-queries="refetchQueriesData" + show-success-alert + @start="versionsMutationLoading = true" + @end="versionsMutationLoading = false" > - <template #empty-state> - <p class="gl-mt-3" data-testid="no-versions-message"> - {{ s__('PackageRegistry|There are no other versions of this package.') }} - </p> + <template #default="{ deletePackages }"> + <package-versions-list + :can-destroy="packageEntity.canDestroy" + :is-loading="isVersionsLoading" + :page-info="versionPageInfo" + :versions="packageEntity.versions.nodes" + @delete="deletePackages" + @prev-page="fetchPreviousVersionsPage" + @next-page="fetchNextVersionsPage" + > + <template #empty-state> + <p class="gl-mt-3" data-testid="no-versions-message"> + {{ s__('PackageRegistry|There are no other versions of this package.') }} + </p> + </template> + </package-versions-list> </template> - </package-versions-list> + </delete-packages> </gl-tab> </gl-tabs> - <delete-package + <delete-packages @start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)" @end="navigateToListWithSuccessModal" > - <template #default="{ deletePackage }"> + <template #default="{ deletePackages }"> <gl-modal ref="deleteModal" size="sm" @@ -430,7 +446,7 @@ export default { data-testid="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" - @primary="deletePackage(packageEntity)" + @primary="deletePackages([packageEntity])" @hidden="resetDeleteModalContent" @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" > @@ -446,7 +462,7 @@ export default { </gl-sprintf> </gl-modal> </template> - </delete-package> + </delete-packages> <gl-modal ref="deleteFileModal" 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 396429d60d8..31c76c95e45 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 @@ -1,6 +1,6 @@ <script> -import { GlAlert, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { createAlert, VARIANT_INFO, VARIANT_SUCCESS, VARIANT_DANGER } from '~/flash'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; @@ -9,33 +9,28 @@ import { GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, DELETE_PACKAGE_SUCCESS_MESSAGE, - DELETE_PACKAGES_ERROR_MESSAGE, - DELETE_PACKAGES_SUCCESS_MESSAGE, EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; -import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; -import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; +import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; export default { components: { - GlAlert, GlEmptyState, GlLink, GlSprintf, PackageList, PackageTitle, PackageSearch, - DeletePackage, + DeletePackages, }, inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], data() { return { - alertVariables: null, packages: {}, sort: '', filters: {}, @@ -114,39 +109,6 @@ export default { historyReplaceState(cleanUrl); } }, - async deletePackages(packageEntities) { - this.mutationLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: destroyPackagesMutation, - variables: { - ids: packageEntities.map((i) => i.id), - }, - awaitRefetchQueries: true, - refetchQueries: [ - { - query: getPackagesQuery, - variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, - }, - ], - }); - - if (data?.destroyPackages?.errors[0]) { - throw new Error(data.destroyPackages.errors[0]); - } - this.showAlert({ - variant: VARIANT_SUCCESS, - message: DELETE_PACKAGES_SUCCESS_MESSAGE, - }); - } catch { - this.showAlert({ - variant: VARIANT_DANGER, - message: DELETE_PACKAGES_ERROR_MESSAGE, - }); - } finally { - this.mutationLoading = false; - } - }, handleSearchUpdate({ sort, filters }) { this.sort = sort; this.filters = { ...filters }; @@ -180,9 +142,6 @@ export default { updateQuery: this.updateQuery, }); }, - showAlert(obj) { - this.alertVariables = { ...obj }; - }, }, i18n: { widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), @@ -201,32 +160,22 @@ export default { <template> <div> - <gl-alert - v-if="alertVariables" - :variant="alertVariables.variant" - class="gl-mt-5" - dismissible - @dismiss="alertVariables = null" - > - {{ alertVariables.message }} - </gl-alert> <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> <package-search class="gl-mb-5" @update="handleSearchUpdate" /> - <delete-package + <delete-packages :refetch-queries="refetchQueriesData" show-success-alert @start="mutationLoading = true" @end="mutationLoading = false" > - <template #default="{ deletePackage }"> + <template #default="{ deletePackages }"> <package-list :list="packages.nodes" :is-loading="isLoading" :page-info="pageInfo" @prev-page="fetchPreviousPage" @next-page="fetchNextPage" - @package:delete="deletePackage" @delete="deletePackages" > <template #empty-state> @@ -245,6 +194,6 @@ export default { </template> </package-list> </template> - </delete-package> + </delete-packages> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue index f1f0b970b15..f95ec4336dc 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, @@ -7,10 +8,14 @@ import { KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, SET_CLEANUP_POLICY_BUTTON, + READY_FOR_CLEANUP_MESSAGE, + TIME_TO_NEXT_CLEANUP_MESSAGE, } from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; import Tracking from '~/tracking'; +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; import ExpirationDropdown from './expiration_dropdown.vue'; export default { @@ -36,6 +41,8 @@ export default { KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, SET_CLEANUP_POLICY_BUTTON, + TIME_TO_NEXT_CLEANUP_MESSAGE, + READY_FOR_CLEANUP_MESSAGE, }, data() { return { @@ -69,6 +76,15 @@ export default { keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles, }; }, + nextCleanupMessage() { + const { nextRunAt } = this.value; + const difference = calculateRemainingMilliseconds(nextRunAt); + return difference + ? sprintf(TIME_TO_NEXT_CLEANUP_MESSAGE, { + nextRunAt: approximateDuration(difference / 1000), + }) + : READY_FOR_CLEANUP_MESSAGE; + }, }, methods: { findDefaultOption(option) { @@ -83,6 +99,15 @@ export default { variables: { input: this.mutationVariables, }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: packagesCleanupPolicyQuery, + variables: { + projectPath: this.projectPath, + }, + }, + ], }) .then(({ data }) => { const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? []; @@ -119,6 +144,9 @@ export default { data-testid="keep-n-duplicated-package-files-dropdown" @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" /> + <p v-if="value.nextRunAt" data-testid="next-run-at"> + {{ nextCleanupMessage }} + </p> <div class="gl-mt-7 gl-display-flex gl-align-items-center"> <gl-button data-testid="save-button" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index a9b47cbd343..731fb3e4c45 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -74,6 +74,12 @@ export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__( export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__( 'PackageRegistry|Examples of assets include .pom & .jar files', ); +export const TIME_TO_NEXT_CLEANUP_MESSAGE = s__( + 'PackageRegistry|Packages and assets will not be deleted until cleanup runs in %{nextRunAt}.', +); +export const READY_FOR_CLEANUP_MESSAGE = s__( + 'PackageRegistry|Packages and assets cleanup is ready to be executed when the next cleanup job runs.', +); export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles'; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index d07d0a7673f..7485f8282ee 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui'; -import { filter } from 'lodash'; import { __ } from '~/locale'; export default { @@ -52,24 +51,31 @@ export default { return this.pagination.hasPreviousPage || this.pagination.hasNextPage; }, disableDeleteButton() { - return this.isLoading || filter(this.selectedReferences).length === 0; + return this.isLoading || this.selectedItems.length === 0; }, selectedItems() { return this.items.filter(this.isSelected); }, - selectAll: { - get() { - return this.items.every(this.isSelected); - }, - set(value) { - this.items.forEach((item) => { - const id = item[this.idProperty]; - this.$set(this.selectedReferences, id, value); - }); - }, + disabled() { + return this.items.length === 0; + }, + checked() { + return this.items.every(this.isSelected); + }, + indeterminate() { + return !this.checked && this.items.some(this.isSelected); + }, + label() { + return this.checked ? __('Unselect all') : __('Select all'); }, }, methods: { + onChange(event) { + this.items.forEach((item) => { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, event); + }); + }, selectItem(item) { const id = item[this.idProperty]; this.$set(this.selectedReferences, id, !this.selectedReferences[id]); @@ -80,7 +86,7 @@ export default { }, }, i18n: { - deleteSelected: __('Delete Selected'), + deleteSelected: __('Delete selected'), }, }; </script> @@ -91,9 +97,18 @@ export default { v-if="!hiddenDelete" class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center" > - <gl-form-checkbox v-model="selectAll" class="gl-ml-2 gl-pt-2"> - <span class="gl-font-weight-bold">{{ title }}</span> - </gl-form-checkbox> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-checkbox + class="gl-ml-2 gl-pt-2" + :aria-label="label" + :checked="checked" + :disabled="disabled" + :indeterminate="indeterminate" + @change="onChange" + /> + + <p class="gl-font-weight-bold gl-mb-0">{{ title }}</p> + </div> <gl-button :disabled="disableDeleteButton" diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js new file mode 100644 index 00000000000..feceeb0b10a --- /dev/null +++ b/app/assets/javascripts/pages/abuse_reports/index.js @@ -0,0 +1,3 @@ +import { initLinkToSpam } from '~/abuse_reports'; + +initLinkToSpam(); diff --git a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js index 455c637a6b3..8b8147425bc 100644 --- a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js +++ b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js @@ -20,10 +20,65 @@ function setUserInternalRegexPlaceholder(checkbox) { } } -export default function initUserInternalRegexPlaceholder() { +function initUserInternalRegexPlaceholder() { const checkbox = document.getElementById('application_setting_user_default_external'); setUserInternalRegexPlaceholder(checkbox); checkbox.addEventListener('change', () => { setUserInternalRegexPlaceholder(checkbox); }); } + +/** + * Sets up logic inside "Dormant users" subsection: + * - checkbox enables/disables additional input + * - shows/hides an inline error on input validation + */ +function initDeactivateDormantUsersPeriodInputSection() { + const DISPLAY_NONE_CLASS = 'gl-display-none'; + + /** @type {HTMLInputElement} */ + const checkbox = document.getElementById('application_setting_deactivate_dormant_users'); + /** @type {HTMLInputElement} */ + const input = document.getElementById('application_setting_deactivate_dormant_users_period'); + /** @type {HTMLDivElement} */ + const errorLabel = document.getElementById( + 'application_setting_deactivate_dormant_users_period_error', + ); + + if (!checkbox || !input || !errorLabel) return; + + const hideInputErrorLabel = () => { + if (input.checkValidity()) { + errorLabel.classList.add(DISPLAY_NONE_CLASS); + } + }; + + const handleInputInvalidState = (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + errorLabel.classList.remove(DISPLAY_NONE_CLASS); + return false; + }; + + const updateInputDisabledState = () => { + input.disabled = !checkbox.checked; + if (input.disabled) { + hideInputErrorLabel(); + } + }; + + // Show error when input is invalid + input.addEventListener('invalid', handleInputInvalidState); + // Hide error when input changes + input.addEventListener('input', hideInputErrorLabel); + input.addEventListener('change', hideInputErrorLabel); + + // Handle checkbox change and set initial state + checkbox.addEventListener('change', updateInputDisabledState); + updateInputDisabledState(); +} + +export default function initAccountAndLimitsSection() { + initUserInternalRegexPlaceholder(); + initDeactivateDormantUsersPeriodInputSection(); +} diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index c48d99da990..8a810ca649c 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,9 +1,9 @@ -import initUserInternalRegexPlaceholder from '../account_and_limits'; +import initAccountAndLimitsSection from '../account_and_limits'; import initGitpod from '../gitpod'; import initSignupRestrictions from '../signup_restrictions'; (() => { - initUserInternalRegexPlaceholder(); + initAccountAndLimitsSection(); initGitpod(); initSignupRestrictions(); })(); diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index f1e92cf195a..366be334e87 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,5 +1,4 @@ import initVariableList from '~/ci/ci_variable_list'; -import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import selfMonitor from '~/self_monitor'; import initSettingsPanels from '~/settings_panels'; @@ -8,5 +7,4 @@ initVariableList('js-instance-variables'); selfMonitor(); // Initialize expandable settings panels initSettingsPanels(); -projectSelect(); initSearchSettings(); diff --git a/app/assets/javascripts/pages/admin/hooks/index.js b/app/assets/javascripts/pages/admin/hooks/index.js new file mode 100644 index 00000000000..82e601426f1 --- /dev/null +++ b/app/assets/javascripts/pages/admin/hooks/index.js @@ -0,0 +1,3 @@ +import { initHookTestDropdowns } from '~/webhooks'; + +initHookTestDropdowns(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue new file mode 100644 index 00000000000..72cfc005782 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue @@ -0,0 +1,37 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import CancelJobsModal from './cancel_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from './constants'; + +export default { + name: 'CancelJobs', + components: { + GlButton, + CancelJobsModal, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + props: { + url: { + type: String, + required: true, + }, + }, + modalId: CANCEL_JOBS_MODAL_ID, + buttonText: CANCEL_JOBS_BUTTON_TEXT, + buttonTooltip: CANCEL_BUTTON_TOOLTIP, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal="$options.modalId" + v-gl-tooltip="$options.buttonTooltip" + variant="danger" + >{{ $options.buttonText }}</gl-button + > + <cancel-jobs-modal :modal-id="$options.modalId" :url="url" @confirm="$emit('confirm')" /> + </div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue index b608b3b9492..d5857294617 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue @@ -5,10 +5,9 @@ import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import { CANCEL_TEXT, - STOP_JOBS_MODAL_ID, - STOP_JOBS_FAILED_TEXT, - STOP_JOBS_MODAL_TITLE, - STOP_JOBS_WARNING, + CANCEL_JOBS_FAILED_TEXT, + CANCEL_JOBS_MODAL_TITLE, + CANCEL_JOBS_WARNING, PRIMARY_ACTION_TEXT, } from './constants'; @@ -21,6 +20,10 @@ export default { type: String, required: true, }, + modalId: { + type: String, + required: true, + }, }, methods: { onSubmit() { @@ -32,7 +35,7 @@ export default { }) .catch((error) => { createAlert({ - message: STOP_JOBS_FAILED_TEXT, + message: CANCEL_JOBS_FAILED_TEXT, }); throw error; }); @@ -45,20 +48,19 @@ export default { cancelAction: { text: CANCEL_TEXT, }, - STOP_JOBS_WARNING, - STOP_JOBS_MODAL_ID, - STOP_JOBS_MODAL_TITLE, + CANCEL_JOBS_WARNING, + CANCEL_JOBS_MODAL_TITLE, }; </script> <template> <gl-modal - :modal-id="$options.STOP_JOBS_MODAL_ID" + :modal-id="modalId" :action-primary="$options.primaryAction" :action-cancel="$options.cancelAction" + :title="$options.CANCEL_JOBS_MODAL_TITLE" @primary="onSubmit" > - <template #modal-title>{{ $options.STOP_JOBS_MODAL_TITLE }}</template> - {{ $options.STOP_JOBS_WARNING }} + {{ $options.CANCEL_JOBS_WARNING }} </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js index 9e2d464bc4d..cfde1fc0a2b 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js @@ -1,11 +1,12 @@ import { s__, __ } from '~/locale'; -export const STOP_JOBS_MODAL_ID = 'stop-jobs-modal'; -export const STOP_JOBS_MODAL_TITLE = s__('AdminArea|Stop all jobs?'); -export const STOP_JOBS_BUTTON_TEXT = s__('AdminArea|Stop all jobs'); +export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; +export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); +export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs'); +export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs'); export const CANCEL_TEXT = __('Cancel'); -export const STOP_JOBS_FAILED_TEXT = s__('AdminArea|Stopping jobs failed'); -export const PRIMARY_ACTION_TEXT = s__('AdminArea|Stop jobs'); -export const STOP_JOBS_WARNING = s__( - 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.', +export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed'); +export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); +export const CANCEL_JOBS_WARNING = s__( + "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", ); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue new file mode 100644 index 00000000000..c5a0509b625 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue @@ -0,0 +1,19 @@ +<script> +export default { + inject: { + jobStatuses: { + default: null, + }, + url: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, +}; +</script> + +<template> + <div>{{ __('Jobs') }}</div> +</template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index c82b186f671..9df52557212 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,31 +1,33 @@ import Vue from 'vue'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import { STOP_JOBS_MODAL_ID } from './components/constants'; -import StopJobsModal from './components/stop_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID } from './components/constants'; +import CancelJobsModal from './components/cancel_jobs_modal.vue'; +import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue'; Vue.use(Translate); function initJobs() { const buttonId = 'js-stop-jobs-button'; - const stopJobsButton = document.getElementById(buttonId); - if (stopJobsButton) { + const cancelJobsButton = document.getElementById(buttonId); + if (cancelJobsButton) { // eslint-disable-next-line no-new new Vue({ - el: `#js-${STOP_JOBS_MODAL_ID}`, + el: `#js-${CANCEL_JOBS_MODAL_ID}`, components: { - StopJobsModal, + CancelJobsModal, }, mounted() { - stopJobsButton.classList.remove('disabled'); - stopJobsButton.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, STOP_JOBS_MODAL_ID, `#${buttonId}`); + cancelJobsButton.classList.remove('disabled'); + cancelJobsButton.addEventListener('click', () => { + this.$root.$emit(BV_SHOW_MODAL, CANCEL_JOBS_MODAL_ID, `#${buttonId}`); }); }, render(createElement) { - return createElement(STOP_JOBS_MODAL_ID, { + return createElement(CANCEL_JOBS_MODAL_ID, { props: { - url: stopJobsButton.dataset.url, + url: cancelJobsButton.dataset.url, + modalId: CANCEL_JOBS_MODAL_ID, }, }); }, @@ -33,4 +35,28 @@ function initJobs() { } } -initJobs(); +export function initAdminJobsApp() { + const containerEl = document.getElementById('admin-jobs-app'); + + if (!containerEl) return false; + + const { jobStatuses, emptyStateSvgPath, url } = containerEl.dataset; + + return new Vue({ + el: containerEl, + provide: { + url, + emptyStateSvgPath, + jobStatuses: JSON.parse(jobStatuses), + }, + render(createElement) { + return createElement(AdminJobsTableApp); + }, + }); +} + +if (gon.features.adminJobsVue) { + initAdminJobsApp(); +} else { + initJobs(); +} diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue index c75c031b0b1..fa8f78839f3 100644 --- a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue +++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue @@ -1,34 +1,31 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Api from '~/api'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; export default { i18n: { - dropdownHeader: __('Namespaces'), + headerText: __('Namespaces'), searchPlaceholder: __('Search for Namespace'), - anyNamespace: __('Any namespace'), + reset: __('Clear'), }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlLoadingIcon, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { - showAny: { - type: Boolean, + origSelectedId: { + type: String, + required: false, + default: '', + }, + origSelectedText: { + type: String, required: false, - default: false, + default: '', }, - placeholder: { + toggleTextPlaceholder: { type: String, required: false, default: __('Namespace'), @@ -42,56 +39,72 @@ export default { data() { return { namespaceOptions: [], - selectedNamespaceId: null, - selectedNamespace: null, + selectedNamespaceId: this.origSelectedId, + selectedNamespaceText: this.origSelectedText, searchTerm: '', isLoading: false, }; }, computed: { - selectedNamespaceName() { - if (this.selectedNamespaceId === null) { - return this.placeholder; - } - return this.selectedNamespace; + toggleText() { + return this.selectedNamespaceText || this.toggleTextPlaceholder; }, }, watch: { - searchTerm() { - this.fetchNamespaces(this.searchTerm); + selectedNamespaceId(val) { + if (!val) { + this.selectedNamespaceText = null; + } + + this.selectedNamespaceText = this.namespaceOptions.find(({ value }) => value === val)?.text; }, }, mounted() { this.fetchNamespaces(); }, methods: { - fetchNamespaces(filter) { + fetchNamespaces() { this.isLoading = true; this.namespaceOptions = []; - return Api.namespaces(filter, (namespaces) => { - this.namespaceOptions = namespaces; + + return Api.namespaces(this.searchTerm, (namespaces) => { + this.namespaceOptions = this.formatNamespaceOptions(namespaces); this.isLoading = false; }); }, - selectNamespace(key) { - this.selectedNamespaceId = this.namespaceOptions[key].id; - this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]); - this.$emit('setNamespace', this.selectedNamespaceId); + formatNamespaceOptions(namespaces) { + if (!namespaces) { + return []; + } + + return namespaces.map((namespace) => { + return { + value: String(namespace.id), + text: this.getNamespaceString(namespace), + }; + }); }, - selectAnyNamespace() { - this.selectedNamespaceId = null; - this.selectedNamespace = null; - this.$emit('setNamespace', null); + selectNamespace(value) { + this.selectedNamespaceId = value; + this.$emit('setNamespace', this.selectedNamespaceId); }, getNamespaceString(namespace) { return `${namespace.kind}: ${namespace.full_path}`; }, + search: debounce(function debouncedSearch(searchQuery) { + this.searchTerm = searchQuery?.trim(); + this.fetchNamespaces(); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + onReset() { + this.selectedNamespaceId = null; + this.$emit('setNamespace', null); + }, }, }; </script> <template> - <div class="gl-display-flex"> + <div class="gl-display-flex gl-w-full"> <input v-if="fieldName" :name="fieldName" @@ -99,45 +112,19 @@ export default { type="hidden" data-testid="hidden-input" /> - <gl-dropdown - :text="selectedNamespaceName" - :header-text="$options.i18n.dropdownHeader" - toggle-class="dropdown-menu-toggle large" - data-testid="namespace-dropdown" - :right="true" - > - <template #header> - <gl-search-box-by-type - v-model.trim="searchTerm" - class="namespace-search-box" - debounce="250" - :placeholder="$options.i18n.searchPlaceholder" - /> - </template> - - <template v-if="showAny"> - <gl-dropdown-item @click="selectAnyNamespace"> - {{ $options.i18n.anyNamespace }} - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - - <gl-loading-icon v-if="isLoading" /> - - <gl-dropdown-item - v-for="(namespace, key) in namespaceOptions" - :key="namespace.id" - @click="selectNamespace(key)" - > - {{ getNamespaceString(namespace) }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + :items="namespaceOptions" + :header-text="$options.i18n.headerText" + :reset-button-label="$options.i18n.reset" + :toggle-text="toggleText" + :search-placeholder="$options.i18n.searchPlaceholder" + :searching="isLoading" + :selected="selectedNamespaceId" + toggle-class="gl-w-full gl-flex-direction-column gl-align-items-stretch!" + searchable + @reset="onReset" + @search="search" + @select="selectNamespace" + /> </div> </template> - -<style scoped> -/* workaround position: relative imposed by .top-area .nav-controls */ -.namespace-search-box >>> input { - position: static; -} -</style> diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index 3098d06510b..49ee89de772 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import ProjectsList from '~/projects_list'; import NamespaceSelect from './components/namespace_select.vue'; @@ -12,16 +11,17 @@ function mountNamespaceSelect() { return false; } - const { showAny, fieldName, placeholder, updateLocation } = el.dataset; + const { fieldName, toggleTextPlaceholder, selectedId, selectedText, updateLocation } = el.dataset; return new Vue({ el, render(createComponent) { return createComponent(NamespaceSelect, { props: { - showAny: parseBoolean(showAny), fieldName, - placeholder, + toggleTextPlaceholder, + origSelectedId: selectedId, + origSelectedText: selectedText, }, on: { setNamespace(newNamespace) { diff --git a/app/assets/javascripts/pages/admin/runners/new/index.js b/app/assets/javascripts/pages/admin/runners/new/index.js new file mode 100644 index 00000000000..5048ad7b57a --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/new/index.js @@ -0,0 +1,3 @@ +import { initAdminNewRunner } from '~/ci/runner/admin_new_runner'; + +initAdminNewRunner(); diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 08c247a498b..2ca11e96f69 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -3,7 +3,7 @@ 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 projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, @@ -11,7 +11,7 @@ initFilteredSearch({ useDefaultState: true, }); -projectSelect(); +initNewResourceDropdown(); initManualOrdering(); mountIssuesDashboardApp(); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 1350837476b..a8c59ea6f3d 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -2,7 +2,9 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import projectSelect from '~/project_select'; +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); @@ -12,4 +14,7 @@ initFilteredSearch({ useDefaultState: true, }); -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MERGE_REQUEST, + query: searchUserProjectsWithMergeRequestsEnabled, +}); diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js index b526fce6f7b..88061d9ca22 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/index/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js @@ -1,3 +1,12 @@ -import projectSelect from '~/project_select'; +import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; +import { RESOURCE_TYPE_MILESTONE } from '~/vue_shared/components/new_resource_dropdown/constants'; +import searchUserGroupsAndProjects from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql'; -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MILESTONE, + query: searchUserGroupsAndProjects, + extractProjects: (data) => [ + ...(data?.user?.groups?.nodes ?? []), + ...(data?.projects?.nodes ?? []), + ], +}); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index c5d62ae5daf..2fdf3c42935 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -133,10 +133,10 @@ export default class Todos { restoreBtn.classList.add('hidden'); doneBtn.classList.remove('hidden'); } else if (target === doneBtn) { - row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); + row.classList.add('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50'); restoreBtn.classList.remove('hidden'); } else if (target === restoreBtn) { - row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); + row.classList.remove('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50'); doneBtn.classList.remove('hidden'); } else { row.parentNode.removeChild(row); @@ -147,17 +147,17 @@ export default class Todos { e.stopPropagation(); e.preventDefault(); - const target = e.currentTarget; - target.setAttribute('disabled', true); - target.classList.add('disabled'); + const { currentTarget } = e; + currentTarget.setAttribute('disabled', true); + currentTarget.classList.add('disabled'); - target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + currentTarget.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); - axios[target.dataset.method](target.dataset.href, { + axios[currentTarget.dataset.method](currentTarget.href, { ids: this.todo_ids, }) .then(({ data }) => { - this.updateAllState(target, data); + this.updateAllState(currentTarget, data); this.updateBadges(data); }) .catch(() => diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index fb685247bd4..dec06fe6f4d 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -2,10 +2,10 @@ import { GROUP_BADGE } from '~/badges/constants'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import initTransferGroupForm from '~/groups/init_transfer_group_form'; -import { initGroupSelects } from '~/vue_shared/components/group_select/init_group_selects'; +import { initGroupSelects } from '~/vue_shared/components/entity_select/init_group_selects'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; -import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import initConfirmDanger from '~/init_confirm_danger'; @@ -22,7 +22,8 @@ mountBadgeSettings(GROUP_BADGE); // Initialize Subgroups selector initGroupSelects(); -projectSelect(); +// Initialize project selectors +initProjectSelects(); initSearchSettings(); initCascadingSettingsLockPopovers(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index ceda2c8fa17..1b3c7ba5a52 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -12,7 +12,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; const APP_OPTIONS = { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'activity']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', 'granted', @@ -32,10 +31,6 @@ const APP_OPTIONS = { }, [MEMBER_TYPES.group]: { tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, - }, requestFormatter: groupLinkRequestFormatter, filteredSearchBar: { show: true, diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index bf0147ca885..2cf75fcf666 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -3,7 +3,9 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { initBulkUpdateSidebar } from '~/issuable'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import projectSelect from '~/project_select'; +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 searchUserGroupProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql'; const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; @@ -16,4 +18,8 @@ initFilteredSearch({ useDefaultState: true, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); -projectSelect(); +initNewResourceDropdown({ + resourceType: RESOURCE_TYPE_MERGE_REQUEST, + query: searchUserGroupProjectsWithMergeRequestsEnabled, + extractProjects: (data) => data?.group?.projects?.nodes, +}); diff --git a/app/assets/javascripts/pages/groups/usage_quotas/index.js b/app/assets/javascripts/pages/groups/usage_quotas/index.js new file mode 100644 index 00000000000..dab2d0b17d2 --- /dev/null +++ b/app/assets/javascripts/pages/groups/usage_quotas/index.js @@ -0,0 +1,3 @@ +import initUsageQuotas from '~/usage_quotas'; + +initUsageQuotas(); diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js new file mode 100644 index 00000000000..ef227b82172 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/saved_replies/index.js @@ -0,0 +1,3 @@ +import { initSavedReplies } from '~/saved_replies'; + +initSavedReplies(); diff --git a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js new file mode 100644 index 00000000000..1d7cf4a5b8e --- /dev/null +++ b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import AirflowDags from '~/airflow/dags/components/dags.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const initShowDags = () => { + const element = document.querySelector('#js-show-airflow-dags'); + if (!element) { + return null; + } + + const dags = JSON.parse(element.dataset.dags); + const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); + + return new Vue({ + el: element, + render(h) { + return h(AirflowDags, { + props: { + dags, + pagination, + }, + }); + }, + }); +}; + +initShowDags(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 46704d96552..667fd89af55 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -16,6 +16,7 @@ import syntaxHighlight from '~/syntax_highlight'; import ZenMode from '~/zen_mode'; import '~/sourcegraph/load'; import DiffStats from '~/diffs/components/diff_stats.vue'; +import { initReportAbuse } from '~/projects/report_abuse'; const hasPerfBar = document.querySelector('.with-performance-bar'); const performanceHeight = hasPerfBar ? 35 : 0; @@ -26,6 +27,7 @@ new ShortcutsNavigation(); initCommitBoxInfo(); initDeprecatedNotes(); +initReportAbuse(); const loadDiffStats = () => { const diffStatsElements = document.querySelectorAll('#js-diff-stats'); @@ -67,6 +69,7 @@ if (filesContainer.length) { handleLocationHash(); new Diff(); loadDiffStats(); + initReportAbuse(); }) .catch(() => { createAlert({ message: __('An error occurred while retrieving diff files') }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index c0eb2a8fd77..82035008459 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -10,6 +10,8 @@ import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import UserCallout from '~/user_callout'; import initTopicsTokenSelector from '~/projects/settings/topics'; +import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects'; +import initPruneObjectsButton from '~/projects/prune_objects_button'; import initProjectPermissionsSettings from '../shared/permissions'; import initProjectLoadingSpinner from '../shared/save_project_loader'; @@ -17,6 +19,7 @@ initFilePickers(); initConfirmDanger(); initSettingsPanels(); initProjectDeleteButton(); +initPruneObjectsButton(); mountBadgeSettings(PROJECT_BADGE); new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new @@ -30,3 +33,4 @@ dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr- initSearchSettings(); initTopicsTokenSelector(); +initProjectSelects(); diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js new file mode 100644 index 00000000000..9a3bb25de70 --- /dev/null +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { generateRefDestinationPath } from './ref_switcher_utils'; + +Vue.use(Translate); + +const REF_SWITCH_HEADER = s__('FindFile|Switch branch/tag'); + +export default () => { + const el = document.getElementById('js-blob-ref-switcher'); + if (!el) return false; + + const { projectId, ref, namespace } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + translations: { + dropdownHeader: REF_SWITCH_HEADER, + searchPlaceholder: REF_SWITCH_HEADER, + }, + }, + on: { + input(selected) { + visitUrl(generateRefDestinationPath(selected, namespace)); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js new file mode 100644 index 00000000000..5fecd024f1a --- /dev/null +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js @@ -0,0 +1,28 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + +/** + * Generates a ref destination url based on the selected ref and current url. + * @param {string} selectedRef - The selected ref from the ref dropdown. + * @param {string} namespace - The destination namespace for the path. + */ +export function generateRefDestinationPath(selectedRef, namespace) { + if (!selectedRef || !namespace) { + return window.location.href; + } + + const { pathname } = window.location; + const encodedHash = '%23'; + + const [projectRootPath] = pathname.split(namespace); + + const destinationPath = joinPaths( + projectRootPath, + namespace, + encodeURI(selectedRef).replace(/#/g, encodedHash), + ); + + const newURL = new URL(window.location); + newURL.pathname = destinationPath; + + return newURL.href; +} diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index f47888f0cb8..e207df2434b 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -1,7 +1,9 @@ import $ from 'jquery'; import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file'; import ProjectFindFile from '~/projects/project_find_file'; +import InitBlobRefSwitcher from '../ref_switcher'; +InitBlobRefSwitcher(); const findElement = document.querySelector('.js-file-finder'); const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { url: findElement.dataset.fileFindUrl, 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 2028af8b8f0..85fe3477d7c 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 @@ -16,7 +16,7 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import validation from '~/vue_shared/directives/validation'; import { VISIBILITY_LEVEL_PRIVATE_STRING, @@ -25,8 +25,24 @@ import { VISIBILITY_LEVELS_STRING_TO_INTEGER, VISIBILITY_LEVELS_INTEGER_TO_STRING, } from '~/visibility_level/constants'; +import { START_RULE, CONTAINS_RULE } from '~/projects/project_name_rules'; import ProjectNamespace from './project_namespace.vue'; +const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + message: __('Please fill out this field.'), + }, + nameStartPattern: { + isInvalid: (el) => el.validity?.patternMismatch && !START_RULE.reg.test(el.value), + message: START_RULE.msg, + }, + nameContainsPattern: { + isInvalid: (el) => el.validity?.patternMismatch && !CONTAINS_RULE.reg.test(el.value), + message: CONTAINS_RULE.msg, + }, +}; + const initFormField = ({ value, required = true, skipValidation = false }) => ({ value, required, @@ -48,7 +64,7 @@ export default { ProjectNamespace, }, directives: { - validation: validation(), + validation: validation(feedbackMap), }, inject: { newGroupPath: { @@ -109,6 +125,15 @@ export default { }; }, computed: { + projectNameDescription() { + if (this.form.fields.name.state === false) { + return null; + } + + return s__( + 'ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.', + ); + }, projectVisibilityLevel() { return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; }, @@ -248,6 +273,7 @@ export default { }, }, csrf, + projectNamePattern: `(${START_RULE.reg.source})|(${CONTAINS_RULE.reg.source})`, }; </script> @@ -257,8 +283,10 @@ export default { <gl-form-group :label="__('Project name')" + :description="projectNameDescription" label-for="fork-name" :invalid-feedback="form.fields.name.feedback" + data-testid="fork-name-form-group" > <gl-form-input id="fork-name" @@ -268,6 +296,7 @@ export default { data-testid="fork-name-input" :state="form.fields.name.state" required + :pattern="$options.projectNamePattern" /> </gl-form-group> diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 65e7f48ed24..10c794c9ba2 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -2,6 +2,9 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts'; import Vue from 'vue'; import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { __ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import RefSelector from '~/ref/components/ref_selector.vue'; import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; @@ -13,6 +16,7 @@ waitForCSSLoaded(() => { const monthContainer = document.getElementById('js-month-chart'); const weekdayContainer = document.getElementById('js-weekday-chart'); const hourContainer = document.getElementById('js-hour-chart'); + const branchSelector = document.getElementById('js-project-graph-ref-switcher'); const LANGUAGE_CHART_HEIGHT = 300; const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { if (firstDayOfWeek === 0) { @@ -173,4 +177,38 @@ waitForCSSLoaded(() => { }); }, }); + + const { projectId, projectBranch, graphPath } = branchSelector.dataset; + + const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; + const graphsPathPrefix = graphPath.match(GRAPHS_PATH_REGEX)?.[0]; + if (!graphsPathPrefix) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Path is not correct'); + } + + // eslint-disable-next-line no-new + new Vue({ + el: branchSelector, + name: 'RefSelector', + render(createComponent) { + return createComponent(RefSelector, { + props: { + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: projectBranch, + translations: { + dropdownHeader: __('Switch branch/tag'), + searchPlaceholder: __('Search branches and tags'), + }, + projectId, + }, + class: 'gl-w-20', + on: { + input(selected) { + visitUrl(`${graphsPathPrefix}/${encodeURIComponent(selected)}/charts`); + }, + }, + }); + }, + }); }); diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js index 9e559354205..f25547f9982 100644 --- a/app/assets/javascripts/pages/projects/hooks/index.js +++ b/app/assets/javascripts/pages/projects/hooks/index.js @@ -1,7 +1,8 @@ import initSearchSettings from '~/search_settings'; -import initWebhookForm from '~/webhooks'; +import initWebhookForm, { initHookTestDropdowns } from '~/webhooks'; import { initPushEventsEditForm } from '~/webhooks/webhook'; initSearchSettings(); initWebhookForm(); initPushEventsEditForm(); +initHookTestDropdowns(); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 37cf345fe77..1075241e172 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import initTerraformNotification from '~/projects/terraform_notification'; import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new -initTerraformNotification(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue deleted file mode 100644 index 693dc6a15ad..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue +++ /dev/null @@ -1,15 +0,0 @@ -<script> -import { s__ } from '~/locale'; - -export default { - name: 'IncludedInTrialIndicator', - i18n: { - trialOnly: s__('LearnGitlab|- Included in trial'), - }, -}; -</script> -<template> - <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} - </span> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue deleted file mode 100644 index 54e15b6552c..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ /dev/null @@ -1,146 +0,0 @@ -<script> -import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui'; -import eventHub from '~/invite_members/event_hub'; -import { s__ } from '~/locale'; -import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils'; -import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants'; -import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; - -export default { - components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard }, - i18n: { - title: s__('LearnGitLab|Learn GitLab'), - description: s__( - 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', - ), - percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), - successfulInvitations: s__( - "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.", - ), - }, - props: { - actions: { - required: true, - type: Object, - }, - sections: { - required: true, - type: Object, - }, - project: { - required: true, - type: Object, - }, - }, - data() { - return { - showSuccessfulInvitationsAlert: false, - actionsData: this.actions, - }; - }, - actionSections: Object.keys(ACTION_SECTIONS), - computed: { - maxValue() { - return Object.keys(this.actionsData).length; - }, - progressValue() { - return Object.values(this.actionsData).filter((a) => a.completed).length; - }, - progressPercentage() { - return Math.round((this.progressValue / this.maxValue) * 100); - }, - }, - mounted() { - if (this.getCookieForInviteMembers()) { - this.openInviteMembersModal('celebrate'); - } - - eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); - }, - beforeDestroy() { - eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); - }, - methods: { - getCookieForInviteMembers() { - const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE)); - - removeCookie(INVITE_MODAL_OPEN_COOKIE); - - return value; - }, - openInviteMembersModal(mode) { - eventHub.$emit('openModal', { mode, source: 'learn-gitlab' }); - }, - handleShowSuccessfulInvitationsAlert() { - this.showSuccessfulInvitationsAlert = true; - this.markActionAsCompleted('userAdded'); - }, - actionsFor(section) { - const actions = Object.fromEntries( - Object.entries(this.actionsData).filter( - ([action]) => ACTION_LABELS[action].section === section, - ), - ); - return actions; - }, - svgFor(section) { - return this.sections[section].svg; - }, - markActionAsCompleted(completedAction) { - Object.keys(this.actionsData).forEach((action) => { - if (action === completedAction) { - this.actionsData[action].completed = true; - this.modifySidebarPercentage(); - } - }); - }, - modifySidebarPercentage() { - const el = document.querySelector('.sidebar-top-level-items .active .count'); - el.textContent = `${this.progressPercentage}%`; - }, - }, -}; -</script> -<template> - <div> - <gl-alert - v-if="showSuccessfulInvitationsAlert" - class="gl-mt-5" - @dismiss="showSuccessfulInvitationsAlert = false" - > - <gl-sprintf :message="$options.i18n.successfulInvitations"> - <template #projectName> - <strong>{{ project.name }}</strong> - </template> - </gl-sprintf> - </gl-alert> - <div class="row"> - <div class="gl-mb-7 gl-ml-5"> - <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> - <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p> - </div> - </div> - <div class="gl-mb-3"> - <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage"> - <gl-sprintf :message="$options.i18n.percentageCompleted"> - <template #percentage>{{ progressPercentage }}</template> - <template #percentSymbol>%</template> - </gl-sprintf> - </p> - <gl-progress-bar :value="progressValue" :max="maxValue" /> - </div> - <div class="row"> - <div - v-for="section in $options.actionSections" - :key="section" - class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4" - > - <learn-gitlab-section-card - :section="section" - :svg="svgFor(section)" - :actions="actionsFor(section)" - /> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue deleted file mode 100644 index e8f0e6c47ee..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { GlCard } from '@gitlab/ui'; -import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; - -import LearnGitlabSectionLink from './learn_gitlab_section_link.vue'; - -export default { - name: 'LearnGitlabSectionCard', - components: { GlCard, LearnGitlabSectionLink }, - i18n: { - ...ACTION_SECTIONS, - }, - props: { - section: { - required: true, - type: String, - }, - svg: { - required: true, - type: String, - }, - actions: { - required: true, - type: Object, - }, - }, - computed: { - sortedActions() { - return Object.entries(this.actions).sort( - (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position, - ); - }, - }, -}; -</script> -<template> - <gl-card - class="gl-pt-0 h-100" - header-class="gl-bg-white gl-border-0 gl-pb-0" - body-class="gl-pt-0" - > - <template #header> - <img :src="svg" /> - <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2> - <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p> - </template> - <template #default> - <learn-gitlab-section-link - v-for="[action, value] in sortedActions" - :key="action" - :action="action" - :value="value" - /> - </template> - </gl-card> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue deleted file mode 100644 index d9b0dbbb9b0..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlLink, GlIcon, GlButton, GlPopover, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import { isExperimentVariant } from '~/experimentation/utils'; -import eventHub from '~/invite_members/event_hub'; -import { s__, __ } from '~/locale'; -import { ACTION_LABELS } from '../constants'; -import IncludedInTrialIndicator from './included_in_trial_indicator.vue'; - -export default { - name: 'LearnGitlabSectionLink', - components: { - GlLink, - GlIcon, - GlButton, - GlPopover, - GitlabExperiment, - IncludedInTrialIndicator, - }, - directives: { - GlTooltip, - }, - i18n: { - contactAdmin: s__('LearnGitlab|Contact your administrator to enable this action.'), - viewAdminList: s__('LearnGitlab|View administrator list'), - watchHow: __('Watch how'), - }, - props: { - action: { - required: true, - type: String, - }, - value: { - required: true, - type: Object, - }, - }, - data() { - return { - popoverId: uniqueId('contact-admin-'), - }; - }, - computed: { - showInviteModalLink() { - return ( - this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') - ); - }, - openInNewTab() { - return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; - }, - popoverText() { - return this.value.message || this.$options.i18n.contactAdmin; - }, - }, - methods: { - openModal() { - eventHub.$emit('openModal', { source: 'learn_gitlab' }); - }, - actionLabelValue(value) { - return ACTION_LABELS[this.action][value]; - }, - }, -}; -</script> -<template> - <div class="gl-mb-4"> - <div class="flex align-items-center"> - <span v-if="value.completed" class="gl-text-green-500"> - <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> - {{ actionLabelValue('title') }} - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </span> - <div v-else-if="showInviteModalLink"> - <gl-link - data-track-action="click_link" - :data-track-label="actionLabelValue('trackLabel')" - data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" - data-testid="invite-for-help-continuous-onboarding-experiment-link" - @click="openModal" - >{{ actionLabelValue('title') }}</gl-link - > - - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </div> - <div v-else-if="value.enabled"> - <gl-link - :target="openInNewTab ? '_blank' : '_self'" - :href="value.url" - data-testid="uncompleted-learn-gitlab-link" - data-qa-selector="uncompleted_learn_gitlab_link" - data-track-action="click_link" - :data-track-label="actionLabelValue('trackLabel')" - >{{ actionLabelValue('title') }}</gl-link - > - - <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" /> - </div> - <template v-else> - <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div> - <gl-button - :id="popoverId" - category="tertiary" - icon="question-o" - class="ml-auto" - :aria-label="popoverText" - size="small" - data-testid="contact-admin-popover-trigger" - /> - <gl-popover - :target="popoverId" - placement="top" - triggers="hover focus" - data-testid="contact-admin-popover" - > - <p>{{ popoverText }}</p> - <gl-link - :href="value.url" - class="font-size-inherit" - data-testid="view-administrator-link-text" - > - {{ $options.i18n.viewAdminList }} - </gl-link> - </gl-popover> - </template> - <gitlab-experiment name="video_tutorials_continuous_onboarding"> - <template #control></template> - <template #candidate> - <gl-button - v-if="actionLabelValue('videoTutorial')" - v-gl-tooltip - category="tertiary" - icon="live-preview" - :title="$options.i18n.watchHow" - :aria-label="$options.i18n.watchHow" - :href="actionLabelValue('videoTutorial')" - target="_blank" - class="ml-auto" - size="small" - data-testid="video-tutorial-link" - data-track-action="click_video_link" - :data-track-label="actionLabelValue('trackLabel')" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - data-track-experiment="video_tutorials_continuous_onboarding" - /> - </template> - </gitlab-experiment> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js deleted file mode 100644 index cb1a0302d91..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ /dev/null @@ -1,133 +0,0 @@ -import { s__ } from '~/locale'; - -export const ACTION_LABELS = { - gitWrite: { - title: s__('LearnGitLab|Create a repository'), - actionLabel: s__('LearnGitLab|Create a repository'), - description: s__('LearnGitLab|Create or import your first repository into your new project.'), - trackLabel: 'create_a_repository', - section: 'workspace', - position: 1, - }, - userAdded: { - title: s__('LearnGitLab|Invite your colleagues'), - actionLabel: s__('LearnGitLab|Invite your colleagues'), - description: s__( - 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.', - ), - trackLabel: 'invite_your_colleagues', - section: 'workspace', - position: 0, - }, - pipelineCreated: { - title: s__("LearnGitLab|Set up your first project's CI/CD"), - actionLabel: s__('LearnGitLab|Set up CI/CD'), - description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'), - trackLabel: 'set_up_your_first_project_s_ci_cd', - section: 'workspace', - position: 2, - }, - trialStarted: { - title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'), - actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'), - description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), - trackLabel: 'start_a_free_trial_of_gitlab_ultimate', - section: 'workspace', - position: 3, - openInNewTab: true, - }, - codeOwnersEnabled: { - title: s__('LearnGitLab|Add code owners'), - actionLabel: s__('LearnGitLab|Add code owners'), - description: s__( - 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.', - ), - trackLabel: 'add_code_owners', - trialRequired: true, - section: 'workspace', - position: 4, - openInNewTab: true, - videoTutorial: 'https://vimeo.com/670896787', - }, - requiredMrApprovalsEnabled: { - title: s__('LearnGitLab|Enable require merge approvals'), - actionLabel: s__('LearnGitLab|Enable require merge approvals'), - description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'), - trackLabel: 'enable_require_merge_approvals', - trialRequired: true, - section: 'workspace', - position: 5, - openInNewTab: true, - videoTutorial: 'https://vimeo.com/670904904', - }, - mergeRequestCreated: { - title: s__('LearnGitLab|Submit a merge request (MR)'), - actionLabel: s__('LearnGitLab|Submit a merge request (MR)'), - description: s__('LearnGitLab|Review and edit proposed changes to source code.'), - trackLabel: 'submit_a_merge_request_mr', - section: 'plan', - position: 1, - }, - issueCreated: { - title: s__('LearnGitLab|Create an issue'), - actionLabel: s__('LearnGitLab|Create an issue'), - description: s__( - 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.', - ), - trackLabel: 'create_an_issue', - section: 'plan', - position: 0, - }, - securityScanEnabled: { - title: s__('LearnGitLab|Run a Security scan using CI/CD'), - actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'), - description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), - trackLabel: 'run_a_security_scan_using_ci_cd', - section: 'deploy', - position: 1, - }, - licenseScanningRun: { - title: s__('LearnGitLab|Scan dependencies for licenses'), - trackLabel: 'scan_dependencies_for_licenses', - trialRequired: true, - section: 'deploy', - position: 2, - }, - secureDependencyScanningRun: { - title: s__('LearnGitLab|Scan dependencies for vulnerabilities'), - trackLabel: 'scan_dependencies_for_vulnerabilities', - trialRequired: true, - section: 'deploy', - position: 3, - }, - secureDastRun: { - title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'), - trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast', - trialRequired: true, - section: 'deploy', - position: 4, - }, -}; - -export const ACTION_SECTIONS = { - workspace: { - title: s__('LearnGitLab|Set up your workspace'), - description: s__( - "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:", - ), - }, - plan: { - title: s__('LearnGitLab|Plan and execute'), - description: s__( - 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:', - ), - }, - deploy: { - title: s__('LearnGitLab|Deploy'), - description: s__( - 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:', - ), - }, -}; - -export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup'; diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js deleted file mode 100644 index af4a6f8a0c9..00000000000 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ /dev/null @@ -1,31 +0,0 @@ -import Vue from 'vue'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import LearnGitlab from '../components/learn_gitlab.vue'; - -function initLearnGitlab() { - const el = document.getElementById('js-learn-gitlab-app'); - - if (!el) { - return false; - } - - const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); - const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); - const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); - - return new Vue({ - el, - render(createElement) { - return createElement(LearnGitlab, { - props: { actions, sections, project }, - }); - }, - }); -} - -initInviteMembersModal(); -initInviteMembersTrigger(); - -initLearnGitlab(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js deleted file mode 100644 index 653f903c6d1..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ /dev/null @@ -1,66 +0,0 @@ -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import { localTimeAgo } from '~/lib/utils/datetime_utility'; -import initCompareAutocomplete from './compare_autocomplete'; -import initTargetProjectDropdown from './target_project_dropdown'; - -const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, params) => { - $emptyState.hide(); - $loadingIndicator.show(); - $commitList.empty(); - - return axios - .get(url, { - params, - }) - .then(({ data }) => { - $loadingIndicator.hide(); - $commitList.html(data); - localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago')); - - if (!data) { - $emptyState.show(); - } - }); -}; - -export default (mrNewCompareNode) => { - const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset; - - if (!window.gon?.features?.mrCompareDropdowns) { - initTargetProjectDropdown(); - } - - const updateSourceBranchCommitList = () => - updateCommitList( - sourceBranchUrl, - $(mrNewCompareNode).find('.js-source-commit-empty'), - $(mrNewCompareNode).find('.js-source-loading'), - $(mrNewCompareNode).find('.mr_source_commit'), - { - ref: $(mrNewCompareNode).find("input[name='merge_request[source_branch]']").val(), - }, - ); - const updateTargetBranchCommitList = () => - updateCommitList( - targetBranchUrl, - $(mrNewCompareNode).find('.js-target-commit-empty'), - $(mrNewCompareNode).find('.js-target-loading'), - $(mrNewCompareNode).find('.mr_target_commit'), - { - target_project_id: $(mrNewCompareNode) - .find("input[name='merge_request[target_project_id]']") - .val(), - ref: $(mrNewCompareNode).find("input[name='merge_request[target_branch]']").val(), - }, - ); - initCompareAutocomplete('branches', ($dropdown) => { - if ($dropdown.is('.js-target-branch')) { - updateTargetBranchCommitList(); - } else if ($dropdown.is('.js-source-branch')) { - updateSourceBranchCommitList(); - } - }); - updateSourceBranchCommitList(); - updateTargetBranchCommitList(); -}; diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js deleted file mode 100644 index 65942464e2b..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js +++ /dev/null @@ -1,91 +0,0 @@ -/* eslint-disable func-names */ - -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { __ } from '~/locale'; -import { fixTitle } from '~/tooltips'; - -export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { - $('.js-compare-dropdown').each(function () { - const $dropdown = $(this); - const selected = $dropdown.data('selected'); - const defaultText = $dropdown.data('defaultText').trim(); - const $dropdownContainer = $dropdown.closest('.dropdown'); - const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - initDeprecatedJQueryDropdown($dropdown, { - data(term, callback) { - const params = { - ref: $dropdown.data('ref'), - search: term, - }; - - if (limitTo) { - params.find = limitTo; - } - - axios - .get($dropdown.data('refsUrl'), { - params, - }) - .then(({ data }) => { - if (limitTo) { - callback(data[capitalizeFirstCharacter(limitTo)] || []); - } else { - callback(data); - } - }) - .catch(() => - createAlert({ - message: __('Error fetching refs'), - }), - ); - }, - selectable: true, - filterable: true, - filterRemote: Boolean($dropdown.data('refsUrl')), - fieldName: $dropdown.data('fieldName'), - filterInput: 'input[type="search"]', - renderRow(ref) { - const link = $('<a />') - .attr('href', '#') - .addClass(ref === selected ? 'is-active' : '') - .text(ref) - .attr('data-ref', ref); - if (ref.header != null) { - return $('<li />').addClass('dropdown-header').text(ref.header); - } - return $('<li />').append(link); - }, - id(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel(obj, $el) { - if ($el.hasClass('is-active')) { - return $el.text().trim(); - } - - return defaultText; - }, - clicked: () => clickHandler($dropdown), - }); - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - const text = $filterInput.val(); - $fieldInput.val(text); - $('.dropdown-toggle-text', $dropdown).text(text); - $dropdownContainer.removeClass('open'); - }); - - $dropdownContainer.on('click', '.dropdown-content a', (e) => { - $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); - if ($dropdown.hasClass('has-tooltip')) { - fixTitle($dropdown); - } - }); - }); -} diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index b3868653d6a..2718765ee23 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,35 +1,78 @@ -import $ from 'jquery'; import Vue from 'vue'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; -import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue'; -import initCompare from './compare'; +import CompareApp from '~/merge_requests/components/compare_app.vue'; +import { __ } from '~/locale'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { - initCompare(mrNewCompareNode); - - const el = document.getElementById('js-target-project-dropdown'); - const { targetProjectsPath, currentProject } = el.dataset; + const targetCompareEl = document.getElementById('js-target-project-dropdown'); + const sourceCompareEl = document.getElementById('js-source-project-dropdown'); + const compareEl = document.querySelector('.js-merge-request-new-compare'); // eslint-disable-next-line no-new new Vue({ - el, - name: 'TargetProjectDropdown', + el: sourceCompareEl, + name: 'SourceCompareApp', provide: { - targetProjectsPath, - currentProject: JSON.parse(currentProject), + currentProject: JSON.parse(sourceCompareEl.dataset.currentProject), + currentBranch: JSON.parse(sourceCompareEl.dataset.currentBranch), + branchCommitPath: compareEl.dataset.sourceBranchUrl, + inputs: { + project: { + id: 'merge_request_source_project_id', + name: 'merge_request[source_project_id]', + }, + branch: { + id: 'merge_request_source_branch', + name: 'merge_request[source_branch]', + }, + }, + i18n: { + projectHeaderText: __('Select source project'), + branchHeaderText: __('Select source branch'), + }, + toggleClass: { + project: 'js-source-project', + branch: 'js-source-branch gl-font-monospace', + }, + branchQaSelector: 'source_branch_dropdown', }, render(h) { - return h(TargetProjectDropdown, { - on: { - 'project-selected': function projectSelectedFunction(refsUrl) { - const $targetBranchDropdown = $('.js-target-branch'); - $targetBranchDropdown.data('refsUrl', refsUrl); - $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); - }, + return h(CompareApp); + }, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: targetCompareEl, + name: 'TargetCompareApp', + provide: { + currentProject: JSON.parse(targetCompareEl.dataset.currentProject), + currentBranch: JSON.parse(targetCompareEl.dataset.currentBranch), + projectsPath: targetCompareEl.dataset.targetProjectsPath, + branchCommitPath: compareEl.dataset.targetBranchUrl, + inputs: { + project: { + id: 'merge_request_target_project_id', + name: 'merge_request[target_project_id]', }, - }); + branch: { + id: 'merge_request_target_branch', + name: 'merge_request[target_branch]', + }, + }, + i18n: { + projectHeaderText: __('Select target project'), + branchHeaderText: __('Select target branch'), + }, + toggleClass: { + project: 'js-target-project', + branch: 'js-target-branch gl-font-monospace', + }, + }, + render(h) { + return h(CompareApp); }, }); } else { diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js deleted file mode 100644 index e9f0e008435..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js +++ /dev/null @@ -1,23 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; - -export default () => { - const $targetProjectDropdown = $('.js-target-project'); - initDeprecatedJQueryDropdown($targetProjectDropdown, { - selectable: true, - fieldName: $targetProjectDropdown.data('fieldName'), - filterable: true, - id(obj, $el) { - return $el.data('id'); - }, - toggleLabel(obj, $el) { - return $el.text().trim(); - }, - clicked({ $el }) { - $('.mr_target_commit').empty(); - const $targetBranchDropdown = $('.js-target-branch'); - $targetBranchDropdown.data('refsUrl', $el.data('refsUrl')); - $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); - }, - }); -}; 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 b3a09cc0be3..af75c05b300 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -5,7 +5,6 @@ import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import UsersSelect from '~/users_select'; initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); @@ -18,7 +17,6 @@ initFilteredSearch({ useDefaultState: true, }); -new UsersSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initIssuableByEmail(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index f0a955e5360..91394755367 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,5 +1,5 @@ import initNotesApp from '~/mr_notes/init_notes'; -import { initReportAbuse } from '~/projects/merge_requests'; +import { initReportAbuse } from '~/projects/report_abuse'; import { initMrPage } from '../page'; initMrPage(); diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js index c1acef5ac13..fee6258eddc 100644 --- a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js @@ -1,27 +1,4 @@ -import Vue from 'vue'; +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue'; -const initShowCandidate = () => { - const element = document.querySelector('#js-show-ml-candidate'); - if (!element) { - return; - } - - const container = document.createElement('div'); - element.appendChild(container); - - const candidate = JSON.parse(element.dataset.candidate); - - // eslint-disable-next-line no-new - new Vue({ - el: container, - provide: { - candidate, - }, - render(h) { - return h(MlCandidate); - }, - }); -}; - -initShowCandidate(); +initSimpleApp('#js-show-ml-candidate', MlCandidate); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js new file mode 100644 index 00000000000..e9ffd4b528b --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import MlExperimentsIndex from '~/ml/experiment_tracking/routes/experiments/index'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +const initIndexMlExperiments = () => { + const element = document.querySelector('#js-project-ml-experiments-index'); + if (!element) { + return undefined; + } + + const props = { + experiments: JSON.parse(element.dataset.experiments), + pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)), + }; + + return new Vue({ + el: element, + render(h) { + return h(MlExperimentsIndex, { props }); + }, + }); +}; + +initIndexMlExperiments(); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 6947b15dcbe..0e64d8c17db 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -14,7 +14,7 @@ const initShowExperiment = () => { const candidates = JSON.parse(element.dataset.candidates); const metricNames = JSON.parse(element.dataset.metrics); const paramNames = JSON.parse(element.dataset.params); - const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); + const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)); // eslint-disable-next-line no-new new Vue({ @@ -23,7 +23,7 @@ const initShowExperiment = () => { candidates, metricNames, paramNames, - pagination, + pageInfo, }, render(h) { return h(MlExperiment); diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 2dabcfadfab..414636f0a74 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -1,7 +1,39 @@ import $ from 'jquery'; +import Vue from 'vue'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import ShortcutsNetwork from '~/behaviors/shortcuts/shortcuts_network'; +import RefSelector from '~/ref/components/ref_selector.vue'; import Network from '../network'; +const initRefSwitcher = () => { + const refSwitcherEl = document.getElementById('js-graph-ref-switcher'); + const NETWORK_PATH_REGEX = /^(.*?)\/-\/network/g; + + if (!refSwitcherEl) return false; + + const { projectId, ref, networkPath } = refSwitcherEl.dataset; + const networkRootPath = networkPath.match(NETWORK_PATH_REGEX)?.[0]; // gets the network path without the ref + + return new Vue({ + el: refSwitcherEl, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selectedRef) { + visitUrl(joinPaths(networkRootPath, selectedRef)); + }, + }, + }); + }, + }); +}; + +initRefSwitcher(); + (() => { if (!$('.network-graph').length) return; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 4c9eb830ff6..5773737c41b 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -9,7 +9,6 @@ import axios from '~/lib/utils/axios_utils'; import { serializeForm } from '~/lib/utils/forms'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import projectSelect from '~/project_select'; const BRANCH_REF_TYPE = 'heads'; const TAG_REF_TYPE = 'tags'; @@ -44,13 +43,6 @@ export default class Project { $(this).parents('.auto-devops-implicitly-enabled-banner').remove(); return e.preventDefault(); }); - - Project.projectSelectDropdown(); - } - - static projectSelectDropdown() { - projectSelect(); - $('.project-item-select').on('click', (e) => Project.changeProject($(e.currentTarget).val())); } static changeProject(url) { diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 2fd372a45b8..79a4ed0f9c3 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -21,7 +21,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'activity']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', 'granted', @@ -41,10 +40,6 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), { }, [MEMBER_TYPES.group]: { tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, - }, requestFormatter: groupLinkRequestFormatter, filteredSearchBar: { show: true, diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 895c7d0a18e..964c6ca9792 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -4,7 +4,6 @@ import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; -import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initSettingsPanels from '~/settings_panels'; @@ -44,7 +43,5 @@ initArtifactsSettings(); initProjectRunners(); initSharedRunnersToggle(); initInstallRunner(); -initRunnerAwsDeployments(); - initTokenAccess(); initCiSecureFiles(); 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 5fa3288bbef..f2bc4796324 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 @@ -49,14 +49,10 @@ export default { infrastructureLabel: s__('ProjectSettings|Infrastructure'), infrastructureHelpText: s__('ProjectSettings|Configure your infrastructure.'), monitorLabel: s__('ProjectSettings|Monitor'), - packagesHelpText: s__( - 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', - ), packageRegistryHelpText: s__('ProjectSettings|Publish, store, and view packages in a project.'), packageRegistryForEveryoneHelpText: s__( 'ProjectSettings|Anyone can pull packages with a package manager API.', ), - packagesLabel: s__('ProjectSettings|Packages'), packageRegistryLabel: s__('ProjectSettings|Package registry'), packageRegistryForEveryoneLabel: s__( 'ProjectSettings|Allow anyone to pull from Package Registry', @@ -355,9 +351,6 @@ export default { this.visibilityLevel < this.currentSettings.visibilityLevel ); }, - packageRegistryAccessLevelEnabled() { - return this.glFeatures.packageRegistryAccessLevel; - }, packageRegistryEnabled() { return this.packageRegistryAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -392,14 +385,12 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.buildsAccessLevel, ); - if (this.packageRegistryAccessLevelEnabled) { - if ( - this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE || - (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE && - oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER) - ) { - this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; - } + if ( + this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE || + (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE && + oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER) + ) { + this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel); this.snippetsAccessLevel = Math.min( @@ -459,10 +450,7 @@ export default { this.repositoryAccessLevel = featureAccessLevel.EVERYONE; if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED) this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE; - if ( - this.packageRegistryAccessLevelEnabled && - this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS - ) { + if (this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) { this.packageRegistryAccessLevel = PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[value]; } @@ -488,19 +476,17 @@ export default { this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE; this.highlightChanges(); - } else if (this.packageRegistryAccessLevelEnabled) { - if ( - value === VISIBILITY_LEVEL_PUBLIC_INTEGER && - this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE - ) { - // eslint-disable-next-line prefer-destructuring - this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; - } else if ( - value === VISIBILITY_LEVEL_INTERNAL_INTEGER && - this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0] - ) { - this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE; - } + } else if ( + value === VISIBILITY_LEVEL_PUBLIC_INTEGER && + this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE + ) { + // eslint-disable-next-line prefer-destructuring + this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; + } else if ( + value === VISIBILITY_LEVEL_INTERNAL_INTEGER && + this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0] + ) { + this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE; } }, @@ -770,22 +756,6 @@ export default { </p> </project-setting-row> <project-setting-row - v-if="packagesAvailable && !packageRegistryAccessLevelEnabled" - ref="package-settings" - :help-path="packagesHelpPath" - :label="$options.i18n.packagesLabel" - :help-text="$options.i18n.packagesHelpText" - > - <gl-toggle - v-model="packagesEnabled" - class="gl-my-2" - :disabled="!repositoryEnabled" - :label="$options.i18n.packagesLabel" - label-position="hidden" - name="project[packages_enabled]" - /> - </project-setting-row> - <project-setting-row ref="pipeline-settings" :label="$options.i18n.ciCdLabel" :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" @@ -889,7 +859,7 @@ export default { /> </project-setting-row> <project-setting-row - v-if="packageRegistryAccessLevelEnabled && packagesAvailable" + v-if="packagesAvailable" :help-path="packagesHelpPath" :label="$options.i18n.packageRegistryLabel" :help-text="$options.i18n.packageRegistryHelpText" diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 1de36f4a0fb..33d4090011f 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -6,6 +6,7 @@ import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert' import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; import Star from '~/projects/star'; +import initTerraformNotification from '~/projects/terraform_notification'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; @@ -44,6 +45,7 @@ initUploadFileTrigger(); initInviteMembersModal(); initInviteMembersTrigger(); initClustersDeprecationAlert(); +initTerraformNotification(); initReadMore(); new Star(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/search/show/refresh_counts.js b/app/assets/javascripts/pages/search/show/refresh_counts.js deleted file mode 100644 index f3f6312cb7c..00000000000 --- a/app/assets/javascripts/pages/search/show/refresh_counts.js +++ /dev/null @@ -1,24 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -function showCount(el, count) { - el.textContent = count; - el.classList.remove('hidden'); -} - -function refreshCount(el) { - const { url } = el.dataset; - - return axios - .get(url) - .then(({ data }) => showCount(el, data.count)) - .catch((e) => { - // eslint-disable-next-line no-console - console.error(`Failed to fetch search count from '${url}'.`, e); - }); -} - -export default function refreshCounts() { - const elements = Array.from(document.querySelectorAll('.js-search-count')); - - return Promise.all(elements.map(refreshCount)); -} diff --git a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js b/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js deleted file mode 100644 index f3807a33a2b..00000000000 --- a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; - -export function initRunnerAwsDeployments(componentId = 'js-runner-aws-deployments') { - const el = document.getElementById(componentId); - - if (!el) { - return null; - } - - return new Vue({ - el, - render(createElement) { - return createElement(RunnerAwsDeployments); - }, - }); -} 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 8e2f542aec0..0d2bbfbbc43 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -119,6 +119,12 @@ export default { isContentEditorActive: false, switchEditingControlDisabled: false, isFormDirty: getIsFormDirty(this.pageInfo), + formFieldProps: { + placeholder: this.$options.i18n.content.placeholder, + 'aria-label': this.$options.i18n.content.label, + id: 'wiki_content', + name: 'wiki[content]', + }, }; }, computed: { @@ -338,16 +344,13 @@ export default { <gl-form-group> <markdown-editor v-model="content" + :form-field-props="formFieldProps" :render-markdown-path="pageInfo.markdownPreviewPath" :markdown-docs-path="pageInfo.markdownHelpPath" :uploads-path="pageInfo.uploadsPath" :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" - :form-field-placeholder="$options.i18n.content.placeholder" - :form-field-aria-label="$options.i18n.content.label" - form-field-id="wiki_content" - form-field-name="wiki[content]" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" @keydown.ctrl.enter="submitFormShortcut" diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js new file mode 100644 index 00000000000..f1b4e00c810 --- /dev/null +++ b/app/assets/javascripts/pages/users/show/index.js @@ -0,0 +1,16 @@ +import { s__ } from '~/locale'; +import { createAlert } from '~/flash'; + +if (window.gon.features?.profileTabsVue) { + import('~/profile') + .then(({ initProfileTabs }) => { + initProfileTabs(); + }) + .catch(() => { + createAlert({ + message: s__( + 'UserProfile|An error occurred loading the profile. Please refresh the page to try again.', + ), + }); + }); +} diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index ea8005e8dfb..69d60a7caf9 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -158,7 +158,7 @@ export default { v-model="sortOrder" :toggle-text="$options.sortOrderOptions[sortOrder].text" :items="Object.values($options.sortOrderOptions)" - right + placement="right" data-testid="performance-bar-sort-order" /> </div> diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 139da5dabbd..e37f63d4053 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -20,6 +20,8 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-web-hook-disabled-callout', '.js-merge-request-settings-callout', '.js-ultimate-feature-removal-banner', + '.js-geo-enable-hashed-storage-callout', + '.js-geo-migrate-hashed-storage-callout', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index 85ca52f633e..e650a48bc2a 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -10,6 +10,8 @@ export const ONE_COL_WIDTH = 180; export const STAGE_VIEW = 'stage'; export const LAYER_VIEW = 'layer'; + +export const SKIP_RETRY_MODAL_KEY = 'skip_retry_modal'; export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; export const SINGLE_JOB = 'single_job'; @@ -20,3 +22,5 @@ export const BRIDGE_KIND = 'BRIDGE'; export const ACTION_FAILURE = 'action_failure'; export const IID_FAILURE = 'missing_iid'; + +export const RETRY_ACTION_TITLE = 'Retry'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1a05710a13e..49df71beeec 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -2,7 +2,10 @@ import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; -import { generateColumnsFromLayersListMemoized } from '../parsing_utils'; +import { + generateColumnsFromLayersListMemoized, + keepLatestDownstreamPipelines, +} from '../parsing_utils'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; @@ -44,6 +47,11 @@ export default { required: false, default: () => ({}), }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, type: { type: String, required: false, @@ -76,7 +84,9 @@ export default { return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`; }, downstreamPipelines() { - return this.hasDownstreamPipelines ? this.pipeline.downstream : []; + return this.hasDownstreamPipelines + ? keepLatestDownstreamPipelines(this.pipeline.downstream) + : []; }, layout() { return this.isStageView @@ -181,9 +191,11 @@ export default { :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :show-links="showJobLinks" + :skip-retry-modal="skipRetryModal" :type="$options.pipelineTypeConstants.UPSTREAM" :view-type="viewType" @error="onError" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> </template> <template #main> @@ -210,11 +222,13 @@ export default { :highlighted-jobs="highlightedJobs" :is-stage-view="isStageView" :job-hovered="hoveredJobName" + :skip-retry-modal="skipRetryModal" :source-job-hovered="hoveredSourceJobName" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipeline.id" :user-permissions="pipeline.userPermissions" @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" @jobHover="setJob" @updateMeasurements="getMeasurements" /> @@ -228,12 +242,15 @@ export default { :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" + :skip-retry-modal="skipRetryModal" :show-links="showJobLinks" :type="$options.pipelineTypeConstants.DOWNSTREAM" :view-type="viewType" + data-testid="downstream-pipelines" @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" @scrollContainer="slidePipelineContainer" @error="onError" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 4d7596e6e16..8f76d7535f1 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -8,7 +8,14 @@ import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; import { reportToSentry, reportMessageToSentry } from '../../utils'; -import { ACTION_FAILURE, IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; +import { + ACTION_FAILURE, + IID_FAILURE, + LAYER_VIEW, + SKIP_RETRY_MODAL_KEY, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from './constants'; import PipelineGraph from './graph_component.vue'; import GraphViewSelector from './graph_view_selector.vue'; import { @@ -53,6 +60,7 @@ export default { currentViewType: STAGE_VIEW, canRefetchHeaderPipeline: false, pipeline: null, + skipRetryModal: false, showAlert: false, showLinks: false, }; @@ -206,8 +214,8 @@ export default { if (!this.pipelineIid) { this.reportFailure({ type: IID_FAILURE, skipSentry: true }); } - toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); + this.skipRetryModal = Boolean(JSON.parse(localStorage.getItem(SKIP_RETRY_MODAL_KEY))); }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -259,6 +267,9 @@ export default { updateShowLinksState(val) { this.showLinks = val; }, + setSkipRetryModal() { + this.skipRetryModal = true; + }, updateViewType(type) { this.currentViewType = type; }, @@ -293,10 +304,12 @@ export default { :config-paths="configPaths" :pipeline="pipeline" :computed-pipeline-info="getPipelineInfo()" + :skip-retry-modal="skipRetryModal" :show-links="showLinks" :view-type="graphViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" + @setSkipRetryModal="setSkipRetryModal" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 4f2be27486c..992e3d2f552 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,13 +1,14 @@ <script> -import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { sprintf, __ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue'; -import { BRIDGE_KIND, SINGLE_JOB } from './constants'; +import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -35,17 +36,32 @@ import { BRIDGE_KIND, SINGLE_JOB } from './constants'; */ export default { + confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'), i18n: { bridgeBadgeText: __('Trigger job'), unauthorizedTooltip: __('You are not authorized to run this manual job'), + confirmationModal: { + title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'), + description: s__( + 'PipelineGraph|Retrying a trigger job will create a new downstream pipeline.', + ), + linkText: s__('PipelineGraph|What is a downstream pipeline?'), + footer: __("Don't show this again"), + actionPrimary: { text: __('Retry') }, + actionCancel: { text: __('Cancel') }, + }, + runAgainTooltipText: __('Run again'), }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, CiIcon, - JobNameComponent, GlBadge, + GlForm, + GlFormCheckbox, GlLink, + GlModal, + JobNameComponent, }, directives: { GlTooltip: GlTooltipDirective, @@ -86,6 +102,11 @@ export default { required: false, default: -1, }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, sourceJobHovered: { type: String, required: false, @@ -102,6 +123,13 @@ export default { default: SINGLE_JOB, }, }, + data() { + return { + currentSkipModalValue: this.skipRetryModal, + showConfirmationModal: false, + shouldTriggerActionClick: false, + }; + }, computed: { boundary() { return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; @@ -115,6 +143,12 @@ export default { hasDetails() { return this.status.hasDetails; }, + hasRetryAction() { + return Boolean(this.job?.status?.action?.title === RETRY_ACTION_TITLE); + }, + isRetryableBridge() { + return this.isBridge && this.hasRetryAction; + }, isSingleItem() { return this.type === SINGLE_JOB; }, @@ -127,6 +161,11 @@ export default { nameComponent() { return this.hasDetails ? 'gl-link' : 'div'; }, + retryTriggerJobWarningText() { + return sprintf(this.$options.i18n.confirmationModal.title, { + jobName: this.job.name, + }); + }, showStageName() { return Boolean(this.stageName); }, @@ -205,11 +244,34 @@ export default { }, ]; }, + withConfirmationModal() { + return this.isRetryableBridge && !this.skipRetryModal; + }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === 'retry' && group === 'success' + ? this.$options.i18n.runAgainTooltipText + : title; + }, + }, + watch: { + skipRetryModal(val) { + this.currentSkipModalValue = val; + this.shouldTriggerActionClick = false; + }, }, errorCaptured(err, _vm, info) { reportToSentry('job_item', `error: ${err}, info: ${info}`); }, methods: { + handleConfirmationModalPreferences() { + if (this.currentSkipModalValue) { + this.$emit('setSkipRetryModal'); + localStorage.setItem(SKIP_RETRY_MODAL_KEY, String(this.currentSkipModalValue)); + } + }, hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, @@ -227,6 +289,15 @@ export default { pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, + executePendingAction() { + this.shouldTriggerActionClick = true; + }, + showActionConfirmationModal() { + this.showConfirmationModal = true; + }, + toggleSkipRetryModalCheckbox() { + this.currentSkipModalValue = !this.currentSkipModalValue; + }, }, }; </script> @@ -272,12 +343,16 @@ export default { <action-component v-if="hasAction" - :tooltip-text="status.action.title" + :tooltip-text="jobActionTooltipText" :link="status.action.path" :action-icon="status.action.icon" class="gl-mr-1" + :should-trigger-click="shouldTriggerActionClick" + :with-confirmation-modal="withConfirmationModal" data-qa-selector="job_action_button" + @actionButtonClicked="handleConfirmationModalPreferences" @pipelineActionRequestComplete="pipelineActionRequestComplete" + @showActionConfirmationModal="showActionConfirmationModal" /> <action-component v-if="hasUnauthorizedManualAction" @@ -287,5 +362,28 @@ export default { :link="`unauthorized-${computedJobId}`" class="gl-mr-1" /> + <gl-modal + v-if="showConfirmationModal" + ref="modal" + v-model="showConfirmationModal" + modal-id="action-confirmation-modal" + :title="retryTriggerJobWarningText" + :action-cancel="$options.i18n.confirmationModal.actionCancel" + :action-primary="$options.i18n.confirmationModal.actionPrimary" + @primary="executePendingAction" + @close="handleConfirmationModalPreferences" + @hide="handleConfirmationModalPreferences" + > + <p class="gl-mb-1">{{ $options.i18n.confirmationModal.description }}</p> + <gl-link :href="$options.confirmationModalDocLink" target="_blank">{{ + $options.i18n.confirmationModal.linkText + }}</gl-link> + <div class="gl-mt-4 gl-display-flex"> + <gl-form> + <gl-form-checkbox class="gl-min-h-0" @input="toggleSkipRetryModalCheckbox" /> + </gl-form> + <p class="gl-m-0">{{ $options.i18n.confirmationModal.footer }}</p> + </div> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 225706265c3..9b4e5d471d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -7,13 +7,13 @@ import { GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; +import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import { PIPELINE_GRAPHQL_TYPE } from '../../constants'; import { reportToSentry } from '../../utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; @@ -118,7 +118,7 @@ export default { return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; }, graphqlPipelineId() { - return convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, this.pipeline.id); + return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id); }, hasUpdatePipelinePermissions() { return Boolean(this.pipeline?.userPermissions?.updatePipeline); diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index b06c2f15042..02e426064c9 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -36,6 +36,11 @@ export default { type: Boolean, required: true, }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, type: { type: String, required: true, @@ -229,8 +234,10 @@ export default { :pipeline="currentPipeline" :computed-pipeline-info="getPipelineLayers(pipeline.id)" :show-links="showLinks" + :skip-retry-modal="skipRetryModal" :is-linked-pipeline="true" :view-type="graphViewType" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 4aec28295bd..ffd0fec2ca8 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -53,6 +53,11 @@ export default { required: false, default: () => ({}), }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, sourceJobHovered: { type: String, required: false, @@ -164,6 +169,7 @@ export default { v-if="singleJobExists(group)" :job="group.jobs[0]" :job-hovered="jobHovered" + :skip-retry-modal="skipRetryModal" :source-job-hovered="sourceJobHovered" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipelineId" @@ -174,6 +180,7 @@ export default { 'gl-transition-duration-slow gl-transition-timing-function-ease', ]" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" /> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> <job-group-dropdown diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 387b01aee7e..7020bfc1e65 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -39,6 +39,16 @@ export default { type: String, required: true, }, + withConfirmationModal: { + type: Boolean, + required: false, + default: false, + }, + shouldTriggerClick: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -52,6 +62,14 @@ export default { return `${actionIconDash} js-icon-${actionIconDash}`; }, }, + watch: { + shouldTriggerClick(flag) { + if (flag && this.withConfirmationModal) { + this.executeAction(); + this.$emit('actionButtonClicked'); + } + }, + }, errorCaptured(err, _vm, info) { reportToSentry('action_component', `error: ${err}, info: ${info}`); }, @@ -63,6 +81,13 @@ export default { * */ onClickAction() { + if (this.withConfirmationModal) { + this.$emit('showActionConfirmationModal'); + } else { + this.executeAction(); + } + }, + executeAction() { this.$root.$emit(BV_HIDE_TOOLTIP, `js-ci-action-${this.link}`); this.isDisabled = true; this.isLoading = true; @@ -91,6 +116,7 @@ export default { <template> <gl-button :id="`js-ci-action-${link}`" + ref="button" :class="cssClass" :disabled="isDisabled" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index cae4e11c13f..e158f8809b5 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -170,3 +170,13 @@ export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipe }; export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); + +export const keepLatestDownstreamPipelines = (downstreamPipelines = []) => { + return downstreamPipelines.filter((pipeline) => { + if (pipeline.source_job) { + return !pipeline?.source_job?.retried || false; + } + + return !pipeline?.sourceJob?.retried || false; + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 51b46f25048..66bf5068149 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue'; @@ -33,6 +33,9 @@ import JobNameComponent from '../jobs_shared/job_name_component.vue'; */ export default { + i18n: { + runAgainTooltipText: __('Run again'), + }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, @@ -129,6 +132,14 @@ export default { ? `${this.$options.hoverClass} ${this.cssClassJobName}` : this.cssClassJobName; }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === 'retry' && group === 'success' + ? this.$options.i18n.runAgainTooltipText + : title; + }, }, errorCaptured(err, _vm, info) { reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); @@ -177,7 +188,7 @@ export default { <action-component v-if="hasAction" - :tooltip-text="status.action.title" + :tooltip-text="jobActionTooltipText" :link="status.action.path" :action-icon="status.action.icon" data-qa-selector="action_button" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index c498f12d5c7..4111823e0bb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -311,7 +311,7 @@ export default { this.resetRequestData(); } - this.updateContent(this.requestData); + this.updateContent({ ...this.requestData, page: '1' }); }, changeVisibilityPipelineID(val) { this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index ed32d643c0e..365572f194b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,6 +2,7 @@ import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; @@ -115,6 +116,10 @@ export default { eventHub.$off('openConfirmationModal', this.setModalData); }, methods: { + getDownstreamPipelines(pipeline) { + const downstream = pipeline.triggered; + return keepLatestDownstreamPipelines(downstream); + }, setModalData(data) { this.pipelineId = data.pipeline.id; this.pipeline = data.pipeline; @@ -171,7 +176,7 @@ export default { <template #cell(stages)="{ item }"> <pipeline-mini-graph - :downstream-pipelines="item.triggered" + :downstream-pipelines="getDownstreamPipelines(item)" :pipeline-path="item.path" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 2f37f90e625..820501089ed 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -9,7 +9,6 @@ export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; -export const PIPELINE_GRAPHQL_TYPE = 'Ci::Pipeline'; export const ICONS = { TAG: 'tag', diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index f00378733fc..ba51347ad69 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,6 +1,7 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; +import { pipelineTabName } from './constants'; import { createPipelineHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; @@ -38,6 +39,12 @@ export default async function initPipelineDetailsBundle() { routes, }); + // We handle the shortcut `pipelines/latest` by forwarding the user to the pipeline graph + // tab and changing the route to the correct `pipelines/:id` + if (window.location.pathname.endsWith('latest')) { + router.replace({ name: pipelineTabName }); + } + try { const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider, router); createPipelineTabs(appOptions); diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index d0ee6871a48..6360ccc41bc 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -34,6 +34,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { totalJobCount, licenseManagementApiUrl, licenseManagementSettingsPath, + licenseScanCount, licensesApiPath, canManageLicenses, summaryEndpoint, @@ -87,6 +88,7 @@ export const createAppOptions = (selector, apolloProvider, router) => { totalJobCount, licenseManagementApiUrl, licenseManagementSettingsPath, + licenseScanCount, licensesApiPath, canManageLicenses: parseBoolean(canManageLicenses), summaryEndpoint, diff --git a/app/assets/javascripts/profile/components/activity_tab.vue b/app/assets/javascripts/profile/components/activity_tab.vue new file mode 100644 index 00000000000..aae5c489e88 --- /dev/null +++ b/app/assets/javascripts/profile/components/activity_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Activity'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/contributed_projects_tab.vue b/app/assets/javascripts/profile/components/contributed_projects_tab.vue new file mode 100644 index 00000000000..e490643e57a --- /dev/null +++ b/app/assets/javascripts/profile/components/contributed_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Contributed projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue new file mode 100644 index 00000000000..47651c33eb8 --- /dev/null +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Followers'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue new file mode 100644 index 00000000000..6d9631c5e89 --- /dev/null +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Following'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/groups_tab.vue b/app/assets/javascripts/profile/components/groups_tab.vue new file mode 100644 index 00000000000..6c4847872a7 --- /dev/null +++ b/app/assets/javascripts/profile/components/groups_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Groups'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue new file mode 100644 index 00000000000..e884c2d7083 --- /dev/null +++ b/app/assets/javascripts/profile/components/overview_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Overview'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/personal_projects_tab.vue b/app/assets/javascripts/profile/components/personal_projects_tab.vue new file mode 100644 index 00000000000..285f01930e7 --- /dev/null +++ b/app/assets/javascripts/profile/components/personal_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Personal projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue new file mode 100644 index 00000000000..2425d56c52a --- /dev/null +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -0,0 +1,72 @@ +<script> +import { GlTabs } from '@gitlab/ui'; + +import OverviewTab from './overview_tab.vue'; +import ActivityTab from './activity_tab.vue'; +import GroupsTab from './groups_tab.vue'; +import ContributedProjectsTab from './contributed_projects_tab.vue'; +import PersonalProjectsTab from './personal_projects_tab.vue'; +import StarredProjectsTab from './starred_projects_tab.vue'; +import SnippetsTab from './snippets_tab.vue'; +import FollowersTab from './followers_tab.vue'; +import FollowingTab from './following_tab.vue'; + +export default { + components: { + GlTabs, + OverviewTab, + ActivityTab, + GroupsTab, + ContributedProjectsTab, + PersonalProjectsTab, + StarredProjectsTab, + SnippetsTab, + FollowersTab, + FollowingTab, + }, + tabs: [ + { + key: 'overview', + component: OverviewTab, + }, + { + key: 'activity', + component: ActivityTab, + }, + { + key: 'groups', + component: GroupsTab, + }, + { + key: 'contributedProjects', + component: ContributedProjectsTab, + }, + { + key: 'personalProjects', + component: PersonalProjectsTab, + }, + { + key: 'starredProjects', + component: StarredProjectsTab, + }, + { + key: 'snippets', + component: SnippetsTab, + }, + { + key: 'followers', + component: FollowersTab, + }, + { + key: 'following', + component: FollowingTab, + }, + ], +}; +</script> + +<template> + <gl-tabs> + <component :is="component" v-for="{ key, component } in $options.tabs" :key="key" /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/profile/components/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets_tab.vue new file mode 100644 index 00000000000..d64c5b900a5 --- /dev/null +++ b/app/assets/javascripts/profile/components/snippets_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Snippets'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/components/starred_projects_tab.vue b/app/assets/javascripts/profile/components/starred_projects_tab.vue new file mode 100644 index 00000000000..b9ef1e6e713 --- /dev/null +++ b/app/assets/javascripts/profile/components/starred_projects_tab.vue @@ -0,0 +1,17 @@ +<script> +import { GlTab } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('UserProfile|Starred projects'), + }, + components: { GlTab }, +}; +</script> + +<template> + <gl-tab :title="$options.i18n.title"> + <!-- placeholder --> + </gl-tab> +</template> diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js new file mode 100644 index 00000000000..5378ed3d743 --- /dev/null +++ b/app/assets/javascripts/profile/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; + +import ProfileTabs from './components/profile_tabs.vue'; + +export const initProfileTabs = () => { + const el = document.getElementById('js-profile-tabs'); + + if (!el) return false; + + return new Vue({ + el, + render(createElement) { + return createElement(ProfileTabs); + }, + }); +}; diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue index 74dd2d5628a..a8a25dc2ec6 100644 --- a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue +++ b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue @@ -110,8 +110,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span>{{ ' ' }}</span + ><span class="k">print</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span > </td> @@ -120,8 +120,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="k">print</span><span class="p">(</span><span class="n">i</span> + <span>{{ ' ' }}</span + ><span class="k">print</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span > </td> @@ -162,8 +162,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + <span>{{ ' ' }}</span + ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span ><span class="p">):</span></span > @@ -173,8 +173,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span + <span>{{ ' ' }}</span + ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span ><span class="bp">self</span><span class="p">,</span> <span class="n">x</span ><span class="p">):</span></span > @@ -186,8 +186,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="n">val</span> <span class="o">=</span> <span class="n">x</span></span > </td> @@ -196,8 +196,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="n">val</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="n">val</span> <span class="o">=</span> <span class="n">x</span></span > </td> @@ -208,8 +208,8 @@ export default { </td> <td class="line_content parallel left-side old"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span> <span class="o">=</span> <span class="bp">None</span></span > </td> @@ -218,8 +218,8 @@ export default { </td> <td class="line_content parallel right-side new"> <span> - <span>{{ ' ' }}</span> - <span class="bp">self</span><span class="p">.</span><span class="nb">next</span> + <span>{{ ' ' }}</span + ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span> <span class="o">=</span> <span class="bp">None</span></span > </td> diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js deleted file mode 100644 index 705234537a8..00000000000 --- a/app/assets/javascripts/project_select.js +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable func-names */ - -import $ from 'jquery'; -import { createAlert } from '~/flash'; -import Api from './api'; -import { loadCSSFile } from './lib/utils/css_utils'; -import { s__ } from './locale'; -import ProjectSelectComboButton from './project_select_combo_button'; - -const projectSelect = async () => { - await loadCSSFile(gon.select2_css_path); - - $('.ajax-project-select').each(function (i, select) { - let placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - const isInstantiated = $(select).data('select2'); - this.groupId = $(select).data('groupId'); - this.userId = $(select).data('userId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; - - placeholder = s__('ProjectSelect|Search for project'); - if (this.includeGroups) { - placeholder += s__('ProjectSelect| or group'); - } - - $(select).select2({ - placeholder, - minimumInputLength: 0, - query: (query) => { - let projectsCallback; - const finalCallback = function (projects) { - const data = { - results: projects, - }; - return query.callback(data); - }; - if (this.includeGroups) { - projectsCallback = function (projects) { - const groupsCallback = function (groups) { - const data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects( - this.groupId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - order_by: 'similarity', - simple: true, - }, - projectsCallback, - ).catch(() => { - createAlert({ - message: s__('ProjectSelect|Something went wrong while fetching projects'), - }); - }); - } else if (this.userId) { - return Api.userProjects( - this.userId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } - return Api.projects( - query.term, - { - order_by: this.orderBy, - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - membership: !this.allProjects, - }, - projectsCallback, - ); - }, - id(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text(project) { - return project.name_with_namespace || project.name; - }, - - initSelection(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, - - allowClear: this.allowClear, - - dropdownCssClass: 'ajax-project-dropdown', - }); - if (isInstantiated || simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); -}; - -export default () => { - if ($('.ajax-project-select').length) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(projectSelect) - .catch(() => {}); - } -}; diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js deleted file mode 100644 index ad80032c551..00000000000 --- a/app/assets/javascripts/project_select_combo_button.js +++ /dev/null @@ -1,122 +0,0 @@ -import $ from 'jquery'; -import { sprintf, __ } from '~/locale'; -import { sanitizeUrl } from '~/lib/utils/url_utility'; -import AccessorUtilities from './lib/utils/accessor'; -import { loadCSSFile } from './lib/utils/css_utils'; - -export default class ProjectSelectComboButton { - constructor(select) { - this.projectSelectInput = $(select); - this.newItemBtn = $('.js-new-project-item-link'); - this.resourceType = this.newItemBtn.data('type'); - this.resourceLabel = this.newItemBtn.data('label'); - this.formattedText = this.deriveTextVariants(); - this.groupId = this.projectSelectInput.data('groupId'); - this.bindEvents(); - this.initLocalStorage(); - } - - bindEvents() { - this.projectSelectInput - .siblings('.new-project-item-select-button') - .on('click', (e) => this.openDropdown(e)); - - this.newItemBtn.on('click', (e) => { - if (!this.getProjectFromLocalStorage()) { - e.preventDefault(); - this.openDropdown(e); - } - }); - - this.projectSelectInput.on('change', () => this.selectProject()); - } - - initLocalStorage() { - const localStorageIsSafe = AccessorUtilities.canUseLocalStorage(); - - if (localStorageIsSafe) { - this.localStorageKey = [ - 'group', - this.groupId, - this.formattedText.localStorageItemType, - 'recent-project', - ].join('-'); - this.setBtnTextFromLocalStorage(); - } - } - - // eslint-disable-next-line class-methods-use-this - openDropdown(event) { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(() => { - // eslint-disable-next-line promise/no-nesting - loadCSSFile(gon.select2_css_path) - .then(() => { - $(event.currentTarget).siblings('.project-item-select').select2('open'); - }) - .catch(() => {}); - }) - .catch(() => {}); - } - - selectProject() { - const selectedProjectData = JSON.parse(this.projectSelectInput.val()); - const projectUrl = `${selectedProjectData.url}/${this.projectSelectInput.data('relativePath')}`; - const projectName = selectedProjectData.name; - - const projectMeta = { - url: projectUrl, - name: projectName, - }; - - this.setNewItemBtnAttributes(projectMeta); - this.setProjectInLocalStorage(projectMeta); - } - - setBtnTextFromLocalStorage() { - const cachedProjectData = this.getProjectFromLocalStorage(); - - this.setNewItemBtnAttributes(cachedProjectData); - } - - setNewItemBtnAttributes(project) { - if (project) { - this.newItemBtn.attr('href', sanitizeUrl(project.url)); - this.newItemBtn.text( - sprintf(__('New %{type} in %{project}'), { - type: this.resourceLabel, - project: project.name, - }), - ); - } else { - this.newItemBtn.text( - sprintf(__('Select project to create %{type}'), { - type: this.formattedText.presetTextSuffix, - }), - ); - } - } - - getProjectFromLocalStorage() { - const projectString = localStorage.getItem(this.localStorageKey); - - return JSON.parse(projectString); - } - - setProjectInLocalStorage(projectMeta) { - const projectString = JSON.stringify(projectMeta); - - localStorage.setItem(this.localStorageKey, projectString); - } - - deriveTextVariants() { - // the trailing slice call depluralizes each of these strings (e.g. new-issues -> new-issue) - const localStorageItemType = `new-${this.resourceType.split('_').join('-').slice(0, -1)}`; - const presetTextSuffix = this.resourceType.split('_').join(' ').slice(0, -1); - - return { - localStorageItemType, // new-issue / new-merge-request - presetTextSuffix, // issue / merge request - }; - } -} diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index a037e721677..0ed154c47dd 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -1,12 +1,7 @@ <script> -import { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { debounce } from 'lodash'; import { I18N_NO_RESULTS_MESSAGE, I18N_BRANCH_HEADER, @@ -16,11 +11,7 @@ import { export default { name: 'BranchesDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, + GlCollapsibleListbox, }, props: { value: { @@ -46,19 +37,17 @@ export default { }, computed: { ...mapGetters(['joinedBranches']), - ...mapState(['isFetching', 'branch', 'branches']), - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.joinedBranches.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), - ); + ...mapState(['isFetching']), + listboxItems() { + return this.joinedBranches.map((value) => ({ value, text: value })); }, }, watch: { // Parent component can set the branch value (e.g. when the user selects a different project) // and we need to keep the search term in sync with the selected value value(val) { - this.searchTermChanged(val); + this.searchTerm = val; + this.fetchBranches(this.searchTerm); }, }, mounted() { @@ -67,50 +56,29 @@ export default { methods: { ...mapActions(['fetchBranches']), selectBranch(branch) { - this.$emit('selectBranch', branch); - this.searchTerm = branch; // enables isSelected to work as expected - }, - isSelected(selectedBranch) { - return selectedBranch === this.branch; + this.$emit('input', branch); }, + debouncedSearch: debounce(function debouncedSearch() { + this.fetchBranches(this.searchTerm); + }, 250), searchTermChanged(value) { - this.searchTerm = value; - this.fetchBranches(value); + this.searchTerm = value.trim(); + this.debouncedSearch(value); }, }, }; </script> <template> - <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle"> - <gl-search-box-by-type - :value="searchTerm" - trim - autocomplete="off" - :debounce="250" - :placeholder="$options.i18n.branchSearchPlaceholder" - data-testid="dropdown-search-box" - @input="searchTermChanged" - /> - <gl-dropdown-item - v-for="branch in filteredResults" - v-show="!isFetching" - :key="branch" - :name="branch" - :is-checked="isSelected(branch)" - is-check-item - data-testid="dropdown-item" - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon size="sm" class="gl-mx-auto" /> - </gl-dropdown-text> - <gl-dropdown-text - v-if="!filteredResults.length && !isFetching" - data-testid="empty-result-message" - > - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.branchHeaderTitle" + :toggle-text="value" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.branchSearchPlaceholder" + :searching="isFetching" + :selected="value" + :no-results-text="$options.i18n.noResultsMessage" + @search="searchTermChanged" + @select="selectBranch" + /> </template> diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 1febe8ceaab..f78afef1c17 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -141,11 +141,7 @@ export default { :value="targetProjectId" /> - <projects-dropdown - class="gl-w-half" - :value="targetProjectName" - @selectProject="setSelectedProject" - /> + <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" /> </gl-form-group> <gl-form-group @@ -155,12 +151,7 @@ export default { > <input id="start_branch" type="hidden" name="start_branch" :value="branch" /> - <branches-dropdown - class="gl-w-half" - :value="branch" - :blanked="isRevert" - @selectBranch="setBranch" - /> + <branches-dropdown :value="branch" :blanked="isRevert" @input="setBranch" /> </gl-form-group> <gl-form-checkbox diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue index 6288bcdaad0..d43f5b99e2c 100644 --- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { I18N_NO_RESULTS_MESSAGE, @@ -10,10 +10,7 @@ import { export default { name: 'ProjectsDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, + GlCollapsibleListbox, }, props: { value: { @@ -41,17 +38,20 @@ export default { project.name.toLowerCase().includes(lowerCasedFilterTerm), ); }, + listboxItems() { + return this.filteredResults.map(({ id, name }) => ({ value: id, text: name })); + }, selectedProject() { return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {}; }, }, methods: { - selectProject(project) { - this.$emit('selectProject', project.id); - this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project - }, - isSelected(selectedProject) { - return selectedProject === this.selectedProject; + selectProject(value) { + this.$emit('selectProject', value); + + // when we select a project, we want the dropdown to filter to the selected project + const project = this.listboxItems.find((x) => x.value === value); + this.filterTerm = project?.text || ''; }, filterTermChanged(value) { this.filterTerm = value; @@ -60,28 +60,15 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle"> - <gl-search-box-by-type - :value="filterTerm" - trim - autocomplete="off" - :placeholder="$options.i18n.projectSearchPlaceholder" - data-testid="dropdown-search-box" - @input="filterTermChanged" - /> - <gl-dropdown-item - v-for="project in filteredResults" - :key="project.name" - :name="project.name" - :is-checked="isSelected(project)" - is-check-item - data-testid="dropdown-item" - @click="selectProject(project)" - > - {{ project.name }} - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.projectHeaderTitle" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.projectSearchPlaceholder" + :selected="selectedProject.id" + :toggle-text="selectedProject.name" + :no-results-text="$options.i18n.noResultsMessage" + @search="filterTermChanged" + @select="selectProject" + /> </template> diff --git a/app/assets/javascripts/projects/commit/store/getters.js b/app/assets/javascripts/projects/commit/store/getters.js index e0c36df8a75..b039ee3ba63 100644 --- a/app/assets/javascripts/projects/commit/store/getters.js +++ b/app/assets/javascripts/projects/commit/store/getters.js @@ -1,7 +1,7 @@ -import { uniq } from 'lodash'; +import { uniq, uniqBy } from 'lodash'; export const joinedBranches = (state) => { return uniq(state.branches).sort(); }; -export const sortedProjects = (state) => uniq(state.projects).sort(); +export const sortedProjects = (state) => uniqBy(state.projects, 'id').sort(); diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 0256eec6d56..dafc4bc5abf 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -6,6 +6,7 @@ import { getQueryHeaders, toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { formatStages } from '../utils'; import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; @@ -91,7 +92,8 @@ export default { }, computed: { downstreamPipelines() { - return this.pipeline?.downstream?.nodes; + const downstream = this.pipeline?.downstream?.nodes; + return keepLatestDownstreamPipelines(downstream); }, pipelinePath() { return this.pipeline?.path ?? ''; diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql index c6a0d48626a..9257cc7de7b 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql @@ -18,6 +18,10 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { icon label } + sourceJob { + id + retried + } } } upstream { diff --git a/app/assets/javascripts/projects/merge_requests/index.js b/app/assets/javascripts/projects/merge_requests/index.js deleted file mode 100644 index 25a70121d68..00000000000 --- a/app/assets/javascripts/projects/merge_requests/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue'; - -export const initReportAbuse = () => { - const el = document.getElementById('js-report-abuse-dropdown-item'); - - if (!el) return false; - - const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; - - return new Vue({ - el, - provide: { reportAbusePath, reportedUserId, reportedFromUrl }, - render(createElement) { - return createElement(ReportAbuseDropdownItem); - }, - }); -}; diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js index eeef1fb5afc..4f62aa29ce4 100644 --- a/app/assets/javascripts/projects/project_name_rules.js +++ b/app/assets/javascripts/projects/project_name_rules.js @@ -1,28 +1,29 @@ import { __ } from '~/locale'; -const rulesReg = [ - { - reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, - msg: __("Name must start with a letter, digit, emoji, or '_'"), - }, - { - reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, - msg: __("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"), - }, -]; +export const START_RULE = { + reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, + msg: __('Name must start with a letter, digit, emoji, or underscore.'), +}; + +export const CONTAINS_RULE = { + reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, + msg: __( + 'Name can contain only lowercase or uppercase letters, digits, emojis, spaces, dots, underscores, dashes, or pluses.', + ), +}; + +const rulesReg = [START_RULE, CONTAINS_RULE]; /** * * @param {string} text * @returns {string} msg */ -function checkRules(text) { +export const checkRules = (text) => { for (const item of rulesReg) { if (!item.reg.test(text)) { return item.msg; } } return ''; -} - -export { checkRules }; +}; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index d71e80dffcf..99ea02aaa4f 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -90,13 +90,16 @@ const validateGroupNamespaceDropdown = (e) => { const checkProjectName = (projectNameInput) => { const msg = checkRules(projectNameInput.value); - const projectNameError = document.querySelector('#project_name_error'); + const projectNameError = document.querySelector('#js-project-name-error'); + const projectNameDescription = document.getElementById('js-project-name-description'); if (!projectNameError) return; if (msg) { projectNameError.innerText = msg; - projectNameError.classList.remove('hidden'); + projectNameError.classList.remove('gl-display-none'); + projectNameDescription.classList.add('gl-display-none'); } else { - projectNameError.classList.add('hidden'); + projectNameError.classList.add('gl-display-none'); + projectNameDescription.classList.remove('gl-display-none'); } }; diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js index 84b8936c17f..2dd5f821d90 100644 --- a/app/assets/javascripts/projects/project_visibility.js +++ b/app/assets/javascripts/projects/project_visibility.js @@ -44,21 +44,6 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) { }); } -function handleSelect2DropdownChange(namespaceSelector) { - if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) { - return; - } - const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex]; - setVisibilityOptions(selectedNamespace.dataset); -} - export default function initProjectVisibilitySelector() { eventHub.$on('update-visibility', setVisibilityOptions); - - const namespaceSelector = document.querySelector('select.js-select-namespace'); - if (namespaceSelector) { - const el = document.querySelector('.select2.js-select-namespace'); - el.addEventListener('change', () => handleSelect2DropdownChange(namespaceSelector)); - handleSelect2DropdownChange(namespaceSelector); - } } diff --git a/app/assets/javascripts/projects/prune_objects_button.js b/app/assets/javascripts/projects/prune_objects_button.js new file mode 100644 index 00000000000..dba73f6a19d --- /dev/null +++ b/app/assets/javascripts/projects/prune_objects_button.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import PruneUnreachableObjectsButton from './prune_unreachable_objects_button.vue'; + +export default (selector = '#js-project-prune-unreachable-objects-button') => { + const el = document.querySelector(selector); + + if (!el) return; + + const { pruneObjectsPath, pruneObjectsDocPath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(PruneUnreachableObjectsButton, { + props: { + pruneObjectsPath, + pruneObjectsDocPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/prune_unreachable_objects_button.vue b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue new file mode 100644 index 00000000000..1387fbb78c0 --- /dev/null +++ b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue @@ -0,0 +1,75 @@ +<script> +import { GlButton, GlLink, GlModal, GlModalDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlLink, + GlModal, + }, + PRUNE_UNREACHABLE_OBJECTS_MODAL_ID: 'prune-objects-modal', + MODAL_ACTION_PRIMARY: { + text: s__('UpdateProject|Prune'), + attributes: [{ variant: 'danger' }], + }, + MODAL_ACTION_CANCEL: { + text: s__('UpdateProject|Cancel'), + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + pruneObjectsPath: { + type: String, + required: true, + }, + pruneObjectsDocPath: { + type: String, + required: true, + }, + }, + computed: { + csrfToken() { + return csrf.token; + }, + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <form ref="form" :action="pruneObjectsPath" method="post"> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input value="true" type="hidden" name="prune" /> + <gl-modal + :modal-id="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID" + :title="s__('UpdateProject|Are you sure you want to prune unreachable objects?')" + :action-primary="$options.MODAL_ACTION_PRIMARY" + :action-cancel="$options.MODAL_ACTION_CANCEL" + size="sm" + :no-focus-on-show="true" + @ok="submitForm" + > + <p> + {{ s__('UpdateProject|Pruning unreachable objects can lead to repository corruption.') }} + <gl-link :href="pruneObjectsDocPath" target="_blank"> + {{ s__('UpdateProject|Learn more.') }} + </gl-link> + {{ s__('UpdateProject|Are you sure you want to prune?') }} + </p> + </gl-modal> + <gl-button + v-gl-modal="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID" + category="primary" + variant="danger" + > + {{ s__('UpdateProject|Prune unreachable objects') }} + </gl-button> + </form> +</template> diff --git a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue index 31890249f41..ff76ca7c862 100644 --- a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue +++ b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue @@ -12,6 +12,7 @@ export default { MountingPortal, AbuseCategorySelector, }, + inject: ['reportedUserId', 'reportedFromUrl'], i18n: { reportAbuse: s__('ReportAbuse|Report abuse to administrator'), }, @@ -21,21 +22,23 @@ export default { }; }, methods: { - openDrawer() { - this.open = true; - }, - closeDrawer() { - this.open = false; + toggleDrawer(open) { + this.open = open; }, }, }; </script> <template> <span> - <gl-dropdown-item @click="openDrawer">{{ $options.i18n.reportAbuse }}</gl-dropdown-item> + <gl-dropdown-item @click="toggleDrawer(true)">{{ $options.i18n.reportAbuse }}</gl-dropdown-item> <mounting-portal mount-to="#js-report-abuse-drawer" name="abuse-category-selector" append> - <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + <abuse-category-selector + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="open" + @close-drawer="toggleDrawer(false)" + /> </mounting-portal> </span> </template> diff --git a/app/assets/javascripts/projects/report_abuse/index.js b/app/assets/javascripts/projects/report_abuse/index.js new file mode 100644 index 00000000000..9bcfdbf6165 --- /dev/null +++ b/app/assets/javascripts/projects/report_abuse/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue'; + +export const initReportAbuse = () => { + const items = document.querySelectorAll('.js-report-abuse-dropdown-item'); + + items.forEach((el) => { + if (!el) return false; + + const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; + + return new Vue({ + el, + name: 'ReportAbuseDropdownItemRoot', + provide: { + reportAbusePath, + reportedUserId: parseInt(reportedUserId, 10), + reportedFromUrl, + }, + render(createElement) { + return createElement(ReportAbuseDropdownItem); + }, + }); + }); +}; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue index 541923bb735..95e140f30a9 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue @@ -4,7 +4,7 @@ import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export const i18n = { - allowedToPush: s__('BranchRules|Allowed to push'), + allowedToPush: s__('BranchRules|Allowed to push and merge'), forcePushTitle: s__( 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.', ), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js index 61c37a2348a..a98c2439cde 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -1,10 +1,10 @@ import { s__ } from '~/locale'; export const I18N = { - manageProtectionsLinkTitle: s__('BranchRules|Manage in Protected Branches'), - targetBranch: s__('BranchRules|Target Branch'), + manageProtectionsLinkTitle: s__('BranchRules|Manage in protected branches'), + targetBranch: s__('BranchRules|Target branch'), branchNameOrPattern: s__('BranchRules|Branch name or pattern'), - branch: s__('BranchRules|Target Branch'), + branch: s__('BranchRules|Target branch'), allBranches: s__('BranchRules|All branches'), matchingBranchesLinkTitle: s__('BranchRules|%{total} matching %{subject}'), protectBranchTitle: s__('BranchRules|Protect branch'), @@ -20,7 +20,7 @@ export const I18N = { ), disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'), approvalsTitle: s__('BranchRules|Approvals'), - manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'), + manageApprovalsLinkTitle: s__('BranchRules|Manage in merge request approvals'), approvalsDescription: s__( 'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Learn more.%{linkEnd}', ), @@ -28,9 +28,9 @@ export const I18N = { statusChecksDescription: s__( 'BranchRules|Check for a status response in merge requests. Failures do not block merges. %{linkStart}Learn more.%{linkEnd}', ), - statusChecksLinkTitle: s__('BranchRules|Manage in Status checks'), + statusChecksLinkTitle: s__('BranchRules|Manage in status checks'), statusChecksHeader: s__('BranchRules|Status checks (%{total})'), - allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'), + allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'), allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'), approvalsHeader: s__('BranchRules|Required approvals (%{total})'), noData: s__('BranchRules|No data to display'), diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index 6260c8dd4d0..740868e1d75 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -3,7 +3,7 @@ import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, n__ } from '~/locale'; import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility'; import { helpPagePath } from '~/helpers/help_page_helper'; -import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import branchRulesQuery from 'ee_else_ce/projects/settings/branch_rules/queries/branch_rules_details.query.graphql'; import { getAccessLevels } from '../../../utils'; import Protection from './protection.vue'; import { @@ -12,22 +12,16 @@ import { BRANCH_PARAM_NAME, WILDCARDS_HELP_PATH, PROTECTED_BRANCHES_HELP_PATH, - APPROVALS_HELP_PATH, - STATUS_CHECKS_HELP_PATH, } from './constants'; const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH); const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH); -const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH); -const statusChecksHelpDocLink = helpPagePath(STATUS_CHECKS_HELP_PATH); export default { name: 'RuleView', i18n: I18N, wildcardsHelpDocLink, protectedBranchesHelpDocLink, - approvalsHelpDocLink, - statusChecksHelpDocLink, components: { Protection, GlSprintf, GlLink, GlLoadingIcon }, inject: { projectPath: { @@ -36,12 +30,6 @@ export default { protectedBranchesPath: { default: '', }, - approvalRulesPath: { - default: '', - }, - statusChecksPath: { - default: '', - }, branchesPath: { default: '', }, @@ -58,7 +46,7 @@ export default { const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch); this.branchRule = branchRule; this.branchProtection = branchRule?.branchProtection; - this.approvalRules = branchRule?.approvalRules; + this.approvalRules = branchRule?.approvalRules?.nodes || []; this.statusChecks = branchRule?.externalStatusChecks?.nodes || []; this.matchingBranchesCount = branchRule?.matchingBranchesCount; }, @@ -98,20 +86,6 @@ export default { total: this.pushAccessLevels?.total || 0, }); }, - approvalsHeader() { - const total = this.approvals.reduce( - (sum, { approvalsRequired }) => sum + approvalsRequired, - 0, - ); - return sprintf(this.$options.i18n.approvalsHeader, { - total, - }); - }, - statusChecksHeader() { - return sprintf(this.$options.i18n.statusChecksHeader, { - total: this.statusChecks.length, - }); - }, allBranches() { return this.branch === ALL_BRANCHES_WILDCARD; }, @@ -131,8 +105,13 @@ export default { const subject = n__('branch', 'branches', total); return sprintf(this.$options.i18n.matchingBranchesLinkTitle, { total, subject }); }, - approvals() { - return this.approvalRules?.nodes || []; + // needed to override EE component + statusChecksHeader() { + return ''; + }, + // needed to override EE component + approvalsHeader() { + return ''; }, }, methods: { @@ -199,40 +178,46 @@ export default { :groups="mergeAccessLevels.groups" /> + <!-- EE start --> <!-- Approvals --> - <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> - <gl-sprintf :message="$options.i18n.approvalsDescription"> - <template #link="{ content }"> - <gl-link :href="$options.approvalsHelpDocLink"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <template v-if="approvalsHeader"> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4> + <gl-sprintf :message="$options.i18n.approvalsDescription"> + <template #link="{ content }"> + <gl-link :href="$options.approvalsHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> - <protection - class="gl-mt-3" - :header="approvalsHeader" - :header-link-title="$options.i18n.manageApprovalsLinkTitle" - :header-link-href="approvalRulesPath" - :approvals="approvals" - /> + <protection + class="gl-mt-3" + :header="approvalsHeader" + :header-link-title="$options.i18n.manageApprovalsLinkTitle" + :header-link-href="approvalRulesPath" + :approvals="approvalRules" + /> + </template> <!-- Status checks --> - <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4> - <gl-sprintf :message="$options.i18n.statusChecksDescription"> - <template #link="{ content }"> - <gl-link :href="$options.statusChecksHelpDocLink"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <template v-if="statusChecksHeader"> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4> + <gl-sprintf :message="$options.i18n.statusChecksDescription"> + <template #link="{ content }"> + <gl-link :href="$options.statusChecksHelpDocLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> - <protection - class="gl-mt-3" - :header="statusChecksHeader" - :header-link-title="$options.i18n.statusChecksLinkTitle" - :header-link-href="statusChecksPath" - :status-checks="statusChecks" - /> + <protection + class="gl-mt-3" + :header="statusChecksHeader" + :header-link-title="$options.i18n.statusChecksLinkTitle" + :header-link-href="statusChecksPath" + :status-checks="statusChecks" + /> + </template> + <!-- EE end --> </div> </template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 7639acc1181..081d6cec958 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import View from './components/view/index.vue'; +import View from 'ee_else_ce/projects/settings/branch_rules/components/view/index.vue'; export default function mountBranchRules(el) { if (!el) { diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql index a832e59aa67..aa736469749 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -4,24 +4,14 @@ query getBranchRulesDetails($projectPath: ID!) { branchRules { nodes { name + matchingBranchesCount branchProtection { allowForcePush - codeOwnerApprovalRequired mergeAccessLevels { edges { node { accessLevel accessLevelDescription - group { - id - avatarUrl - } - user { - id - name - avatarUrl - webUrl - } } } } @@ -30,45 +20,10 @@ query getBranchRulesDetails($projectPath: ID!) { node { accessLevel accessLevelDescription - group { - id - avatarUrl - } - user { - id - name - avatarUrl - webUrl - } - } - } - } - } - approvalRules { - nodes { - id - name - type - approvalsRequired - eligibleApprovers { - nodes { - id - name - username - webUrl - avatarUrl } } } } - externalStatusChecks { - nodes { - id - name - externalUrl - } - } - matchingBranchesCount } } } diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 9b669024a8b..f3d392a0ec4 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,23 +1,22 @@ <script> -import { s__ } from '~/locale'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; +import { expandSection } from '~/settings_panels'; +import { scrollToElement } from '~/lib/utils/common_utils'; import BranchRule from './components/branch_rule.vue'; - -export const i18n = { - queryError: s__( - 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', - ), - emptyState: s__( - 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.', - ), -}; +import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants'; export default { name: 'BranchRules', - i18n, + i18n: I18N, components: { BranchRule, + GlButton, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, apollo: { branchRules: { @@ -36,20 +35,27 @@ export default { }, }, inject: { - projectPath: { - default: '', - }, + projectPath: { default: '' }, }, data() { return { branchRules: [], }; }, + methods: { + showProtectedBranches() { + // Protected branches section is on the same page as the branch rules section. + expandSection(this.$options.protectedBranchesAnchor); + scrollToElement(this.$options.protectedBranchesAnchor); + }, + }, + modalId: BRANCH_PROTECTION_MODAL_ID, + protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR, }; </script> <template> - <div class="settings-content"> + <div class="settings-content gl-mb-0"> <branch-rule v-for="(rule, index) in branchRules" :key="`${rule.name}-${index}`" @@ -61,6 +67,21 @@ export default { :matching-branches-count="rule.matchingBranchesCount" /> - <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> + <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div> + + <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{ + $options.i18n.addBranchRule + }}</gl-button> + + <gl-modal + :ref="$options.modalId" + :modal-id="$options.modalId" + :title="$options.i18n.addBranchRule" + :ok-title="$options.i18n.createProtectedBranch" + @ok="showProtectedBranches" + > + <p>{{ $options.i18n.branchRuleModalDescription }}</p> + <p>{{ $options.i18n.branchRuleModalContent }}</p> + </gl-modal> </div> </template> 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 4a24df4b0dc..fa96eee5f92 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 @@ -13,7 +13,7 @@ export const i18n = { approvalRules: s__('BranchRules|%{total} approval %{subject}'), matchingBranches: s__('BranchRules|%{total} matching %{subject}'), pushAccessLevels: s__('BranchRules|Allowed to merge'), - mergeAccessLevels: s__('BranchRules|Allowed to push'), + mergeAccessLevels: s__('BranchRules|Allowed to push and merge'), }; export default { @@ -106,7 +106,7 @@ export default { }, approvalDetails() { const approvalDetails = []; - if (this.isWildcard) { + if (this.isWildcard || this.matchingBranchesCount > 1) { approvalDetails.push(this.matchingBranchesText); } if (this.branchProtection?.allowForcePush) { diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js new file mode 100644 index 00000000000..4413d8eab4e --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js @@ -0,0 +1,22 @@ +import { s__ } from '~/locale'; + +export const I18N = { + queryError: s__( + 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', + ), + emptyState: s__( + 'ProtectedBranch|After you configure a protected branch, merge request approval, or status check, it appears here.', + ), + addBranchRule: s__('BranchRules|Add branch rule'), + branchRuleModalDescription: s__( + 'BranchRules|To create a branch rule, you first need to create a protected branch.', + ), + branchRuleModalContent: s__( + 'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.', + ), + createProtectedBranch: s__('BranchRules|Create protected branch'), +}; + +export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings'; + +export const BRANCH_PROTECTION_MODAL_ID = 'addBranchRuleModal'; diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js index 7bcfde39178..ea4574119c0 100644 --- a/app/assets/javascripts/projects/settings/utils.js +++ b/app/assets/javascripts/projects/settings/utils.js @@ -9,7 +9,7 @@ export const getAccessLevels = (accessLevels = {}) => { } else if (node.group) { accessLevelTypes.groups.push(node); } else { - accessLevelTypes.roles.push(node); + accessLevelTypes.roles.push({ accessLevelDescription: node.accessLevelDescription }); } }); diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue deleted file mode 100644 index 52d1ed96b21..00000000000 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownSectionHeader, GlDropdownItem, GlBadge, GlIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - name: 'RefResultsSection', - components: { - GlDropdownSectionHeader, - GlDropdownItem, - GlBadge, - GlIcon, - }, - props: { - showHeader: { - type: Boolean, - required: false, - default: true, - }, - - sectionTitle: { - type: String, - required: true, - }, - - totalCount: { - type: Number, - required: true, - }, - - /** - * An array of object that have the following properties: - * - * - name (String, required): The name of the ref that will be displayed - * - value (String, optional): The value that will be selected when the ref - * is selected. If not provided, `name` will be used as the value. - * For example, commits use the short SHA for `name` - * and long SHA for `value`. - * - subtitle (String, optional): Text to render underneath the name. - * For example, used to render the commit's title underneath its SHA. - * - default (Boolean, optional): Whether or not to render a "default" - * indicator next to the item. Used to indicate - * the project's default branch. - * - */ - items: { - type: Array, - required: true, - validator: (items) => Array.isArray(items) && items.every((item) => item.name), - }, - - /** - * The currently selected ref. - * Used to render a check mark by the selected item. - * */ - selectedRef: { - type: String, - required: false, - default: '', - }, - - /** - * An error object that indicates that an error - * occurred while fetching items for this section - */ - error: { - type: Error, - required: false, - default: null, - }, - - /** The message to display if an error occurs */ - errorMessage: { - type: String, - required: false, - default: '', - }, - shouldShowCheck: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - totalCountText() { - return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`; - }, - }, - methods: { - showCheck(item) { - if (!this.shouldShowCheck) { - return false; - } - return item.name === this.selectedRef || item.value === this.selectedRef; - }, - }, -}; -</script> - -<template> - <div> - <gl-dropdown-section-header v-if="showHeader"> - <div class="gl-display-flex align-items-center" data-testid="section-header"> - <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> - <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> - </div> - </gl-dropdown-section-header> - <template v-if="error"> - <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3"> - <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> - <span>{{ errorMessage }}</span> - </div> - </template> - <template v-else> - <gl-dropdown-item - v-for="item in items" - :key="item.name" - @click="$emit('selected', item.value || item.name)" - > - <div class="gl-display-flex align-items-start"> - <gl-icon - name="mobile-issue-close" - class="gl-mr-2 gl-flex-shrink-0" - :class="{ 'gl-visibility-hidden': !showCheck(item) }" - /> - - <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> - <span class="gl-font-monospace">{{ item.name }}</span> - <span class="gl-text-gray-400">{{ item.subtitle }}</span> - </div> - - <gl-badge v-if="item.default" size="sm" variant="info">{{ - s__('DefaultBranchLabel|default') - }}</gl-badge> - </div> - </gl-dropdown-item> - </template> - </div> -</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 10967fb84ed..359909b8f3b 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -1,13 +1,8 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce, isArray } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { sprintf } from '~/locale'; import { ALL_REF_TYPES, SEARCH_DEBOUNCE_MS, @@ -15,21 +10,16 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, - BRANCH_REF_TYPE, - TAG_REF_TYPE, } from '../constants'; import createStore from '../stores'; -import RefResultsSection from './ref_results_section.vue'; +import { formatListBoxItems, formatErrors } from '../format_refs'; export default { name: 'RefSelector', components: { - GlDropdown, - GlDropdownDivider, - GlSearchBoxByType, - GlSprintf, - GlLoadingIcon, - RefResultsSection, + GlBadge, + GlIcon, + GlCollapsibleListbox, }, inheritAttrs: false, props: { @@ -87,6 +77,11 @@ export default { required: false, default: '', }, + toggleButtonClass: { + type: [String, Object, Array], + required: false, + default: null, + }, }, data() { return { @@ -106,35 +101,33 @@ export default { ...this.translations, }; }, - showBranchesSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_BRANCHES) && - Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error) - ); + listBoxItems() { + return formatListBoxItems(this.branches, this.tags, this.commits); }, - showTagsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_TAGS) && - Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error) - ); + branches() { + return this.enabledRefTypes.includes(REF_TYPE_BRANCHES) ? this.matches.branches.list : []; }, - showCommitsSection() { - return ( - this.enabledRefTypes.includes(REF_TYPE_COMMITS) && - Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error) - ); + tags() { + return this.enabledRefTypes.includes(REF_TYPE_TAGS) ? this.matches.tags.list : []; }, - showNoResults() { - return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; + commits() { + return this.enabledRefTypes.includes(REF_TYPE_COMMITS) ? this.matches.commits.list : []; }, - showSectionHeaders() { - return this.enabledRefTypes.length > 1; - }, - toggleButtonClass() { - return { - 'gl-inset-border-1-red-500!': !this.state, - 'gl-font-monospace': Boolean(this.selectedRef), - }; + extendedToggleButtonClass() { + const classes = [ + { + 'gl-inset-border-1-red-500!': !this.state, + 'gl-font-monospace': Boolean(this.selectedRef), + }, + ]; + + if (Array.isArray(this.toggleButtonClass)) { + classes.push(...this.toggleButtonClass); + } else { + classes.push(this.toggleButtonClass); + } + + return classes; }, footerSlotProps() { return { @@ -143,6 +136,9 @@ export default { query: this.lastQuery, }; }, + errors() { + return formatErrors(this.matches.branches, this.matches.tags, this.matches.commits); + }, selectedRefForDisplay() { if (this.useSymbolicRefNames && this.selectedRef) { return this.selectedRef.replace(/^refs\/(tags|heads)\//, ''); @@ -153,11 +149,12 @@ export default { buttonText() { return this.selectedRefForDisplay || this.i18n.noRefSelected; }, - isTagRefType() { - return this.refType === TAG_REF_TYPE; - }, - isBranchRefType() { - return this.refType === BRANCH_REF_TYPE; + noResultsMessage() { + return this.lastQuery + ? sprintf(this.i18n.noResultsWithQuery, { + query: this.lastQuery, + }) + : this.i18n.noResults; }, }, watch: { @@ -185,9 +182,7 @@ export default { // because we need to access the .cancel() method // lodash attaches to the function, which is // made inaccessible by Vue. - this.debouncedSearch = debounce(function search() { - this.search(); - }, SEARCH_DEBOUNCE_MS); + this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); @@ -214,14 +209,8 @@ export default { 'setSelectedRef', ]), ...mapActions({ storeSearch: 'search' }), - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - onSearchBoxEnter() { - this.debouncedSearch.cancel(); - this.search(); - }, - onSearchBoxInput() { + onSearchBoxInput(searchQuery = '') { + this.query = searchQuery?.trim(); this.debouncedSearch(); }, selectRef(ref) { @@ -231,104 +220,55 @@ export default { search() { this.storeSearch(this.query); }, + totalCountText(count) { + return count > 999 ? this.i18n.totalCountLabel : `${count}`; + }, }, }; </script> <template> <div> - <gl-dropdown - :header-text="i18n.dropdownHeader" - :toggle-class="toggleButtonClass" - :text="buttonText" + <gl-collapsible-listbox class="ref-selector gl-w-full" + block + searchable + :selected="selectedRef" + :header-text="i18n.dropdownHeader" + :items="listBoxItems" + :no-results-text="noResultsMessage" + :searching="isLoading" + :search-placeholder="i18n.searchPlaceholder" + :toggle-class="extendedToggleButtonClass" + :toggle-text="buttonText" v-bind="$attrs" v-on="$listeners" - @shown="focusSearchBox" + @hidden="$emit('hide')" + @search="onSearchBoxInput" + @select="selectRef" > - <template #header> - <gl-search-box-by-type - ref="searchBox" - v-model.trim="query" - :placeholder="i18n.searchPlaceholder" - autocomplete="off" - data-qa-selector="ref_selector_searchbox" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> + <template #group-label="{ group }"> + {{ group.text }} <gl-badge size="sm">{{ totalCountText(group.options.length) }}</gl-badge> </template> - - <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> - - <div - v-else-if="showNoResults" - class="gl-text-center gl-mx-3 gl-py-3" - data-testid="no-results" - > - <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> - <template #query> - <b class="gl-word-break-all">{{ lastQuery }}</b> - </template> - </gl-sprintf> - - <span v-else>{{ i18n.noResults }}</span> - </div> - - <template v-else> - <template v-if="showBranchesSection"> - <ref-results-section - :section-title="i18n.branches" - :total-count="matches.branches.totalCount" - :items="matches.branches.list" - :selected-ref="selectedRef" - :error="matches.branches.error" - :error-message="i18n.branchesErrorMessage" - :show-header="showSectionHeaders" - data-testid="branches-section" - data-qa-selector="branches_section" - :should-show-check="!useSymbolicRefNames || isBranchRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> - </template> - - <template v-if="showTagsSection"> - <ref-results-section - :section-title="i18n.tags" - :total-count="matches.tags.totalCount" - :items="matches.tags.list" - :selected-ref="selectedRef" - :error="matches.tags.error" - :error-message="i18n.tagsErrorMessage" - :show-header="showSectionHeaders" - data-testid="tags-section" - :should-show-check="!useSymbolicRefNames || isTagRefType" - @selected="selectRef($event)" - /> - - <gl-dropdown-divider v-if="showCommitsSection" /> - </template> - - <template v-if="showCommitsSection"> - <ref-results-section - :section-title="i18n.commits" - :total-count="matches.commits.totalCount" - :items="matches.commits.list" - :selected-ref="selectedRef" - :error="matches.commits.error" - :error-message="i18n.commitsErrorMessage" - :show-header="showSectionHeaders" - data-testid="commits-section" - @selected="selectRef($event)" - /> - </template> + <template #list-item="{ item }"> + {{ item.text }} + <gl-badge v-if="item.default" size="sm" variant="info">{{ + i18n.defaultLabelText + }}</gl-badge> </template> - <template #footer> <slot name="footer" v-bind="footerSlotProps"></slot> + <div + v-for="errorMessage in errors" + :key="errorMessage" + data-testid="red-selector-error-list" + class="gl-display-flex gl-align-items-flex-start gl-text-red-500 gl-mx-4 gl-my-3" + > + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> </template> - </gl-dropdown> + </gl-collapsible-listbox> <input v-if="name" data-testid="selected-ref-form-field" diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index f4faa535166..4b5b18cf6c1 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,5 +1,5 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; export const REF_TYPE_TAGS = 'REF_TYPE_TAGS'; @@ -13,6 +13,7 @@ export const X_TOTAL_HEADER = 'x-total'; export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const DEFAULT_I18N = Object.freeze({ + defaultLabelText: __('default'), dropdownHeader: __('Select Git revision'), searchPlaceholder: __('Search by Git revision'), noResultsWithQuery: __('No matching results for "%{query}"'), @@ -24,4 +25,5 @@ export const DEFAULT_I18N = Object.freeze({ tags: __('Tags'), commits: __('Commits'), noRefSelected: __('No ref selected'), + totalCountLabel: s__('TotalRefCountIndicator|1000+'), }); diff --git a/app/assets/javascripts/ref/format_refs.js b/app/assets/javascripts/ref/format_refs.js new file mode 100644 index 00000000000..af310a35ef4 --- /dev/null +++ b/app/assets/javascripts/ref/format_refs.js @@ -0,0 +1,60 @@ +import { DEFAULT_I18N } from './constants'; + +function convertToListBoxItems(items) { + return items.map((item) => ({ + text: item.name, + value: item.value || item.name, + default: item.default, + })); +} + +/** + * Format multiple lists to array of group options for listbox + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of group items with header and options + */ +export const formatListBoxItems = (branches, tags, commits) => { + const listBoxItems = []; + + const addToFinalResult = (items, header) => { + if (items && items.length > 0) { + listBoxItems.push({ + text: header, + options: convertToListBoxItems(items), + }); + } + }; + + addToFinalResult(branches, DEFAULT_I18N.branches); + addToFinalResult(tags, DEFAULT_I18N.tags); + addToFinalResult(commits, DEFAULT_I18N.commits); + + return listBoxItems; +}; + +/** + * Check error existence and add to final array + * @param branches list of branches + * @param tags list of tags + * @param commits list of commits + * @returns {*[]} array of error messages + */ +export const formatErrors = (branches, tags, commits) => { + const errorsList = []; + + if (branches && branches.error) { + errorsList.push(DEFAULT_I18N.branchesErrorMessage); + } + + if (tags && tags.error) { + errorsList.push(DEFAULT_I18N.tagsErrorMessage); + } + + if (commits && commits.error) { + errorsList.push(DEFAULT_I18N.commitsErrorMessage); + } + + return errorsList; +}; diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index 102f1228355..adae92a92e9 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -1,10 +1,10 @@ <script> import { GlFormGroup, GlFormRadioGroup, GlButton } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { - issuableTypesMap, itemAddFailureTypesMap, linkedIssueTypesMap, addRelatedIssueErrorMap, @@ -54,7 +54,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, hasError: { type: Boolean, diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 09ecad2d90e..8d6a3110f35 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -1,11 +1,11 @@ <script> import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import { TYPE_ISSUE } from '~/issues/constants'; import { autoCompleteTextMap, inputPlaceholderConfidentialTextMap, inputPlaceholderTextMap, - issuableTypesMap, } from '../constants'; import IssueToken from './issue_token.vue'; @@ -54,7 +54,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, confidential: { type: Boolean, diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 11de734f5d4..7387b9ab87c 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { TYPE_ISSUE } from '~/issues/constants'; import { defaultSortableOptions } from '~/sortable/constants'; export default { @@ -88,7 +89,7 @@ export default { document.body.classList.remove('is-dragging'); }, issuableOrderingId({ epicIssueId, id }) { - return this.issuableType === 'issue' ? epicIssueId : id; + return this.issuableType === TYPE_ISSUE ? epicIssueId : id; }, }, }; diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index 795eb3b0083..ed70e1ce8a8 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -25,12 +25,13 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: */ import { createAlert } from '~/flash'; import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { __ } from '~/locale'; import { relatedIssuesRemoveErrorMap, pathIndeterminateErrorMap, addRelatedIssueErrorMap, - issuableTypesMap, PathIdSeparator, } from '../constants'; import RelatedIssuesService from '../services/related_issues_service'; @@ -65,7 +66,7 @@ export default { issuableType: { type: String, required: false, - default: issuableTypesMap.ISSUE, + default: TYPE_ISSUE, }, allowAutoComplete: { type: Boolean, @@ -142,7 +143,7 @@ export default { this.store.setRelatedIssues(data.issuables); }) .catch((res) => { - if (res && res.status !== 404) { + if (res && res.status !== HTTP_STATUS_NOT_FOUND) { createAlert({ message: relatedIssuesRemoveErrorMap[this.issuableType] }); } }); diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js index d1b2d41d7ae..2a4ce70511b 100644 --- a/app/assets/javascripts/related_issues/constants.js +++ b/app/assets/javascripts/related_issues/constants.js @@ -1,4 +1,5 @@ import { __, sprintf } from '~/locale'; +import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; export const issuableTypesMap = { ISSUE: 'issue', @@ -21,7 +22,7 @@ export const linkedIssueTypesTextMap = { export const autoCompleteTextMap = { true: { - [issuableTypesMap.ISSUE]: sprintf( + [TYPE_ISSUE]: sprintf( __(' or %{emphasisStart}#issue id%{emphasisEnd}'), { emphasisStart: '<', emphasisEnd: '>' }, false, @@ -31,7 +32,7 @@ export const autoCompleteTextMap = { { emphasisStart: '<', emphasisEnd: '>' }, false, ), - [issuableTypesMap.EPIC]: sprintf( + [TYPE_EPIC]: sprintf( __(' or %{emphasisStart}&epic id%{emphasisEnd}'), { emphasisStart: '<', emphasisEnd: '>' }, false, @@ -43,33 +44,33 @@ export const autoCompleteTextMap = { ), }, false: { - [issuableTypesMap.ISSUE]: '', - [issuableTypesMap.EPIC]: '', - [issuableTypesMap.MERGE_REQUEST]: __(' or references (e.g. path/to/project!merge_request_id)'), + [TYPE_ISSUE]: '', + [TYPE_EPIC]: '', + [issuableTypesMap.MERGE_REQUEST]: __(' or references'), }, }; export const inputPlaceholderTextMap = { - [issuableTypesMap.ISSUE]: __('Paste issue link'), + [TYPE_ISSUE]: __('Paste issue link'), [issuableTypesMap.INCIDENT]: __('Paste link'), - [issuableTypesMap.EPIC]: __('Paste epic link'), + [TYPE_EPIC]: __('Paste epic link'), [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), }; export const inputPlaceholderConfidentialTextMap = { - [issuableTypesMap.ISSUE]: __('Paste confidential issue link'), - [issuableTypesMap.EPIC]: __('Paste confidential epic link'), + [TYPE_ISSUE]: __('Paste confidential issue link'), + [TYPE_EPIC]: __('Paste confidential epic link'), [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), }; export const relatedIssuesRemoveErrorMap = { - [issuableTypesMap.ISSUE]: __('An error occurred while removing issues.'), - [issuableTypesMap.EPIC]: __('An error occurred while removing epics.'), + [TYPE_ISSUE]: __('An error occurred while removing issues.'), + [TYPE_EPIC]: __('An error occurred while removing epics.'), }; export const pathIndeterminateErrorMap = { - [issuableTypesMap.ISSUE]: __('We could not determine the path to remove the issue'), - [issuableTypesMap.EPIC]: __('We could not determine the path to remove the epic'), + [TYPE_ISSUE]: __('We could not determine the path to remove the issue'), + [TYPE_EPIC]: __('We could not determine the path to remove the epic'), }; export const itemAddFailureTypesMap = { @@ -78,8 +79,8 @@ export const itemAddFailureTypesMap = { }; export const addRelatedIssueErrorMap = { - [issuableTypesMap.ISSUE]: __('Issue cannot be found.'), - [issuableTypesMap.EPIC]: __('Epic cannot be found.'), + [TYPE_ISSUE]: __('Issue cannot be found.'), + [TYPE_EPIC]: __('Epic cannot be found.'), }; export const addRelatedItemErrorMap = { @@ -94,9 +95,9 @@ export const addRelatedItemErrorMap = { * them inside i18n functions. */ export const issuableIconMap = { - [issuableTypesMap.ISSUE]: 'issues', + [TYPE_ISSUE]: 'issues', [issuableTypesMap.INCIDENT]: 'issues', - [issuableTypesMap.EPIC]: 'epic', + [TYPE_EPIC]: 'epic', }; export const PathIdSeparator = { @@ -105,30 +106,30 @@ export const PathIdSeparator = { }; export const issuablesBlockHeaderTextMap = { - [issuableTypesMap.ISSUE]: __('Linked items'), + [TYPE_ISSUE]: __('Linked items'), [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'), - [issuableTypesMap.EPIC]: __('Linked epics'), + [TYPE_EPIC]: __('Linked epics'), }; export const issuablesBlockHelpTextMap = { - [issuableTypesMap.ISSUE]: __('Learn more about linking issues'), + [TYPE_ISSUE]: __('Learn more about linking issues'), [issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'), - [issuableTypesMap.EPIC]: __('Learn more about linking epics'), + [TYPE_EPIC]: __('Learn more about linking epics'), }; export const issuablesBlockAddButtonTextMap = { - [issuableTypesMap.ISSUE]: __('Add a related issue'), - [issuableTypesMap.EPIC]: __('Add a related epic'), + [TYPE_ISSUE]: __('Add a related issue'), + [TYPE_EPIC]: __('Add a related epic'), }; export const issuablesFormCategoryHeaderTextMap = { - [issuableTypesMap.ISSUE]: __('The current issue'), + [TYPE_ISSUE]: __('The current issue'), [issuableTypesMap.INCIDENT]: __('The current incident'), - [issuableTypesMap.EPIC]: __('The current epic'), + [TYPE_EPIC]: __('The current epic'), }; export const issuablesFormInputTextMap = { - [issuableTypesMap.ISSUE]: __('the following issues'), + [TYPE_ISSUE]: __('the following issues'), [issuableTypesMap.INCIDENT]: __('the following incidents or issues'), - [issuableTypesMap.EPIC]: __('the following epics'), + [TYPE_EPIC]: __('the following epics'), }; diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js index c77a67c4287..cc00ef10dda 100644 --- a/app/assets/javascripts/related_issues/index.js +++ b/app/assets/javascripts/related_issues/index.js @@ -1,9 +1,10 @@ import Vue from 'vue'; -import apolloProvider from '~/issues/show/graphql'; +import { TYPE_ISSUE } from '~/issues/constants'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; import RelatedIssuesRoot from './components/related_issues_root.vue'; -export function initRelatedIssues(issueType = 'issue') { +export function initRelatedIssues(issueType = TYPE_ISSUE) { const el = document.querySelector('.js-related-issues-root'); if (!el) { diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 965b9fa09d6..ff92cdd42c6 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -13,6 +13,7 @@ import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; +import { putCreateReleaseNotification } from '~/releases/release_notification_service'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import AssetLinksForm from './asset_links_form.vue'; import ConfirmDeleteModal from './confirm_delete_modal.vue'; @@ -49,6 +50,7 @@ export default { 'newMilestonePath', 'manageMilestonesPath', 'projectId', + 'projectPath', 'groupId', 'groupMilestonesAvailable', 'tagNotes', @@ -150,6 +152,7 @@ export default { submitForm() { if (!this.isFormSubmissionDisabled) { this.saveRelease(); + putCreateReleaseNotification(this.projectPath, this.release.name); } }, }, @@ -161,7 +164,7 @@ export default { <gl-sprintf :message=" __( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.', + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}1.0.0%{codeEnd}, %{codeStart}2.1.0-pre%{codeEnd}.', ) " > diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 1b360b79b0c..9f200856db3 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -244,21 +244,19 @@ export default { </script> <template> <div class="gl-display-flex gl-flex-direction-column gl-mt-3"> - <div class="gl-align-self-end gl-mb-3"> + <releases-empty-state v-if="shouldRenderEmptyState" /> + <div v-else class="gl-align-self-end gl-mb-3"> <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" /> <gl-button v-if="newReleasePath" :href="newReleasePath" - :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" variant="confirm" >{{ $options.i18n.newRelease }}</gl-button > </div> - <releases-empty-state v-if="shouldRenderEmptyState" /> - <release-block v-for="(release, index) in releases" :key="getReleaseKey(release, index)" diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 7147cfa01c8..544f2de5132 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,6 +1,7 @@ <script> import { createAlert } from '~/flash'; import { s__ } from '~/locale'; +import { popCreateReleaseNotification } from '~/releases/release_notification_service'; import oneReleaseQuery from '../graphql/queries/one_release.query.graphql'; import { convertGraphQLRelease } from '../util'; import ReleaseBlock from './release_block.vue'; @@ -49,6 +50,9 @@ export default { }, }, }, + mounted() { + popCreateReleaseNotification(this.fullPath); + }, methods: { showFlash(error) { createAlert({ diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 6d415471b14..2118c26fd81 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -67,12 +67,13 @@ export default { <gl-link v-gl-tooltip class="d-flex align-items-center monospace" - :title="__('Download evidence JSON')" - :download="evidenceTitle(index)" + target="_blank" + :title="__('Open evidence JSON in new tab')" :href="evidenceUrl(index)" > <gl-icon name="review-list" class="align-middle gl-mr-3" /> <span>{{ evidenceTitle(index) }}</span> + <gl-icon name="external-link" class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0" /> </gl-link> <expand-button> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index 1761f4360d1..cc28980a6bf 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -121,7 +121,7 @@ export default { <gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" /> {{ link.name }} <gl-icon - v-if="link.external" + v-if="section.title" v-gl-tooltip name="external-link" :aria-label="$options.externalLinkTooltipText" diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue index 800497c186a..ae94bd6872e 100644 --- a/app/assets/javascripts/releases/components/releases_empty_state.vue +++ b/app/assets/javascripts/releases/components/releases_empty_state.vue @@ -1,44 +1,33 @@ <script> -import { GlEmptyState, GlLink } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { name: 'ReleasesEmptyState', components: { GlEmptyState, - GlLink, - }, - inject: { - documentationPath: { - default: '', - }, - illustrationPath: { - default: '', - }, }, + inject: ['documentationPath', 'illustrationPath', 'newReleasePath'], i18n: { - emptyStateTitle: __('Getting started with releases'), - emptyStateText: __( - "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", + emptyStateTitle: s__('Release|Getting started with releases'), + emptyStateText: s__( + "Release|Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", ), - releasesDocumentation: __('Releases documentation'), - moreInformation: __('More information'), + releasesDocumentation: s__('Release|Learn more about releases'), + moreInformation: s__('Release|More information'), + newRelease: s__('Release|Create a new release'), }, }; </script> <template> - <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath"> - <template #description> - <span id="releases-description"> - {{ $options.i18n.emptyStateText }} - <gl-link - :href="documentationPath" - :aria-label="$options.i18n.releasesDocumentation" - target="_blank" - > - {{ $options.i18n.moreInformation }} - </gl-link> - </span> - </template> - </gl-empty-state> + <gl-empty-state + class="gl-layout-w-limited" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateText" + :svg-path="illustrationPath" + :primary-button-link="newReleasePath" + :primary-button-text="$options.i18n.newRelease" + :secondary-button-link="documentationPath" + :secondary-button-text="$options.i18n.releasesDocumentation" + /> </template> diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js new file mode 100644 index 00000000000..a4f926d7561 --- /dev/null +++ b/app/assets/javascripts/releases/release_notification_service.js @@ -0,0 +1,23 @@ +import { s__, sprintf } from '~/locale'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; + +const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`; + +export const putCreateReleaseNotification = (projectPath, releaseName) => { + window.sessionStorage.setItem(createReleaseSessionKey(projectPath), releaseName); +}; + +export const popCreateReleaseNotification = (projectPath) => { + const key = createReleaseSessionKey(projectPath); + const createdRelease = window.sessionStorage.getItem(key); + + if (createdRelease) { + createAlert({ + message: sprintf(s__('Release|Release %{createdRelease} has been successfully created.'), { + createdRelease, + }), + variant: VARIANT_SUCCESS, + }); + window.sessionStorage.removeItem(key); + } +}; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index f80e75501c9..ccd168aafc9 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -40,6 +40,7 @@ export default { [types.UPDATE_RELEASE_TAG_NAME](state, tagName) { state.release.tagName = tagName; + state.existingRelease = null; }, [types.UPDATE_RELEASE_TAG_MESSAGE](state, tagMessage) { state.release.tagMessage = tagMessage; @@ -118,6 +119,7 @@ export default { state.fetchError = error; state.isFetchingTagNotes = false; state.tagNotes = ''; + state.existingRelease = null; }, [types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) { state.includeTagNotes = includeTagNotes; diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index a480710f8ac..68b2cf6f3da 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -4,7 +4,7 @@ const viewers = { image: () => import('./image_viewer.vue'), video: () => import('./video_viewer.vue'), empty: () => import('./empty_viewer.vue'), - text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'), + text: () => import('~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'), pdf: () => import('./pdf_viewer.vue'), lfs: () => import('./lfs_viewer.vue'), audio: () => import('./audio_viewer.vue'), diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 980fa140eb5..9804837b200 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; import { createAlert } from '~/flash'; import forkDetailsQuery from '../queries/fork_details.query.graphql'; @@ -9,9 +9,9 @@ export const i18n = { inaccessibleProject: s__('ForkedFromProjectPath|Forked from an inaccessible project.'), upToDate: s__('ForksDivergence|Up to date with the upstream repository.'), unknown: s__('ForksDivergence|This fork has diverged from the upstream repository.'), - behind: s__('ForksDivergence|%{behind} %{commit_word} behind'), - ahead: s__('ForksDivergence|%{ahead} %{commit_word} ahead of'), - behindAndAhead: s__('ForksDivergence|%{messages} the upstream repository.'), + behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'), + ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'), + behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'), error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), }; @@ -20,6 +20,7 @@ export default { components: { GlIcon, GlLink, + GlSprintf, GlSkeletonLoader, }, apollo: { @@ -28,7 +29,7 @@ export default { variables() { return { projectPath: this.projectPath, - ref: this.selectedRef, + ref: this.selectedBranch, }; }, skip() { @@ -48,7 +49,7 @@ export default { type: String, required: true, }, - selectedRef: { + selectedBranch: { type: String, required: true, }, @@ -62,6 +63,16 @@ export default { required: false, default: '', }, + aheadComparePath: { + type: String, + required: false, + default: '', + }, + behindComparePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -116,7 +127,7 @@ export default { return this.$options.i18n.unknown; } if (this.hasBehindAheadMessage) { - return sprintf(this.$options.i18n.behindAndAhead, { + return sprintf(this.$options.i18n.behindAhead, { messages: this.behindAheadMessage, }); } @@ -134,8 +145,15 @@ export default { {{ $options.i18n.forkedFrom }} <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> <gl-skeleton-loader v-if="isLoading" :lines="1" /> - <div v-else class="gl-text-secondary"> - {{ forkDivergenceMessage }} + <div v-else class="gl-text-secondary" data-testid="divergence-message"> + <gl-sprintf :message="forkDivergenceMessage"> + <template #aheadLink="{ content }"> + <gl-link :href="aheadComparePath">{{ content }}</gl-link> + </template> + <template #behindLink="{ content }"> + <gl-link :href="behindComparePath">{{ content }}</gl-link> + </template> + </gl-sprintf> </div> </div> <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex"> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 8feac6b8e35..90949536cc1 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -14,7 +14,6 @@ export default { url: this.blob.webPath, }; }, - loadingKey: 'loading', }, }, components: { @@ -34,9 +33,13 @@ export default { data() { return { readme: null, - loading: 0, }; }, + computed: { + isLoading() { + return this.$apollo.queries.readme.loading; + }, + }, watch: { readme(newVal) { if (newVal) { @@ -64,7 +67,7 @@ export default { </div> </div> <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about"> - <gl-loading-icon v-if="loading > 0" size="lg" color="dark" class="my-4 mx-auto" /> + <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" ref="readme" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 27ac11f3c58..6dd059a349f 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -203,7 +203,7 @@ export default { :is="linkComponent" ref="link" v-gl-hover-load="handlePreload" - v-gl-tooltip:tooltip-container + v-gl-tooltip="{ placement: 'left', boundary: 'viewport' }" :title="fullPath" :to="routerLinkTo" :href="url" diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index e5d22f50d72..494e270a66c 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -69,7 +69,7 @@ export default function setupVueRepositoryList() { if (!forkEl) { return null; } - const { sourceName, sourcePath } = forkEl.dataset; + const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset; return new Vue({ el: forkEl, apolloProvider, @@ -77,9 +77,11 @@ export default function setupVueRepositoryList() { return h(ForkInfo, { props: { projectPath, - selectedRef: ref, + selectedBranch: ref, sourceName, sourcePath, + aheadComparePath, + behindComparePath, }, }); }, @@ -131,7 +133,7 @@ export default function setupVueRepositoryList() { }, on: { input(selectedRef) { - visitUrl(generateRefDestinationPath(projectRootPath, selectedRef)); + visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef)); }, }, }); diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js new file mode 100644 index 00000000000..95d0c55bb04 --- /dev/null +++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js @@ -0,0 +1,106 @@ +import { nextTick } from 'vue'; +import { + LEGACY_FALLBACKS, + EVENT_ACTION, + EVENT_LABEL_FALLBACK, + LINES_PER_CHUNK, +} 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 { TEXT_FILE_TYPE } from '../constants'; + +/* + * This mixin is intended to be used as an interface between our highlight worker and Vue components + */ +export default { + mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, + data() { + return { + chunks: [], + }; + }, + methods: { + trackEvent(label, language) { + this.track(EVENT_ACTION, { label, property: language }); + }, + isUnsupportedLanguage(language) { + const supportedLanguages = Object.keys(languageLoader); + const isUnsupportedLanguage = !supportedLanguages.includes(language); + + return LEGACY_FALLBACKS.includes(language) || isUnsupportedLanguage; + }, + handleUnsupportedLanguage(language) { + this.trackEvent(EVENT_LABEL_FALLBACK, language); + this?.onError(); + }, + initHighlightWorker({ rawTextBlob, language, simpleViewer }) { + if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return; + + if (this.isUnsupportedLanguage(language)) { + this.handleUnsupportedLanguage(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: + * + * 1. `splitIntoChunks` with the first 70 lines of raw text. + * This ensures that we start rendering raw content in the DOM as soon as we can so that + * the user can see content as fast as possible (improves perceived performance and LCP). + * 2. `instructWorker` to start highlighting the first 70 lines. + * This ensures that we display highlighted** content to the user as fast as possible + * (improves perceived performance and makes the first 70 lines look nice). + * 3. `instructWorker` to start highlighting all the content. + * This is the longest task. It ensures that we highlight all content, since the first 70 + * lines are already rendered, this can happen in the background. + */ + + // Render the first 70 lines (raw text) ASAP, this improves perceived performance and LCP. + const firstSeventyLines = rawTextBlob.split(/\r?\n/).slice(0, LINES_PER_CHUNK).join('\n'); + + 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); + + // Instruct the worker to start highlighting all lines in the background. + this.instructWorker(rawTextBlob, language); + }, + handleWorkerMessage({ data }) { + this.chunks = data; + this.highlightHash(); // highlight the line if a line number hash is present in the URL + }, + instructWorker(content, language) { + this.highlightWorker.postMessage({ content, language }); + }, + async highlightHash() { + const { hash } = this.$route; + if (!hash) return; + + // Make the chunk containing the line number visible + const lineNumber = hash.substring(hash.indexOf('L') + 1).split('-')[0]; + const chunkToHighlight = this.chunks.find( + (chunk) => + chunk.startingFrom <= lineNumber && chunk.startingFrom + chunk.totalLines >= lineNumber, + ); + + if (chunkToHighlight) { + chunkToHighlight.isHighlighted = true; + } + + // Line numbers in the DOM needs to update first based on changes made to `chunks`. + await nextTick(); + + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + }, + }, +}; diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js index f296b5e9b4a..c62f7f709c4 100644 --- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -5,9 +5,9 @@ import { joinPaths } from '~/lib/utils/url_utility'; * Example: /root/Flight/-/blob/fix/main/test/spec/utils_spec.js * Group 1: /-/blob * Group 2: blob - * Group 3: main/test/spec/utils_spec.js + * Group 3: /test/spec/utils_spec.js */ -const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; +const getNamespaceTargetRegex = (ref) => new RegExp(`(/-/(blob|tree))/${ref}/(.*)`); /** * Generates a ref destination path based on the selected ref and current path. @@ -15,11 +15,12 @@ const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; * @param {string} projectRootPath - The root path for a project. * @param {string} selectedRef - The selected ref from the ref dropdown. */ -export function generateRefDestinationPath(projectRootPath, selectedRef) { +export function generateRefDestinationPath(projectRootPath, ref, selectedRef) { const currentPath = window.location.pathname; const encodedHash = '%23'; let namespace = '/-/tree'; let target; + const NAMESPACE_TARGET_REGEX = getNamespaceTargetRegex(ref); const match = NAMESPACE_TARGET_REGEX.exec(currentPath); if (match) { [, namespace, , target] = match; diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 7b5babdd3a6..87996d0bb85 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -7,6 +7,7 @@ export * from './api/namespaces_api'; export * from './api/tags_api'; export * from './api/alert_management_alerts_api'; export * from './api/harbor_registry'; +export * from './api/environments_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/saved_replies/components/app.vue new file mode 100644 index 00000000000..db8476c44f3 --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/app.vue @@ -0,0 +1,23 @@ +<script> +export default {}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-lg-4"> + <h4 class="gl-mt-0"> + {{ __('Saved Replies') }} + </h4> + <p> + {{ + __( + 'Saved replies can be used when creating comments inside issues, merge requests, and epics.', + ) + }} + </p> + </div> + <div class="col-lg-8"> + <router-view /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue new file mode 100644 index 00000000000..30089cfa53f --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list.vue @@ -0,0 +1,57 @@ +<script> +import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import savedRepliesQuery from '../queries/saved_replies.query.graphql'; +import ListItem from './list_item.vue'; + +export default { + apollo: { + savedReplies: { + query: savedRepliesQuery, + update: (r) => r.currentUser?.savedReplies?.nodes, + result({ data }) { + const pageInfo = data.currentUser?.savedReplies?.pageInfo; + + this.count = data.currentUser?.savedReplies?.count; + + if (pageInfo) { + this.pageInfo = pageInfo; + } + }, + }, + }, + components: { + GlLoadingIcon, + GlKeysetPagination, + GlSprintf, + ListItem, + }, + data() { + return { + savedReplies: [], + count: 0, + pageInfo: {}, + }; + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" /> + <template v-else> + <h5 class="gl-font-lg" data-testid="title"> + <gl-sprintf :message="__('My saved replies (%{count})')"> + <template #count>{{ count }}</template> + </gl-sprintf> + </h5> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" /> + </ul> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue new file mode 100644 index 00000000000..dfa9a405dee --- /dev/null +++ b/app/assets/javascripts/saved_replies/components/list_item.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + reply: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li class="gl-mb-5"> + <div class="gl-display-flex gl-align-items-center"> + <strong>{{ reply.name }}</strong> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div> + </li> +</template> diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/saved_replies/index.js new file mode 100644 index 00000000000..5022ff62b10 --- /dev/null +++ b/app/assets/javascripts/saved_replies/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import routes from './routes'; +import App from './components/app.vue'; + +export const initSavedReplies = () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const el = document.getElementById('js-saved-replies-root'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const router = new VueRouter({ + base: el.dataset.basePath, + mode: 'history', + routes, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + router, + apolloProvider, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue new file mode 100644 index 00000000000..38f51dbc365 --- /dev/null +++ b/app/assets/javascripts/saved_replies/pages/index.vue @@ -0,0 +1,15 @@ +<script> +import List from '../components/list.vue'; + +export default { + components: { + List, + }, +}; +</script> + +<template> + <div> + <list /> + </div> +</template> diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql new file mode 100644 index 00000000000..af1f12f3ceb --- /dev/null +++ b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql @@ -0,0 +1,19 @@ +query savedReplies { + currentUser { + id + savedReplies { + nodes { + id + name + content + } + count + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/saved_replies/routes.js new file mode 100644 index 00000000000..bd582a5ed86 --- /dev/null +++ b/app/assets/javascripts/saved_replies/routes.js @@ -0,0 +1,8 @@ +import IndexComponent from './pages/index.vue'; + +export default [ + { + path: '/', + component: IndexComponent, + }, +]; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d4ee857c9c1..d71785d7fac 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,6 +1,5 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; -import refreshCounts from '~/pages/search/show/refresh_counts'; import syntaxHighlight from '~/syntax_highlight'; import { initSidebar, sidebarInitState } from './sidebar'; import { initSearchSort } from './sort'; @@ -24,8 +23,4 @@ export const initSearchApp = () => { setHighlightClass(query.search); // Code Highlighting initBlobRefSwitcher(); // Code Search Branch Picker - - if (!gon.features?.searchPageVerticalNav) { - refreshCounts(); // Other Scope Tab Counts - } }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 6f29864c0a2..2efc80fef75 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -2,28 +2,34 @@ import { mapState } from 'vuex'; import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS } from '../constants'; +import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants'; import ResultsFilters from './results_filters.vue'; +import LanguageFilter from './language_filter.vue'; export default { name: 'GlobalSearchSidebar', components: { ResultsFilters, ScopeNavigation, + LanguageFilter, }, mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery']), - showFilters() { + showIssueAndMergeFilters() { return this.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS; }, + showBlobFilter() { + return this.urlQuery.scope === SCOPE_BLOB && this.glFeatures.searchBlobsLanguageAggregation; + }, }, }; </script> <template> <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5"> - <scope-navigation v-if="glFeatures.searchPageVerticalNav" /> - <results-filters v-if="showFilters" /> + <scope-navigation /> + <results-filters v-if="showIssueAndMergeFilters" /> + <language-filter v-if="showBlobFilter" /> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue new file mode 100644 index 00000000000..b580d58b21b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue @@ -0,0 +1,81 @@ +<script> +import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { intersection } from 'lodash'; +import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants'; +import { formatSearchResultCount } from '../../store/utils'; + +export default { + name: 'CheckboxFilter', + components: { + GlFormCheckboxGroup, + GlFormCheckbox, + }, + props: { + filterData: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['query']), + scope() { + return this.query.scope; + }, + queryFilters() { + return this.query[this.filterData?.filterParam] || []; + }, + dataFilters() { + return Object.values(this.filterData?.filters || []); + }, + flatDataFilterValues() { + return this.dataFilters.map(({ value }) => value); + }, + selectedFilter: { + get() { + return intersection(this.flatDataFilterValues, this.queryFilters); + }, + set(value) { + this.setQuery({ key: this.filterData?.filterParam, value }); + }, + }, + labelCountClasses() { + return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500']; + }, + }, + methods: { + ...mapActions(['setQuery']), + getFormatedCount(count) { + return formatSearchResultCount(count); + }, + }, + NAV_LINK_COUNT_DEFAULT_CLASSES, + LABEL_DEFAULT_CLASSES, +}; +</script> + +<template> + <div class="gl-mx-5"> + <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <gl-form-checkbox-group v-model="selectedFilter"> + <gl-form-checkbox + v-for="f in dataFilters" + :key="f.label" + :value="f.label" + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + :class="$options.LABEL_DEFAULT_CLASSES" + > + <span + class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full" + > + <span data-testid="label"> + {{ f.label }} + </span> + <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount"> + {{ getFormatedCount(f.count) }} + </span> + </span> + </gl-form-checkbox> + </gl-form-checkbox-group> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index fbfc24a94ae..e7aa3d61409 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,19 +7,13 @@ export default { components: { RadioFilter, }, - mixins: [glFeatureFlagsMixin()], - computed: { - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, - }, confidentialFilterData, }; </script> <template> <div> - <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" /> + <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" /> <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter.vue new file mode 100644 index 00000000000..26ce204cb5c --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/language_filter.vue @@ -0,0 +1,122 @@ +<script> +import { GlButton, GlAlert, GlForm } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { __, s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data'; +import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants'; +import { convertFiltersData } from '../utils'; +import CheckboxFilter from './checkbox_filter.vue'; + +export default { + name: 'LanguageFilter', + components: { + CheckboxFilter, + GlButton, + GlAlert, + GlForm, + }, + mixins: [glFeatureFlagsMixin()], + data() { + return { + showAll: false, + }; + }, + i18n: { + showMore: s__('GlobalSearch|Show more'), + apply: __('Apply'), + showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }), + loadError: s__('GlobalSearch|Aggregations load error.'), + }, + computed: { + ...mapState(['aggregations', 'sidebarDirty']), + ...mapGetters(['langugageAggregationBuckets']), + hasBuckets() { + return this.langugageAggregationBuckets.length > 0; + }, + filtersData() { + return convertFiltersData(this.shortenedLanguageFilters); + }, + shortenedLanguageFilters() { + if (!this.hasShowMore) { + return this.langugageAggregationBuckets; + } + if (this.showAll) { + return this.trimBuckets(MAX_ITEM_LENGTH); + } + return this.trimBuckets(DEFAULT_ITEM_LENGTH); + }, + hasShowMore() { + return this.langugageAggregationBuckets.length > DEFAULT_ITEM_LENGTH; + }, + hasOverMax() { + return this.langugageAggregationBuckets.length > MAX_ITEM_LENGTH; + }, + dividerClasses() { + return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD]; + }, + }, + async created() { + await this.fetchLanguageAggregation(); + }, + methods: { + ...mapActions(['applyQuery', 'fetchLanguageAggregation']), + onShowMore() { + this.showAll = true; + }, + trimBuckets(length) { + return this.langugageAggregationBuckets.slice(0, length); + }, + }, + HR_DEFAULT_CLASSES, +}; +</script> + +<template> + <gl-form + v-if="hasBuckets" + class="gl-pt-5 gl-md-pt-0 language-filter-checkbox" + @submit.prevent="applyQuery" + > + <hr :class="dividerClasses" /> + <div + v-if="!aggregations.error" + class="gl-overflow-x-hidden gl-overflow-y-auto" + :class="{ 'language-filter-max-height': showAll }" + > + <checkbox-filter class="gl-px-5" :filter-data="filtersData" /> + <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{ + $options.i18n.showingMax + }}</span> + </div> + <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{ + $options.i18n.loadError + }}</gl-alert> + <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all"> + <gl-button + data-testid="show-more-button" + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm" + @click="onShowMore" + > + {{ $options.i18n.showMore }} + </gl-button> + </div> + <div v-if="!aggregations.error"> + <hr :class="$options.HR_DEFAULT_CLASSES" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-mx-5 gl-px-5"> + <gl-button + category="primary" + variant="confirm" + type="submit" + :disabled="!sidebarDirty" + data-testid="apply-button" + > + {{ $options.i18n.apply }} + </gl-button> + </div> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index ff7a044736d..4d9cc9d6450 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; @@ -15,24 +14,17 @@ export default { StatusFilter, ConfidentialityFilter, }, - mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['urlQuery', 'sidebarDirty']), showReset() { return this.urlQuery.state || this.urlQuery.confidential; }, - searchPageVerticalNavFeatureFlag() { - return this.glFeatures.searchPageVerticalNav; - }, showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope); }, showStatusFilter() { return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope); }, - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -42,13 +34,10 @@ export default { <template> <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> - <hr - v-if="searchPageVerticalNavFeatureFlag" - class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" - /> + <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" /> <status-filter v-if="showStatusFilter" /> <confidentiality-filter v-if="showConfidentialityFilter" /> - <div class="gl-display-flex gl-align-items-center gl-mt-4" :class="ffBasedXPadding"> + <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index 3c280a5d696..5863381e2ef 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -5,6 +5,7 @@ import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; import { formatSearchResultCount } from '../../store/utils'; +import { slugifyWithUnderscore } from '../../../lib/utils/text_utility'; export default { name: 'ScopeNavigation', @@ -46,6 +47,9 @@ export default { isActive(scope, index) { return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0; }, + qaSelectorValue(item) { + return `${slugifyWithUnderscore(item.label)}_tab`; + }, }, NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES, @@ -62,6 +66,7 @@ export default { class="gl-mb-1" :href="item.link" :active="isActive(scope, index)" + :data-qa-selector="qaSelectorValue(item)" @click="handleClick(scope)" ><span>{{ item.label }}</span ><span v-if="item.count" :class="countClasses(isActive(scope, index))"> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 4da96a41ef7..c3deabfcc26 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { stateFilterData } from '../constants/state_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,19 +7,13 @@ export default { components: { RadioFilter, }, - mixins: [glFeatureFlagsMixin()], - computed: { - ffBasedXPadding() { - return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; - }, - }, stateFilterData, }; </script> <template> <div> - <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" /> + <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" /> <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js new file mode 100644 index 00000000000..df44a58a14b --- /dev/null +++ b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js @@ -0,0 +1,18 @@ +import { s__ } from '~/locale'; + +export const DEFAULT_ITEM_LENGTH = 10; +export const MAX_ITEM_LENGTH = 100; + +const header = s__('GlobalSearch|Language'); + +const scopes = { + BLOBS: 'blobs', +}; + +const filterParam = 'language'; + +export const languageFilterData = { + header, + scopes, + filterParam, +}; diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js new file mode 100644 index 00000000000..5c08ad2f959 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/utils.js @@ -0,0 +1,20 @@ +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; + +export const convertFiltersData = (rawBuckets) => { + return rawBuckets.reduce( + (acc, bucket) => { + return { + ...acc, + filters: { + ...acc.filters, + [bucket.key.toUpperCase()]: { + label: bucket.key, + value: bucket.key, + count: bucket.count, + }, + }, + }; + }, + { ...languageFilterData, filters: {} }, + ); +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 2a1b744561d..fc0817be882 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -6,7 +6,13 @@ import { logError } from '~/lib/logger'; import { __ } from '~/locale'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants'; import * as types from './mutation_types'; -import { loadDataFromLS, setFrequentItemToLS, mergeById, isSidebarDirty } from './utils'; +import { + loadDataFromLS, + setFrequentItemToLS, + mergeById, + isSidebarDirty, + getAggregationsUrl, +} from './utils'; export const fetchGroups = ({ commit }, search) => { commit(types.REQUEST_GROUPS); @@ -95,7 +101,7 @@ export const setQuery = ({ state, commit }, { key, value }) => { }; export const applyQuery = ({ state }) => { - visitUrl(setUrlParams({ ...state.query, page: null })); + visitUrl(setUrlParams({ ...state.query, page: null }, window.location.href, false, true)); }; export const resetQuery = ({ state }) => { @@ -117,3 +123,16 @@ export const fetchSidebarCount = ({ commit, state }) => { }); return Promise.all(promises); }; + +export const fetchLanguageAggregation = ({ commit }) => { + commit(types.REQUEST_AGGREGATIONS); + return axios + .get(getAggregationsUrl()) + .then(({ data }) => { + commit(types.RECEIVE_AGGREGATIONS_SUCCESS, data); + }) + .catch((e) => { + logError(e); + commit(types.RECEIVE_AGGREGATIONS_ERROR); + }); +}; diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index e4f67f624ca..ba4fe85db9d 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -1,5 +1,6 @@ import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; export const MAX_FREQUENT_ITEMS = 5; @@ -9,6 +10,10 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; -export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam]; +export const SIDEBAR_PARAMS = [ + stateFilterData.filterParam, + confidentialFilterData.filterParam, + languageFilterData.filterParam, +]; export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index 650af5fa55a..0278239c144 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,3 +1,4 @@ +import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants'; export const frequentGroups = (state) => { @@ -7,3 +8,11 @@ export const frequentGroups = (state) => { export const frequentProjects = (state) => { return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY]; }; + +export const langugageAggregationBuckets = (state) => { + return ( + state.aggregations.data.find( + (aggregation) => aggregation.name === languageFilterData.filterParam, + )?.buckets || [] + ); +}; diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js index 511b93cad2b..4ffbadcd083 100644 --- a/app/assets/javascripts/search/store/mutation_types.js +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -11,3 +11,7 @@ export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY'; export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS'; export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT'; + +export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS'; +export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS'; +export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index c1339845272..f9fd69d2211 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -36,4 +36,13 @@ export default { const item = { ...state.navigation[key], count }; state.navigation = { ...state.navigation, [key]: item }; }, + [types.REQUEST_AGGREGATIONS](state) { + state.aggregations = { fetching: true, error: false, data: [] }; + }, + [types.RECEIVE_AGGREGATIONS_SUCCESS](state, data) { + state.aggregations = { fetching: false, error: false, data: [...data] }; + }, + [types.RECEIVE_AGGREGATIONS_ERROR](state) { + state.aggregations = { fetching: false, error: true, data: [] }; + }, }; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index b64231a8688..d85a135bb4e 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -14,5 +14,11 @@ const createState = ({ query, navigation }) => ({ }, sidebarDirty: false, navigation, + aggregations: { + error: false, + fetching: false, + data: [], + }, }); + export default createState; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 0629bea3239..da6039f4758 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,7 +1,6 @@ <script> import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; @@ -31,7 +30,6 @@ export default { ProjectFilter, MarkdownDrawer, }, - mixins: [glFeatureFlagsMixin()], props: { groupInitialJson: { type: Object, @@ -70,9 +68,6 @@ export default { showSyntaxOptions() { return this.elasticsearchEnabled && this.isDefaultBranch; }, - hasVerticalNav() { - return this.glFeatures.searchPageVerticalNav; - }, isDefaultBranch() { return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; }, @@ -130,6 +125,6 @@ export default { <project-filter :initial-data="projectInitialJson" /> </div> </div> - <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> + <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> </section> </template> diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 7828efc358a..3ebd21609a6 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -4,6 +4,7 @@ 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 AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; @@ -51,6 +52,7 @@ export default { UserCalloutDismisser, TrainingProviderList, }, + directives: { SafeHtml }, inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'], props: { augmentedSecurityFeatures: { @@ -143,7 +145,7 @@ export default { variant="danger" @dismiss="dismissAlert" > - {{ errorMessage }} + <span v-safe-html="errorMessage"></span> </gl-alert> <local-storage-sync v-model="autoDevopsEnabledAlertDismissedProjects" diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 77216408c39..c87dcef6a93 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -9,7 +9,6 @@ import { REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_CLUSTER_IMAGE_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, REPORT_TYPE_CORPUS_MANAGEMENT, REPORT_TYPE_API_FUZZING, @@ -105,18 +104,6 @@ export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( { anchor: 'configuration' }, ); -export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); -export const CLUSTER_IMAGE_SCANNING_DESCRIPTION = __( - 'Check your Kubernetes cluster images for known vulnerabilities.', -); -export const CLUSTER_IMAGE_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/cluster_image_scanning/index', -); -export const CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/cluster_image_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.', @@ -153,7 +140,6 @@ export const SCANNER_NAMES_MAP = { DAST: DAST_SHORT_NAME, API_FUZZING: API_FUZZING_NAME, CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, - CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, @@ -213,13 +199,6 @@ export const securityFeatures = [ type: REPORT_TYPE_CONTAINER_SCANNING, }, { - name: CLUSTER_IMAGE_SCANNING_NAME, - description: CLUSTER_IMAGE_SCANNING_DESCRIPTION, - helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH, - configurationHelpPath: CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING, - }, - { name: SECRET_DETECTION_NAME, description: SECRET_DETECTION_DESCRIPTION, helpPath: SECRET_DETECTION_HELP_PATH, diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js index f2c3f28cefa..654263ba27b 100644 --- a/app/assets/javascripts/service_ping_consent.js +++ b/app/assets/javascripts/service_ping_consent.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { createAlert, hideFlash } from './flash'; +import { createAlert } from './flash'; import axios from './lib/utils/axios_utils'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; @@ -18,7 +18,7 @@ export default () => { }; const hideConsentMessage = () => - hideFlash(document.querySelector('.service-ping-consent-message')); + document.querySelector('.service-ping-consent-message .js-close')?.click(); axios .put(url, data) diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 240e12ee597..323f6f23df6 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; export default { @@ -19,7 +19,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index d17c8a123d5..73cd0044c16 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; @@ -67,7 +67,7 @@ export default { }, issuableType: { type: String, - default: 'issue', + default: TYPE_ISSUE, required: false, }, }, diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index cf07752a0b8..5cdebee04ad 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,4 +1,5 @@ <script> +import { TYPE_ISSUE } from '~/issues/constants'; import CollapsedAssigneeList from './collapsed_assignee_list.vue'; import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; @@ -22,7 +23,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue index 46bda26c327..fab856883cc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -1,4 +1,5 @@ <script> +import { TYPE_ISSUE } from '~/issues/constants'; import AssigneeAvatar from './assignee_avatar.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -15,7 +16,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index f894ef0c42d..d2f0ceb19c9 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import CollapsedAssignee from './collapsed_assignee.vue'; @@ -41,7 +42,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index fd51cd5bb16..ed29ccb3447 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { n__ } from '~/locale'; import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; @@ -16,7 +17,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, signedIn: { type: Boolean, diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 7979f450fdd..caf3bb2f798 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,6 +1,7 @@ <script> import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; @@ -34,7 +35,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, issuableIid: { type: String, @@ -63,7 +64,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, queryVariables() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index d6c679f2f07..8893e90b1e5 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -2,7 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, n__ } from '~/locale'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -58,9 +58,9 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest, IssuableType.Alert].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest, IssuableType.Alert].includes(value); }, }, issuableId: { @@ -118,7 +118,7 @@ export default { computed: { shouldEnableRealtime() { // Note: Realtime is only available on issues right now, future support for MR wil be built later. - return this.issuableType === IssuableType.Issue; + return this.issuableType === TYPE_ISSUE; }, queryVariables() { return { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 29298ef7627..ddbd8866680 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; const AVAILABILITY_STATUS = { @@ -21,7 +21,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index d83ae782e26..71f349bb87e 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,6 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -21,7 +21,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, data() { diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue index 6afaee91d7a..1eeb725d5c9 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_EPIC, WorkspaceType } from '~/issues/constants'; import { confidentialityInfoText } from '~/vue_shared/constants'; export default { @@ -25,7 +25,7 @@ export default { computed: { confidentialBodyText() { return confidentialityInfoText( - this.issuableType === IssuableType.Epic ? WorkspaceType.group : WorkspaceType.project, + this.issuableType === TYPE_EPIC ? WorkspaceType.group : WorkspaceType.project, this.issuableType, ); }, diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index dbedfe57325..f7526bcff3d 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '../../constants'; @@ -53,11 +53,14 @@ export default { ? this.$options.i18n.confidentialityOffWarning : this.$options.i18n.confidentialityOnWarning; }, + isIssue() { + return this.issuableType === TYPE_ISSUE; + }, context() { - return this.issuableType === IssuableType.Issue ? __('project') : __('group'); + return this.isIssue ? __('project') : __('group'); }, workspacePath() { - return this.issuableType === IssuableType.Issue + return this.isIssue ? { projectPath: this.fullPath, } @@ -66,7 +69,7 @@ export default { }; }, permissions() { - return this.issuableType === IssuableType.Issue + return this.isIssue ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'); }, diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 0660e4f58e4..c9ecaf4102f 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -3,7 +3,7 @@ import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql'; import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql'; @@ -65,7 +65,7 @@ export default { return this.contacts?.length; }, queryVariables() { - return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) }; + return { id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId) }; }, contactsLabel() { return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index eb48732f558..77be8022ec0 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants'; @@ -142,7 +142,7 @@ export default { return dateInWords(this.parsedDate, true); }, workspacePath() { - return this.issuableType === IssuableType.Issue + return this.issuableType === TYPE_ISSUE ? { projectPath: this.fullPath, } @@ -235,7 +235,7 @@ export default { help: __('Help'), learnMore: __('Learn more'), }, - dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date', + dateHelpUrl: '/help/user/group/epics/manage_epics.md#start-and-due-date-inheritance', }; </script> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql index 2904857270e..d7456a71aff 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql @@ -1,9 +1,9 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" -query issueLabels($fullPath: ID!, $iid: String) { +query issueLabels($fullPath: ID!, $iid: String, $types: [IssueType!]) { workspace: project(fullPath: $fullPath) { id - issuable: issue(iid: $iid) { + issuable: issue(iid: $iid, types: $types) { id labels { nodes { diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql new file mode 100644 index 00000000000..9ff7ce64d3b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql @@ -0,0 +1,20 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation updateTestCaseLabels($input: UpdateIssueInput!) { + updateIssuableLabels: updateIssue(input: $input) { + issuable: issue { + id + updatedAt + updatedBy { + ...Author + } + labels { + nodes { + ...Label + } + } + } + errors + } +} 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 b7b4bbac661..bf916e26a15 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 @@ -4,7 +4,7 @@ import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labe import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; import { issuableLabelsQueries } from '../../../constants'; @@ -161,10 +161,16 @@ export default { return !isDropdownVariantSidebar(this.variant); }, variables() { - return { + const queryVariables = { iid: this.iid, fullPath: this.fullPath, }; + + if (this.issuableType === IssuableType.TestCase) { + queryVariables.types = ['TEST_CASE']; + } + + return queryVariables; }, update(data) { return data.workspace?.issuable; @@ -255,14 +261,15 @@ export default { }; switch (this.issuableType) { - case IssuableType.Issue: + case TYPE_ISSUE: + case IssuableType.TestCase: return updateVariables; case IssuableType.MergeRequest: return { ...updateVariables, operationMode: MutationOperationMode.Replace, }; - case IssuableType.Epic: + case TYPE_EPIC: return { iid: currentIid, groupPath: this.fullPath, @@ -311,7 +318,8 @@ export default { }; switch (this.issuableType) { - case IssuableType.Issue: + case TYPE_ISSUE: + case IssuableType.TestCase: return { ...removeVariables, removeLabelIds: [labelId], @@ -322,7 +330,7 @@ export default { labelIds: [labelId], operationMode: MutationOperationMode.Remove, }; - case IssuableType.Epic: + case TYPE_EPIC: return { iid: this.iid, removeLabelIds: [getIdFromGraphQLId(labelId)], diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index cdce6617591..9d8f1304911 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,6 +1,7 @@ <script> import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createAlert } from '~/flash'; @@ -9,7 +10,6 @@ import eventHub from '../../event_hub'; import EditForm from './edit_form.vue'; export default { - issue: 'issue', locked: { icon: 'lock', class: 'value', @@ -49,7 +49,7 @@ export default { return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar; }, issuableDisplayName() { - const isInIssuePage = this.getNoteableData.targetType === this.$options.issue; + const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE; return isInIssuePage ? __('issue') : __('merge request'); }, isLocked() { diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue index 1fff089eab4..8072154cd28 100644 --- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue @@ -1,8 +1,8 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { TYPE_MILESTONE } from '~/graphql_shared/constants'; +import { TYPENAME_MILESTONE } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import { __ } from '~/locale'; import { IssuableAttributeType } from '../../constants'; import SidebarDropdown from '../sidebar_dropdown.vue'; @@ -37,7 +37,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, inputName: { @@ -71,7 +71,10 @@ export default { data() { return { milestone: this.milestoneId - ? { id: convertToGraphQLId(TYPE_MILESTONE, this.milestoneId), title: this.milestoneTitle } + ? { + id: convertToGraphQLId(TYPENAME_MILESTONE, this.milestoneId), + title: this.milestoneTitle, + } : placeholderMilestone, }; }, diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue index 02323e5a0c6..9f64ddc8721 100644 --- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue @@ -206,7 +206,7 @@ export default { category="primary" variant="confirm" :disabled="!Boolean(selectedProject)" - class="gl-text-center! issuable-move-button" + class="gl-w-full issuable-move-button" @click="handleMoveClick" >{{ __('Move') }}</gl-button > diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue new file mode 100644 index 00000000000..e1259fad6a7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue @@ -0,0 +1,71 @@ +<script> +import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue'; +import { __ } from '~/locale'; +import { createAlert } from '~/flash'; +import { visitUrl } from '~/lib/utils/url_utility'; +import moveIssueMutation from '../../queries/move_issue.mutation.graphql'; + +export default { + name: 'MoveIssueButton', + components: { ProjectSelect }, + inject: ['projectsAutocompleteEndpoint', 'projectFullPath', 'issueIid'], + + i18n: { + title: __('Move issue'), + titleInProgress: __('Moving issue'), + moveErrorMessage: __('An error occurred while moving the issue.'), + }, + data() { + return { + moveInProgress: false, + }; + }, + computed: { + dropdownButtonTitle() { + return this.moveInProgress ? this.$options.i18n.titleInProgress : this.$options.i18n.title; + }, + }, + methods: { + moveIssue(targetProject) { + this.moveInProgress = true; + return this.$apollo + .mutate({ + mutation: moveIssueMutation, + variables: { + moveIssueInput: { + projectPath: this.projectFullPath, + iid: this.issueIid, + targetProjectPath: targetProject.full_path, + }, + }, + }) + .then(({ data = {} }) => { + if (!data.issueMove) return; + + const { errors } = data.issueMove; + if (errors?.length > 0) { + throw new Error(`Error moving the issue. Error message: ${errors[0].message}`); + } + visitUrl(data.issueMove?.issue.webUrl); + }) + .catch((error) => { + this.moveInProgress = false; + createAlert({ + message: this.$options.i18n.moveErrorMessage, + captureError: true, + error, + }); + }); + }, + }, +}; +</script> +<template> + <project-select + :projects-fetch-path="projectsAutocompleteEndpoint" + :dropdown-button-title="dropdownButtonTitle" + :dropdown-header-title="$options.i18n.title" + :move-in-progress="moveInProgress" + @move-issuable="moveIssue" + /> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue index f69c027e201..56ac4c39e84 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -2,6 +2,7 @@ // 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 { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import ReviewerAvatar from './reviewer_avatar.vue'; @@ -34,7 +35,7 @@ export default { }, issuableType: { type: String, - default: 'issue', + default: TYPE_ISSUE, required: false, }, }, diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 7af8dcb4e3e..bd1d9fbff0c 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -1,6 +1,7 @@ <script> // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 +import { TYPE_ISSUE } from '~/issues/constants'; import CollapsedReviewerList from './collapsed_reviewer_list.vue'; import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; @@ -28,7 +29,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index faa36f3d8d2..8dd58d33ecf 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -4,8 +4,8 @@ import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../../event_hub'; import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql'; @@ -26,7 +26,6 @@ export default { ReviewerTitle, Reviewers, }, - mixins: [glFeatureFlagsMixin()], props: { mediator: { type: Object, @@ -39,7 +38,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, issuableIid: { type: String, @@ -78,7 +77,7 @@ export default { }; }, skip() { - return !this.issuable?.id || !this.isRealtimeEnabled; + return !this.issuable?.id; }, updateQuery( _, @@ -119,9 +118,6 @@ export default { canUpdate() { return this.issuable.userPermissions?.adminMergeRequest || false; }, - isRealtimeEnabled() { - return this.glFeatures.realtimeReviewers; - }, }, created() { this.store = new Store(); 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 217ca2e2548..a3710d9534e 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { TYPE_ISSUE } from '~/issues/constants'; import { __, sprintf, s__ } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; @@ -30,7 +31,7 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: TYPE_ISSUE, }, }, data() { diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue deleted file mode 100644 index 5b624c17b0c..00000000000 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ /dev/null @@ -1,195 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlTooltip, - GlSprintf, - GlButton, -} from '@gitlab/ui'; -import { createAlert } from '~/flash'; -import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; -import SeverityToken from './severity.vue'; - -export default { - i18n: I18N, - components: { - GlLoadingIcon, - GlTooltip, - GlSprintf, - GlDropdown, - GlDropdownItem, - GlButton, - SeverityToken, - }, - inject: ['canUpdate'], - props: { - projectPath: { - type: String, - required: true, - }, - iid: { - type: String, - required: true, - }, - initialSeverity: { - type: String, - required: false, - default: INCIDENT_SEVERITY.UNKNOWN.value, - }, - issuableType: { - type: String, - required: false, - default: ISSUABLE_TYPES.INCIDENT, - validator: (value) => { - // currently severity is supported only for incidents, but this list might be extended - return [ISSUABLE_TYPES.INCIDENT].includes(value); - }, - }, - }, - data() { - return { - isDropdownShowing: false, - isUpdating: false, - severity: this.initialSeverity, - }; - }, - computed: { - severitiesList() { - switch (this.issuableType) { - case ISSUABLE_TYPES.INCIDENT: - return Object.values(INCIDENT_SEVERITY); - default: - return []; - } - }, - dropdownClass() { - return this.isDropdownShowing ? 'show' : 'gl-display-none'; - }, - selectedItem() { - return this.severitiesList.find((severity) => severity.value === this.severity); - }, - }, - mounted() { - document.addEventListener('click', this.handleOffClick); - }, - beforeDestroy() { - document.removeEventListener('click', this.handleOffClick); - }, - methods: { - handleOffClick(event) { - if (!this.isDropdownShowing) { - return; - } - - if (!this.$refs.sidebarSeverity.contains(event.target)) { - this.hideDropdown(); - } - }, - hideDropdown() { - this.isDropdownShowing = false; - const event = new Event('hidden.gl.dropdown'); - this.$el.dispatchEvent(event); - }, - toggleFormDropdown() { - this.isDropdownShowing = !this.isDropdownShowing; - }, - updateSeverity(value) { - this.hideDropdown(); - this.isUpdating = true; - this.$apollo - .mutate({ - mutation: updateIssuableSeverity, - variables: { - iid: this.iid, - severity: value, - projectPath: this.projectPath, - }, - }) - .then((resp) => { - const { - data: { - issueSetSeverity: { - errors = [], - issue: { severity }, - }, - }, - } = resp; - - if (errors[0]) { - throw errors[0]; - } - this.severity = severity; - }) - .catch(() => - createAlert({ - message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`, - }), - ) - .finally(() => { - this.isUpdating = false; - }); - }, - }, -}; -</script> - -<template> - <div ref="sidebarSeverity" class="block"> - <div ref="severity" class="sidebar-collapsed-icon" @click="toggleFormDropdown"> - <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" /> - <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left"> - <gl-sprintf :message="$options.i18n.SEVERITY_VALUE"> - <template #severity> - {{ selectedItem.label }} - </template> - </gl-sprintf> - </gl-tooltip> - </div> - - <div class="hide-collapsed"> - <div - class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold" - > - {{ $options.i18n.SEVERITY }} - <gl-button - v-if="canUpdate" - category="tertiary" - size="small" - class="gl-ml-auto hide-collapsed gl-mr-n2" - data-testid="editButton" - @click="toggleFormDropdown" - @keydown.esc="hideDropdown" - > - {{ $options.i18n.EDIT }} - </gl-button> - </div> - - <gl-dropdown - class="gl-mt-3" - :class="dropdownClass" - block - :header-text="__('Assign severity')" - :text="selectedItem.label" - toggle-class="dropdown-menu-toggle gl-mb-2" - @keydown.esc.native="hideDropdown" - > - <gl-dropdown-item - v-for="option in severitiesList" - :key="option.value" - data-testid="severityDropdownItem" - is-check-item - :is-checked="option.value === severity" - @click="updateSeverity(option.value)" - > - <severity-token :severity="option" /> - </gl-dropdown-item> - </gl-dropdown> - - <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" /> - - <severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue new file mode 100644 index 00000000000..ecb9a2809a0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue @@ -0,0 +1,154 @@ +<script> +import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; +import SeverityToken from './severity.vue'; + +export default { + i18n: I18N, + components: { + GlTooltip, + GlSprintf, + GlDropdown, + GlDropdownItem, + SeverityToken, + SidebarEditableItem, + }, + inject: ['canUpdate'], + props: { + projectPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + initialSeverity: { + type: String, + required: false, + default: INCIDENT_SEVERITY.UNKNOWN.value, + }, + issuableType: { + type: String, + required: false, + default: ISSUABLE_TYPES.INCIDENT, + validator: (value) => { + // currently severity is supported only for incidents, but this list might be extended + return [ISSUABLE_TYPES.INCIDENT].includes(value); + }, + }, + }, + data() { + return { + isUpdating: false, + severity: this.initialSeverity, + }; + }, + computed: { + severitiesList() { + switch (this.issuableType) { + case ISSUABLE_TYPES.INCIDENT: + return Object.values(INCIDENT_SEVERITY); + default: + return []; + } + }, + selectedItem() { + return this.severitiesList.find((severity) => severity.value === this.severity); + }, + }, + methods: { + updateSeverity(value) { + this.$refs.toggle.collapse(); + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: updateIssuableSeverity, + variables: { + iid: this.iid, + severity: value, + projectPath: this.projectPath, + }, + }) + .then((resp) => { + const { + data: { + issueSetSeverity: { + errors = [], + issue: { severity }, + }, + }, + } = resp; + + if (errors[0]) { + throw errors[0]; + } + this.severity = severity; + }) + .catch(() => + createAlert({ + message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`, + }), + ) + .finally(() => { + this.isUpdating = false; + }); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, + }, +}; +</script> + +<template> + <div ref="sidebarSeverity" class="block"> + <sidebar-editable-item + ref="toggle" + :loading="isUpdating" + :title="$options.i18n.SEVERITY" + :can-edit="canUpdate" + @open="showDropdown" + > + <template #collapsed> + <div ref="severity" class="sidebar-collapsed-icon"> + <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" /> + <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left"> + <gl-sprintf :message="$options.i18n.SEVERITY_VALUE"> + <template #severity> + {{ selectedItem.label }} + </template> + </gl-sprintf> + </gl-tooltip> + </div> + <div class="hide-collapsed"> + <severity-token :severity="selectedItem" /> + </div> + </template> + + <template #default> + <gl-dropdown + ref="dropdown" + class="gl-mt-3" + block + :header-text="__('Assign severity')" + :text="selectedItem.label" + > + <gl-dropdown-item + v-for="option in severitiesList" + :key="option.value" + data-testid="severityDropdownItem" + is-check-item + :is-checked="option.value === severity" + @click="updateSeverity(option.value)" + > + <severity-token :severity="option" /> + </gl-dropdown-item> + </gl-dropdown> + </template> + </sidebar-editable-item> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue index 26e2bc96f54..d68e4974ea4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue @@ -8,7 +8,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import { __ } from '~/locale'; import { defaultEpicSort, @@ -70,7 +70,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, workspaceType: { @@ -155,7 +155,7 @@ export default { }, isEpic() { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 - return this.issuableAttribute === IssuableType.Epic; + return this.issuableAttribute === TYPE_EPIC; }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 35667495ace..5df65c4aaaf 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab import { kebabCase, snakeCase } from 'lodash'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -71,7 +71,7 @@ export default { type: String, required: true, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value); }, }, icon: { @@ -153,7 +153,7 @@ export default { }, isEpic() { // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 - return this.issuableAttribute === IssuableType.Epic; + return this.issuableAttribute === TYPE_EPIC; }, formatIssuableAttribute() { return { @@ -188,7 +188,7 @@ export default { fullPath: this.workspacePath, attributeId: this.issuableAttribute === IssuableAttributeType.Milestone && - this.issuableType === IssuableType.Issue + this.issuableType === TYPE_ISSUE ? getIdFromGraphQLId(id) : id, iid: this.iid, diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 0fba1cb5e4e..cbe839d1112 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,7 +1,7 @@ <script> import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -105,7 +105,7 @@ export default { return ICON_ON; }, parentIsGroup() { - return this.issuableType === IssuableType.Epic; + return this.issuableType === TYPE_EPIC; }, subscribeDisabledDescription() { return sprintf(__('Disabled by %{parent} owner'), { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue index ec8e1ee9952..964da3b6138 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -10,8 +10,9 @@ import { GlSprintf, } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate } from '~/lib/utils/datetime_utility'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createTimelogMutation from '../../queries/create_timelog.mutation.graphql'; @@ -127,10 +128,10 @@ export default { }); }, isIssue() { - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, getGraphQLEntityType() { - return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST; }, updateSpentAtDate(val) { this.spentAt = val; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 6f4ced06ddf..cffbb6466f2 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -1,8 +1,9 @@ <script> import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; import { timelogQueries } from '../../constants'; @@ -61,7 +62,7 @@ export default { return this.removingIds.includes(timelogId); }, isIssue() { - return this.issuableType === 'issue'; + return this.issuableType === TYPE_ISSUE; }, getQueryVariables() { return { @@ -69,7 +70,7 @@ export default { }; }, getGraphQLEntityType() { - return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST; }, extractTimelogs(data) { const timelogs = data?.issuable?.timelogs?.nodes || []; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index b32836dc87d..c645b1649d2 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -8,7 +8,7 @@ import { GlLoadingIcon, GlTooltipDirective, } from '@gitlab/ui'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; @@ -173,10 +173,7 @@ export default { return Boolean(this.showHelp); }, isTimeReportSupported() { - return ( - [IssuableType.Issue, IssuableType.MergeRequest].includes(this.issuableType) && - this.issuableId - ); + return [TYPE_ISSUE, IssuableType.MergeRequest].includes(this.issuableType) && this.issuableId; }, timeTrackingIconTitle() { return this.showHelpState ? '' : HOW_TO_TRACK_TIME; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 825a89daf58..14491226b15 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -3,8 +3,9 @@ import { s__, __, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql'; @@ -63,7 +64,7 @@ export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; export const assigneesQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: getIssueAssignees, subscription: issuableAssigneesSubscription, mutation: updateIssueAssigneesMutation, @@ -79,13 +80,13 @@ export const assigneesQueries = { }; export const participantsQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueParticipantsQuery, }, [IssuableType.MergeRequest]: { query: getMergeRequestParticipants, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicParticipantsQuery, }, [IssuableType.Alert]: { @@ -95,7 +96,7 @@ export const participantsQueries = { }; export const userSearchQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: userSearchQuery, }, [IssuableType.MergeRequest]: { @@ -104,24 +105,24 @@ export const userSearchQueries = { }; export const confidentialityQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueConfidentialQuery, mutation: updateIssueConfidentialMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicConfidentialQuery, mutation: updateEpicConfidentialMutation, }, }; export const referenceQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueReferenceQuery, }, [IssuableType.MergeRequest]: { query: mergeRequestReferenceQuery, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicReferenceQuery, }, }; @@ -136,7 +137,7 @@ export const workspaceLabelsQueries = { }; export const issuableLabelsQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { issuableQuery: issueLabelsQuery, mutation: updateIssueLabelsMutation, mutationName: 'updateIssue', @@ -146,11 +147,16 @@ export const issuableLabelsQueries = { mutation: updateMergeRequestLabelsMutation, mutationName: 'mergeRequestSetLabels', }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { issuableQuery: epicLabelsQuery, mutation: updateEpicLabelsMutation, mutationName: 'updateEpic', }, + [IssuableType.TestCase]: { + issuableQuery: issueLabelsQuery, + mutation: updateTestCaseLabelsMutation, + mutationName: 'updateTestCaseLabels', + }, }; export const dateTypes = { @@ -172,11 +178,11 @@ export const dateFields = { }; export const subscribedQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueSubscribedQuery, mutation: updateIssueSubscriptionMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicSubscribedQuery, mutation: updateEpicSubscriptionMutation, }, @@ -192,7 +198,7 @@ export const Tracking = { }; export const timeTrackingQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueTimeTrackingQuery, }, [IssuableType.MergeRequest]: { @@ -201,25 +207,25 @@ export const timeTrackingQueries = { }; export const dueDateQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, }, - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicDueDateQuery, mutation: updateEpicDueDateMutation, }, }; export const startDateQueries = { - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicStartDateQuery, mutation: updateEpicStartDateMutation, }, }; export const timelogQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: getIssueTimelogsQuery, }, [IssuableType.MergeRequest]: { @@ -230,7 +236,7 @@ export const timelogQueries = { export const noAttributeId = null; export const issuableMilestoneQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: projectIssueMilestoneQuery, mutation: projectIssueMilestoneMutation, }, @@ -241,7 +247,7 @@ export const issuableMilestoneQueries = { }; export const milestonesQueries = { - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: { [WorkspaceType.group]: groupMilestonesQuery, [WorkspaceType.project]: projectMilestonesQuery, @@ -277,10 +283,10 @@ export const issuableAttributesQueries = { }; export const todoQueries = { - [IssuableType.Epic]: { + [TYPE_EPIC]: { query: epicTodoQuery, }, - [IssuableType.Issue]: { + [TYPE_ISSUE]: { query: issueTodoQuery, }, [IssuableType.MergeRequest]: { diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js deleted file mode 100644 index 2cce27df598..00000000000 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ /dev/null @@ -1,89 +0,0 @@ -import $ from 'jquery'; -import { escape } from 'lodash'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; - -function isValidProjectId(id) { - return id > 0; -} - -class SidebarMoveIssue { - constructor(mediator, dropdownToggle, confirmButton) { - this.mediator = mediator; - - this.$dropdownToggle = $(dropdownToggle); - this.$confirmButton = $(confirmButton); - - this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this); - } - - init() { - this.initDropdown(); - this.addEventListeners(); - } - - destroy() { - this.removeEventListeners(); - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdownToggle, { - search: { - fields: ['name_with_namespace'], - }, - showMenuAbove: true, - selectable: true, - filterable: true, - filterRemote: true, - multiSelect: false, - // Keep the dropdown open after selecting an option - shouldPropagate: false, - data: (searchTerm, callback) => { - this.mediator - .fetchAutocompleteProjects(searchTerm) - .then(callback) - .catch(() => - createAlert({ - message: __('An error occurred while fetching projects autocomplete.'), - }), - ); - }, - renderRow: (project) => ` - <li> - <a href="#" class="js-move-issue-dropdown-item"> - ${escape(project.name_with_namespace)} - </a> - </li> - `, - clicked: (options) => { - const project = options.selectedObj; - const selectedProjectId = options.isMarking ? project.id : 0; - this.mediator.setMoveToProjectId(selectedProjectId); - - this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId)); - }, - }); - } - - addEventListeners() { - this.$confirmButton.on('click', this.onConfirmClickedWrapper); - } - - removeEventListeners() { - this.$confirmButton.off('click', this.onConfirmClickedWrapper); - } - - onConfirmClicked() { - if (isValidProjectId(this.mediator.store.moveToProjectId)) { - this.$confirmButton.disable().addClass('is-loading'); - - this.mediator.moveIssue().catch(() => { - createAlert({ message: __('An error occurred while moving the issue.') }); - this.$confirmButton.enable().removeClass('is-loading'); - }); - } - } -} - -export default SidebarMoveIssue; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index a308dc8d13c..fb024d818da 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,22 +1,22 @@ -import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { gqlClient } from '~/issues/list/graphql'; import { - isInIssuePage, isInDesignPage, isInIncidentPage, + isInIssuePage, isInMRPage, parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import Translate from '~/vue_shared/translate'; +import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; @@ -34,7 +34,7 @@ import SidebarParticipantsWidget from './components/participants/sidebar_partici import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue'; -import SidebarSeverity from './components/severity/sidebar_severity.vue'; +import SidebarSeverityWidget from './components/severity/sidebar_severity_widget.vue'; import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue'; import StatusDropdown from './components/status/status_dropdown.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; @@ -43,8 +43,8 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue'; import { IssuableAttributeType } from './constants'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; -import SidebarMoveIssue from './lib/sidebar_move_issue'; import trackShowInviteMemberLink from './track_invite_members'; +import MoveIssueButton from './components/move/move_issue_button.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -75,12 +75,12 @@ function mountSidebarTodoWidget() { fullPath: projectPath, issuableId: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? convertToGraphQLId(TYPE_ISSUE, id) - : convertToGraphQLId(TYPE_MERGE_REQUEST, id), + ? convertToGraphQLId(TYPENAME_ISSUE, id) + : convertToGraphQLId(TYPENAME_MERGE_REQUEST, id), issuableIid: iid, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -124,7 +124,7 @@ function mountSidebarAssigneesDeprecated(mediator) { signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'), issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, issuableId: id, assigneeAvailabilityStatus, @@ -142,7 +142,7 @@ function mountSidebarAssigneesWidget() { const { id, iid, fullPath, editable } = getSidebarOptions(); const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage(); - const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest; + const issuableType = isIssuablePage ? TYPE_ISSUE : IssuableType.MergeRequest; // eslint-disable-next-line no-new new Vue({ el, @@ -205,7 +205,7 @@ function mountSidebarReviewers(mediator) { projectPath: fullPath, field: el.dataset.field, issuableType: - isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), }); @@ -276,7 +276,7 @@ function mountSidebarMilestoneWidget() { workspacePath: projectPath, iid: issueIid, issuableType: - isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest, issuableAttribute: IssuableAttributeType.Milestone, icon: 'clock', }, @@ -313,7 +313,7 @@ export function mountMilestoneDropdown() { attrWorkspacePath: fullPath, canAdminMilestone, inputName, - issuableType: isInIssuePage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableType: isInIssuePage() ? TYPE_ISSUE : IssuableType.MergeRequest, milestoneId, milestoneTitle, projectMilestonesPath, @@ -357,7 +357,7 @@ export function mountSidebarLabelsWidget() { variant: DropdownVariant.Sidebar, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, workspaceType: 'project', attrWorkspacePath: el.dataset.projectPath, @@ -397,7 +397,7 @@ function mountSidebarConfidentialityWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -425,7 +425,7 @@ function mountSidebarDueDateWidget() { props: { iid: String(iid), fullPath, - issuableType: IssuableType.Issue, + issuableType: TYPE_ISSUE, }, }), }); @@ -453,7 +453,7 @@ function mountSidebarReferenceWidget() { props: { issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -505,7 +505,7 @@ function mountSidebarParticipantsWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -535,7 +535,7 @@ function mountSidebarSubscriptionsWidget() { fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue + ? TYPE_ISSUE : IssuableType.MergeRequest, }, }), @@ -576,8 +576,8 @@ function mountSidebarTimeTracking() { }); } -function mountSidebarSeverity() { - const el = document.querySelector('.js-sidebar-severity-root'); +function mountSidebarSeverityWidget() { + const el = document.querySelector('.js-sidebar-severity-widget-root'); if (!el) { return null; @@ -587,13 +587,13 @@ function mountSidebarSeverity() { return new Vue({ el, - name: 'SidebarSeverityRoot', + name: 'SidebarSeverityWidgetRoot', apolloProvider, provide: { canUpdate: editable, }, render: (createElement) => - createElement(SidebarSeverity, { + createElement(SidebarSeverityWidget, { props: { projectPath: fullPath, iid: String(iid), @@ -701,6 +701,95 @@ export function mountSubscriptionsDropdown() { }); } +export function mountMoveIssueButton() { + const el = document.querySelector('.js-sidebar-move-issue-block'); + + if (!el) { + return null; + } + + const { projectsAutocompleteEndpoint } = getSidebarOptions(); + const { projectFullPath, issueIid } = el.dataset; + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'MoveIssueDropdownRoot', + apolloProvider, + provide: { + projectsAutocompleteEndpoint, + projectFullPath, + issueIid, + }, + render: (createElement) => createElement(MoveIssueButton), + }); +} + +export function mountAssigneesDropdown() { + const el = document.querySelector('.js-assignee-dropdown'); + const assigneeIdsInput = document.querySelector('.js-assignee-ids-input'); + + if (!el || !assigneeIdsInput) { + return null; + } + + const { fullPath } = el.dataset; + const currentUser = { + id: gon?.current_user_id, + username: gon?.current_username, + name: gon?.current_user_fullname, + avatarUrl: gon?.current_user_avatar_url, + }; + + return new Vue({ + el, + apolloProvider, + data() { + return { + selectedUserName: '', + value: [], + }; + }, + methods: { + onSelectedUnassigned() { + assigneeIdsInput.value = 0; + this.value = []; + this.selectedUserName = __('Unassigned'); + }, + onSelected(selected) { + assigneeIdsInput.value = selected.map((user) => getIdFromGraphQLId(user.id)); + this.value = selected; + this.selectedUserName = selected.map((user) => user.name).join(', '); + }, + }, + render(h) { + const component = this; + + return h(UserSelect, { + props: { + text: component.selectedUserName || __('Select assignee'), + headerText: __('Assign to'), + fullPath, + currentUser, + value: component.value, + }, + on: { + input(selected) { + if (!selected.length) { + component.onSelectedUnassigned(); + return; + } + + component.onSelected(selected); + }, + }, + class: 'gl-w-full', + }); + }, + }); +} + const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; @@ -725,14 +814,9 @@ export function mountSidebar(mediator, store) { mountSidebarSubscriptionsWidget(); mountCopyEmailToClipboard(); mountSidebarTimeTracking(); - mountSidebarSeverity(); + mountSidebarSeverityWidget(); mountSidebarEscalationStatus(); - - new SidebarMoveIssue( - mediator, - $('.js-move-issue'), - $('.js-move-issue-confirmation-button'), - ).init(); + mountMoveIssueButton(); } export { getSidebarOptions }; diff --git a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql index d350072425b..e3ed3c5089b 100644 --- a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql @@ -1,5 +1,9 @@ mutation moveIssue($moveIssueInput: IssueMoveInput!) { issueMove(input: $moveIssueInput) { + issue { + id + webUrl + } errors } } diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 00d3177b75a..af267f65502 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,4 +1,4 @@ -import { TYPE_USER } from '~/graphql_shared/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; @@ -54,7 +54,7 @@ export default class SidebarService { return gqClient.mutate({ mutation: reviewerRereviewMutation, variables: { - userId: convertToGraphQLId(TYPE_USER, `${userId}`), + userId: convertToGraphQLId(TYPENAME_USER, `${userId}`), projectPath: this.fullPath, iid: this.iid.toString(), }, diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js index 6d0e4770e1c..277d43e43a4 100644 --- a/app/assets/javascripts/snippet/snippet_show.js +++ b/app/assets/javascripts/snippet/snippet_show.js @@ -3,11 +3,13 @@ import initDeprecatedNotes from '~/init_deprecated_notes'; import SnippetsAppFactory from '~/snippets'; import SnippetsShow from '~/snippets/components/show.vue'; import ZenMode from '~/zen_mode'; +import { initReportAbuse } from '~/projects/report_abuse'; SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow); initDeprecatedNotes(); loadAwardsHandler(); +initReportAbuse(); // eslint-disable-next-line no-new new ZenMode(); diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue deleted file mode 100644 index fea29458f45..00000000000 --- a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - i18n: { - help: __('Help'), - new: __('New'), - }, -}; -</script> - -<template> - <div class="bottom-links gl-p-3"> - <a href="#" class="gl-text-black-normal" - ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{ - $options.i18n.help - }}</a - > - </div> -</template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index d790e61ca31..62a1e5a6b20 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -40,9 +40,9 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold" + 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 gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none" > <gl-icon aria-hidden="true" :name="icon" /> - <span aria-hidden="true">{{ count }}</span> + <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span> </component> </template> diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue new file mode 100644 index 00000000000..e92a6cbf5f5 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -0,0 +1,38 @@ +<script> +import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdown, + GlTooltip, + }, + i18n: { + createNew: __('Create new...'), + }, + props: { + groups: { + type: Array, + required: true, + }, + }, + toggleId: 'create-menu-toggle', +}; +</script> + +<template> + <div> + <gl-disclosure-dropdown + category="tertiary" + icon="plus" + :items="groups" + no-caret + text-sr-only + :toggle-text="$options.i18n.createNew" + :toggle-id="$options.toggleId" + /> + <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar"> + {{ $options.i18n.createNew }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue new file mode 100644 index 00000000000..8e7c7efa631 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -0,0 +1,178 @@ +<script> +import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { STORAGE_KEY } from '~/whats_new/utils/notification'; + +export default { + components: { + GlBadge, + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GitlabVersionCheckBadge, + }, + i18n: { + help: __('Help'), + support: __('Support'), + docs: __('GitLab documentation'), + plans: __('Compare GitLab plans'), + forum: __('Community forum'), + contribute: __('Contribute to GitLab'), + feedback: __('Provide feedback'), + shortcuts: __('Keyboard shortcuts'), + version: __('Your GitLab version'), + whatsnew: __("What's new"), + }, + props: { + sidebarData: { + type: Object, + required: true, + }, + }, + data() { + return { + showWhatsNewNotification: this.shouldShowWhatsNewNotification(), + }; + }, + computed: { + itemGroups() { + return { + versionCheck: { + items: [ + { + text: this.$options.i18n.version, + href: helpPagePath('update/index'), + version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`, + }, + ], + }, + helpLinks: { + items: [ + { text: this.$options.i18n.help, href: helpPagePath() }, + { text: this.$options.i18n.support, href: this.sidebarData.support_path }, + { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' }, + { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` }, + { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' }, + { + text: this.$options.i18n.contribute, + href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + }, + { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, + ], + }, + helpActions: { + items: [ + { + text: this.$options.i18n.shortcuts, + action: this.showKeyboardShortcuts, + shortcut: '?', + }, + this.sidebarData.display_whats_new && { + text: this.$options.i18n.whatsnew, + action: this.showWhatsNew, + count: + this.showWhatsNewNotification && + this.sidebarData.whats_new_most_recent_release_items_count, + }, + ].filter(Boolean), + }, + }; + }, + updateSeverity() { + return this.sidebarData.gitlab_version_check?.severity; + }, + }, + methods: { + shouldShowWhatsNewNotification() { + if ( + !this.sidebarData.display_whats_new || + localStorage.getItem(STORAGE_KEY) === this.sidebarData.whats_new_version_digest + ) { + return false; + } + return true; + }, + + handleAction({ action }) { + if (action) { + action(); + } + }, + + showKeyboardShortcuts() { + this.$refs.dropdown.close(); + window?.toggleShortcutsHelp(); + }, + + async showWhatsNew() { + this.$refs.dropdown.close(); + 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); + } else { + this.toggleWhatsNewDrawer(); + } + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown ref="dropdown"> + <template #toggle> + <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> + <span v-if="showWhatsNewNotification" class="notification"></span> + {{ $options.i18n.help }} + </gl-button> + </template> + + <gl-disclosure-dropdown-group + v-if="sidebarData.show_version_check" + :group="itemGroups.versionCheck" + > + <template #list-item="{ item }"> + <a + :href="item.href" + tabindex="-1" + class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none" + > + <span class="gl-font-sm gl-font-weight-bold"> + {{ item.text }} + <gl-emoji data-name="rocket" /> + </span> + <span> + <span class="gl-mr-2">{{ item.version }}</span> + <gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" /> + </span> + </a> + </template> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group + :group="itemGroups.helpLinks" + :bordered="sidebarData.show_version_check" + /> + + <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction"> + <template #list-item="{ item }"> + <button + tabindex="-1" + class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900" + > + {{ item.text }} + <gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge> + <kbd v-else-if="item.shortcut" class="flat">?</kbd> + </button> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue new file mode 100644 index 00000000000..edc13e305cf --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -0,0 +1,40 @@ +<script> +import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlDisclosureDropdown, + }, + props: { + items: { + type: Array, + required: true, + }, + }, + methods: { + navigate() { + this.$refs.link.click(); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :items="items" placement="center" @action="navigate"> + <template #toggle> + <slot></slot> + </template> + <template #list-item="{ item }"> + <a + ref="link" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900" + :href="item.href" + tabindex="-1" + > + {{ item.text }} + <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge> + </a> + </template> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index e2eac64f5ad..c4b769dcf24 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -4,7 +4,7 @@ import { context } from '../mock_data'; import UserBar from './user_bar.vue'; import ContextSwitcherToggle from './context_switcher_toggle.vue'; import ContextSwitcher from './context_switcher.vue'; -import BottomBar from './bottom_bar.vue'; +import HelpCenter from './help_center.vue'; export default { context, @@ -13,7 +13,7 @@ export default { UserBar, ContextSwitcherToggle, ContextSwitcher, - BottomBar, + HelpCenter, }, props: { sidebarData: { @@ -31,7 +31,8 @@ export default { <template> <aside - class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999" + id="super-sidebar" + class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08" data-testid="super-sidebar" > <user-bar :sidebar-data="sidebarData" /> @@ -42,8 +43,8 @@ export default { <context-switcher /> </gl-collapse> </div> - <div class="gl-px-3"> - <bottom-bar /> + <div class="gl-p-3"> + <help-center :sidebar-data="sidebarData" /> </div> </div> </aside> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 7ee1776bf07..ee72e8eafb4 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,10 +1,12 @@ <script> -import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui'; +import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import logo from '../../../../views/shared/_logo.svg'; +import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; +import MergeRequestMenu from './merge_request_menu.vue'; export default { logo, @@ -12,15 +14,19 @@ export default { GlAvatar, GlDropdown, GlIcon, + CreateMenu, NewNavToggle, Counter, + MergeRequestMenu, }, i18n: { + createNew: __('Create new...'), issues: __('Issues'), mergeRequests: __('Merge requests'), todoList: __('To-Do list'), }, directives: { + GlTooltip: GlTooltipDirective, SafeHtml, }, inject: ['rootPath', 'toggleNewNavEndpoint'], @@ -39,11 +45,7 @@ export default { <div class="gl-flex-grow-1"> <a v-safe-html="$options.logo" :href="rootPath"></a> </div> - <gl-dropdown variant="link" no-caret> - <template #button-content> - <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" /> - </template> - </gl-dropdown> + <create-menu :groups="sidebarData.create_new_menu_groups" /> <button class="gl-border-none"> <gl-icon name="search" class="gl-vertical-align-middle" /> </button> @@ -56,17 +58,29 @@ export default { </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" + class="gl-flex-basis-third" icon="issues" :count="sidebarData.assigned_open_issues_count" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" /> + <merge-request-menu + class="gl-flex-basis-third gl-display-block!" + :items="sidebarData.merge_request_menu" + > + <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + class="gl-w-full" + tabindex="-1" + icon="merge-request-open" + :count="sidebarData.total_merge_requests_count" + :label="$options.i18n.mergeRequests" + /> + </merge-request-menu> <counter - icon="merge-request-open" - :count="sidebarData.assigned_open_merge_requests_count" - :label="$options.i18n.mergeRequests" - /> - <counter + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" + class="gl-flex-basis-third" icon="todo-done" :count="sidebarData.todos_pending_count" href="/dashboard/todos" diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index eecf32f83df..0ae97a47170 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -2,7 +2,6 @@ import { GlButton, GlIntersectionObserver } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -26,6 +25,9 @@ export default { data() { return { acceptDisabled: true, + observer: new MutationObserver(() => { + this.setScrollableViewportHeight(); + }), }; }, computed: { @@ -34,23 +36,10 @@ export default { mounted() { this.renderGFM(); this.setScrollableViewportHeight(); - - this.$options.flashElements = [ - ...document.querySelectorAll( - Object.values(FLASH_TYPES) - .map((flashType) => `.flash-${flashType}`) - .join(','), - ), - ]; - - this.$options.flashElements.forEach((flashElement) => { - flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }); + this.observer.observe(document.body, { childList: true, subtree: true }); }, beforeDestroy() { - this.$options.flashElements.forEach((flashElement) => { - flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }); + this.observer.disconnect(); }, methods: { renderGFM() { @@ -70,10 +59,6 @@ export default { scrollHeight - clientHeight }px)`; }, - handleFlashClose(event) { - this.setScrollableViewportHeight(); - event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose); - }, trackTrialAcceptTerms, }, }; @@ -96,7 +81,7 @@ export default { </gl-intersection-observer> </div> </div> - <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end"> + <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end gl-p-5"> <form v-if="permissions.canDecline" method="post" :action="paths.decline"> <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue new file mode 100644 index 00000000000..feaf9072ee2 --- /dev/null +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -0,0 +1,258 @@ +<script> +import { + GlAlert, + GlButton, + GlCard, + GlFormInput, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import inboundAddProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql'; +import inboundRemoveProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql'; +import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; +import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; +import inboundGetProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql'; +import TokenProjectsTable from './token_projects_table.vue'; + +export default { + i18n: { + toggleLabelTitle: s__('CICD|Allow access to this project with a CI_JOB_TOKEN'), + toggleHelpText: s__( + `CICD|Manage which projects can use their CI_JOB_TOKEN to access this project. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`, + ), + cardHeaderTitle: s__( + 'CICD|Allow CI job tokens from the following projects to access this project', + ), + settingDisabledMessage: s__( + 'CICD|Enable feature to allow job token access by the following projects.', + ), + addProject: __('Add project'), + cancel: __('Cancel'), + addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'), + projectsFetchError: __('There was a problem fetching the projects'), + scopeFetchError: __('There was a problem fetching the job token scope value'), + }, + fields: [ + { + key: 'project', + label: __('Project with access'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'namespace', + label: __('Namespace'), + 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: { + GlAlert, + GlButton, + GlCard, + GlFormInput, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, + TokenProjectsTable, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + inboundJobTokenScopeEnabled: { + query: inboundGetCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project.ciCdSettings.inboundJobTokenScopeEnabled; + }, + error() { + createAlert({ message: this.$options.i18n.scopeFetchError }); + }, + }, + projects: { + query: inboundGetProjectsWithCIJobTokenScopeQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project?.ciJobTokenScope?.inboundAllowlist?.nodes ?? []; + }, + error() { + createAlert({ message: this.$options.i18n.projectsFetchError }); + }, + }, + }, + data() { + return { + inboundJobTokenScopeEnabled: null, + targetProjectPath: '', + projects: [], + }; + }, + computed: { + isProjectPathEmpty() { + return this.targetProjectPath === ''; + }, + ciJobTokenHelpPage() { + return helpPagePath('ci/jobs/ci_job_token#allow-access-to-your-project-with-a-job-token'); + }, + }, + methods: { + async updateCIJobTokenScope() { + try { + const { + data: { + ciCdSettingsUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundUpdateCIJobTokenScopeMutation, + variables: { + input: { + fullPath: this.fullPath, + inboundJobTokenScopeEnabled: this.inboundJobTokenScopeEnabled, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + this.inboundJobTokenScopeEnabled = !this.inboundJobTokenScopeEnabled; + createAlert({ message: error.message }); + } + }, + async addProject() { + try { + const { + data: { + ciJobTokenScopeAddProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundAddProjectCIJobTokenScopeMutation, + variables: { + projectPath: this.fullPath, + targetProjectPath: this.targetProjectPath, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: error.message }); + } finally { + this.clearTargetProjectPath(); + this.getProjects(); + } + }, + async removeProject(removeTargetPath) { + try { + const { + data: { + ciJobTokenScopeRemoveProject: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: inboundRemoveProjectCIJobTokenScopeMutation, + variables: { + projectPath: this.fullPath, + targetProjectPath: removeTargetPath, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: error.message }); + } finally { + this.getProjects(); + } + }, + clearTargetProjectPath() { + this.targetProjectPath = ''; + }, + getProjects() { + this.$apollo.queries.projects.refetch(); + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" /> + <template v-else> + <gl-toggle + v-model="inboundJobTokenScopeEnabled" + :label="$options.i18n.toggleLabelTitle" + @change="updateCIJobTokenScope" + > + <template #help> + <gl-sprintf :message="$options.i18n.toggleHelpText"> + <template #link="{ content }"> + <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> + + <div> + <gl-card class="gl-mt-5 gl-mb-3"> + <template #header> + <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> + </template> + <template #default> + <gl-form-input + v-model="targetProjectPath" + :placeholder="$options.i18n.addProjectPlaceholder" + /> + </template> + <template #footer> + <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject"> + {{ $options.i18n.addProject }} + </gl-button> + <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> + </template> + </gl-card> + <gl-alert + v-if="!inboundJobTokenScopeEnabled" + class="gl-mb-3" + variant="warning" + :dismissible="false" + :show-icon="false" + > + {{ $options.i18n.settingDisabledMessage }} + </gl-alert> + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue new file mode 100644 index 00000000000..c774f37b1e4 --- /dev/null +++ b/app/assets/javascripts/token_access/components/opt_in_jwt.vue @@ -0,0 +1,125 @@ +<script> +import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +import { createAlert } from '~/flash'; +import { __, s__ } from '~/locale'; +import updateOptInJwtMutation from '../graphql/mutations/update_opt_in_jwt.mutation.graphql'; +import getOptInJwtSettingQuery from '../graphql/queries/get_opt_in_jwt_setting.query.graphql'; +import { LIMIT_JWT_ACCESS_SNIPPET, OPT_IN_JWT_HELP_LINK } from '../constants'; + +export default { + i18n: { + labelText: s__('CICD|Limit JSON Web Token (JWT) access'), + helpText: s__( + `CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline. %{linkStart}Learn more.%{linkEnd}`, + ), + expandedText: s__( + 'CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT.', + ), + copyToClipboard: __('Copy to clipboard'), + fetchError: s__('CICD|There was a problem fetching the token access settings.'), + updateError: s__('CICD|An error occurred while update the setting. Please try again.'), + }, + components: { + CodeInstruction, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, + }, + inject: ['fullPath'], + apollo: { + optInJwt: { + query: getOptInJwtSettingQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ + project: { + ciCdSettings: { optInJwt }, + }, + }) { + return optInJwt; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + data() { + return { + optInJwt: null, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.optInJwt.loading; + }, + }, + methods: { + async updateOptInJwt() { + try { + const { + data: { + ciCdSettingsUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateOptInJwtMutation, + variables: { + input: { + fullPath: this.fullPath, + optInJwt: this.optInJwt, + }, + }, + }); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + createAlert({ message: this.$options.i18n.updateError }); + } + }, + }, + OPT_IN_JWT_HELP_LINK, + LIMIT_JWT_ACCESS_SNIPPET, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" /> + <template v-else> + <gl-toggle + v-model="optInJwt" + class="gl-mt-5" + :label="$options.i18n.labelText" + @change="updateOptInJwt" + > + <template #help> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="$options.OPT_IN_JWT_HELP_LINK" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> + <div v-if="optInJwt" class="gl-mt-5" data-testid="opt-in-jwt-expanded-section"> + <gl-sprintf :message="$options.i18n.expandedText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + <code-instruction + class="gl-mt-3" + :instruction="$options.LIMIT_JWT_ACCESS_SNIPPET" + :copy-text="$options.i18n.copyToClipboard" + multiline + /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index fe99f3e1fdd..0deae1a1d82 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -35,6 +35,27 @@ export default { projectsFetchError: __('There was a problem fetching the projects'), scopeFetchError: __('There was a problem fetching the job token scope value'), }, + fields: [ + { + key: 'project', + label: __('Project that can be accessed'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'namespace', + label: __('Namespace'), + 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: { GlAlert, GlButton, @@ -93,7 +114,7 @@ export default { return this.targetProjectPath === ''; }, ciJobTokenHelpPage() { - return helpPagePath('ci/jobs/ci_job_token'); + return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access'); }, }, methods: { @@ -228,7 +249,11 @@ export default { > {{ $options.i18n.settingDisabledMessage }} </gl-alert> - <token-projects-table :projects="projects" @removeProject="removeProject" /> + <token-projects-table + :projects="projects" + :table-fields="$options.fields" + @removeProject="removeProject" + /> </div> </template> </div> diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue new file mode 100644 index 00000000000..59d59757735 --- /dev/null +++ b/app/assets/javascripts/token_access/components/token_access_app.vue @@ -0,0 +1,27 @@ +<script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import OutboundTokenAccess from './outbound_token_access.vue'; +import InboundTokenAccess from './inbound_token_access.vue'; +import OptInJwt from './opt_in_jwt.vue'; + +export default { + components: { + OutboundTokenAccess, + InboundTokenAccess, + OptInJwt, + }, + mixins: [glFeatureFlagMixin()], + computed: { + inboundTokenAccessEnabled() { + return this.glFeatures.ciInboundJobTokenScope; + }, + }, +}; +</script> +<template> + <div> + <inbound-token-access v-if="inboundTokenAccessEnabled" class="gl-pb-5" /> + <outbound-token-access class="gl-py-5" /> + <opt-in-jwt /> + </div> +</template> diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue index ce33478cbee..c00dd882895 100644 --- a/app/assets/javascripts/token_access/components/token_projects_table.vue +++ b/app/assets/javascripts/token_access/components/token_projects_table.vue @@ -1,32 +1,11 @@ <script> import { GlButton, GlTable } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; export default { i18n: { emptyText: s__('CI/CD|No projects have been added to the scope'), }, - fields: [ - { - key: 'project', - label: __('Projects that can be accessed'), - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', - }, - { - key: 'namespace', - label: __('Namespace'), - 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: { GlButton, GlTable, @@ -41,6 +20,10 @@ export default { type: Array, required: true, }, + tableFields: { + type: Array, + required: true, + }, }, methods: { removeProject(project) { @@ -52,7 +35,7 @@ export default { <template> <gl-table :items="projects" - :fields="$options.fields" + :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }" :empty-text="$options.i18n.emptyText" show-empty diff --git a/app/assets/javascripts/token_access/constants.js b/app/assets/javascripts/token_access/constants.js new file mode 100644 index 00000000000..fb2128462f0 --- /dev/null +++ b/app/assets/javascripts/token_access/constants.js @@ -0,0 +1,14 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const LIMIT_JWT_ACCESS_SNIPPET = `job_name: + id_tokens: + ID_TOKEN_1: # or any other name + aud: "..." # sub-keyword to configure the token's audience + secrets: + TEST_SECRET: + vault: db/prod +`; + +export const OPT_IN_JWT_HELP_LINK = helpPagePath('ci/secrets/id_token_authentication', { + anchor: 'automatic-id-token-authentication-with-hashicorp-vault', +}); diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..f030a892af2 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,7 @@ +mutation inboundAddProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) { + ciJobTokenScopeAddProject( + input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND } + ) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..cc6736bb80d --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql @@ -0,0 +1,7 @@ +mutation inboundRemoveProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) { + ciJobTokenScopeRemoveProject( + input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND } + ) { + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql new file mode 100644 index 00000000000..aac9feab237 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql @@ -0,0 +1,8 @@ +mutation inboundUpdateCIJobTokenScope($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + inboundJobTokenScopeEnabled + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql new file mode 100644 index 00000000000..c12b5646423 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + optInJwt + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql new file mode 100644 index 00000000000..a1a216b7dc3 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql @@ -0,0 +1,8 @@ +query getOptInJwtSetting($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciCdSettings { + optInJwt + } + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..68d506a6c41 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql @@ -0,0 +1,8 @@ +query inboundGetCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciCdSettings { + inboundJobTokenScopeEnabled + } + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql new file mode 100644 index 00000000000..c51bdcbf7d2 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql @@ -0,0 +1,18 @@ +query inboundGetProjectsWithCIJobTokenScope($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciJobTokenScope { + inboundAllowlist { + nodes { + id + name + namespace { + id + fullPath + } + fullPath + } + } + } + } +} diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index 6a29883290a..0253abe393e 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import TokenAccess from './components/token_access.vue'; +import TokenAccessApp from './components/token_access_app.vue'; Vue.use(VueApollo); @@ -25,7 +25,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { fullPath, }, render(createElement) { - return createElement(TokenAccess); + return createElement(TokenAccessApp); }, }); }; diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js index 6014f1ba3ee..df527e24d93 100644 --- a/app/assets/javascripts/tracking/get_standard_context.js +++ b/app/assets/javascripts/tracking/get_standard_context.js @@ -10,7 +10,7 @@ export default function getStandardContext({ extra = {} } = {}) { ...data, source: SNOWPLOW_JS_SOURCE, google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '', - extra: extra || data.extra, + extra: { ...data.extra, ...extra }, }, }; } diff --git a/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue new file mode 100644 index 00000000000..9322171cad8 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue @@ -0,0 +1,35 @@ +<script> +import { GlSprintf, GlTab, GlTabs } from '@gitlab/ui'; +import { USAGE_QUOTAS_TITLE, USAGE_QUOTAS_SUBTITLE } from '../constants'; + +export default { + name: 'UsageQuotasApp', + components: { GlSprintf, GlTab, GlTabs }, + inject: ['namespaceName'], + computed: { + placeholder() { + return `storage_app_placeholder`; + }, + }, + USAGE_QUOTAS_TITLE, + USAGE_QUOTAS_SUBTITLE, +}; +</script> + +<template> + <section> + <h1>{{ $options.USAGE_QUOTAS_TITLE }}</h1> + <p data-testid="usage-quotas-page-subtitle"> + <gl-sprintf :message="$options.USAGE_QUOTAS_SUBTITLE"> + <template #namespaceName> + <strong> + {{ namespaceName }} + </strong> + </template> + </gl-sprintf> + </p> + <gl-tabs> + <gl-tab title="Storage"> {{ placeholder }} </gl-tab> + </gl-tabs> + </section> +</template> diff --git a/app/assets/javascripts/usage_quotas/constants.js b/app/assets/javascripts/usage_quotas/constants.js new file mode 100644 index 00000000000..f637d241778 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const USAGE_QUOTAS_TITLE = s__('UsageQuota|Usage Quotas'); + +export const USAGE_QUOTAS_SUBTITLE = s__( + 'UsageQuota|Usage of group resources across the projects in the %{namespaceName} group', +); diff --git a/app/assets/javascripts/usage_quotas/index.js b/app/assets/javascripts/usage_quotas/index.js new file mode 100644 index 00000000000..e1032cd8d54 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import UsageQuotasApp from './components/usage_quotas_app.vue'; + +export default () => { + const el = document.getElementById('js-usage-quotas-view'); + + if (!el) { + return false; + } + + const { namespaceName } = el.dataset; + + return new Vue({ + el, + name: 'UsageQuotasView', + provide: { + namespaceName, + }, + render(createElement) { + return createElement(UsageQuotasApp); + }, + }); +}; diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue index aabb7fde396..0e41a214888 100644 --- a/app/assets/javascripts/users/profile/components/report_abuse_button.vue +++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; @@ -14,8 +14,9 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['reportedUserId', 'reportedFromUrl'], i18n: { - reportAbuse: __('Report abuse to administrator'), + reportAbuse: s__('ReportAbuse|Report abuse to administrator'), }, data() { return { @@ -28,11 +29,8 @@ export default { }, }, methods: { - openDrawer() { - this.open = true; - }, - closeDrawer() { - this.open = false; + toggleDrawer(open) { + this.open = open; }, hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); @@ -47,9 +45,14 @@ export default { category="primary" :aria-label="buttonTooltipText" icon="error" - @click="openDrawer" + @click="toggleDrawer(true)" @mouseout="hideTooltips" /> - <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + <abuse-category-selector + :reported-user-id="reportedUserId" + :reported-from-url="reportedFromUrl" + :show-drawer="open" + @close-drawer="toggleDrawer(false)" + /> </span> </template> diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js index 37f8e3ac471..c6b85489785 100644 --- a/app/assets/javascripts/users/profile/index.js +++ b/app/assets/javascripts/users/profile/index.js @@ -10,7 +10,12 @@ export const initReportAbuse = () => { return new Vue({ el, - provide: { reportAbusePath, reportedUserId, reportedFromUrl }, + name: 'ReportAbuseButtonRoot', + provide: { + reportAbusePath, + reportedUserId: parseInt(reportedUserId, 10), + reportedFromUrl, + }, render(createElement) { return createElement(ReportAbuseButton); }, diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 7c1204c511c..1af47b020f7 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -447,7 +447,7 @@ function UsersSelect(currentUser, els, options = {}) { hidden() { if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass(elsClassName)) { - if (window.gon?.features?.realtimeReviewers) { + if (!$dropdown.closest('.merge-request-form').length) { $dropdown.data('deprecatedJQueryDropdown').clearMenu(); $dropdown.closest('.selectbox').children('input[type="hidden"]').remove(); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index 5339d7faf85..917ed259dd0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -35,6 +35,12 @@ export default { return sprintf(__('%{widget} options'), { widget: this.widget }); }, + hasOneOption() { + return this.tertiaryButtons.length === 1; + }, + hasMultipleOptions() { + return this.tertiaryButtons.length > 1; + }, }, methods: { onClickAction(action) { @@ -75,34 +81,59 @@ export default { <template> <div class="gl-display-flex gl-align-items-flex-start"> - <gl-dropdown - v-if="tertiaryButtons.length" - v-gl-tooltip - :title="__('Options')" - :text="dropdownLabel" - icon="ellipsis_v" - no-caret - category="tertiary" - right - lazy - text-sr-only - size="small" - toggle-class="gl-p-2!" - class="gl-display-block gl-md-display-none!" - > - <gl-dropdown-item + <template v-if="hasOneOption"> + <gl-button v-for="(btn, index) in tertiaryButtons" + :id="btn.id" :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" :href="btn.href" :target="btn.target" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="actionButtonQaSelector(btn)" :data-method="btn.dataMethod" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" + category="tertiary" + size="small" + class="gl-md-display-block gl-float-left" @click="onClickAction(btn)" > {{ btn.text }} - </gl-dropdown-item> - </gl-dropdown> - <template v-if="tertiaryButtons.length"> + </gl-button> + </template> + <template v-if="hasMultipleOptions"> + <gl-dropdown + v-gl-tooltip + :title="__('Options')" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :data-clipboard-text="btn.dataClipboardText" + :data-method="btn.dataMethod" + @click="onClickAction(btn)" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> <gl-button v-for="(btn, index) in tertiaryButtons" :id="btn.id" 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 eb93f42e2f3..4b65d6fd9ac 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 @@ -2,6 +2,7 @@ import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; import eventHub from '../../event_hub'; @@ -61,6 +62,7 @@ export default { fetchingApprovals: true, hasApprovalAuthError: false, isApproving: false, + updatedCount: 0, }; }, computed: { @@ -139,9 +141,11 @@ export default { this.fetchingApprovals = false; }) .catch(() => - createAlert({ - message: FETCH_ERROR, - }), + this.alerts.push( + createAlert({ + message: FETCH_ERROR, + }), + ), ); }, methods: { @@ -154,22 +158,26 @@ export default { this.updateApproval( () => this.service.approveMergeRequest(), () => - createAlert({ - message: APPROVE_ERROR, - }), + this.alerts.push( + createAlert({ + message: APPROVE_ERROR, + }), + ), ); }, approveWithAuth(data) { this.updateApproval( () => this.service.approveMergeRequestWithAuth(data), (error) => { - if (error && error.response && error.response.status === 401) { + if (error && error.response && error.response.status === HTTP_STATUS_UNAUTHORIZED) { this.hasApprovalAuthError = true; return; } - createAlert({ - message: APPROVE_ERROR, - }); + this.alerts.push( + createAlert({ + message: APPROVE_ERROR, + }), + ); }, ); }, @@ -177,9 +185,11 @@ export default { this.updateApproval( () => this.service.unapproveMergeRequest(), () => - createAlert({ - message: UNAPPROVE_ERROR, - }), + this.alerts.push( + createAlert({ + message: UNAPPROVE_ERROR, + }), + ), ); }, updateApproval(serviceFn, errFn) { @@ -188,6 +198,7 @@ export default { return serviceFn() .then((data) => { this.mr.setApprovals(data); + this.updatedCount += 1; if (!window.gon?.features?.realtimeMrStatusChange) { eventHub.$emit('MRWidgetUpdateRequested'); @@ -241,10 +252,10 @@ export default { /> <approvals-summary v-else - :approved="isApproved" - :approvals-left="approvals.approvals_left || 0" - :rules-left="approvals.approvalRuleNamesLeft" - :approvers="approvedBy" + :project-path="mr.targetProjectFullPath" + :iid="`${mr.iid}`" + :updated-count="updatedCount" + :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index d7255eb6ad2..697d953874c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,4 +1,5 @@ <script> +import { GlSkeletonLoader } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; import { @@ -7,32 +8,68 @@ import { APPROVED_BY_OTHERS, } from '~/vue_merge_request_widget/components/approvals/messages'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/mappers'; +import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql'; export default { + apollo: { + approvalState: { + query: approvedByQuery, + variables() { + return { + projectPath: this.projectPath, + iid: this.iid, + }; + }, + update: (data) => data.project.mergeRequest, + }, + }, components: { + GlSkeletonLoader, UserAvatarList, }, props: { - approved: { - type: Boolean, + projectPath: { + type: String, required: true, }, - approvalsLeft: { - type: Number, + iid: { + type: String, required: true, }, - rulesLeft: { - type: Array, + updatedCount: { + type: Number, required: false, - default: () => [], + default: 0, }, - approvers: { - type: Array, + multipleApprovalRulesAvailable: { + type: Boolean, required: false, - default: () => [], + default: false, }, }, + data() { + return { + approvalState: {}, + }; + }, computed: { + approvers() { + return this.approvalState.approvedBy?.nodes || []; + }, + approved() { + return this.approvalState.approved || this.approvalState.approvedBy?.nodes.length > 0; + }, + approvalsLeft() { + return this.approvalState.approvalsLeft || 0; + }, + rulesLeft() { + return getApprovalRuleNamesLeft( + this.multipleApprovalRulesAvailable, + (this.approvalState.approvalState?.rules || []).filter((r) => !r.approved), + ); + }, approvalLeftMessage() { if (this.rulesLeft.length) { return sprintf( @@ -81,32 +118,53 @@ export default { if (!this.currentUserId) { return false; } - return this.approvers.some((approver) => approver.id === this.currentUserId); + return this.approvers.some( + (approver) => getIdFromGraphQLId(approver.id) === this.currentUserId, + ); }, approvedByOthers() { if (!this.currentUserId) { return false; } - return this.approvers.some((approver) => approver.id !== this.currentUserId); + return this.approvers.some( + (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId, + ); }, currentUserId() { return gon.current_user_id; }, }, + watch: { + updatedCount() { + this.$apollo.queries.approvalState.refetch(); + }, + }, }; </script> <template> <div data-qa-selector="approvals_summary_content"> - <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span> - <template v-if="hasApprovers"> - <span v-if="approvalLeftMessage">{{ message }}</span> - <span v-else class="gl-font-weight-bold">{{ message }}</span> - <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" - :img-size="24" - :items="approvers" - /> + <div + v-if="$apollo.queries.approvalState.loading" + class="gl-display-inline-block gl-vertical-align-middle" + style="width: 132px; height: 24px" + > + <gl-skeleton-loader :width="132" :height="24"> + <rect width="100" height="24" x="0" y="0" rx="4" /> + <circle cx="120" cy="12" r="12" /> + </gl-skeleton-loader> + </div> + <template v-else> + <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span> + <template v-if="hasApprovers"> + <span v-if="approvalLeftMessage">{{ message }}</span> + <span v-else class="gl-font-weight-bold">{{ message }}</span> + <user-avatar-list + class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" + :img-size="24" + :items="approvers" + /> + </template> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql new file mode 100644 index 00000000000..c8cae6a8885 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql @@ -0,0 +1,16 @@ +query approvedBy($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + id + mergeRequest(iid: $iid) { + id + approvedBy { + nodes { + id + name + avatarUrl + webUrl + } + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue new file mode 100644 index 00000000000..bef1d79a655 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue @@ -0,0 +1,26 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; + +export default { + name: 'BoldText', + components: { + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #bold="{ content }"> + <span class="gl-font-weight-bold" v-text="content"></span> + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 7cfc9431c2a..b78293a9815 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -293,7 +293,7 @@ export default { } }, onClickedAction(action) { - if (action.fullReport) { + if (action.trackFullReportClicked) { this.telemetry?.fullReportClicked(); } }, @@ -323,16 +323,25 @@ export default { data-testid="widget-extension-top-level" > <div - class="gl-flex-grow-1 gl-display-flex gl-align-items-center" + class="gl-flex-grow-1 gl-display-flex gl-align-items-center gl-flex-wrap" data-testid="widget-extension-top-level-summary" > - <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> - <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> + <div v-if="isLoadingSummary" class="gl-w-full gl-line-height-normal"> + {{ widgetLoadingText }} + </div> + <div v-else-if="hasFetchError" class="gl-w-full gl-line-height-normal"> + {{ widgetErrorText }} + </div> <template v-else> - <span v-safe-html="hydratedSummary.subject"></span> + <div + v-safe-html="hydratedSummary.subject" + class="gl-w-full gl-line-height-normal" + ></div> <template v-if="hydratedSummary.meta"> - <br /> - <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> + <div + v-safe-html="hydratedSummary.meta" + class="gl-w-full gl-font-sm gl-line-height-normal" + ></div> </template> </template> </div> 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 d8a361066f4..2dec95c3fda 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 @@ -11,6 +11,7 @@ import { import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -86,6 +87,10 @@ export default { }, }, computed: { + downstreamPipelines() { + const downstream = this.pipeline.triggered; + return keepLatestDownstreamPipelines(downstream); + }, hasPipeline() { return this.pipeline && Object.keys(this.pipeline).length > 0; }, @@ -196,14 +201,13 @@ export default { <div class="ci-widget-content"> <div class="media-body"> <div - class="gl-font-weight-bold" data-testid="pipeline-info-container" data-qa-selector="merge_request_pipeline_info_content" > - {{ pipeline.details.event_type_name || pipeline.details.name }} + {{ pipeline.details.event_type_name }} <gl-link :href="pipeline.path" - class="pipeline-id gl-font-weight-normal pipeline-number" + class="pipeline-id" data-testid="pipeline-id" data-qa-selector="pipeline_link" >#{{ pipeline.id }}</gl-link @@ -275,7 +279,7 @@ export default { <span class="gl-align-items-center gl-display-inline-flex"> <pipeline-mini-graph v-if="pipeline.details.stages" - :downstream-pipelines="pipeline.triggered" + :downstream-pipelines="downstreamPipelines" :is-merge-train="isMergeTrain" :pipeline-path="pipeline.path" :stages="pipeline.details.stages" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue index ecf08f78f57..34a1d1facda 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue @@ -5,16 +5,32 @@ export default { hasChildren: false, }; }, - updated() { - this.hasChildren = this.checkSlots(); - }, mounted() { - this.hasChildren = this.checkSlots(); + const setHasChildren = () => { + this.hasChildren = Boolean(this.$el.innerText.trim()); + }; + + // Set initial. + setHasChildren(); + + if (!this.hasChildren) { + // Observe children changed. + this.observer = new MutationObserver(() => { + setHasChildren(); + + if (this.hasChildren) { + this.observer.disconnect(); + this.observer = undefined; + } + }); + + this.observer.observe(this.$el, { childList: true, subtree: true }); + } }, - methods: { - checkSlots() { - return this.$scopedSlots.default?.()?.some((c) => c.tag); - }, + beforeUnmount() { + if (this.observer) { + this.observer.disconnect(); + } }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index e5688091cc7..6d7ec607557 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -1,17 +1,23 @@ <script> import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; import { DETAILED_MERGE_STATUS } from '../../constants'; export default { i18n: { - approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'), + approvalNeeded: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all required approvals must be given.', + ), blockingMergeRequests: s__( - 'mrWidget|Merge blocked: you can only merge after the above items are resolved.', + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} you can only merge after the above items are resolved.', + ), + externalStatusChecksFailed: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all status checks must pass.', ), - externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'), }, components: { + BoldText, StateContainer, }, props: { @@ -38,10 +44,8 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" - > - {{ failedText }} + <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> + <bold-text :message="failedText" /> </span> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 79e878431ed..837f8b32637 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,9 +1,17 @@ <script> +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; +const message = s__( + 'mrWidget|%{boldStart}Merge unavailable:%{boldEnd} merge requests are read-only on archived projects.', +); + export default { name: 'MRWidgetArchived', + message, components: { + BoldText, StateContainer, }, props: { @@ -17,8 +25,6 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span class="gl-font-weight-bold"> - {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} - </span> + <bold-text :message="$options.message" /> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 922075516f3..670bd36d61e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -16,8 +16,6 @@ export default { </script> <template> <state-container :mr="mr" status="loading"> - <span class="gl-font-weight-bold"> - {{ s__('mrWidget|Checking if merge request can be merged…') }} - </span> + {{ s__('mrWidget|Checking if merge request can be merged…') }} </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index a5d982fe221..83d718f5a54 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -1,5 +1,7 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; import conflictsStateQuery from '../../queries/states/conflicts.query.graphql'; @@ -8,6 +10,7 @@ import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetConflicts', components: { + BoldText, GlSkeletonLoader, GlButton, StateContainer, @@ -55,6 +58,17 @@ export default { ); }, }, + i18n: { + shouldBeRebased: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} fast-forward merge is not possible. To merge this request, first rebase locally.', + ), + shouldBeResolved: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} merge conflicts must be resolved.', + ), + usersWriteBranches: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Users who can write to the source or target branches can resolve the conflicts.', + ), + }, }; </script> <template> @@ -67,22 +81,13 @@ export default { </gl-skeleton-loader> </template> <template v-if="!isLoading"> - <span v-if="state.shouldBeRebased" class="bold gl-ml-0! gl-text-body!"> - {{ - s__(`mrWidget|Merge blocked: fast-forward merge is not possible. - To merge this request, first rebase locally.`) - }} + <span v-if="state.shouldBeRebased" class="gl-ml-0! gl-text-body!"> + <bold-text :message="$options.i18n.shouldBeRebased" /> </span> <template v-else> - <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2"> - {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} - <span v-if="!userPermissions.canMerge"> - {{ - s__( - `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`, - ) - }} - </span> + <span class="gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2"> + <bold-text v-if="userPermissions.canMerge" :message="$options.i18n.shouldBeResolved" /> + <bold-text v-else :message="$options.i18n.usersWriteBranches" /> </span> </template> </template> 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 8a7f15d8d1a..bfc2c282f4c 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 @@ -101,12 +101,14 @@ export default { </span> </state-container> <state-container v-else :mr="mr" status="failed" :actions="actions"> - <span class="gl-font-weight-bold"> - <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error"> - {{ mergeError }} - </span> - <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> - <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> + <span + v-if="mr.mergeError" + class="has-error-message gl-font-weight-bold" + data-testid="merge-error" + > + {{ mergeError }} </span> + <span v-else class="gl-font-weight-bold"> {{ s__('mrWidget|Merge failed.') }} </span> + <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </state-container> </template> 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 51ac2576f75..c94718ca756 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 @@ -2,6 +2,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import eventHub from '../../event_hub'; import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; import StatusIcon from '../mr_widget_status_icon.vue'; @@ -12,6 +13,7 @@ const { MERGE_FAILURE } = transitions; export default { name: 'MRWidgetMerging', components: { + BoldText, StatusIcon, }, props: { @@ -83,11 +85,9 @@ export default { <template> <div class="mr-widget-body mr-state-locked media"> <status-icon status="loading" /> - <div class="media-body"> - <h4> - {{ mergeStatus.message }} - <gl-emoji :data-name="mergeStatus.emoji" /> - </h4> + <div class="media-body" data-testid="merging-state"> + <bold-text :message="mergeStatus.message" /> + <gl-emoji :data-name="mergeStatus.emoji" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 5e073bf7c04..f1ddf94597b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -63,12 +63,14 @@ export default { <status-icon :show-disabled-button="true" status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold js-branch-text" data-testid="widget-content"> - <gl-sprintf :message="warning"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> + <span class="js-branch-text" data-testid="widget-content"> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="warning"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </span> {{ restore }} <gl-icon v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue index d837551a813..536e61e57d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -1,9 +1,17 @@ <script> +import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; +const message = s__( + 'mrWidget|%{boldStart}Ready to be merged automatically.%{boldEnd} Ask someone with write access to this repository to merge this request.', +); + export default { name: 'MRWidgetNotAllowed', + message, components: { + BoldText, StatusIcon, }, }; @@ -13,12 +21,7 @@ export default { <div class="mr-widget-body media"> <status-icon status="success" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - {{ - s__(`mrWidget|Ready to be merged automatically. -Ask someone with write access to this repository to merge this request`) - }} - </span> + <bold-text :message="$options.message" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index 13920daca15..beb6310992f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -1,10 +1,18 @@ <script> +import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; +const message = s__( + "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.", +); + export default { name: 'MRWidgetPipelineBlocked', + message, components: { + BoldText, StatusIcon, }, mixins: [glFeatureFlagMixin()], @@ -14,13 +22,7 @@ export default { <div class="mr-widget-body media"> <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - {{ - s__( - `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, - ) - }} - </span> + <bold-text :message="$options.message" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index d687f0346c7..ec6c2cf34c0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,16 +1,24 @@ <script> import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import simplePoll from '~/lib/utils/simple_poll'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; import StateContainer from '../state_container.vue'; +const i18n = { + rebaseError: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} the source branch must be rebased onto the target branch.', + ), +}; + export default { name: 'MRWidgetRebase', + i18n, apollo: { state: { query: rebaseQuery, @@ -21,6 +29,7 @@ export default { }, }, components: { + BoldText, GlSkeletonLoader, GlButton, StateContainer, @@ -69,9 +78,6 @@ export default { } return 'success'; }, - fastForwardMergeText() { - return __('Merge blocked: the source branch must be rebased onto the target branch.'); - }, showRebaseWithoutPipeline() { return ( !this.mr.onlyAllowMergeIfPipelineSucceeds || @@ -146,29 +152,29 @@ export default { <template v-if="!isLoading"> <span v-if="rebaseInProgress || isMakingRequest" - class="gl-ml-0! gl-text-body! gl-font-weight-bold" + class="gl-ml-0! gl-text-body!" data-testid="rebase-message" - >{{ __('Rebase in progress') }}</span + >{{ s__('mrWidget|Rebase in progress') }}</span > <span v-if="!rebaseInProgress && !canPushToSourceBranch" - class="gl-text-body! gl-font-weight-bold gl-ml-0!" + class="gl-text-body! gl-ml-0!" data-testid="rebase-message" - >{{ fastForwardMergeText }}</span > + <bold-text :message="$options.i18n.rebaseError" /> + </span> <div v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1" > <span v-if="!rebasingError" - class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" + class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" data-testid="rebase-message" data-qa-selector="no_fast_forward_message_content" - >{{ - __('Merge blocked: the source branch must be rebased onto the target branch.') - }}</span > + <bold-text :message="$options.i18n.rebaseError" /> + </span> <span v-else class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3" @@ -187,7 +193,7 @@ export default { class="gl-align-self-start" @click="rebase" > - {{ __('Rebase') }} + {{ s__('mrWidget|Rebase') }} </gl-button> <gl-button v-if="showRebaseWithoutPipeline" @@ -199,7 +205,7 @@ export default { class="gl-align-self-start gl-mr-2" @click="rebaseWithoutCi" > - {{ __('Rebase without pipeline') }} + {{ s__('mrWidget|Rebase without pipeline') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index 853895a4296..1896851952b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -2,11 +2,13 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'PipelineFailed', components: { + BoldText, GlLink, GlSprintf, StatusIcon, @@ -24,7 +26,10 @@ export default { }, i18n: { failedMessage: s__( - `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`, + `mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. Push a commit that fixes the failure or %{linkStart}learn about other solutions.%{linkEnd}`, + ), + blockedMessage: s__( + "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.", ), }, }; @@ -34,20 +39,17 @@ export default { <div class="mr-widget-body media"> <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-font-weight-bold"> - <span v-if="mr.isPipelineBlocked"> - {{ - s__( - `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, - ) - }} - </span> + <span> + <bold-text v-if="mr.isPipelineBlocked" :message="$options.i18n.blockedMessage" /> <gl-sprintf v-else :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> {{ content }} </gl-link> </template> + <template #bold="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> </gl-sprintf> </span> </div> 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 23b163e2c6a..bb8990a48b1 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 @@ -15,6 +15,7 @@ import { isEmpty } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { createAlert } from '~/flash'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; @@ -98,7 +99,7 @@ export default { }, variables() { return { - issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id), }; }, updateQuery( @@ -524,6 +525,7 @@ export default { v-model="removeSourceBranch" :disabled="isRemoveSourceBranchButtonDisabled" class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0" + data-testid="delete-source-branch-checkbox" > {{ __('Delete source branch') }} </gl-form-checkbox> @@ -634,6 +636,7 @@ export default { v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" variant="confirm" + data-testid="merge-immediately-dropdown" data-qa-selector="merge_moment_dropdown" toggle-class="btn-icon js-merge-moment" > @@ -643,7 +646,8 @@ export default { </template> <gl-dropdown-item icon-name="warning" - button-class="accept-merge-request js-merge-immediately-button" + button-class="accept-merge-request" + data-testid="merge-immediately-button" data-qa-selector="merge_immediately_menu_item" @click="handleMergeImmediatelyButtonClick" > @@ -697,7 +701,11 @@ export default { :merge-commit-path="mr.mergeCommitPath" /> </li> - <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> + <li + v-if="mr.state !== 'closed'" + class="gl-line-height-normal" + data-testid="source-branch-deleted-text" + > {{ sourceBranchDeletedText }} </li> <li v-if="mr.relatedLinks" class="gl-line-height-normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 27919f90cc3..2aa345b420e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -1,11 +1,13 @@ <script> import { GlButton } from '@gitlab/ui'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import { I18N_SHA_MISMATCH } from '../../i18n'; import StateContainer from '../state_container.vue'; export default { name: 'ShaMismatch', components: { + BoldText, GlButton, StateContainer, }, @@ -24,10 +26,10 @@ export default { <template> <state-container :mr="mr" status="failed"> <span - class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" + class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" > - {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} + <bold-text :message="$options.i18n.I18N_SHA_MISMATCH.warningMessage" /> </span> <template #actions> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 9f3748599dc..0fd5551979d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,11 +1,17 @@ <script> import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import StateContainer from '../state_container.vue'; +const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.'); + export default { name: 'UnresolvedDiscussions', + message, components: { + BoldText, GlButton, StateContainer, }, @@ -25,10 +31,8 @@ export default { <template> <state-container :mr="mr" status="failed"> - <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" - > - {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} + <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> + <bold-text :message="$options.message" /> </span> <template #actions> <gl-button 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 211fbba305f..02d4f2499fe 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 @@ -2,18 +2,23 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import { createAlert } from '~/flash'; -import toast from '~/vue_shared/plugins/global_toast'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import MergeRequest from '~/merge_request'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import getStateQuery from '../../queries/get_state.query.graphql'; import draftQuery from '../../queries/states/draft.query.graphql'; import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql'; import StateContainer from '../state_container.vue'; -import eventHub from '../../event_hub'; + +// Export for testing +export const MSG_SOMETHING_WENT_WRONG = __('Something went wrong. Please try again.'); +export const MSG_MARK_READY = s__('mrWidget|Mark as ready'); export default { name: 'WorkInProgress', components: { + BoldText, GlButton, StateContainer, }, @@ -62,7 +67,7 @@ export default { ) { if (errors?.length) { createAlert({ - message: __('Something went wrong. Please try again.'), + message: MSG_SOMETHING_WENT_WRONG, }); return; @@ -109,19 +114,12 @@ export default { }, }, }) => { - toast(__('Marked as ready. Merging is now allowed.')); - document.querySelector( - '.merge-request .detail-page-description .title', - ).textContent = title; - - if (!window.gon?.features?.realtimeMrStatusChange) { - eventHub.$emit('MRWidgetUpdateRequested'); - } + MergeRequest.toggleDraftStatus(title, true); }, ) .catch(() => createAlert({ - message: __('Something went wrong. Please try again.'), + message: MSG_SOMETHING_WENT_WRONG, }), ) .finally(() => { @@ -129,13 +127,19 @@ export default { }); }, }, + i18n: { + removeDraftStatus: s__( + 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Select %{boldStart}Mark as ready%{boldEnd} to remove it from Draft status.', + ), + }, + MSG_MARK_READY, }; </script> <template> <state-container :mr="mr" status="failed"> - <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1"> - {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }} + <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> + <bold-text :message="$options.i18n.removeDraftStatus" /> </span> <template #actions> <gl-button @@ -148,7 +152,7 @@ export default { data-testid="removeWipButton" @click="handleRemoveDraft" > - {{ s__('mrWidget|Mark as ready') }} + {{ $options.MSG_MARK_READY }} </gl-button> </template> </state-container> 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 7343c98938c..73129a86877 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 @@ -2,6 +2,7 @@ import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { logError } from '~/lib/logger'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; @@ -17,8 +18,12 @@ import ActionButtons from './action_buttons.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; 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.'; export default { + MISSING_RESPONSE_HEADERS, + components: { ActionButtons, StatusIcon, @@ -92,6 +97,23 @@ export default { type: Boolean, required: true, }, + /** + * A button is composed of the following properties: + * + * { + * "id": string, + * "href": string, + * "dataMethod": string, + * "dataClipboardText": string, + * "icon": string, + * "variant": string, + * "loading": boolean, + * "testId":string, + * "text": string, + * "class": string | Object, + * "trackFullReportClicked": boolean, + * } + */ actionButtons: { type: Array, required: false, @@ -182,7 +204,7 @@ export default { }, methods: { onActionClick(action) { - if (action.fullReport) { + if (action.trackFullReportClicked) { this.telemetryHub?.fullReportClicked(); } }, @@ -225,6 +247,14 @@ export default { }, method: 'fetchData', successCallback: (response) => { + if ( + typeof response.status === 'undefined' || + typeof response.headers === 'undefined' + ) { + logError(MISSING_RESPONSE_HEADERS); + throw new Error(MISSING_RESPONSE_HEADERS); + } + const headers = normalizeHeaders(response.headers); if (headers['POLL-INTERVAL']) { diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 7109bed7743..85ae298fcea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -4,9 +4,7 @@ import { stateToComponentMap as classStateMap, stateKey } from './stores/state_m export const SUCCESS = 'success'; export const WARNING = 'warning'; -export const DANGER = 'danger'; export const INFO = 'info'; -export const CONFIRM = 'confirm'; export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds'; export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds'; @@ -28,39 +26,39 @@ export const SP_ICON_NAME = 'status_notfound'; export const MERGE_ACTIVE_STATUS_PHRASES = [ { - message: s__('mrWidget|Merging! Drum roll, please…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Drum roll, please…'), emoji: 'drum', }, { - message: s__("mrWidget|Merging! We're almost there…"), + message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} We're almost there…"), emoji: 'sparkles', }, { - message: s__('mrWidget|Merging! Changes will land soon…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes will land soon…'), emoji: 'airplane_arriving', }, { - message: s__('mrWidget|Merging! Changes are being shipped…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes are being shipped…'), emoji: 'ship', }, { - message: s__("mrWidget|Merging! Everything's good…"), + message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} Everything's good…"), emoji: 'relieved', }, { - message: s__('mrWidget|Merging! This is going to be great…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} This is going to be great…'), emoji: 'heart_eyes', }, { - message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Lift-off in 5… 4… 3…'), emoji: 'rocket', }, { - message: s__('mrWidget|Merging! The changes are leaving the station…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} The changes are leaving the station…'), emoji: 'bullettrain_front', }, { - message: s__('mrWidget|Merging! Take a deep breath and relax…'), + message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Take a deep breath and relax…'), emoji: 'sunglasses', }, ]; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index ca95e1b5de8..ff225afbc7b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -44,7 +44,12 @@ export default { console.log('Hello world'); }, }, - { text: 'Full report', href: this.conflictsDocsPath, target: '_blank', fullReport: true }, + { + text: 'Full report', + href: this.conflictsDocsPath, + target: '_blank', + trackFullReportClicked: true, + }, ]; }, shouldCollapse() { diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue index f0b20adc5cf..6155a912683 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue @@ -70,6 +70,9 @@ export default { artifacts() { return this.reportArtifacts || []; }, + hasSecurityReports() { + return this.artifacts.length > 0; + }, }, methods: { handleIsLoading(value) { @@ -99,6 +102,7 @@ export default { <template> <mr-widget + v-if="hasSecurityReports" :has-error="hasError" :error-text="$options.i18n.apiError" :status-icon-name="$options.icons.warning" @@ -108,7 +112,7 @@ export default { :summary="$options.i18n.scansHaveRun" @is-loading="handleIsLoading" > - <template v-if="artifacts.length > 0" #action-buttons> + <template #action-buttons> <div class="gl-ml-3"> <gl-dropdown v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js index 626a99f7d64..c5cbed4a280 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js @@ -115,7 +115,7 @@ export default { href: report.job_path, text: this.$options.i18n.fullLog, target: '_blank', - fullReport: true, + trackFullReportClicked: true, }; actions.push(action); } diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js index 97b9b59e2c3..6ac462d4ad5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -1,6 +1,7 @@ import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import { EXTENSION_ICONS } from '../../constants'; import { @@ -74,7 +75,7 @@ export default { text: this.$options.i18n.fullReport, href: `${this.pipeline.path}/test_report`, target: '_blank', - fullReport: true, + trackFullReportClicked: true, testId: 'full-report-link', }); @@ -91,7 +92,7 @@ export default { ...response, data: { hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS), - parsingInProgress: status === 204, + parsingInProgress: status === HTTP_STATUS_NO_CONTENT, ...data, summary: { recentlyFailed: countRecentlyFailedTests(suites), diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 5380bcae003..5ca56074031 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -17,7 +17,7 @@ export const SQUASH_BEFORE_MERGE = { }; export const I18N_SHA_MISMATCH = { - warningMessage: __('Merge blocked: new changes were just added.'), + warningMessage: s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} new changes were just added.'), actionButtonLabel: __('Review changes'), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mappers.js b/app/assets/javascripts/vue_merge_request_widget/mappers.js new file mode 100644 index 00000000000..63c4c3dc871 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mappers.js @@ -0,0 +1,3 @@ +export function getApprovalRuleNamesLeft(_, rules) { + return rules; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index 943011949fd..7d0871f696b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -1,14 +1,15 @@ -import { hideFlash } from '~/flash'; - export default { + data() { + return { + alerts: [], + }; + }, methods: { clearError() { this.$emit('clearError'); this.hasApprovalAuthError = false; - const flashEl = document.querySelector('.flash-alert'); - if (flashEl) { - hideFlash(flashEl); - } + this.alerts.forEach((alert) => alert.dismiss()); + this.alerts = []; }, refreshApprovals() { return this.service.fetchApprovals().then((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 00024a594dc..ecbee6544ab 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 @@ -15,8 +15,9 @@ import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { setFaviconOverlay } from '../lib/utils/favicon'; +import { setFaviconOverlay } from '~/lib/utils/favicon'; import Loading from './components/loading.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; @@ -120,7 +121,7 @@ export default { }, variables() { return { - issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id), }; }, updateQuery( 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 85df2ea63c8..f6a7ef58c10 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -30,6 +30,7 @@ export default class MergeRequestStore { this.machineValue = this.stateMachine.value; this.mergeDetailsCollapsed = window.innerWidth < 768; this.mergeError = data.mergeError; + this.multipleApprovalRulesAvailable = data.multiple_approval_rules_available || false; this.id = data.id; this.setPaths(data); 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 3c73f42b6b1..634b7da3def 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 @@ -34,7 +34,7 @@ export default { <template> <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> - <div class="gl-display-inline-flex gl-align-items-center"> + <div class="gl-display-inline-flex gl-align-items-center gl-relative"> <div class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" > diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index 49181bb847d..3a3929fba9b 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -16,7 +16,7 @@ export default { handleBlobRichViewer(this.$refs.content, this.type); }, safeHtmlConfig: { - ADD_TAGS: ['copy-code'], + ADD_TAGS: ['gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 271cfd210a6..52a5d6e1b86 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -42,11 +42,6 @@ export default { required: false, default: true, }, - iconClasses: { - type: String, - required: false, - default: '', - }, }, computed: { title() { @@ -73,7 +68,7 @@ export default { :href="detailsPath" @click="$emit('ciStatusBadgeClick')" > - <ci-icon :status="status" :css-classes="iconClasses" /> + <ci-icon :status="status" /> <template v-if="showText"> <span class="gl-ml-2">{{ status.text }}</span> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js new file mode 100644 index 00000000000..0fb5a2d5534 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js @@ -0,0 +1,16 @@ +import { __, s__ } from '~/locale'; + +export const RESET_LABEL = __('Reset'); +export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); + +// Groups +export const GROUP_TOGGLE_TEXT = __('Search for a group'); +export const GROUP_HEADER_TEXT = __('Select a group'); +export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); +export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); + +// Projects +export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project'); +export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project'); +export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.'); +export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.'); diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index d295052e2ce..45c50dce8ce 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -1,28 +1,15 @@ <script> import { debounce } from 'lodash'; -import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import axios from '~/lib/utils/axios_utils'; -import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Api from '~/api'; +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { groupsPath } from './utils'; -import { - TOGGLE_TEXT, - RESET_LABEL, - FETCH_GROUPS_ERROR, - FETCH_GROUP_ERROR, - QUERY_TOO_SHORT_MESSAGE, -} from './constants'; +import { RESET_LABEL, QUERY_TOO_SHORT_MESSAGE } from './constants'; const MINIMUM_QUERY_LENGTH = 3; -const GROUPS_PER_PAGE = 20; export default { components: { GlFormGroup, - GlAlert, GlCollapsibleListbox, }, props: { @@ -48,13 +35,20 @@ export default { required: false, default: false, }, - parentGroupID: { + headerText: { type: String, - required: false, - default: null, + required: true, }, - groupsFilter: { + defaultToggleText: { type: String, + required: true, + }, + fetchItems: { + type: Function, + required: true, + }, + fetchInitialSelectionText: { + type: Function, required: false, default: null, }, @@ -63,10 +57,10 @@ export default { return { pristine: true, searching: false, - hasMoreGroups: true, + hasMoreItems: true, infiniteScrollLoading: false, searchString: '', - groups: [], + items: [], page: 1, selectedValue: null, selectedText: null, @@ -78,14 +72,14 @@ export default { set(value) { this.selectedValue = value; this.selectedText = - value === null ? null : this.groups.find((group) => group.value === value).full_name; + value === null ? null : this.items.find((item) => item.value === value).text; }, get() { return this.selectedValue; }, }, toggleText() { - return this.selectedText ?? this.$options.i18n.toggleText; + return this.selectedText ?? this.defaultToggleText; }, resetButtonLabel() { return this.clearable ? RESET_LABEL : ''; @@ -109,90 +103,64 @@ export default { search: debounce(function debouncedSearch(searchString) { this.searchString = searchString; if (this.isSearchQueryTooShort) { - this.groups = []; + this.items = []; } else { - this.fetchGroups(); + this.fetchEntities(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - async fetchGroups(page = 1) { + async fetchEntities(page = 1) { if (page === 1) { this.searching = true; - this.groups = []; - this.hasMoreGroups = true; + this.items = []; + this.hasMoreItems = true; } else { this.infiniteScrollLoading = true; } - try { - const { data, headers } = await axios.get( - Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), - { - params: { - search: this.searchString, - per_page: GROUPS_PER_PAGE, - page, - }, - }, - ); - const groups = data.length ? data : data.results || []; - - this.groups.push( - ...groups.map((group) => ({ - ...group, - value: String(group.id), - })), - ); + const { items, totalPages } = await this.fetchItems(this.searchString, page); - const { totalPages } = parseIntPagination(normalizeHeaders(headers)); - if (page === totalPages) { - this.hasMoreGroups = false; - } + this.items.push(...items); - this.page = page; - this.searching = false; - this.infiniteScrollLoading = false; - } catch (error) { - this.handleError({ message: FETCH_GROUPS_ERROR, error }); + if (page === totalPages) { + this.hasMoreItems = false; } + + this.page = page; + this.searching = false; + this.infiniteScrollLoading = false; }, async fetchInitialSelection() { if (!this.initialSelection) { this.pristine = false; return; } - this.searching = true; - try { - const group = await Api.group(this.initialSelection); - this.selectedValue = this.initialSelection; - this.selectedText = group.full_name; - this.pristine = false; - this.searching = false; - } catch (error) { - this.handleError({ message: FETCH_GROUP_ERROR, error }); + + if (!this.fetchInitialSelectionText) { + 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.pristine = false; + this.searching = false; }, onShown() { - if (!this.searchString && !this.groups.length) { - this.fetchGroups(); + if (!this.searchString && !this.items.length) { + this.fetchEntities(); } }, onReset() { this.selected = null; }, onBottomReached() { - this.fetchGroups(this.page + 1); - }, - handleError({ message, error }) { - Sentry.captureException(error); - this.errorMessage = message; - }, - dismissError() { - this.errorMessage = ''; + this.fetchEntities(this.page + 1); }, }, i18n: { - toggleText: TOGGLE_TEXT, - selectGroup: __('Select a group'), noResultsText: __('No results found.'), searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE, }, @@ -201,20 +169,21 @@ export default { <template> <gl-form-group :label="label"> - <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ - errorMessage - }}</gl-alert> + <slot name="error"></slot> + <template v-if="Boolean($scopedSlots.label)" #label> + <slot name="label"></slot> + </template> <gl-collapsible-listbox ref="listbox" v-model="selected" - :header-text="$options.i18n.selectGroup" + :header-text="headerText" :reset-button-label="resetButtonLabel" :toggle-text="toggleText" :loading="searching && pristine" :searching="searching" - :items="groups" + :items="items" :no-results-text="noResultsText" - :infinite-scroll="hasMoreGroups" + :infinite-scroll="hasMoreItems" :infinite-scroll-loading="infiniteScrollLoading" searchable @shown="onShown" @@ -223,10 +192,7 @@ export default { @bottom-reached="onBottomReached" > <template #list-item="{ item }"> - <div class="gl-font-weight-bold"> - {{ item.full_name }} - </div> - <div class="gl-text-gray-300">{{ item.full_path }}</div> + <slot name="list-item" :item="item"></slot> </template> </gl-collapsible-listbox> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> 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 new file mode 100644 index 00000000000..ff137d764ee --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -0,0 +1,137 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import { groupsPath } from './utils'; +import { + GROUP_TOGGLE_TEXT, + GROUP_HEADER_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, +} from './constants'; +import EntitySelect from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelect, + }, + props: { + label: { + type: String, + required: false, + default: '', + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + parentGroupID: { + type: String, + required: false, + default: null, + }, + groupsFilter: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchGroups(searchString = '', page = 1) { + let groups = []; + let totalPages = 0; + try { + const { data = [], headers } = await axios.get( + Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), + { + params: { + search: searchString, + per_page: DEFAULT_PER_PAGE, + page, + }, + }, + ); + groups = data.map((group) => ({ + ...group, + text: group.full_name, + value: String(group.id), + })); + + totalPages = parseIntPagination(normalizeHeaders(headers)).totalPages; + } catch (error) { + this.handleError({ message: FETCH_GROUPS_ERROR, error }); + } + return { items: groups, totalPages }; + }, + async fetchGroupName(groupId) { + let groupName = ''; + try { + const group = await Api.group(groupId); + groupName = group.full_name; + } catch (error) { + this.handleError({ message: FETCH_GROUP_ERROR, error }); + } + return groupName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + toggleText: GROUP_TOGGLE_TEXT, + selectGroup: GROUP_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-select + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :clearable="clearable" + :header-text="$options.i18n.selectGroup" + :default-toggle-text="$options.i18n.toggleText" + :fetch-items="fetchGroups" + :fetch-initial-selection-text="fetchGroupName" + > + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + <template #list-item="{ item }"> + <div class="gl-font-weight-bold"> + {{ item.full_name }} + </div> + <div class="gl-text-gray-300">{{ item.full_path }}</div> + </template> + </entity-select> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js index dbfac8a0339..dbfac8a0339 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js new file mode 100644 index 00000000000..1afbeda74c4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ProjectSelect from './project_select.vue'; + +const SELECTOR = '.js-vue-project-select'; + +export const initProjectSelects = () => { + if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) { + // eslint-disable-next-line no-console + console.warn(`Attempted to initialize ProjectSelect but '${SELECTOR}' not found in the page`); + } + + document.querySelectorAll(SELECTOR).forEach((el) => { + const { + label, + inputName, + inputId, + groupId, + userId, + orderBy, + selected: initialSelection, + } = el.dataset; + const includeSubgroups = parseBoolean(el.dataset.includeSubgroups); + const membership = parseBoolean(el.dataset.membership); + const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel); + + return new Vue({ + el, + name: 'ProjectSelectRoot', + render(createElement) { + return createElement(ProjectSelect, { + props: { + label, + hasHtmlLabel, + inputName, + inputId, + groupId, + userId, + orderBy, + includeSubgroups, + membership, + initialSelection, + }, + }); + }, + }); + }); +}; 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 new file mode 100644 index 00000000000..393991d746e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -0,0 +1,168 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import Api from '~/api'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { + PROJECT_TOGGLE_TEXT, + PROJECT_HEADER_TEXT, + FETCH_PROJECTS_ERROR, + FETCH_PROJECT_ERROR, +} from './constants'; +import EntitySelector from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelector, + }, + directives: { + SafeHtml, + }, + props: { + label: { + type: String, + required: true, + }, + hasHtmlLabel: { + type: Boolean, + required: false, + default: false, + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + groupId: { + type: String, + required: false, + default: null, + }, + userId: { + type: String, + required: false, + default: null, + }, + includeSubgroups: { + type: Boolean, + required: false, + default: false, + }, + membership: { + type: Boolean, + required: false, + default: false, + }, + orderBy: { + type: String, + required: false, + default: 'similarity', + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchProjects(searchString = '') { + let projects = []; + try { + const { data = [] } = await (() => { + const commonParams = { + order_by: this.orderBy, + simple: true, + }; + + if (this.groupId) { + return Api.groupProjects(this.groupId, searchString, { + ...commonParams, + with_shared: true, + include_subgroups: this.includeSubgroups, + simple: true, + }); + } + // Note: the whole userId handling supports a single project selector that is slated for + // removal. Once we have deleted app/views/clusters/clusters/_advanced_settings.html.haml, + // we should be able to clean this up. + if (this.userId) { + return Api.userProjects( + this.userId, + searchString, + { + with_shared: true, + include_subgroups: this.includeSubgroups, + }, + (res) => ({ data: res }), + ); + } + return Api.projects(searchString, { + ...commonParams, + membership: this.membership, + }); + })(); + projects = data.map((item) => ({ + text: item.name_with_namespace || item.name, + value: String(item.id), + })); + } catch (error) { + this.handleError({ message: FETCH_PROJECTS_ERROR, error }); + } + return { items: projects, totalPages: 1 }; + }, + async fetchProjectName(projectId) { + let projectName = ''; + try { + const { data: project } = await Api.project(projectId); + projectName = project.name_with_namespace; + } catch (error) { + this.handleError({ message: FETCH_PROJECT_ERROR, error }); + } + return projectName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + searchForProject: PROJECT_TOGGLE_TEXT, + selectProject: PROJECT_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-selector + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :header-text="$options.i18n.selectProject" + :default-toggle-text="$options.i18n.searchForProject" + :fetch-items="fetchProjects" + :fetch-initial-selection-text="fetchProjectName" + clearable + > + <template v-if="hasHtmlLabel" #label> + <span v-safe-html="label"></span> + </template> + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + </entity-selector> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js index 0a4622269f4..0a4622269f4 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/utils.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index adf34f822ed..6a10557c6bc 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,7 +1,7 @@ <script> +import { getIconForFile } from '@gitlab/svgs/src/file_icon_map'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { FILE_SYMLINK_MODE } from '../constants'; -import getIconForFile from './file_icon/file_icon_map'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -88,7 +88,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" :inline="true" /> <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> - <use v-bind="{ 'xlink:href': spriteHref }" /> + <use :href="spriteHref" /> </svg> <gl-icon v-else diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js deleted file mode 100644 index 8686d317c8a..00000000000 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ /dev/null @@ -1,610 +0,0 @@ -const fileExtensionIcons = { - html: 'html', - htm: 'html', - html_vm: 'html', - asp: 'html', - jade: 'pug', - pug: 'pug', - md: 'markdown', - markdown: 'markdown', - mdown: 'markdown', - mkd: 'markdown', - mkdn: 'markdown', - rst: 'markdown', - blink: 'blink', - css: 'css', - scss: 'sass', - sass: 'sass', - less: 'less', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - xml: 'xml', - plist: 'xml', - xsd: 'xml', - dtd: 'xml', - xsl: 'xml', - xslt: 'xml', - resx: 'xml', - iml: 'xml', - xquery: 'xml', - tmLanguage: 'xml', - manifest: 'xml', - project: 'xml', - png: 'image', - jpeg: 'image', - jpg: 'image', - gif: 'image', - svg: 'image', - ico: 'image', - tif: 'image', - tiff: 'image', - psd: 'image', - psb: 'image', - ami: 'image', - apx: 'image', - bmp: 'image', - bpg: 'image', - brk: 'image', - cur: 'image', - dds: 'image', - dng: 'image', - exr: 'image', - fpx: 'image', - gbr: 'image', - img: 'image', - jbig2: 'image', - jb2: 'image', - jng: 'image', - jxr: 'image', - pbm: 'image', - pgf: 'image', - pic: 'image', - raw: 'image', - webp: 'image', - js: 'javascript', - ejs: 'javascript', - esx: 'javascript', - jsx: 'react', - tsx: 'react', - ini: 'settings', - dlc: 'settings', - dll: 'settings', - config: 'settings', - conf: 'settings', - properties: 'settings', - prop: 'settings', - settings: 'settings', - option: 'settings', - props: 'settings', - toml: 'settings', - prefs: 'settings', - ts: 'typescript', - marko: 'markojs', - pdf: 'pdf', - xlsx: 'table', - xls: 'table', - ods: 'table', - csv: 'table', - tsv: 'table', - vscodeignore: 'vscode', - vsixmanifest: 'vscode', - vsix: 'vscode', - suo: 'visualstudio', - sln: 'visualstudio', - csproj: 'visualstudio', - vb: 'visualstudio', - pdb: 'database', - sql: 'database', - pks: 'database', - pkb: 'database', - accdb: 'database', - mdb: 'database', - sqlite: 'database', - cs: 'csharp', - zip: 'zip', - tar: 'zip', - gz: 'zip', - xz: 'zip', - bzip2: 'zip', - gzip: 'zip', - rar: 'zip', - tgz: 'zip', - exe: 'exe', - msi: 'exe', - java: 'java', - jar: 'java', - jsp: 'java', - c: 'c', - m: 'c', - h: 'h', - cc: 'cpp', - cpp: 'cpp', - mm: 'cpp', - cxx: 'cpp', - hpp: 'hpp', - go: 'go', - py: 'python', - url: 'url', - sh: 'console', - ksh: 'console', - csh: 'console', - tcsh: 'console', - zsh: 'console', - bash: 'console', - bat: 'console', - cmd: 'console', - ps1: 'powershell', - psm1: 'powershell', - psd1: 'powershell', - ps1xml: 'powershell', - psc1: 'powershell', - pssc: 'powershell', - gradle: 'gradle', - doc: 'word', - docx: 'word', - odt: 'word', - rtf: 'word', - cer: 'certificate', - cert: 'certificate', - crt: 'certificate', - pub: 'key', - key: 'key', - pem: 'key', - asc: 'key', - gpg: 'key', - woff: 'font', - woff2: 'font', - ttf: 'font', - eot: 'font', - suit: 'font', - otf: 'font', - bmap: 'font', - fnt: 'font', - odttf: 'font', - ttc: 'font', - font: 'font', - fonts: 'font', - sui: 'font', - ntf: 'font', - mrf: 'font', - lib: 'lib', - bib: 'lib', - rb: 'ruby', - erb: 'ruby', - fs: 'fsharp', - fsx: 'fsharp', - fsi: 'fsharp', - fsproj: 'fsharp', - swift: 'swift', - ino: 'arduino', - dockerignore: 'docker', - dockerfile: 'docker', - tex: 'tex', - cls: 'tex', - sty: 'tex', - pptx: 'powerpoint', - ppt: 'powerpoint', - pptm: 'powerpoint', - potx: 'powerpoint', - pot: 'powerpoint', - potm: 'powerpoint', - ppsx: 'powerpoint', - ppsm: 'powerpoint', - pps: 'powerpoint', - ppam: 'powerpoint', - ppa: 'powerpoint', - odp: 'powerpoint', - webm: 'movie', - mkv: 'movie', - flv: 'movie', - vob: 'movie', - ogv: 'movie', - ogg: 'music', - gifv: 'movie', - avi: 'movie', - mov: 'movie', - qt: 'movie', - wmv: 'movie', - yuv: 'movie', - rm: 'movie', - rmvb: 'movie', - mp4: 'movie', - m4v: 'movie', - mpg: 'movie', - mp2: 'movie', - mpeg: 'movie', - mpe: 'movie', - mpv: 'movie', - m2v: 'movie', - vdi: 'virtual', - vbox: 'virtual', - ics: 'email', - mp3: 'music', - flac: 'music', - m4a: 'music', - wma: 'music', - aiff: 'music', - coffee: 'coffee', - txt: 'document', - graphql: 'graphql', - rs: 'rust', - raml: 'raml', - xaml: 'xaml', - hs: 'haskell', - kt: 'kotlin', - kts: 'kotlin', - patch: 'git', - lua: 'lua', - clj: 'clojure', - cljs: 'clojure', - groovy: 'groovy', - r: 'r', - rmd: 'r', - dart: 'dart', - as: 'actionscript', - mxml: 'mxml', - ahk: 'autohotkey', - swf: 'flash', - swc: 'swc', - cmake: 'cmake', - asm: 'assembly', - a51: 'assembly', - inc: 'assembly', - nasm: 'assembly', - s: 'assembly', - ms: 'assembly', - agc: 'assembly', - ags: 'assembly', - aea: 'assembly', - argus: 'assembly', - mitigus: 'assembly', - binsource: 'assembly', - vue: 'vue', - ml: 'ocaml', - mli: 'ocaml', - cmx: 'ocaml', - lock: 'lock', - hbs: 'handlebars', - mustache: 'handlebars', - pl: 'perl', - pm: 'perl', - hx: 'haxe', - pp: 'puppet', - ex: 'elixir', - exs: 'elixir', - ls: 'livescript', - erl: 'erlang', - twig: 'twig', - jl: 'julia', - elm: 'elm', - pure: 'purescript', - tpl: 'smarty', - styl: 'stylus', - re: 'reason', - rei: 'reason', - cmj: 'bucklescript', - merlin: 'merlin', - v: 'verilog', - vhd: 'verilog', - sv: 'verilog', - svh: 'verilog', - nb: 'mathematica', - wl: 'wolframlanguage', - wls: 'wolframlanguage', - njk: 'nunjucks', - nunjucks: 'nunjucks', - robot: 'robot', - sol: 'solidity', - au3: 'autoit', - haml: 'haml', - yang: 'yang', - tf: 'terraform', - tfvars: 'terraform', - tfstate: 'terraform', - applescript: 'applescript', - cake: 'cake', - feature: 'cucumber', - nim: 'nim', - nimble: 'nim', - apib: 'apiblueprint', - apiblueprint: 'apiblueprint', - tag: 'riot', - vfl: 'vfl', - kl: 'kl', - pcss: 'postcss', - sss: 'postcss', - todo: 'todo', - cfml: 'coldfusion', - cfc: 'coldfusion', - lucee: 'coldfusion', - cabal: 'cabal', - nix: 'nix', - slim: 'slim', - http: 'http', - rest: 'http', - rql: 'restql', - restql: 'restql', - kv: 'kivy', - graphcool: 'graphcool', - sbt: 'sbt', - cr: 'crystal', - cu: 'cuda', - cuh: 'cuda', - log: 'log', -}; - -const twoFileExtensionIcons = { - 'gradle.kts': 'gradle', - 'md.rendered': 'markdown', - 'markdown.rendered': 'markdown', - 'mdown.rendered': 'markdown', - 'mkd.rendered': 'markdown', - 'mkdn.rendered': 'markdown', - 'YAML-tmLanguage': 'yaml', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', - 'd.ts': 'typescript-def', - 'code-workplace': 'vscode', - '7z': 'zip', - 'c++': 'cpp', - 'vbox-prev': 'virtual', - 'js.map': 'javascript-map', - 'css.map': 'css-map', - 'spec.ts': 'test-ts', - 'test.ts': 'test-ts', - 'ts.snap': 'test-ts', - 'spec.tsx': 'test-jsx', - 'test.tsx': 'test-jsx', - 'tsx.snap': 'test-jsx', - 'spec.jsx': 'test-jsx', - 'test.jsx': 'test-jsx', - 'jsx.snap': 'test-jsx', - 'spec.js': 'test-js', - 'test.js': 'test-js', - 'js.snap': 'test-js', - 'routing.ts': 'angular-routing', - 'routing.js': 'angular-routing', - 'module.ts': 'angular', - 'module.js': 'angular', - 'ng-template': 'angular', - 'component.ts': 'angular-component', - 'component.js': 'angular-component', - 'guard.ts': 'angular-guard', - 'guard.js': 'angular-guard', - 'service.ts': 'angular-service', - 'service.js': 'angular-service', - 'pipe.ts': 'angular-pipe', - 'pipe.js': 'angular-pipe', - 'filter.js': 'angular-pipe', - 'directive.ts': 'angular-directive', - 'directive.js': 'angular-directive', - 'resolver.ts': 'angular-resolver', - 'resolver.js': 'angular-resolver', - 'tf.json': 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', - 'reducer.ts': 'ngrx-reducer', - 'rootReducer.ts': 'ngrx-reducer', - 'state.ts': 'ngrx-state', - 'actions.ts': 'ngrx-actions', - 'effects.ts': 'ngrx-effects', - 'drone.yml': 'drone', -}; - -const fileNameIcons = { - '.jscsrc': 'json', - '.jshintrc': 'json', - 'tsconfig.json': 'json', - 'tslint.json': 'json', - 'composer.lock': 'json', - '.jsbeautifyrc': 'json', - '.esformatter': 'json', - 'cdp.pid': 'json', - '.htaccess': 'xml', - '.jshintignore': 'settings', - '.buildignore': 'settings', - makefile: 'settings', - '.mrconfig': 'settings', - '.yardopts': 'settings', - 'gradle.properties': 'gradle', - gradlew: 'gradle', - 'gradle-wrapper.properties': 'gradle', - COPYING: 'certificate', - 'COPYING.LESSER': 'certificate', - LICENSE: 'certificate', - LICENCE: 'certificate', - 'LICENSE.md': 'certificate', - 'LICENCE.md': 'certificate', - 'LICENSE.txt': 'certificate', - 'LICENCE.txt': 'certificate', - '.gitlab-license': 'certificate', - dockerfile: 'docker', - 'docker-compose.yml': 'docker', - '.mailmap': 'email', - '.gitignore': 'git', - '.gitconfig': 'git', - '.gitattributes': 'git', - '.gitmodules': 'git', - '.gitkeep': 'git', - 'git-history': 'git', - '.Rhistory': 'r', - 'cmakelists.txt': 'cmake', - 'cmakecache.txt': 'cmake', - 'angular-cli.json': 'angular', - '.angular-cli.json': 'angular', - '.vfl': 'vfl', - '.kl': 'kl', - 'postcss.config.js': 'postcss', - '.postcssrc.js': 'postcss', - 'project.graphcool': 'graphcool', - 'webpack.js': 'webpack', - 'webpack.ts': 'webpack', - 'webpack.base.js': 'webpack', - 'webpack.base.ts': 'webpack', - 'webpack.config.js': 'webpack', - 'webpack.config.ts': 'webpack', - 'webpack.common.js': 'webpack', - 'webpack.common.ts': 'webpack', - 'webpack.config.common.js': 'webpack', - 'webpack.config.common.ts': 'webpack', - 'webpack.config.common.babel.js': 'webpack', - 'webpack.config.common.babel.ts': 'webpack', - 'webpack.dev.js': 'webpack', - 'webpack.dev.ts': 'webpack', - 'webpack.config.dev.js': 'webpack', - 'webpack.config.dev.ts': 'webpack', - 'webpack.config.dev.babel.js': 'webpack', - 'webpack.config.dev.babel.ts': 'webpack', - 'webpack.prod.js': 'webpack', - 'webpack.prod.ts': 'webpack', - 'webpack.server.js': 'webpack', - 'webpack.server.ts': 'webpack', - 'webpack.client.js': 'webpack', - 'webpack.client.ts': 'webpack', - 'webpack.config.server.js': 'webpack', - 'webpack.config.server.ts': 'webpack', - 'webpack.config.client.js': 'webpack', - 'webpack.config.client.ts': 'webpack', - 'webpack.config.production.babel.js': 'webpack', - 'webpack.config.production.babel.ts': 'webpack', - 'webpack.config.prod.babel.js': 'webpack', - 'webpack.config.prod.babel.ts': 'webpack', - 'webpack.config.prod.js': 'webpack', - 'webpack.config.prod.ts': 'webpack', - 'webpack.config.production.js': 'webpack', - 'webpack.config.production.ts': 'webpack', - 'webpack.config.staging.js': 'webpack', - 'webpack.config.staging.ts': 'webpack', - 'webpack.config.babel.js': 'webpack', - 'webpack.config.babel.ts': 'webpack', - 'webpack.config.base.babel.js': 'webpack', - 'webpack.config.base.babel.ts': 'webpack', - 'webpack.config.base.js': 'webpack', - 'webpack.config.base.ts': 'webpack', - 'webpack.config.staging.babel.js': 'webpack', - 'webpack.config.staging.babel.ts': 'webpack', - 'webpack.config.coffee': 'webpack', - 'webpack.config.test.js': 'webpack', - 'webpack.config.test.ts': 'webpack', - 'webpack.config.vendor.js': 'webpack', - 'webpack.config.vendor.ts': 'webpack', - 'webpack.config.vendor.production.js': 'webpack', - 'webpack.config.vendor.production.ts': 'webpack', - 'webpack.test.js': 'webpack', - 'webpack.test.ts': 'webpack', - 'webpack.dist.js': 'webpack', - 'webpack.dist.ts': 'webpack', - 'webpackfile.js': 'webpack', - 'webpackfile.ts': 'webpack', - 'ionic.config.json': 'ionic', - '.io-config.json': 'ionic', - 'gulpfile.js': 'gulp', - 'gulpfile.ts': 'gulp', - 'gulpfile.babel.js': 'gulp', - 'package.json': 'nodejs', - 'package-lock.json': 'nodejs', - '.nvmrc': 'nodejs', - '.npmignore': 'npm', - '.npmrc': 'npm', - '.yarnrc': 'yarn', - '.yarnrc.yml': 'yarn', - 'yarn.lock': 'yarn', - '.yarnclean': 'yarn', - '.yarn-integrity': 'yarn', - 'yarn-error.log': 'yarn', - 'androidmanifest.xml': 'android', - '.env': 'tune', - '.env.example': 'tune', - '.babelrc': 'babel', - 'contributing.md': 'contributing', - 'contributing.md.rendered': 'contributing', - 'readme.md': 'readme', - 'readme.md.rendered': 'readme', - changelog: 'changelog', - 'changelog.md': 'changelog', - 'changelog.md.rendered': 'changelog', - CREDITS: 'credits', - 'credits.txt': 'credits', - 'credits.md': 'credits', - 'credits.md.rendered': 'credits', - '.flowconfig': 'flow', - 'favicon.png': 'favicon', - 'karma.conf.js': 'karma', - 'karma.conf.ts': 'karma', - 'karma.conf.coffee': 'karma', - 'karma.config.js': 'karma', - 'karma.config.ts': 'karma', - 'karma-main.js': 'karma', - 'karma-main.ts': 'karma', - '.bithoundrc': 'bithound', - 'appveyor.yml': 'appveyor', - '.travis.yml': 'travis', - 'protractor.conf.js': 'protractor', - 'protractor.conf.ts': 'protractor', - 'protractor.conf.coffee': 'protractor', - 'protractor.config.js': 'protractor', - 'protractor.config.ts': 'protractor', - 'fuse.js': 'fusebox', - procfile: 'heroku', - '.editorconfig': 'editorconfig', - '.gitlab-ci.yml': 'gitlab', - '.bowerrc': 'bower', - 'bower.json': 'bower', - '.eslintrc.js': 'eslint', - '.eslintrc.yaml': 'eslint', - '.eslintrc.yml': 'eslint', - '.eslintrc.json': 'eslint', - '.eslintrc': 'eslint', - '.eslintignore': 'eslint', - 'code_of_conduct.md': 'conduct', - 'code_of_conduct.md.rendered': 'conduct', - '.watchmanconfig': 'watchman', - 'aurelia.json': 'aurelia', - 'mocha.opts': 'mocha', - jenkinsfile: 'jenkins', - 'firebase.json': 'firebase', - '.firebaserc': 'firebase', - Rakefile: 'ruby', - 'rollup.config.js': 'rollup', - 'rollup.config.ts': 'rollup', - 'rollup-config.js': 'rollup', - 'rollup-config.ts': 'rollup', - 'rollup.config.prod.js': 'rollup', - 'rollup.config.prod.ts': 'rollup', - 'rollup.config.dev.js': 'rollup', - 'rollup.config.dev.ts': 'rollup', - 'rollup.config.prod.vendor.js': 'rollup', - 'rollup.config.prod.vendor.ts': 'rollup', - '.hhconfig': 'hack', - '.stylelintrc': 'stylelint', - 'stylelint.config.js': 'stylelint', - '.stylelintrc.json': 'stylelint', - '.stylelintrc.yaml': 'stylelint', - '.stylelintrc.yml': 'stylelint', - '.stylelintrc.js': 'stylelint', - '.stylelintignore': 'stylelint', - '.codeclimate.yml': 'code-climate', - '.prettierrc': 'prettier', - 'prettier.config.js': 'prettier', - '.prettierrc.js': 'prettier', - '.prettierrc.json': 'prettier', - '.prettierrc.yaml': 'prettier', - '.prettierrc.yml': 'prettier', - '.prettierignore': 'prettier', - 'nodemon.json': 'nodemon', - '.sonarrc': 'sonar', - browserslist: 'browserlist', - '.browserslistrc': 'browserlist', - '.snyk': 'snyk', - '.drone.yml': 'drone', -}; - -export default function getIconForFile(name) { - return ( - fileNameIcons[name] || - twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || - fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || - '' - ); -} diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 8a3a174f414..dfeb12d5cf5 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -43,11 +43,6 @@ export default { isBlob() { return this.file.type === 'blob'; }, - levelIndentation() { - return { - marginLeft: this.level ? `${this.level * 8}px` : null, - }; - }, fileClass() { return { 'file-open': this.isBlob && this.file.opened, @@ -144,7 +139,6 @@ export default { > <span ref="textOutput" - :style="levelIndentation" class="file-row-name" :title="file.name" data-qa-selector="file_name_content" @@ -198,6 +192,7 @@ export default { line-height: 16px; text-overflow: ellipsis; white-space: nowrap; + margin-left: calc(var(--level) * 16px); } .file-row-name .file-row-icon { diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue index e7817b8f910..2e0cdbb12f9 100644 --- a/app/assets/javascripts/vue_shared/components/file_tree.vue +++ b/app/assets/javascripts/vue_shared/components/file_tree.vue @@ -20,11 +20,16 @@ export default { return this.file.isHeader ? 0 : this.level + 1; }, }, + methods: { + hasChildren(childFile) { + return childFile.tree?.length; + }, + }, }; </script> <template> - <div> + <div :style="{ '--level': level }"> <component :is="fileRowComponent" :level="level" @@ -39,6 +44,8 @@ export default { :file-row-component="fileRowComponent" :level="childFilesLevel" :file="childFile" + :class="{ 'tree-list-parent': hasChildren(childFile) }" + class="gl-relative" v-bind="$attrs" v-on="$listeners" /> 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 993b4c11c0e..5b98af8c732 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 @@ -50,6 +50,7 @@ export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); 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_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); @@ -67,6 +68,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee'; export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_CONTACT = 'contact'; +export const TOKEN_TYPE_GROUP = 'group'; export const TOKEN_TYPE_EPIC = 'epic'; // As health status gets reused between issue lists and boards // this is in the shared constants. Until we have not decoupled the EE filtered search bar @@ -85,5 +87,4 @@ export const TOKEN_TYPE_STATUS = 'status'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; - export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index e0fa06c159e..c8aeac75645 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -93,7 +94,7 @@ export default { return `${getIdFromGraphQLId(contact.id)}`; }, formatContactGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Contact', id); + return convertToGraphQLId(TYPENAME_CRM_CONTACT, id); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index 3f030c8698c..ff0571031b5 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -90,7 +91,7 @@ export default { return `${getIdFromGraphQLId(organization.id)}`; }, formatOrganizationGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Organization', id); + return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, id); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 71c50ef292a..9449e071a0d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -79,6 +79,9 @@ export default { // labels.json and /groups/:id/labels & /projects/:id/labels // return response differently. this.labels = Array.isArray(res) ? res : res.data; + if (this.config.fetchLatestLabels) { + this.fetchLatestLabels(searchTerm); + } }) .catch(() => createAlert({ @@ -89,6 +92,21 @@ export default { this.loading = false; }); }, + fetchLatestLabels(searchTerm) { + this.config + .fetchLatestLabels(searchTerm) + .then((res) => { + // We'd want to avoid doing this check but + // labels.json and /groups/:id/labels & /projects/:id/labels + // return response differently. + this.labels = Array.isArray(res) ? res : res.data; + }) + .catch(() => + createAlert({ + message: __('There was a problem fetching latest labels.'), + }), + ); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js deleted file mode 100644 index 06537d682fe..00000000000 --- a/app/assets/javascripts/vue_shared/components/group_select/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -import { __ } from '~/locale'; - -export const TOGGLE_TEXT = __('Search for a group'); -export const RESET_LABEL = __('Reset'); -export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); -export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); -export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 8e459cc21ac..28baabbdb81 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.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 CiIconBadge from './ci_badge_link.vue'; +import CiBadgeLink from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; /** @@ -16,7 +16,7 @@ import TimeagoTooltip from './time_ago_tooltip.vue'; */ export default { components: { - CiIconBadge, + CiBadgeLink, TimeagoTooltip, GlButton, GlAvatarLink, @@ -120,7 +120,7 @@ export default { data-testid="ci-header-content" > <section class="header-main-content gl-mr-3"> - <ci-icon-badge :status="status" /> + <ci-badge-link class="gl-mr-3" :status="status" /> <strong data-testid="ci-header-item-text">{{ item }}</strong> diff --git a/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue new file mode 100644 index 00000000000..b704cec2475 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue @@ -0,0 +1,61 @@ +<script> +import { GlAlert, GlLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'IncubationAlert', + components: { GlAlert, GlLink }, + props: { + featureName: { + type: String, + required: true, + }, + linkToFeedbackIssue: { + type: String, + required: true, + }, + }, + data() { + return { + isAlertDismissed: false, + }; + }, + computed: { + shouldShowAlert() { + return !this.isAlertDismissed; + }, + titleLabel() { + return sprintf(this.$options.i18n.titleLabel, { featureName: this.featureName }); + }, + }, + methods: { + dismissAlert() { + this.isAlertDismissed = true; + }, + }, + i18n: { + titleLabel: s__('Incubation|%{featureName} is in incubating phase'), + contentLabel: s__( + 'Incubation|GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited.', + ), + learnMoreLabel: s__('Incubation|Learn more about incubating features'), + feedbackLabel: s__('Incubation|Give feedback on this feature'), + }, +}; +</script> + +<template> + <gl-alert + v-if="shouldShowAlert" + :title="titleLabel" + variant="warning" + :primary-button-text="$options.i18n.feedbackLabel" + :primary-button-link="linkToFeedbackIssue" + @dismiss="dismissAlert" + > + {{ $options.i18n.contentLabel }} + <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{ + $options.i18n.learnMoreLabel + }}</gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue new file mode 100644 index 00000000000..b5afe92316a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue @@ -0,0 +1,62 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export default { + name: 'KeysetPagination', + components: { + GlKeysetPagination, + }, + props: { + startCursor: { + type: String, + required: false, + default: '', + }, + endCursor: { + type: String, + required: false, + default: '', + }, + hasNextPage: { + type: Boolean, + required: true, + }, + hasPreviousPage: { + type: Boolean, + required: true, + }, + }, + computed: { + previousPageLink() { + return setUrlParams({ cursor: this.startCursor }); + }, + nextPageLink() { + return setUrlParams({ cursor: this.endCursor }); + }, + isPaginationVisible() { + return this.hasPreviousPage || this.hasNextPage; + }, + }, + i18n: { + previousPageButtonLabel: __('Prev'), + nextPageButtonLabel: __('Next'), + }, +}; +</script> + +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + :start-cursor="startCursor" + :end-cursor="endCursor" + :has-previous-page="hasPreviousPage" + :has-next-page="hasNextPage" + :prev-text="$options.i18n.previousPageButtonLabel" + :next-text="$options.i18n.nextPageButtonLabel" + :prev-button-link="previousPageLink" + :next-button-link="nextPageLink" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js index d80c1ff8b0c..9a88ab44f3d 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js @@ -1,9 +1,10 @@ import { issuableTypes } from '~/boards/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import blockingIssuesQuery from './graphql/blocking_issues.query.graphql'; import blockingEpicsQuery from './graphql/blocking_epics.query.graphql'; export const blockingIssuablesQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { query: blockingIssuesQuery, }, [issuableTypes.epic]: { diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue index 253aca8837d..f5b4870d59f 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue @@ -1,8 +1,9 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { issuableTypes } from '~/boards/constants'; -import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; import { blockingIssuablesQueries } from './constants'; @@ -10,16 +11,16 @@ import { blockingIssuablesQueries } from './constants'; export default { i18n: { issuableType: { - [issuableTypes.issue]: __('issue'), + [TYPE_ISSUE]: __('issue'), [issuableTypes.epic]: __('epic'), }, }, graphQLIdType: { - [issuableTypes.issue]: TYPE_ISSUE, - [issuableTypes.epic]: TYPE_EPIC, + [TYPE_ISSUE]: TYPENAME_ISSUE, + [issuableTypes.epic]: TYPENAME_EPIC, }, referenceFormatter: { - [issuableTypes.issue]: (r) => r.split('/')[1], + [TYPE_ISSUE]: (r) => r.split('/')[1], }, defaultDisplayLimit: 3, textTruncateWidth: 80, @@ -42,7 +43,7 @@ export default { type: String, required: true, validator(value) { - return [issuableTypes.issue, issuableTypes.epic].includes(value); + return [TYPE_ISSUE, issuableTypes.epic].includes(value); }, }, }, @@ -119,7 +120,7 @@ export default { ); }, blockIcon() { - return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; + return this.issuableType === TYPE_ISSUE ? 'issue-block' : 'entity-blocked'; }, glIconId() { return `blocked-icon-${this.uniqueId}`; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 7b76fc3fc6d..6f4cddbdfa2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -82,6 +82,11 @@ export default { required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, line: { type: Object, required: false, @@ -257,6 +262,7 @@ export default { contacts: this.enableAutocomplete, }, true, + this.autocompleteDataSources, ); }, beforeDestroy() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c53118b9f62..7e6b0e4a63b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -41,33 +41,25 @@ export default { required: false, default: true, }, - formFieldId: { - type: String, - required: true, - }, - formFieldName: { - type: String, - required: true, - }, enablePreview: { type: Boolean, required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, enableAutocomplete: { type: Boolean, required: false, default: true, }, - formFieldPlaceholder: { - type: String, - required: false, - default: '', - }, - formFieldAriaLabel: { - type: String, - required: false, - default: '', + formFieldProps: { + type: Object, + required: true, + validator: (prop) => prop.id && prop.name, }, autofocus: { type: Boolean, @@ -152,6 +144,7 @@ export default { :textarea-value="value" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" :uploads-path="uploadsPath" :enable-preview="enablePreview" show-content-editor-switcher @@ -160,16 +153,13 @@ export default { > <template #textarea> <textarea - :id="formFieldId" + v-bind="formFieldProps" ref="textarea" :value="value" - :name="formFieldName" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" :data-supports-quick-actions="supportsQuickActions" data-qa-selector="markdown_editor_form_field" - :aria-label="formFieldAriaLabel" - :placeholder="formFieldPlaceholder" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" > @@ -189,9 +179,8 @@ export default { @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input - :id="formFieldId" + v-bind="formFieldProps" :value="value" - :name="formFieldName" data-qa-selector="markdown_editor_form_field" type="hidden" /> diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js new file mode 100644 index 00000000000..e5dca170965 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js @@ -0,0 +1,26 @@ +import { __ } from '~/locale'; + +export const RESOURCE_TYPE_ISSUE = 'issue'; +export const RESOURCE_TYPE_MERGE_REQUEST = 'merge-request'; +export const RESOURCE_TYPE_MILESTONE = 'milestone'; + +export const RESOURCE_TYPES = [ + RESOURCE_TYPE_ISSUE, + RESOURCE_TYPE_MERGE_REQUEST, + RESOURCE_TYPE_MILESTONE, +]; + +export const RESOURCE_OPTIONS = { + [RESOURCE_TYPE_ISSUE]: { + path: 'issues/new', + label: __('issue'), + }, + [RESOURCE_TYPE_MERGE_REQUEST]: { + path: 'merge_requests/new', + label: __('merge request'), + }, + [RESOURCE_TYPE_MILESTONE]: { + path: 'milestones/new', + label: __('milestone'), + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..578914dbbaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,18 @@ +query searchUserGroupProjectsWithMergeRequestsEnabled($fullPath: ID!, $search: String) { + group(fullPath: $fullPath) { + id + projects( + search: $search + withMergeRequestsEnabled: true + includeSubgroups: true + sort: ACTIVITY_DESC + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql new file mode 100644 index 00000000000..8fe92cf7c6c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql @@ -0,0 +1,21 @@ +query searchUserGroupsAndProjects($username: String!, $search: String) { + projects(sort: "latest_activity_desc", membership: true) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + + user(username: $username) { + id + groups(search: $search) { + nodes { + id + name + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql new file mode 100644 index 00000000000..a630c885d28 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithIssuesEnabled($search: String) { + projects( + search: $search + membership: true + withIssuesEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..44ebf755728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithMergeRequestsEnabled($search: String) { + projects( + search: $search + membership: true + withMergeRequestsEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js new file mode 100644 index 00000000000..f3905dabedd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import NewResourceDropdown from './new_resource_dropdown.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initNewResourceDropdown = (props = {}) => { + const el = document.querySelector('.js-new-resource-dropdown'); + + if (!el) { + return false; + } + + const { groupId, fullPath, username } = el.dataset; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(NewResourceDropdown, { + props: { + withLocalStorage: true, + groupId, + queryVariables: { + ...(fullPath + ? { + fullPath, + } + : {}), + ...(username + ? { + username, + } + : {}), + }, + ...props, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue new file mode 100644 index 00000000000..b079181bd10 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue @@ -0,0 +1,208 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import AccessorUtilities from '~/lib/utils/accessor'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import searchUserProjectsWithIssuesEnabled from './graphql/search_user_projects_with_issues_enabled.query.graphql'; +import { RESOURCE_TYPE_ISSUE, RESOURCE_TYPES, RESOURCE_OPTIONS } from './constants'; + +export default { + i18n: { + noMatchesFound: __('No matches found'), + toggleButtonLabel: __('Toggle project select'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, + LocalStorageSync, + }, + props: { + resourceType: { + type: String, + required: false, + default: RESOURCE_TYPE_ISSUE, + validator: (value) => RESOURCE_TYPES.includes(value), + }, + query: { + type: Object, + required: false, + default: () => searchUserProjectsWithIssuesEnabled, + }, + groupId: { + type: String, + required: false, + default: '', + }, + queryVariables: { + type: Object, + required: false, + default: () => ({}), + }, + extractProjects: { + type: Function, + required: false, + default: (data) => data?.projects?.nodes, + }, + withLocalStorage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projects: [], + search: '', + selectedProject: {}, + shouldSkipQuery: true, + }; + }, + apollo: { + projects: { + query() { + return this.query; + }, + variables() { + return { + search: this.search, + ...this.queryVariables, + }; + }, + update(data) { + return this.extractProjects(data) || []; + }, + error(error) { + createAlert({ + message: __('An error occurred while loading projects.'), + captureError: true, + error, + }); + }, + skip() { + return this.shouldSkipQuery; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + computed: { + localStorageKey() { + return `group-${this.groupId}-new-${this.resourceType}-recent-project`; + }, + resourceOptions() { + return RESOURCE_OPTIONS[this.resourceType]; + }, + defaultDropdownText() { + return sprintf(__('Select project to create %{type}'), { type: this.resourceOptions.label }); + }, + dropdownHref() { + return this.hasSelectedProject + ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, this.resourceOptions.path) + : undefined; + }, + dropdownText() { + return this.hasSelectedProject + ? sprintf(__('New %{type} in %{project}'), { + type: this.resourceOptions.label, + project: this.selectedProject.name, + }) + : this.defaultDropdownText; + }, + hasSelectedProject() { + return this.selectedProject.webUrl; + }, + showNoSearchResultsText() { + return !this.projects.length && this.search; + }, + canUseLocalStorage() { + return this.withLocalStorage && AccessorUtilities.canUseLocalStorage(); + }, + selectedProjectForLocalStorage() { + const { webUrl, name } = this.selectedProject; + + return { webUrl, name }; + }, + }, + methods: { + handleDropdownClick() { + if (!this.dropdownHref) { + this.$refs.dropdown.show(); + } + }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + this.$refs.search.focusInput(); + }, + selectProject(project) { + this.selectedProject = project; + }, + initFromLocalStorage(storedProject) { + // Historically, the selected project was saved with the URL as the `url` property, so we are + // falling back to that legacy property if `webUrl` is empty. This ensures that we support + // localStorage data that was persisted prior to this change. + let webUrl = storedProject.webUrl || storedProject.url; + + // The select2 implementation used to include the resource path in the local storage. We + // need to clean this up so that we can then re-build a fresh URL in the computed prop. + webUrl = webUrl.endsWith(this.resourceOptions.path) + ? webUrl.slice(0, webUrl.length - this.resourceOptions.path.length) + : webUrl; + const dashSuffix = `${DASH_SCOPE}/`; + webUrl = webUrl.endsWith(dashSuffix) + ? webUrl.slice(0, webUrl.length - dashSuffix.length) + : webUrl; + + this.selectedProject = { webUrl, name: storedProject.name }; + }, + }, +}; +</script> + +<template> + <local-storage-sync + :storage-key="localStorageKey" + :value="selectedProjectForLocalStorage" + @input="initFromLocalStorage" + > + <gl-dropdown + ref="dropdown" + right + split + :split-href="dropdownHref" + :text="dropdownText" + :toggle-text="$options.i18n.toggleButtonLabel" + variant="confirm" + data-testid="new-resource-dropdown" + @click="handleDropdownClick" + @shown="handleDropdownShown" + > + <gl-search-box-by-type ref="search" v-model.trim="search" /> + <gl-loading-icon v-if="$apollo.queries.projects.loading" /> + <template v-else> + <gl-dropdown-item + v-for="project of projects" + :key="project.id" + @click="selectProject(project)" + > + {{ project.nameWithNamespace || project.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="showNoSearchResultsText"> + {{ $options.i18n.noMatchesFound }} + </gl-dropdown-text> + </template> + </gl-dropdown> + </local-storage-sync> +</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 5516c9943b8..5d0ee6adffe 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -33,6 +33,7 @@ export default { 'gl-border-t-transparent': !this.first && !this.selected, 'gl-border-t-gray-100': this.first && !this.selected, 'gl-border-b-gray-100': !this.selected, + 'gl-border-t-transparent!': this.selected && !this.first, 'gl-bg-blue-50 gl-border-blue-200': this.selected, }; }, @@ -126,10 +127,9 @@ export default { <slot name="right-action"></slot> </div> </div> - <div class="gl-display-flex"> + <div v-if="isDetailsShown" class="gl-display-flex"> <div class="gl-w-7"></div> <div - v-if="isDetailsShown" class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" > <div diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue deleted file mode 100644 index e3e3b9abc3c..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue'; - -export default { - components: { - GlButton, - RunnerAwsDeploymentsModal, - }, - directives: { - GlModalDirective, - }, - modalId: 'runner-aws-deployments-modal', - i18n: { - buttonText: s__('Runners|Deploy GitLab Runner in AWS'), - }, - data() { - return { - opened: false, - }; - }, - methods: { - onClick() { - this.opened = true; - }, - }, -}; -</script> -<template> - <div> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mt-4" - data-testid="show-modal-button" - variant="confirm" - @click="onClick" - > - {{ $options.i18n.buttonText }} - </gl-button> - <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue deleted file mode 100644 index 08acde1aefc..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; - -export default { - components: { - GlModal, - RunnerAwsInstructions, - }, - props: { - modalId: { - type: String, - required: true, - }, - }, - methods: { - onClose() { - this.$refs.modal.close(); - }, - }, - i18n_title: s__('Runners|Deploy GitLab Runner in AWS'), -}; -</script> -<template> - <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm"> - <runner-aws-instructions @close="onClose" /> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js index 3dbc5246c3d..b66c89d1372 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -4,6 +4,7 @@ export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN'; export const PLATFORM_DOCKER = 'docker'; export const PLATFORM_KUBERNETES = 'kubernetes'; +export const PLATFORM_AWS = 'aws'; export const AWS_README_URL = 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue index cafebdfe5f4..8a234889e6f 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue @@ -2,6 +2,7 @@ import { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, @@ -11,6 +12,7 @@ import { import Tracking from '~/tracking'; import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { AWS_README_URL, AWS_CF_BASE_URL, @@ -22,13 +24,22 @@ export default { components: { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, GlAccordion, GlAccordionItem, + ModalCopyButton, }, mixins: [Tracking.mixin()], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + }, data() { return { selectedIndex: 0, @@ -65,16 +76,20 @@ export default { }, }, i18n: { - title: s__('Runners|Deploy GitLab Runner in AWS'), instructions: s__( - 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', + 'Runners|Select your preferred runner, then choose the capacity for the runner in the AWS CloudFormation console.', ), chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), dontSeeWhatYouAreLookingFor: s__( "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", ), + runnerRegistrationToken: s__('Runners|Runner Registration token'), + copyInstructions: s__('Runners|Copy registration token'), moreDetails: __('More Details'), lessDetails: __('Less Details'), + close: __('Close'), + deployRunnerInAws: s__('Runners|Deploy GitLab Runner in AWS'), + externalLink: __('(external link)'), }, readmeUrl: AWS_README_URL, easyButtons: AWS_EASY_BUTTONS, @@ -83,6 +98,7 @@ export default { <template> <div> <p>{{ $options.i18n.instructions }}</p> + <gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only> <gl-form-radio v-for="(easyButton, idx) in $options.easyButtons" @@ -113,10 +129,23 @@ export default { </template> </gl-sprintf> </p> + <template v-if="registrationToken"> + <h5 class="gl-mb-3">{{ $options.i18n.runnerRegistrationToken }}</h5> + <div class="gl-display-flex"> + <pre class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line">{{ registrationToken }}</pre> + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="registrationToken" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> <footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3"> - <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + <gl-button @click="onClose()">{{ $options.i18n.close }}</gl-button> <gl-button variant="confirm" @click="onOk()"> - {{ s__('Runners|Deploy GitLab Runner in AWS') }} + {{ $options.i18n.deployRunnerInAws }} + <gl-icon name="external-link" :aria-label="$options.i18n.externalLink" /> </gl-button> </footer> </div> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index 729fe9c462c..22d9b88fa41 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -14,11 +14,12 @@ import { import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { __, s__ } from '~/locale'; import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql'; -import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants'; +import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants'; import RunnerCliInstructions from './instructions/runner_cli_instructions.vue'; import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue'; import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue'; +import RunnerAwsInstructions from './instructions/runner_aws_instructions.vue'; export default { components: { @@ -104,6 +105,8 @@ export default { return RunnerDockerInstructions; case PLATFORM_KUBERNETES: return RunnerKubernetesInstructions; + case PLATFORM_AWS: + return RunnerAwsInstructions; default: return null; } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 28a16cd846a..092e8ba6c15 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,64 +1,55 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import ChunkLine from './chunk_line.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; /* * We only highlight the chunk that is currently visible to the user. * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. * - * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, - * so by making text transparent and rendering raw (non-highlighted) text, - * the browser spends less resources on painting content that is not immediately relevant. - * - * Why use transparent text as opposed to hiding content entirely? - * 1. If content is hidden entirely, native find text (⌘ + F) won't work. - * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, + * so by rendering raw (non-highlighted) text, the browser spends less resources on painting + * content that is not immediately relevant. + * Why use plaintext as opposed to hiding content entirely? + * If content is hidden entirely, native find text (⌘ + F) won't work. */ export default { components: { - ChunkLine, GlIntersectionObserver, }, + directives: { + SafeHtml, + }, + mixins: [glFeatureFlagMixin()], props: { - isFirstChunk: { + isHighlighted: { type: Boolean, - required: false, - default: false, + required: true, }, chunkIndex: { type: Number, required: false, default: 0, }, - isHighlighted: { - type: Boolean, + rawContent: { + type: String, required: true, }, - content: { + highlightedContent: { type: String, required: true, }, - startingFrom: { - type: Number, - required: false, - default: 0, - }, totalLines: { type: Number, required: false, default: 0, }, - totalChunks: { + startingFrom: { type: Number, required: false, default: 0, }, - language: { - type: String, - required: false, - default: null, - }, blamePath: { type: String, required: true, @@ -66,37 +57,37 @@ export default { }, data() { return { + hasAppeared: false, isLoading: true, }; }, computed: { + shouldHighlight() { + return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); + }, lines() { return this.content.split('\n'); }, + pageSearchString() { + if (!this.glFeatures.fileLineBlame) return ''; + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, }, - created() { - if (this.isFirstChunk) { + if (this.chunkIndex === 0) { + // Display first chunk ASAP in order to improve perceived performance this.isLoading = false; return; } - window.requestIdleCallback(async () => { + window.requestIdleCallback(() => { this.isLoading = false; - const { hash } = this.$route; - if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { - // when the last chunk is loaded scroll to the hash - await this.$nextTick(); - const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - lineHighlighter.highlightHash(hash); - } }); }, methods: { handleChunkAppear() { - if (!this.isHighlighted) { - this.$emit('appear', this.chunkIndex); - } + this.hasAppeared = true; }, calculateLineNumber(index) { return this.startingFrom + index + 1; @@ -106,28 +97,37 @@ export default { </script> <template> <gl-intersection-observer @appear="handleChunkAppear"> - <div v-if="isHighlighted"> - <chunk-line - v-for="(line, index) in lines" - :key="index" - :number="calculateLineNumber(index)" - :content="line" - :language="language" - :blame-path="blamePath" - /> - </div> - <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> - <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> - <span + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div v-for="(n, index) in totalLines" - v-once - :id="`L${calculateLineNumber(index)}`" :key="index" - data-testid="line-number" - v-text="calculateLineNumber(index)" - ></span> + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + v-if="glFeatures.fileLineBlame" + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" + > + {{ calculateLineNumber(index) }} + </a> + </div> </div> - <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + + <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + + <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-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> </div> </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue new file mode 100644 index 00000000000..28a16cd846a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue @@ -0,0 +1,133 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import ChunkLine from './chunk_line.vue'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, + * so by making text transparent and rendering raw (non-highlighted) text, + * the browser spends less resources on painting content that is not immediately relevant. + * + * Why use transparent text as opposed to hiding content entirely? + * 1. If content is hidden entirely, native find text (⌘ + F) won't work. + * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + */ +export default { + components: { + ChunkLine, + GlIntersectionObserver, + }, + props: { + isFirstChunk: { + type: Boolean, + required: false, + default: false, + }, + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + isHighlighted: { + type: Boolean, + required: true, + }, + content: { + type: String, + required: true, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + totalChunks: { + type: Number, + required: false, + default: 0, + }, + language: { + type: String, + required: false, + default: null, + }, + blamePath: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: true, + }; + }, + computed: { + lines() { + return this.content.split('\n'); + }, + }, + + created() { + if (this.isFirstChunk) { + this.isLoading = false; + return; + } + + window.requestIdleCallback(async () => { + this.isLoading = false; + const { hash } = this.$route; + if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { + // when the last chunk is loaded scroll to the hash + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + } + }); + }, + methods: { + handleChunkAppear() { + if (!this.isHighlighted) { + this.$emit('appear', this.chunkIndex); + } + }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, + }, +}; +</script> +<template> + <gl-intersection-observer @appear="handleChunkAppear"> + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="calculateLineNumber(index)" + :content="line" + :language="language" + :blame-path="blamePath" + /> + </div> + <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> + <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> + <span + v-for="(n, index) in totalLines" + v-once + :id="`L${calculateLineNumber(index)}`" + :key="index" + data-testid="line-number" + v-text="calculateLineNumber(index)" + ></span> + </div> + <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + </div> + </gl-intersection-observer> +</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 f382ded90d7..15335ea6edc 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -120,6 +120,8 @@ export const EVENT_LABEL_FALLBACK = 'legacy_fallback'; export const LINES_PER_CHUNK = 70; +export const NEWLINE = '\n'; + export const BIDI_CHARS = [ '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right) '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left) diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index efafa67a733..11708b6f1f6 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,192 +1,40 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, -} from './constants'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk.vue'; -import { registerPlugins } from './plugins/index'; -/* - * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, - * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. - * - * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). - * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, - * it does not trigger a repaint on a parent element that wraps all 1000 lines. - */ export default { components: { - GlLoadingIcon, Chunk, }, + directives: { + SafeHtml, + }, mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, props: { blob: { type: Object, required: true, }, - }, - data() { - return { - languageDefinition: null, - content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], - hljs: null, - firstChunk: null, - chunks: {}, - isLoading: true, - isLineSelected: false, - lineHighlighter: null, - }; - }, - computed: { - splitContent() { - return this.content.split(/\r?\n/); - }, - lineNumbers() { - return this.splitContent.length; - }, - unsupportedLanguage() { - const supportedLanguages = Object.keys(languageLoader); - const unsupportedLanguage = - !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language?.toLowerCase()); - - return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; - }, - totalChunks() { - return Object.keys(this.chunks).length; + chunks: { + type: Array, + required: false, + default: () => [], }, }, - async created() { + created() { + this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); - this.trackEvent(EVENT_LABEL_VIEWER); - - if (this.unsupportedLanguage) { - this.handleUnsupportedLanguage(); - return; - } - - this.generateFirstChunk(); - this.hljs = await this.loadHighlightJS(); - - if (this.language) { - this.languageDefinition = await this.loadLanguage(); - } - - // Highlight the first chunk as soon as highlight.js is available - this.highlightChunk(null, true); - - window.requestIdleCallback(async () => { - // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first - this.generateRemainingChunks(); - this.isLoading = false; - await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - }); - }, - methods: { - trackEvent(label) { - this.track(EVENT_ACTION, { label, property: this.blob.language }); - }, - handleUnsupportedLanguage() { - this.trackEvent(EVENT_LABEL_FALLBACK); - this.$emit('error'); - }, - generateFirstChunk() { - const lines = this.splitContent.splice(0, LINES_PER_CHUNK); - this.firstChunk = this.createChunk(lines); - }, - generateRemainingChunks() { - const result = {}; - for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { - const chunkIndex = Math.floor(i / LINES_PER_CHUNK); - const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); - result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); - } - - this.chunks = result; - }, - createChunk(lines, startingFrom = 0) { - return { - content: lines.join('\n'), - startingFrom, - totalLines: lines.length, - language: this.language, - isHighlighted: false, - }; - }, - highlightChunk(index, isFirstChunk) { - const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; - - if (chunk.isHighlighted) { - return; - } - - const { highlightedContent, language } = this.highlight(chunk.content, this.language); - - Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); - - this.selectLine(); - - this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); - }, - highlight(content, language) { - let detectedLanguage = language; - let highlightedContent; - if (this.hljs) { - registerPlugins(this.hljs, this.blob.fileType, this.content); - if (!detectedLanguage) { - const hljsHighlightAuto = this.hljs.highlightAuto(content); - highlightedContent = hljsHighlightAuto.value; - detectedLanguage = hljsHighlightAuto.language; - } else if (this.languageDefinition) { - highlightedContent = this.hljs.highlight(content, { language: this.language }).value; - } - } - - return { highlightedContent, language: detectedLanguage }; - }, - loadHighlightJS() { - // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) - return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); - }, - async loadLanguage() { - let languageDefinition; - - try { - languageDefinition = await languageLoader[this.language](); - this.hljs.registerLanguage(this.language, languageDefinition.default); - } catch (message) { - this.$emit('error', message); - } - - return languageDefinition; - }, - async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; - } - - this.isLineSelected = true; - await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); - }, }, userColorScheme: window.gon.user_color_scheme, - currentlySelectedLine: null, }; </script> + <template> <div class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" @@ -196,32 +44,15 @@ export default { data-qa-selector="blob_viewer_file_content" > <chunk - v-if="firstChunk" - :lines="firstChunk.lines" - :total-lines="firstChunk.totalLines" - :content="firstChunk.content" - :starting-from="firstChunk.startingFrom" - :is-highlighted="firstChunk.isHighlighted" - is-first-chunk - :language="firstChunk.language" - :blame-path="blob.blamePath" - /> - - <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" + v-for="(chunk, _, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue new file mode 100644 index 00000000000..26cf45c7570 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue @@ -0,0 +1,227 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, +} from './constants'; +import Chunk from './components/chunk_deprecated.vue'; +import { registerPlugins } from './plugins/index'; + +/* + * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, + * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. + * + * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). + * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, + * it does not trigger a repaint on a parent element that wraps all 1000 lines. + */ +export default { + components: { + GlLoadingIcon, + Chunk, + }, + mixins: [Tracking.mixin()], + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + languageDefinition: null, + content: this.blob.rawTextBlob, + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], + hljs: null, + firstChunk: null, + chunks: {}, + isLoading: true, + isLineSelected: false, + lineHighlighter: null, + }; + }, + computed: { + splitContent() { + return this.content.split(/\r?\n/); + }, + lineNumbers() { + return this.splitContent.length; + }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + const unsupportedLanguage = + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language?.toLowerCase()); + + return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; + }, + totalChunks() { + return Object.keys(this.chunks).length; + }, + }, + async created() { + addBlobLinksTracking(); + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + + this.generateFirstChunk(); + this.hljs = await this.loadHighlightJS(); + + if (this.language) { + this.languageDefinition = await this.loadLanguage(); + } + + // Highlight the first chunk as soon as highlight.js is available + this.highlightChunk(null, true); + + window.requestIdleCallback(async () => { + // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first + this.generateRemainingChunks(); + this.isLoading = false; + await this.$nextTick(); + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + }); + }, + methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, + generateFirstChunk() { + const lines = this.splitContent.splice(0, LINES_PER_CHUNK); + this.firstChunk = this.createChunk(lines); + }, + generateRemainingChunks() { + const result = {}; + for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); + } + + this.chunks = result; + }, + createChunk(lines, startingFrom = 0) { + return { + content: lines.join('\n'), + startingFrom, + totalLines: lines.length, + language: this.language, + isHighlighted: false, + }; + }, + highlightChunk(index, isFirstChunk) { + const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; + + if (chunk.isHighlighted) { + return; + } + + const { highlightedContent, language } = this.highlight(chunk.content, this.language); + + Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); + + this.selectLine(); + + this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); + }, + highlight(content, language) { + let detectedLanguage = language; + let highlightedContent; + if (this.hljs) { + registerPlugins(this.hljs, this.blob.fileType, this.content); + if (!detectedLanguage) { + const hljsHighlightAuto = this.hljs.highlightAuto(content); + highlightedContent = hljsHighlightAuto.value; + detectedLanguage = hljsHighlightAuto.language; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(content, { language: this.language }).value; + } + } + + return { highlightedContent, language: detectedLanguage }; + }, + loadHighlightJS() { + // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) + return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await languageLoader[this.language](); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + async selectLine() { + if (this.isLineSelected || !this.lineHighlighter) { + return; + } + + this.isLineSelected = true; + await this.$nextTick(); + this.lineHighlighter.highlightHash(this.$route.hash); + }, + }, + userColorScheme: window.gon.user_color_scheme, + currentlySelectedLine: null, +}; +</script> +<template> + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + data-qa-selector="blob_viewer_file_content" + > + <chunk + v-if="firstChunk" + :lines="firstChunk.lines" + :total-lines="firstChunk.totalLines" + :content="firstChunk.content" + :starting-from="firstChunk.startingFrom" + :is-highlighted="firstChunk.isHighlighted" + is-first-chunk + :language="firstChunk.language" + :blame-path="blob.blamePath" + /> + + <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> + <chunk + v-for="(chunk, key, index) in chunks" + v-else + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" + /> + </div> +</template> 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 0da57f9e6fa..142c135e9c1 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,15 +1,47 @@ -import hljs from 'highlight.js/lib/core'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import hljs from 'highlight.js'; import { registerPlugins } from '../plugins/index'; +import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = async (fileType, content, language) => { - const languageDefinition = await languageLoader[language](); - +const initHighlightJs = (fileType, content) => { registerPlugins(hljs, fileType, content); - hljs.registerLanguage(language, languageDefinition.default); }; -export const highlight = (fileType, content, language) => { - initHighlightJs(fileType, content, language); - return hljs.highlight(content, { language }).value; +const splitByLineBreaks = (content = '') => content.split(/\r?\n/); + +const createChunk = (language, rawChunkLines, highlightedChunkLines = [], startingFrom = 0) => ({ + highlightedContent: highlightedChunkLines.join(NEWLINE), + rawContent: rawChunkLines.join(NEWLINE), + totalLines: rawChunkLines.length, + startingFrom, + language, +}); + +const splitIntoChunks = (language, rawContent, highlightedContent) => { + const result = []; + const splitRawContent = splitByLineBreaks(rawContent); + const splitHighlightedContent = splitByLineBreaks(highlightedContent); + + for (let i = 0; i < splitRawContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const highlightedChunk = splitHighlightedContent.slice(i, i + LINES_PER_CHUNK); + const rawChunk = splitRawContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = createChunk(language, rawChunk, highlightedChunk, i); + } + + return result; +}; + +const highlight = (fileType, rawContent, lang) => { + const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()]; + let result; + + if (language) { + initHighlightJs(fileType, rawContent, language); + const highlightedContent = hljs.highlight(rawContent, { language }).value; + result = splitIntoChunks(language, rawContent, highlightedContent); + } + + return result; }; + +export { highlight, splitIntoChunks }; diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index bd5b7b77017..ad81c14d9e5 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -1,7 +1,9 @@ <script> -import { historyPushState } from '~/lib/utils/common_utils'; +import { historyPushState, historyReplaceState } from '~/lib/utils/common_utils'; import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; +export const HISTORY_PUSH_UPDATE_METHOD = 'push'; +export const HISTORY_REPLACE_UPDATE_METHOD = 'replace'; export const URL_SET_PARAMS_STRATEGY = 'set'; export const URL_MERGE_PARAMS_STRATEGY = 'merge'; @@ -24,6 +26,13 @@ export default { default: URL_MERGE_PARAMS_STRATEGY, validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value), }, + historyUpdateMethod: { + type: String, + required: false, + default: HISTORY_PUSH_UPDATE_METHOD, + validator: (value) => + [HISTORY_PUSH_UPDATE_METHOD, HISTORY_REPLACE_UPDATE_METHOD].includes(value), + }, }, watch: { query: { @@ -40,9 +49,14 @@ export default { updateQuery(newQuery) { const url = this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY - ? setUrlParams(this.query, window.location.href, true) + ? setUrlParams(this.query, window.location.href, true, true, true) : mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }); - historyPushState(url); + + if (this.historyUpdateMethod === HISTORY_PUSH_UPDATE_METHOD) { + historyPushState(url); + } else { + historyReplaceState(url); + } }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 231f5ff3d1f..167db3ce1f2 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -74,8 +74,8 @@ export default { <user-avatar-link v-for="item in visibleItems" :key="item.id" - :link-href="item.web_url" - :img-src="item.avatar_url" + :link-href="item.web_url || item.webUrl" + :img-src="item.avatar_url || item.avatarUrl" :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 86a99b8f0ed..edcfabe7da3 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -2,18 +2,19 @@ import { debounce } from 'lodash'; import { GlDropdown, - GlDropdownForm, GlDropdownDivider, + GlDropdownForm, GlDropdownItem, - GlSearchBoxByType, GlLoadingIcon, + GlSearchBoxByType, GlTooltipDirective, } from '@gitlab/ui'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { @@ -47,7 +48,8 @@ export default { }, iid: { type: String, - required: true, + required: false, + default: null, }, value: { type: Array, @@ -65,7 +67,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, isEditing: { type: Boolean, @@ -160,20 +162,17 @@ export default { } return { ...variables, - mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + mergeRequestId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.issuableId), }; }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, users() { - if (!this.participants) { - return []; - } - - const filteredParticipants = this.participants.filter( - (user) => user.name.includes(this.search) || user.username.includes(this.search), - ); + const filteredParticipants = + this.participants?.filter( + (user) => user.name.includes(this.search) || user.username.includes(this.search), + ) || []; // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 @@ -254,6 +253,10 @@ export default { this.$emit('input', selected); } }, + unassign() { + this.$emit('input', []); + this.$refs.dropdown.hide(); + }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); this.$emit('input', selected); @@ -323,7 +326,7 @@ export default { :is-checked="selectedIsEmpty" is-check-centered data-testid="unassign" - @click.native.capture.stop="$emit('input', [])" + @click.native.capture.stop="unassign" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned 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 98630512308..28bec63b244 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -423,6 +423,7 @@ export default { target="_blank" :href="webIdeUrl" block + @click="dismissCalloutOnActionClicked(dismiss)" > {{ __('Try it out now') }} </gl-link> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index c93dd95a886..fd151751372 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -1,5 +1,5 @@ import { __, n__, sprintf } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; const INTERVALS = { minute: 'minute', @@ -88,9 +88,9 @@ export const confidentialityInfoText = (workspaceType, issuableType) => ), { workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'), - issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'), + issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'), permissions: - issuableType === IssuableType.Issue + issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'), }, 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 5eb3da3c62e..d78530239a5 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 @@ -173,6 +173,7 @@ export default { :can-edit="enableEdit" :task-list-update-path="taskListUpdatePath" /> + <slot name="secondary-content"></slot> <small v-if="isUpdated" class="edited-text gl-font-sm!"> {{ __('Edited') }} <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js index 9b1cbfe218b..6fe98764fcd 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -1,8 +1,8 @@ export const SEVERITY_CLASS_NAME_MAP = { - critical: 'text-danger-800', - high: 'text-danger-600', - medium: 'text-warning-400', - low: 'text-warning-200', - info: 'text-primary-400', - unknown: 'text-secondary-400', + critical: 'gl-text-red-800', + high: 'gl-text-red-600', + medium: 'gl-text-orange-400', + low: 'gl-text-orange-200', + info: 'gl-text-blue-400', + unknown: 'gl-text-gray-400', }; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index f3cb5fc16f0..f620bad8dba 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -24,42 +24,37 @@ export const fetchDiffData = (state, endpoint, category) => { /** * Returns given vulnerability enriched with the corresponding * feedback (`dismissal` or `issue` type) - * @param {Object} vulnerability - * @param {Array} feedback + * @param {Object} vulnerabilityObject + * @param {Array} feedbackList */ -export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => - feedback +export const enrichVulnerabilityWithFeedback = (vulnerabilityObject, feedbackList = []) => { + const vulnerability = { ...vulnerabilityObject }; + // Some records may have a null `uuid`, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. + // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 + feedbackList .filter((fb) => - // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. - // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 - fb.finding_uuid !== null - ? fb.finding_uuid === vulnerability.finding_uuid + fb.finding_uuid + ? fb.finding_uuid === vulnerability.uuid : fb.project_fingerprint === vulnerability.project_fingerprint, ) - .reduce((vuln, fb) => { - if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { - return { - ...vuln, - isDismissed: true, - dismissalFeedback: fb, - }; + .forEach((feedback) => { + if (feedback.feedback_type === FEEDBACK_TYPE_DISMISSAL) { + vulnerability.isDismissed = true; + vulnerability.dismissalFeedback = feedback; + } else if (feedback.feedback_type === FEEDBACK_TYPE_ISSUE && feedback.issue_iid) { + vulnerability.hasIssue = true; + vulnerability.issue_feedback = feedback; + } else if ( + feedback.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && + feedback.merge_request_iid + ) { + vulnerability.hasMergeRequest = true; + vulnerability.merge_request_feedback = feedback; } - if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) { - return { - ...vuln, - hasIssue: true, - issue_feedback: fb, - }; - } - if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) { - return { - ...vuln, - hasMergeRequest: true, - merge_request_feedback: fb, - }; - } - return vuln; - }, vulnerability); + }); + + return vulnerability; +}; /** * Generates the added, fixed, and existing vulnerabilities from the API report. diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue new file mode 100644 index 00000000000..78e5dff6f59 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue @@ -0,0 +1,69 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'HookTestDropdown', + components: { + GlDisclosureDropdown, + }, + props: { + items: { + type: Array, + required: true, + }, + size: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + itemsWithAction() { + return this.items.map((item) => ({ + text: item.text, + action: () => this.testHook(item.href), + })); + }, + }, + methods: { + testHook(href) { + // HACK: Trigger @rails/ujs's data-method handling. + // + // The more obvious approaches of (1) declaratively rendering the + // links using GlDisclosureDropdown's list-item slot and (2) using + // item.extraAttrs to set the data-method attributes on the links + // do not work for reasons laid out in + // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134. + // + // Sending the POST with axios also doesn't work, since the + // endpoints return 302 redirects. Since axios uses XMLHTTPRequest, + // it transparently follows redirects, meaning the Location header + // of the first response cannot be inspected/acted upon by JS. We + // could manually trigger a reload afterwards, but that would mean + // a duplicate fetch of the current page: one by the XHR, and one + // by the explicit reload. It would also mean losing the flash + // alert set by the backend, making the feature useless for the + // user. + // + // The ideal fix here would be to refactor the test endpoint to + // return a JSON response, removing the need for a redirect/page + // reload to show the result. + const a = document.createElement('a'); + a.setAttribute('hidden', ''); + a.href = href; + a.dataset.method = 'post'; + document.body.appendChild(a); + a.click(); + a.remove(); + }, + }, + i18n: { + test: __('Test'), + }, +}; +</script> + +<template> + <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" /> +</template> diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js index 7d04978280b..6eb7cbea72c 100644 --- a/app/assets/javascripts/webhooks/index.js +++ b/app/assets/javascripts/webhooks/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import FormUrlApp from './components/form_url_app.vue'; +import TestDropdown from './components/test_dropdown.vue'; export default () => { const el = document.querySelector('.js-vue-webhook-form'); @@ -23,3 +24,22 @@ export default () => { }, }); }; + +const initHookTestDropdown = (el) => { + const { items, size } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(TestDropdown, { + props: { + items: JSON.parse(items), + size, + }, + }); + }, + }); +}; + +export const initHookTestDropdowns = (selector = '.js-webhook-test-dropdown') => + document.querySelectorAll(selector).forEach(initHookTestDropdown); diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 9e5361e8302..472bc1dfacc 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -35,7 +35,11 @@ export default { const body = document.querySelector('body'); const { namespaceId } = body.dataset; - this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); + this.track('click_whats_new_drawer', { + label: 'namespace_id', + value: namespaceId, + property: 'navigation_top', + }); }, methods: { ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']), diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js index 41aff202f48..f9b725ed429 100644 --- a/app/assets/javascripts/whats_new/utils/notification.js +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -5,6 +5,8 @@ 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) { diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 2a0913e380a..8ec8482657d 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -62,6 +62,7 @@ export default { :value="state" :options="$options.states" :disabled="disabled" + data-testid="work-item-state-select" class="gl-w-auto hide-select-decoration gl-pl-3" :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" @change="setState" diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index b2c8b7ae1db..6aa3c54705c 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -35,7 +35,7 @@ export default { <template> <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full" + class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-w-full" :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue index 92a2fcaf1df..bca061f5e01 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -22,6 +22,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import axios from '~/lib/utils/axios_utils'; import { getLocationHash } from '~/lib/utils/url_utility'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -87,6 +88,9 @@ export default { descriptionVersion() { return this.descriptionVersions[this.note.description_version_id]; }, + noteId() { + return getIdFromGraphQLId(this.note.id); + }, }, mounted() { renderGFM(this.$refs['gfm-content']); @@ -129,7 +133,7 @@ export default { <note-header :author="note.author" :created-at="note.createdAt" - :note-id="note.id" + :note-id="noteId" :is-system-note="true" > <span ref="gfm-content" v-safe-html="actionTextHtml"></span> 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 new file mode 100644 index 00000000000..b3f17aff2ae --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -0,0 +1,211 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { clearDraft } from '~/lib/utils/autosave'; +import Tracking from '~/tracking'; +import { ASC } from '~/notes/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { updateCommentState } from '~/work_items/graphql/cache_utils'; +import { getWorkItemQuery } from '../../utils'; +import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql'; +import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants'; +import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; +import WorkItemCommentLocked from './work_item_comment_locked.vue'; +import WorkItemCommentForm from './work_item_comment_form.vue'; + +export default { + constantOptions: { + avatarUrl: window.gon.current_user_avatar_url, + }, + components: { + GlAvatar, + GlButton, + WorkItemNoteSignedOut, + WorkItemCommentLocked, + WorkItemCommentForm, + }, + mixins: [glFeatureFlagMixin(), Tracking.mixin()], + props: { + workItemId: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, + discussionId: { + type: String, + required: false, + default: '', + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, + addPadding: { + type: Boolean, + required: false, + default: false, + }, + workItemType: { + type: String, + required: true, + }, + sortOrder: { + type: String, + required: false, + default: ASC, + }, + }, + data() { + return { + workItem: {}, + isEditing: false, + isSubmitting: false, + isSubmittingWithKeydown: false, + }; + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + signedIn() { + return Boolean(window.gon.current_user_id); + }, + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.discussionId ? `${this.discussionId}-comment` : `${this.workItemId}-comment`; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: `type_${this.workItemType}`, + }; + }, + markdownPreviewPath() { + return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ + this.workItemType + }`; + }, + timelineEntryClass() { + return { + 'timeline-entry gl-mb-3': true, + 'gl-p-4': this.addPadding, + }; + }, + isProjectArchived() { + return this.workItem?.project?.archived; + }, + canUpdate() { + return this.workItem?.userPermissions?.updateWorkItem; + }, + }, + watch: { + autofocus: { + immediate: true, + handler(focus) { + if (focus) { + this.isEditing = true; + } + }, + }, + }, + methods: { + async updateWorkItem(commentText) { + this.isSubmitting = true; + this.$emit('replying', commentText); + const { queryVariables, fetchByIid } = this; + + try { + this.track('add_work_item_comment'); + + await this.$apollo.mutate({ + mutation: createNoteMutation, + variables: { + input: { + noteableId: this.workItemId, + body: commentText, + discussionId: this.discussionId || null, + }, + }, + update(store, createNoteData) { + if (createNoteData.data?.createNote?.errors?.length) { + throw new Error(createNoteData.data?.createNote?.errors[0]); + } + updateCommentState(store, createNoteData, fetchByIid, queryVariables); + }, + }); + clearDraft(this.autosaveKey); + this.$emit('replied'); + this.cancelEditing(); + } catch (error) { + this.$emit('error', error.message); + Sentry.captureException(error); + } + + this.isSubmitting = false; + }, + cancelEditing() { + this.isEditing = false; + this.$emit('cancelEditing'); + }, + }, +}; +</script> + +<template> + <li :class="timelineEntryClass"> + <work-item-note-signed-out v-if="!signedIn" /> + <work-item-comment-locked + v-else-if="!canUpdate" + :work-item-type="workItemType" + :is-project-archived="isProjectArchived" + /> + <div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap"> + <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> + <work-item-comment-form + v-if="isEditing" + :work-item-type="workItemType" + :aria-label="__('Add a comment')" + :is-submitting="isSubmitting" + :autosave-key="autosaveKey" + @submitForm="updateWorkItem" + @cancelEditing="cancelEditing" + /> + <gl-button + v-else + class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" + @click="isEditing = true" + >{{ __('Add a comment') }}</gl-button + > + </div> + </li> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue new file mode 100644 index 00000000000..fd407fd9d9f --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -0,0 +1,126 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__, __ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +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 { + constantOptions: { + markdownDocsPath: helpPagePath('user/markdown'), + }, + components: { + GlButton, + MarkdownEditor, + }, + inject: ['fullPath'], + props: { + workItemType: { + type: String, + required: true, + }, + ariaLabel: { + type: String, + required: true, + }, + autosaveKey: { + type: String, + required: true, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + initialValue: { + type: String, + required: false, + default: '', + }, + commentButtonText: { + type: String, + required: false, + default: __('Comment'), + }, + }, + data() { + return { + commentText: getDraft(this.autosaveKey) || this.initialValue || '', + }; + }, + computed: { + markdownPreviewPath() { + return joinPaths( + '/', + gon.relative_url_root || '', + this.fullPath, + `/preview_markdown?target_type=${this.workItemType}`, + ); + }, + formFieldProps() { + return { + 'aria-label': this.ariaLabel, + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-add-or-edit-comment', + name: 'work-item-add-or-edit-comment', + }; + }, + }, + methods: { + setCommentText(newText) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + }, + async cancelEditing() { + if (this.commentText && this.commentText !== this.initialValue) { + const msg = s__('WorkItem|Are you sure you want to cancel editing?'); + + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + primaryBtnVariant: 'danger', + }); + + if (!confirmed) { + return; + } + } + + this.$emit('cancelEditing'); + clearDraft(this.autosaveKey); + }, + }, +}; +</script> + +<template> + <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> + <markdown-editor + :value="commentText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :form-field-props="formFieldProps" + data-testid="work-item-add-comment" + class="gl-mb-3" + autofocus + use-bottom-toolbar + @input="setCommentText" + @keydown.meta.enter="$emit('submitForm', commentText)" + @keydown.ctrl.enter="$emit('submitForm', commentText)" + @keydown.esc.stop="cancelEditing" + /> + <gl-button + category="primary" + variant="confirm" + data-testid="confirm-button" + :loading="isSubmitting" + @click="$emit('submitForm', commentText)" + >{{ commentButtonText }} + </gl-button> + <gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing" + >{{ __('Cancel') }} + </gl-button> + </form> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue index f837d025b7f..f837d025b7f 100644 --- a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue new file mode 100644 index 00000000000..bda00f978b9 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -0,0 +1,191 @@ +<script> +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { ASC } from '~/notes/constants'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue'; +import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue'; +import WorkItemAddNote from './work_item_add_note.vue'; + +export default { + components: { + TimelineEntryItem, + GlAvatarLink, + GlAvatar, + WorkItemNote, + WorkItemAddNote, + ToggleRepliesWidget, + DiscussionNotesRepliesWrapper, + WorkItemNoteReplying, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + discussion: { + type: Array, + required: true, + }, + sortOrder: { + type: String, + default: ASC, + required: false, + }, + }, + data() { + return { + isExpanded: false, + autofocus: false, + isReplying: false, + replyingText: '', + }; + }, + computed: { + note() { + return this.discussion[0]; + }, + author() { + return this.note.author; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + hasReplies() { + return this.replies?.length; + }, + replies() { + if (this.discussion?.length > 1) { + return this.discussion.slice(1); + } + return null; + }, + discussionId() { + return this.discussion[0]?.discussion?.id || ''; + }, + }, + methods: { + showReplyForm() { + this.isExpanded = true; + this.autofocus = true; + }, + hideReplyForm() { + this.isExpanded = this.hasReplies; + this.autofocus = false; + }, + toggleDiscussion() { + this.isExpanded = !this.isExpanded; + this.autofocus = this.isExpanded; + }, + threadKey(note) { + /* eslint-disable @gitlab/require-i18n-strings */ + return `${note.id}-thread`; + }, + onReplied() { + this.isExpanded = true; + this.isReplying = false; + this.replyingText = ''; + }, + onReplying(commentText) { + this.isReplying = true; + this.replyingText = commentText; + }, + }, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ 'internal-note': note.internal }" + :data-note-id="note.id" + class="note note-wrapper note-comment gl-px-0" + > + <div class="timeline-avatar gl-float-left"> + <gl-avatar-link :href="author.webUrl"> + <gl-avatar + :src="author.avatarUrl" + :entity-name="author.username" + :alt="author.name" + :size="32" + /> + </gl-avatar-link> + </div> + + <div class="timeline-content"> + <div class="discussion-body"> + <div class="discussion-wrapper"> + <div class="discussion-notes"> + <ul class="notes"> + <work-item-note + :is-first-note="true" + :note="note" + :discussion-id="discussionId" + :work-item-type="workItemType" + :class="{ 'gl-mb-5': hasReplies }" + @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', note)" + @error="$emit('error', $event)" + /> + <discussion-notes-replies-wrapper> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + @toggle="toggleDiscussion({ discussionId })" + /> + <template v-if="isExpanded"> + <template v-for="reply in replies"> + <work-item-note + :key="threadKey(reply)" + :discussion-id="discussionId" + :note="reply" + :work-item-type="workItemType" + @startReplying="showReplyForm" + @deleteNote="$emit('deleteNote', reply)" + @error="$emit('error', $event)" + /> + </template> + <work-item-note-replying v-if="isReplying" :body="replyingText" /> + <work-item-add-note + :autofocus="autofocus" + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + :fetch-by-iid="fetchByIid" + :discussion-id="discussionId" + :work-item-type="workItemType" + :sort-order="sortOrder" + :add-padding="true" + @cancelEditing="hideReplyForm" + @replied="onReplied" + @replying="onReplying" + @error="$emit('error', $event)" + /> + </template> + </discussion-notes-replies-wrapper> + </ul> + </div> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 5efa9c94f2b..5dd21a5f76f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -1,42 +1,126 @@ <script> -import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { __ } from '~/locale'; +import { updateDraft, clearDraft } from '~/lib/utils/autosave'; +import { renderMarkdown } from '~/notes/utils'; +import EditedAt from '~/issues/show/components/edited.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; +import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; +import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; +import WorkItemCommentForm from './work_item_comment_form.vue'; export default { + name: 'WorkItemNoteThread', + i18n: { + moreActionsText: __('More actions'), + deleteNoteText: __('Delete comment'), + }, components: { - NoteHeader, - NoteBody, TimelineEntryItem, - GlAvatarLink, + NoteBody, + NoteHeader, + NoteActions, GlAvatar, + GlAvatarLink, + GlDropdown, + GlDropdownItem, + WorkItemCommentForm, + EditedAt, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { note: { type: Object, required: true, }, + isFirstNote: { + type: Boolean, + required: false, + default: false, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + isEditing: false, + }; }, computed: { author() { return this.note.author; }, - noteAnchorId() { - return `note_${this.note.id}`; + entryClass() { + return { + 'note note-wrapper note-comment': true, + 'gl-p-4': !this.isFirstNote, + }; + }, + showReply() { + return this.note.userPermissions.createNote && this.isFirstNote; + }, + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.note.id}-comment`; + }, + lastEditedBy() { + return this.note.lastEditedBy; + }, + hasAdminPermission() { + return this.note.userPermissions.adminNote; + }, + }, + methods: { + showReplyForm() { + this.$emit('startReplying'); + }, + startEditing() { + this.isEditing = true; + updateDraft(this.autosaveKey, this.note.body); + }, + async updateNote(newText) { + this.isEditing = false; + try { + await this.$apollo.mutate({ + mutation: updateWorkItemNoteMutation, + variables: { + input: { + id: this.note.id, + body: newText, + }, + }, + optimisticResponse: { + updateNote: { + errors: [], + note: { + ...this.note, + bodyHtml: renderMarkdown(newText), + }, + }, + }, + }); + clearDraft(this.autosaveKey); + } catch (error) { + updateDraft(this.autosaveKey, newText); + this.isEditing = true; + this.$emit('error', __('Something went wrong when updating a comment. Please try again')); + Sentry.captureException(error); + } }, }, }; </script> <template> - <timeline-entry-item - :id="noteAnchorId" - :class="{ 'internal-note': note.internal }" - :data-note-id="note.id" - class="note note-wrapper note-comment" - > - <div class="timeline-avatar gl-float-left"> + <timeline-entry-item :class="entryClass"> + <div v-if="!isFirstNote" :key="note.id" class="timeline-avatar gl-float-left"> <gl-avatar-link :href="author.webUrl"> <gl-avatar :src="author.avatarUrl" @@ -46,14 +130,57 @@ export default { /> </gl-avatar-link> </div> - - <div class="timeline-content"> + <work-item-comment-form + v-if="isEditing" + :work-item-type="workItemType" + :aria-label="__('Edit comment')" + :autosave-key="autosaveKey" + :initial-value="note.body" + :comment-button-text="__('Save comment')" + :class="{ 'gl-pl-8': !isFirstNote }" + @cancelEditing="isEditing = false" + @submitForm="updateNote" + /> + <div v-else class="timeline-content-inner" data-testid="note-wrapper"> <div class="note-header"> <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" /> + <note-actions + :show-reply="showReply" + :show-edit="hasAdminPermission" + @startReplying="showReplyForm" + @startEditing="startEditing" + /> + <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link --> + <gl-dropdown + v-if="hasAdminPermission" + v-gl-tooltip + icon="ellipsis_v" + text-sr-only + right + :text="$options.i18n.moreActionsText" + :title="$options.i18n.moreActionsText" + category="tertiary" + no-caret + > + <gl-dropdown-item + variant="danger" + data-testid="delete-note-action" + @click="$emit('deleteNote')" + > + {{ $options.i18n.deleteNoteText }} + </gl-dropdown-item> + </gl-dropdown> </div> <div class="timeline-discussion-body"> - <note-body :note="note" /> + <note-body ref="noteBody" :note="note" /> </div> + <edited-at + v-if="note.lastEditedBy" + :updated-at="note.lastEditedAt" + :updated-by-name="lastEditedBy.name" + :updated-by-path="lastEditedBy.webPath" + :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'" + /> </div> </timeline-entry-item> </template> 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 new file mode 100644 index 00000000000..c17e855e527 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -0,0 +1,47 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; + +export default { + name: 'WorkItemNoteActions', + i18n: { + editButtonText: __('Edit comment'), + }, + components: { + GlButton, + ReplyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + showReply: { + type: Boolean, + required: true, + }, + showEdit: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="note-actions"> + <reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" /> + <gl-button + v-if="showEdit" + v-gl-tooltip + data-testid="edit-work-item-note" + data-track-action="click_button" + data-track-label="edit_button" + category="tertiary" + icon="pencil" + :title="$options.i18n.editButtonText" + :aria-label="$options.i18n.editButtonText" + @click="$emit('startEditing')" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue index dcee8750f81..95397b58925 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue @@ -3,6 +3,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { + name: 'WorkItemNoteBody', directives: { SafeHtml, }, @@ -12,12 +13,22 @@ export default { required: true, }, }, - mounted() { - this.renderGFM(); + watch: { + 'note.bodyHtml': { + immediate: true, + async handler(newVal, oldVal) { + if (newVal === oldVal) { + return; + } + await this.$nextTick(); + this.renderGFM(); + }, + }, }, methods: { renderGFM() { renderGFM(this.$refs['note-body']); + gl?.lazyLoader?.searchLazyImages(); }, }, safeHtmlConfig: { diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue new file mode 100644 index 00000000000..46f61ccd204 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue @@ -0,0 +1,53 @@ +<script> +import { GlAvatar } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import NoteHeader from '~/notes/components/note_header.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +export default { + name: 'WorkItemNoteReplying', + components: { + TimelineEntryItem, + GlAvatar, + NoteHeader, + }, + directives: { + SafeHtml, + }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], + }, + constantOptions: { + avatarUrl: window.gon.current_user_avatar_url, + }, + props: { + body: { + type: String, + required: false, + default: '', + }, + }, + computed: { + author() { + return { + avatarUrl: window.gon.current_user_avatar_url, + id: window.gon.current_user_id, + name: window.gon.current_user_fullname, + username: window.gon.current_username, + }; + }, + }, +}; +</script> + +<template> + <timeline-entry-item class="note note-wrapper note-comment gl-p-4 being-posted"> + <div class="timeline-avatar gl-float-left"> + <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> + </div> + <div class="note-header"> + <note-header :author="author" /> + </div> + <div ref="note-body" v-safe-html:[$options.safeHtmlConfig]="body" class="note-body"></div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue index 3ef4a16bc57..3ef4a16bc57 100644 --- a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue new file mode 100644 index 00000000000..355f17e970b --- /dev/null +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -0,0 +1,80 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlAlert, + GlButton, + }, + props: { + error: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isOpen: true, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + }, + methods: { + hide() { + this.isOpen = false; + }, + show() { + this.isOpen = true; + }, + toggle() { + this.isOpen = !this.isOpen; + }, + }, +}; +</script> + +<template> + <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"> + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + <slot name="header"></slot> + </h5> + <slot name="header-suffix"></slot> + </div> + <slot name="header-right"></slot> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="widget-toggle" + @click="toggle" + /> + </div> + </div> + <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismissAlert')"> + {{ error }} + </gl-alert> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="widget-body" + > + <slot name="body"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index c2980405a19..fc4c05d96b2 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -313,6 +313,7 @@ export default { :view-only="!canUpdate" :allow-clear-all="isEditing" class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" + data-testid="work-item-assignees-input" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue deleted file mode 100644 index 65042f1431d..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_comment_form.vue +++ /dev/null @@ -1,228 +0,0 @@ -<script> -import { GlAvatar, GlButton } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -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 { __, s__ } from '~/locale'; -import Tracking from '~/tracking'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils'; -import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; -import WorkItemCommentLocked from './work_item_comment_locked.vue'; - -export default { - constantOptions: { - markdownDocsPath: helpPagePath('user/markdown'), - avatarUrl: window.gon.current_user_avatar_url, - }, - components: { - GlAvatar, - GlButton, - MarkdownEditor, - WorkItemNoteSignedOut, - WorkItemCommentLocked, - }, - mixins: [glFeatureFlagMixin(), Tracking.mixin()], - props: { - workItemId: { - type: String, - required: true, - }, - fullPath: { - type: String, - required: true, - }, - fetchByIid: { - type: Boolean, - required: false, - default: false, - }, - queryVariables: { - type: Object, - required: true, - }, - }, - data() { - return { - workItem: {}, - isEditing: false, - isSubmitting: false, - isSubmittingWithKeydown: false, - commentText: '', - }; - }, - apollo: { - workItem: { - query() { - return getWorkItemQuery(this.fetchByIid); - }, - variables() { - return this.queryVariables; - }, - update(data) { - return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; - }, - skip() { - return !this.queryVariables.id && !this.queryVariables.iid; - }, - error() { - this.$emit('error', i18n.fetchError); - }, - }, - }, - computed: { - signedIn() { - return Boolean(window.gon.current_user_id); - }, - autosaveKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.workItemId}-comment`; - }, - canEdit() { - // maybe this should use `NotePermissions.updateNote`, but if - // we don't have any notes yet, that permission isn't on WorkItem - return Boolean(this.workItem?.userPermissions?.updateWorkItem); - }, - tracking() { - return { - category: TRACKING_CATEGORY_SHOW, - label: 'item_comment', - property: `type_${this.workItemType}`, - }; - }, - workItemType() { - return this.workItem?.workItemType?.name; - }, - markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; - }, - isProjectArchived() { - return this.workItem?.project?.archived; - }, - }, - methods: { - startEditing() { - this.isEditing = true; - this.commentText = getDraft(this.autosaveKey) || ''; - }, - async cancelEditing() { - if (this.commentText) { - const msg = s__('WorkItem|Are you sure you want to cancel editing?'); - - const confirmed = await confirmAction(msg, { - primaryBtnText: __('Discard changes'), - cancelBtnText: __('Continue editing'), - }); - - if (!confirmed) { - return; - } - } - - this.isEditing = false; - clearDraft(this.autosaveKey); - }, - async updateWorkItem(event = {}) { - const { key } = event; - - if (key) { - this.isSubmittingWithKeydown = true; - } - - this.isSubmitting = true; - - try { - this.track('add_work_item_comment'); - - const { - data: { createNote }, - } = await this.$apollo.mutate({ - mutation: createNoteMutation, - variables: { - input: { - noteableId: this.workItem.id, - body: this.commentText, - }, - }, - }); - - if (createNote.errors?.length) { - throw new Error(createNote.errors[0]); - } - - const client = this.$apollo.provider.defaultClient; - client.refetchQueries({ - include: [getWorkItemNotesQuery(this.fetchByIid)], - }); - - this.isEditing = false; - clearDraft(this.autosaveKey); - } catch (error) { - this.$emit('error', error.message); - Sentry.captureException(error); - } - - this.isSubmitting = false; - }, - setCommentText(newText) { - this.commentText = newText; - updateDraft(this.autosaveKey, this.commentText); - }, - }, -}; -</script> - -<template> - <li class="timeline-entry"> - <work-item-note-signed-out v-if="!signedIn" /> - <work-item-comment-locked - v-else-if="!canEdit" - :work-item-type="workItemType" - :is-project-archived="isProjectArchived" - /> - <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap"> - <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> - <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> - <markdown-editor - class="gl-mb-3" - :value="commentText" - :render-markdown-path="markdownPreviewPath" - :markdown-docs-path="$options.constantOptions.markdownDocsPath" - :form-field-aria-label="__('Add a comment')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-add-comment" - form-field-name="work-item-add-comment" - enable-autocomplete - autofocus - use-bottom-toolbar - @input="setCommentText" - @keydown.meta.enter="updateWorkItem" - @keydown.ctrl.enter="updateWorkItem" - @keydown.esc="cancelEditing" - /> - <gl-button - category="primary" - variant="confirm" - :loading="isSubmitting" - @click="updateWorkItem" - >{{ __('Comment') }} - </gl-button> - <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing" - >{{ __('Cancel') }} - </gl-button> - </form> - <gl-button - v-else - class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" - @click="startEditing" - >{{ __('Add a comment') }}</gl-button - > - </div> - </li> -</template> diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue new file mode 100644 index 00000000000..d1a707f2a8a --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -0,0 +1,115 @@ +<script> +import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getWorkItemQuery } from '../utils'; + +export default { + components: { + GlAvatarLink, + GlSprintf, + TimeAgoTooltip, + }, + props: { + fetchByIid: { + type: Boolean, + required: true, + }, + workItemId: { + type: String, + required: false, + default: null, + }, + workItemIid: { + type: String, + required: false, + default: null, + }, + fullPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + createdAt() { + return this.workItem?.createdAt || ''; + }, + updatedAt() { + return this.workItem?.updatedAt || ''; + }, + author() { + return this.workItem?.author ?? {}; + }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + queryVariables() { + return this.fetchByIid + ? { + fullPath: this.fullPath, + iid: this.workItemIid, + } + : { + id: this.workItemId, + }; + }, + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + skip() { + return !this.workItemId && !this.workItemIid; + }, + update(data) { + const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + return workItem ?? {}; + }, + }, + }, +}; +</script> + +<template> + <div class="gl-mb-3"> + <span data-testid="work-item-created"> + <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> + <template #timeAgo> + <time-ago-tooltip :time="createdAt" /> + </template> + <template #author> + <gl-avatar-link + class="js-user-link gl-text-body gl-font-weight-bold" + :title="author.name" + :data-user-id="authorId" + :href="author.webUrl" + > + {{ author.name }} + </gl-avatar-link> + </template> + </gl-sprintf> + <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')"> + <template #timeAgo> + <time-ago-tooltip :time="createdAt" /> + </template> + </gl-sprintf> + </span> + + <span + v-if="updatedAt" + class="gl-ml-5 gl-display-none gl-sm-display-inline-block" + data-testid="work-item-updated" + > + <gl-sprintf :message="__('Updated %{timeAgo}')"> + <template #timeAgo> + <time-ago-tooltip :time="updatedAt" /> + </template> + </gl-sprintf> + </span> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 07da0279b41..399c220bc96 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -10,7 +10,7 @@ import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getWorkItemQuery } from '../utils'; +import { getWorkItemQuery, autocompleteDataSources, markdownPreviewPath } from '../utils'; import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; @@ -46,7 +46,8 @@ export default { required: true, }, }, - markdownDocsPath: helpPagePath('user/markdown'), + markdownDocsPath: helpPagePath('user/project/quick_actions'), + quickActionsDocsPath: helpPagePath('user/project/quick_actions'), data() { return { workItem: {}, @@ -56,6 +57,12 @@ export default { descriptionText: '', descriptionHtml: '', conflictedDescription: '', + formFieldProps: { + 'aria-label': __('Description'), + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-description', + name: 'work-item-description', + }, }; }, apollo: { @@ -134,9 +141,10 @@ export default { return this.workItemDescription?.lastEditedBy?.webPath; }, markdownPreviewPath() { - return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ - this.workItemType - }`; + return markdownPreviewPath(this.fullPath, this.workItem.iid); + }, + autocompleteDataSources() { + return autocompleteDataSources(this.fullPath, this.workItem.iid); }, }, methods: { @@ -241,11 +249,11 @@ export default { :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-description" - form-field-name="work-item-description" + :form-field-props="formFieldProps" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" enable-autocomplete + supports-quick-actions init-on-autofocus use-bottom-toolbar @input="setDescriptionText" @@ -259,19 +267,19 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" + :quick-actions-docs-path="$options.quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" class="gl-px-3 bordered-box gl-mt-5" > <template #textarea> <textarea - id="work-item-description" + v-bind="formFieldProps" ref="textarea" v-model="descriptionText" :disabled="isSubmitting" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" - data-supports-quick-actions="false" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" + data-supports-quick-actions="true" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @keydown.exact.esc.stop="cancelEditing" diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index d58983c013d..9a2cdc1c172 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -47,6 +47,7 @@ export default { await this.$nextTick(); renderGFM(this.$refs['gfm-content']); + gl?.lazyLoader?.searchLazyImages(); if (this.canEdit) { this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); 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 ade954b2a7f..262c093a1d0 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -19,7 +19,7 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url import { isPositiveInteger } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { sprintfWorkItem, @@ -51,6 +51,7 @@ import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; +import WorkItemCreatedUpdated from './work_item_created_updated.vue'; import WorkItemDescription from './work_item_description.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; @@ -74,6 +75,7 @@ export default { GlEmptyState, WorkItemAssignees, WorkItemActions, + WorkItemCreatedUpdated, WorkItemDescription, WorkItemDueDate, WorkItemLabels, @@ -123,8 +125,9 @@ export default { workItem: {}, updateInProgress: false, modalWorkItemId: isPositiveInteger(workItemId) - ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId) : null, + modalWorkItemIid: getParameterByName('work_item_iid'), }; }, apollo: { @@ -136,7 +139,7 @@ export default { return this.queryVariables; }, skip() { - return !this.workItemId; + return !this.workItemId && !this.workItemIid; }, update(data) { const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; @@ -290,7 +293,10 @@ export default { return this.isWidgetPresent(WIDGET_TYPE_NOTES); }, fetchByIid() { - return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + return ( + (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) || + false + ); }, queryVariables() { return this.fetchByIid @@ -310,8 +316,8 @@ export default { }, }, mounted() { - if (this.modalWorkItemId) { - this.openInModal(undefined, { id: this.modalWorkItemId }); + if (this.modalWorkItemId || this.modalWorkItemIid) { + this.openInModal(undefined, { id: this.modalWorkItemId, iid: this.modalWorkItemIid }); } }, methods: { @@ -439,24 +445,33 @@ export default { Sentry.captureException(error); } }, - updateUrl(modalWorkItemId) { + updateUrl(modalWorkItem) { + const params = this.fetchByIid + ? { work_item_iid: modalWorkItem?.iid } + : { work_item_id: getIdFromGraphQLId(modalWorkItem?.id) }; + updateHistory({ - url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }), + url: setUrlParams(params), replace: true, }); }, openInModal(event, modalWorkItem) { + if (!this.workItemsMvc2Enabled) { + return; + } + if (event) { event.preventDefault(); - this.updateUrl(modalWorkItem.id); + this.updateUrl(modalWorkItem); } if (this.isModal) { - this.$emit('update-modal', event, modalWorkItem.id); + this.$emit('update-modal', event, modalWorkItem); return; } this.modalWorkItemId = modalWorkItem.id; + this.modalWorkItemIid = modalWorkItem.iid; this.$refs.modal.show(); }, }, @@ -559,6 +574,12 @@ export default { :can-update="canUpdate" @error="updateError = $event" /> + <work-item-created-updated + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" @@ -696,6 +717,7 @@ export default { v-if="!isModal" ref="modal" :work-item-id="modalWorkItemId" + :work-item-iid="modalWorkItemIid" :show="true" @close="updateUrl" /> diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index faea80a9de8..1b8e97bf717 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -50,12 +50,16 @@ export default { return { error: undefined, updatedWorkItemId: null, + updatedWorkItemIid: null, }; }, computed: { displayedWorkItemId() { return this.updatedWorkItemId || this.workItemId; }, + displayedWorkItemIid() { + return this.updatedWorkItemIid || this.workItemIid; + }, }, methods: { deleteWorkItem() { @@ -122,6 +126,7 @@ export default { }, closeModal() { this.updatedWorkItemId = null; + this.updatedWorkItemIid = null; this.error = ''; this.$emit('close'); }, @@ -134,9 +139,10 @@ export default { show() { this.$refs.modal.show(); }, - updateModal($event, workItemId) { - this.updatedWorkItemId = workItemId; - this.$emit('update-modal', $event, workItemId); + updateModal($event, workItem) { + this.updatedWorkItemId = workItem.id; + this.updatedWorkItemIid = workItem.iid; + this.$emit('update-modal', $event, workItem); }, }, }; @@ -150,6 +156,7 @@ export default { modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" scrollable + data-testid="work-item-detail-modal" @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -160,7 +167,7 @@ export default { is-modal :work-item-parent-id="issueGid" :work-item-id="displayedWorkItemId" - :work-item-iid="workItemIid" + :work-item-iid="displayedWorkItemIid" class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate" @close="hide" @deleteWorkItem="deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index 9ee302855c7..03c5b7096b2 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -215,6 +215,7 @@ export default { ref="startDatePicker" v-model="dirtyStartDate" container="body" + data-testid="work-item-start-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.startDateInputId" show-clear-button @@ -240,6 +241,7 @@ export default { ref="dueDatePicker" v-model="dirtyDueDate" container="body" + data-testid="work-item-due-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.dueDateInputId" :min-date="dirtyStartDate" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 45fb0f7f21a..8e9e1def0b9 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -19,7 +19,13 @@ import { } from '../constants'; function isTokenSelectorElement(el) { - return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); + return ( + el?.classList.contains('gl-label-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); } function addClass(el) { @@ -146,7 +152,17 @@ export default { watch: { labels(newVal) { if (!this.isEditing) { - this.localLabels = newVal.map(addClass); + // remove labels that aren't in list from server + this.localLabels = this.localLabels.filter((label) => + newVal.find((l) => l.id === label.id), + ); + + // add any that we don't have to the end + const labelsToAdd = newVal + .map(addClass) + .filter((label) => !this.localLabels.find((l) => l.id === label.id)); + + this.localLabels = this.localLabels.concat(labelsToAdd); } }, }, @@ -163,10 +179,11 @@ export default { this.setLabels(); }, async setLabels() { - if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; - this.searchKey = ''; this.isEditing = false; + + if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; + try { const { data: { @@ -214,18 +231,23 @@ export default { this.searchStarted = true; }, async focusTokenSelector(labels) { - const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id); - const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id); + const labelsToAdd = without(labels, ...this.localLabels); + const labelIdsToAdd = labelsToAdd.map((label) => label.id); + const labelIdsToRemove = without(this.localLabels, ...labels).map((label) => label.id); - if (labelsToAdd.length > 0) { - this.addLabelIds.push(...labelsToAdd); + if (labelIdsToAdd.length > 0) { + this.addLabelIds.push(...labelIdsToAdd); } - if (labelsToRemove.length > 0) { - this.removeLabelIds.push(...labelsToRemove); + if (labelIdsToRemove.length > 0) { + this.removeLabelIds.push(...labelIdsToRemove); } - this.localLabels = labels; + if (labels.length === 0) { + this.localLabels = []; + } else { + this.localLabels = this.localLabels.concat(labelsToAdd); + } this.handleFocus(); await this.$nextTick(); @@ -265,7 +287,9 @@ export default { :dropdown-items="searchLabels" :loading="isLoading" :view-only="!canUpdate" + :allow-clear-all="isEditing" class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" 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 b078711ec5d..e8578a6d49a 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 @@ -1,46 +1,39 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, - GlAlert, - GlLoadingIcon, - GlTooltipDirective, -} from '@gitlab/ui'; -import { produce } from 'immer'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils'; -import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; +import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { FORM_TYPES, WIDGET_ICONS, - WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY, + WORK_ITEM_STATUS_TEXT, } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql'; +import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; export default { components: { - GlButton, GlDropdown, GlDropdownItem, GlIcon, - GlAlert, GlLoadingIcon, + WidgetWrapper, WorkItemLinkChild, WorkItemLinksForm, WorkItemDetailModal, @@ -105,13 +98,13 @@ export default { data() { return { isShownAddForm: false, - isOpen: true, activeChild: {}, activeToast: null, prefetchedWorkItem: null, error: undefined, parentIssue: null, formType: null, + workItem: null, }; }, computed: { @@ -137,14 +130,8 @@ export default { isChildrenEmpty() { return this.children?.length === 0; }, - toggleIcon() { - return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; - }, - toggleLabel() { - return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); - }, issuableGid() { - return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; + return this.issuableId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issuableId) : null; }, isLoading() { return this.$apollo.queries.workItem.loading; @@ -168,7 +155,7 @@ export default { } else { const workItemId = getParameterByName('work_item_id'); if (workItemId) { - params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId); } } return params; @@ -180,11 +167,8 @@ export default { } }, methods: { - toggle() { - this.isOpen = !this.isOpen; - }, showAddForm(formType) { - this.isOpen = true; + this.$refs.wrapper.show(); this.isShownAddForm = true; this.formType = formType; this.$nextTick(() => { @@ -194,10 +178,6 @@ export default { hideAddForm() { this.isShownAddForm = false; }, - addChild(child) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(child, child.id, client); - }, openChild(child, e) { if (isMetaKey(e)) { return; @@ -211,9 +191,8 @@ export default { this.activeChild = {}; this.updateWorkItemIdUrlQuery(); }, - handleWorkItemDeleted(childId) { - const { defaultClient: client } = this.$apollo.provider.clients; - this.toggleChildFromCache(null, childId, client); + handleWorkItemDeleted(child) { + this.removeHierarchyChild(child); this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery({ id, iid } = {}) { @@ -222,38 +201,31 @@ export default { : { work_item_id: getIdFromGraphQLId(id) }; updateHistory({ url: setUrlParams(params), replace: true }); }, - toggleChildFromCache(workItem, childId, store) { - const sourceData = store.readQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.issuableGid }, - }); - - const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - - const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(workItem); - } + async addHierarchyChild(workItem) { + return this.$apollo.mutate({ + mutation: addHierarchyChildMutation, + variables: { id: this.issuableGid, workItem }, }); - - store.writeQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.issuableGid }, - data: newData, + }, + async removeHierarchyChild(workItem) { + return this.$apollo.mutate({ + mutation: removeHierarchyChildMutation, + variables: { id: this.issuableGid, workItem }, }); }, async updateWorkItem(workItem, childId, parentId) { - return this.$apollo.mutate({ + const response = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: childId, hierarchyWidget: { parentId } } }, - update: (store) => this.toggleChildFromCache(workItem, childId, store), }); + + if (parentId === null) { + await this.removeHierarchyChild(workItem); + } else { + await this.addHierarchyChild(workItem); + } + + return response; }, async undoChildRemoval(workItem, childId) { const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid); @@ -263,7 +235,7 @@ export default { } }, async removeChild(childId) { - const { data } = await this.updateWorkItem(null, childId, null); + const { data } = await this.updateWorkItem({ id: childId }, childId, null); if (data.workItemUpdate.errors.length === 0) { this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { @@ -323,24 +295,23 @@ export default { </script> <template> - <div - class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + <widget-wrapper + ref="wrapper" + :error="error" data-testid="work-item-links" + @dismissAlert="error = undefined" > - <div - class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" - :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" - > - <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5> - <span - class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" - data-testid="children-count" - > - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> - {{ childrenCountLabel }} - </span> - </div> + <template #header>{{ $options.i18n.title }}</template> + <template #header-suffix> + <span + class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" + data-testid="children-count" + > + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> + {{ childrenCountLabel }} + </span> + </template> + <template #header-right> <gl-dropdown v-if="canUpdate" right @@ -361,26 +332,8 @@ export default { {{ $options.i18n.addChildOptionLabel }} </gl-dropdown-item> </gl-dropdown> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> - <gl-button - category="tertiary" - size="small" - :icon="toggleIcon" - :aria-label="toggleLabel" - data-testid="toggle-links" - @click="toggle" - /> - </div> - </div> - <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined"> - {{ error }} - </gl-alert> - <div - v-if="isOpen" - class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" - :class="{ 'gl-p-5 gl-pb-3': !error }" - data-testid="links-body" - > + </template> + <template #body> <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> @@ -401,7 +354,7 @@ export default { :form-type="formType" :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" - @addWorkItemChild="addChild" + @addWorkItemChild="addHierarchyChild" /> <work-item-link-child v-for="child in children" @@ -420,9 +373,9 @@ export default { :work-item-id="activeChild.id" :work-item-iid="activeChild.iid" @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChild.id)" + @workItemDeleted="handleWorkItemDeleted(activeChild)" /> </template> - </div> - </div> + </template> + </widget-wrapper> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index d79aaab38f2..5169a77dd33 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -13,7 +13,6 @@ import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; @@ -42,7 +41,6 @@ export default { GlFormCheckbox, GlTooltip, }, - mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'hasIterationsFeature'], props: { issuableGid: { @@ -161,12 +159,6 @@ export default { return workItemInput; }, - workItemsMvcEnabled() { - return this.glFeatures.workItemsMvc; - }, - workItemsMvc2Enabled() { - return this.glFeatures.workItemsMvc2; - }, isCreateForm() { return this.formType === FORM_TYPES.create; }, 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 81e2bb76900..aa12df424f1 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,9 +1,7 @@ <script> -import { GlButton } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import { __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/url_utility'; @@ -19,6 +17,7 @@ import { } from '../../constants'; import workItemQuery from '../../graphql/work_item.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import WidgetWrapper from '../widget_wrapper.vue'; import OkrActionsSplitButton from './okr_actions_split_button.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; @@ -29,8 +28,8 @@ export default { WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, components: { - GlButton, OkrActionsSplitButton, + WidgetWrapper, WorkItemLinksForm, WorkItemLinkChild, }, @@ -72,20 +71,12 @@ export default { data() { return { isShownAddForm: false, - isOpen: true, - error: null, formType: null, childType: null, prefetchedWorkItem: null, }; }, computed: { - toggleIcon() { - return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; - }, - toggleLabel() { - return this.isOpen ? __('Collapse') : __('Expand'); - }, fetchByIid() { return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, @@ -109,7 +100,7 @@ export default { } else { const workItemId = getParameterByName('work_item_id'); if (workItemId) { - params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId); } } return params; @@ -121,11 +112,8 @@ export default { } }, methods: { - toggle() { - this.isOpen = !this.isOpen; - }, showAddForm(formType, childType) { - this.isOpen = true; + this.$refs.wrapper.show(); this.isShownAddForm = true; this.formType = formType; this.childType = childType; @@ -176,19 +164,11 @@ export default { </script> <template> - <div - class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" - data-testid="work-item-tree" - > - <div - class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" - :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" - > - <div class="gl-display-flex gl-flex-grow-1"> - <h5 class="gl-m-0 gl-line-height-24"> - {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} - </h5> - </div> + <widget-wrapper ref="wrapper" data-testid="work-item-tree"> + <template #header> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </template> + <template #header-right> <okr-actions-split-button @showCreateObjectiveForm=" showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) @@ -203,24 +183,9 @@ export default { showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) " /> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> - <gl-button - category="tertiary" - size="small" - :icon="toggleIcon" - :aria-label="toggleLabel" - data-testid="toggle-tree" - @click="toggle" - /> - </div> - </div> - <div - v-if="isOpen" - class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" - :class="{ 'gl-p-5 gl-pb-3': !error }" - data-testid="tree-body" - > - <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> + </template> + <template #body> + <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> <p class="gl-mb-3"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> @@ -253,6 +218,6 @@ export default { @removeChild="$emit('removeChild', $event)" @click="$emit('show-modal', $event, $event.childItem || child)" /> - </div> - </div> + </template> + </widget-wrapper> </template> 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 a59767d8b70..02b94c5331c 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,13 +1,16 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { s__, __ } from '~/locale'; +import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; import SystemNote from '~/work_items/components/notes/system_note.vue'; import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; import { getWorkItemNotesQuery } from '~/work_items/utils'; -import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; -import WorkItemCommentForm from './work_item_comment_form.vue'; +import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue'; +import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql'; +import WorkItemAddNote from './notes/work_item_add_note.vue'; export default { i18n: { @@ -20,10 +23,11 @@ export default { }, components: { GlSkeletonLoader, + GlModal, ActivityFilter, SystemNote, - WorkItemCommentForm, - WorkItemNote, + WorkItemAddNote, + WorkItemDiscussion, }, props: { workItemId: { @@ -50,38 +54,52 @@ export default { }, data() { return { - notesArray: [], isLoadingMore: false, perPage: DEFAULT_PAGE_SIZE_NOTES, sortOrder: ASC, - changeNotesSortOrderAfterLoading: false, + noteToDelete: null, }; }, computed: { initialLoading() { return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore; }, - pageInfo() { - return this.workItemNotes?.pageInfo; - }, avatarUrl() { return window.gon.current_user_avatar_url; }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, hasNextPage() { return this.pageInfo?.hasNextPage; }, - showInitialLoader() { - return this.initialLoading || this.changeNotesSortOrderAfterLoading; - }, - showTimeline() { - return !this.changeNotesSortOrderAfterLoading; - }, showLoadingMoreSkeleton() { return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading; }, disableActivityFilter() { return this.initialLoading || this.isLoadingMore; }, + formAtTop() { + return this.sortOrder === DESC; + }, + workItemCommentFormProps() { + return { + queryVariables: this.queryVariables, + fullPath: this.fullPath, + workItemId: this.workItemId, + fetchByIid: this.fetchByIid, + workItemType: this.workItemType, + sortOrder: this.sortOrder, + }; + }, + notesArray() { + const notes = this.workItemNotes?.nodes || []; + + if (this.sortOrder === DESC) { + return [...notes].reverse(); + } + return notes; + }, }, apollo: { workItemNotes: { @@ -104,8 +122,6 @@ export default { : data.workItem?.widgets; const discussionNodes = workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || []; - this.notesArray = discussionNodes?.nodes || []; - this.updateSortingOrderIfApplicable(); return discussionNodes; }, skip() { @@ -115,6 +131,8 @@ export default { this.$emit('error', i18n.fetchError); }, result() { + this.updateSortingOrderIfApplicable(); + if (this.hasNextPage) { this.fetchMoreNotes(); } @@ -122,6 +140,11 @@ export default { }, }, methods: { + getDiscussionKey(discussion) { + // discussion key is important like this since after first comment changes + const discussionId = discussion.notes.nodes[0].id; + return discussionId.split('/')[discussionId.split('/').length - 1]; + }, isSystemNote(note) { return note.notes.nodes[0].system; }, @@ -136,17 +159,8 @@ export default { this.changeNotesSortOrder(DESC); } }, - updateInitialSortedOrder(direction) { - this.sortOrder = direction; - // when the direction is reverse , we need to load all since the sorting is on the frontend - if (direction === DESC) { - this.changeNotesSortOrderAfterLoading = true; - } - }, changeNotesSortOrder(direction) { this.sortOrder = direction; - this.notesArray = [...this.notesArray].reverse(); - this.changeNotesSortOrderAfterLoading = false; }, async fetchMoreNotes() { this.isLoadingMore = true; @@ -163,8 +177,44 @@ export default { }) .catch((error) => this.$emit('error', error.message)); this.isLoadingMore = false; - if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) { - this.changeNotesSortOrder(this.sortOrder); + }, + showDeleteNoteModal(note, discussion) { + const isLastNote = discussion.notes.nodes.length === 1; + this.$refs.deleteNoteModal.show(); + this.noteToDelete = { ...note, isLastNote }; + }, + cancelDeletingNote() { + this.noteToDelete = null; + }, + async deleteNote() { + try { + const { id, isLastNote, discussion } = this.noteToDelete; + await this.$apollo.mutate({ + mutation: deleteNoteMutation, + variables: { + input: { + id, + }, + }, + update(cache) { + const deletedObject = isLastNote + ? { __typename: TYPENAME_DISCUSSION, id: discussion.id } + : { __typename: TYPENAME_NOTE, id }; + cache.modify({ + id: cache.identify(deletedObject), + fields: (_, { DELETE }) => DELETE, + }); + }, + optimisticResponse: { + destroyNote: { + note: null, + __typename: 'DestroyNotePayload', + }, + }, + }); + } catch (error) { + this.$emit('error', __('Something went wrong when deleting a comment. Please try again')); + Sentry.captureException(error); } }, }, @@ -172,7 +222,7 @@ export default { </script> <template> - <div class="gl-border-t gl-mt-5"> + <div class="gl-border-t gl-mt-5 work-item-notes"> <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> <activity-filter @@ -181,10 +231,10 @@ export default { :sort-order="sortOrder" :work-item-type="workItemType" @changeSortOrder="changeNotesSortOrder" - @updateSavedSortOrder="updateInitialSortedOrder" + @updateSavedSortOrder="changeNotesSortOrder" /> </div> - <div v-if="showInitialLoader" class="gl-mt-5"> + <div v-if="initialLoading" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" @@ -197,22 +247,38 @@ export default { </gl-skeleton-loader> </div> <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> - <template v-if="showTimeline"> + <template v-if="!initialLoading"> <ul class="notes main-notes-list timeline gl-clearfix!"> - <template v-for="note in notesArray"> + <work-item-add-note + v-if="formAtTop" + v-bind="workItemCommentFormProps" + @error="$emit('error', $event)" + /> + + <template v-for="discussion in notesArray"> <system-note - v-if="isSystemNote(note)" - :key="note.notes.nodes[0].id" - :note="note.notes.nodes[0]" + v-if="isSystemNote(discussion)" + :key="discussion.notes.nodes[0].id" + :note="discussion.notes.nodes[0]" /> - <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" /> + <template v-else> + <work-item-discussion + :key="getDiscussionKey(discussion)" + :discussion="discussion.notes.nodes" + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + :fetch-by-iid="fetchByIid" + :work-item-type="workItemType" + @deleteNote="showDeleteNoteModal($event, discussion)" + @error="$emit('error', $event)" + /> + </template> </template> - <work-item-comment-form - :query-variables="queryVariables" - :full-path="fullPath" - :work-item-id="workItemId" - :fetch-by-iid="fetchByIid" + <work-item-add-note + v-if="!formAtTop" + v-bind="workItemCommentFormProps" @error="$emit('error', $event)" /> </ul> @@ -231,5 +297,17 @@ export default { </gl-skeleton-loader> </template> </div> + <gl-modal + ref="deleteNoteModal" + modal-id="delete-note-modal" + :title="__('Delete comment?')" + :ok-title="__('Delete comment')" + ok-variant="danger" + size="sm" + @primary="deleteNote" + @canceled="cancelDeletingNote" + > + {{ __('Are you sure you want to delete this comment?') }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql new file mode 100644 index 00000000000..30a5d2388b1 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql @@ -0,0 +1,3 @@ +mutation addHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { + addHierarchyChild(id: $id, workItem: $workItem) @client +} diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js new file mode 100644 index 00000000000..16b892b3476 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -0,0 +1,62 @@ +import { produce } from 'immer'; +import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +/** + * Updates the cache manually when adding a main comment + * + * @param store + * @param createNoteData + * @param fetchByIid + * @param queryVariables + * @param sortOrder + */ +export const updateCommentState = (store, { data: { createNote } }, fetchByIid, queryVariables) => { + const notesQuery = getWorkItemNotesQuery(fetchByIid); + const variables = { + ...queryVariables, + pageSize: 100, + }; + const sourceData = store.readQuery({ + query: notesQuery, + variables, + }); + + const finalData = produce(sourceData, (draftData) => { + const notesWidget = fetchByIid + ? draftData.workspace.workItems.nodes[0].widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, + ) + : draftData.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_NOTES); + + // as notes are currently sorted/reversed on the frontend rather than in the query + // we only ever push. + // const arrayPushMethod = sortOrder === ASC ? 'push' : 'unshift'; + const arrayPushMethod = 'push'; + + // manual update of cache with a completely new discussion + if (createNote.note.discussion.notes.nodes.length === 1) { + notesWidget.discussions.nodes[arrayPushMethod]({ + id: createNote.note.discussion.id, + notes: { + nodes: createNote.note.discussion.notes.nodes, + __typename: 'NoteConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Discussion', + }); + } + + if (fetchByIid) { + draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget; + } else { + draftData.workItem.widgets[6] = notesWidget; + } + }); + + store.writeQuery({ + query: notesQuery, + variables, + data: finalData, + }); +}; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql deleted file mode 100644 index 6a7afd7bd5b..00000000000 --- a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation createWorkItemNote($input: CreateNoteInput!) { - createNote(input: $input) { - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql new file mode 100644 index 00000000000..5050aa7cbda --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql @@ -0,0 +1,18 @@ +#import "./work_item_note.fragment.graphql" + +mutation createWorkItemNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + discussion { + id + notes { + nodes { + ...WorkItemNote + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql new file mode 100644 index 00000000000..592b5c2a991 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql @@ -0,0 +1,7 @@ +mutation deleteWorkItemNote($input: DestroyNoteInput!) { + destroyNote(input: $input) { + note { + id + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql new file mode 100644 index 00000000000..3da8e7677e4 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "./work_item_note.fragment.graphql" + +mutation updateWorkItemNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...WorkItemNote + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql new file mode 100644 index 00000000000..58561e33e53 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql @@ -0,0 +1,25 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./work_item_note.fragment.graphql" + +fragment WorkItemDiscussionNote on Note { + id + bodyHtml + system + internal + systemNoteIconName + createdAt + author { + ...User + } + userPermissions { + adminNote + } + discussion { + id + notes { + nodes { + ...WorkItemNote + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql index 5215ea10918..52a7a1f8e23 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql @@ -2,15 +2,29 @@ fragment WorkItemNote on Note { id + body bodyHtml system internal systemNoteIconName createdAt + lastEditedAt + lastEditedBy { + ...User + webPath + } + discussion { + id + } author { ...User } userPermissions { adminNote + awardEmoji + readNote + createNote + resolveNote + repositionNote } } diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql new file mode 100644 index 00000000000..739f2101b5e --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql @@ -0,0 +1,7 @@ +#import "./work_item_discussion_note.fragment.graphql" + +subscription workItemNoteCreated($noteableId: NoteableID) { + workItemNoteCreated(noteableId: $noteableId) { + ...WorkItemDiscussionNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql new file mode 100644 index 00000000000..6a59becdb99 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql @@ -0,0 +1,7 @@ +subscription workItemNoteDeleted($noteableId: NoteableID) { + workItemNoteDeleted(noteableId: $noteableId) { + id + discussionId + lastDiscussionNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql new file mode 100644 index 00000000000..c68d5f491cf --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql @@ -0,0 +1,7 @@ +#import "./work_item_note.fragment.graphql" + +subscription workItemNoteUpdated($noteableId: NoteableID) { + workItemNoteUpdated(noteableId: $noteableId) { + ...WorkItemNote + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql index 9ea9cecc81a..56dc175109f 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql index f401aa5595e..6b37c68cb43 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/work_item_note.fragment.graphql" +#import "./work_item_note.fragment.graphql" query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql new file mode 100644 index 00000000000..3fece06eefa --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql @@ -0,0 +1,3 @@ +mutation removeHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) { + removeHierarchyChild(id: $id, workItem: $workItem) @client +} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 3ee263c149d..ada9f737e6e 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,5 @@ #import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" fragment WorkItem on WorkItem { id @@ -8,12 +9,16 @@ fragment WorkItem on WorkItem { description confidential createdAt + updatedAt closedAt project { id fullPath archived } + author { + ...Author + } workItemType { id name diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index b7813ca4dc6..b5d27231bef 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -3,6 +3,15 @@ #import "~/work_items/graphql/milestone.fragment.graphql" fragment WorkItemMetadataWidgets on WorkItemWidget { + ... on WorkItemWidgetDescription { + type + } + ... on WorkItemWidgetStartAndDueDate { + type + } + ... on WorkItemWidgetNotes { + type + } ... on WorkItemWidgetMilestone { type milestone { @@ -11,6 +20,8 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { } ... on WorkItemWidgetAssignees { type + allowsMultipleAssignees + canInviteMembers assignees { nodes { ...User diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index d2a2d7927d3..bf8eafe3211 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -55,6 +55,7 @@ fragment WorkItemWidgets on WorkItemWidget { children { nodes { id + iid confidential workItemType { id diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 98b59449af7..6aa63aae172 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; import { createRouter } from './router'; +Vue.use(VueApollo); + export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); const { diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index d04d4942253..4f8c720eb1f 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; import ZenMode from '~/zen_mode'; @@ -31,7 +31,7 @@ export default { }, computed: { gid() { - return convertToGraphQLId(TYPE_WORK_ITEM, this.id); + return convertToGraphQLId(TYPENAME_WORK_ITEM, this.id); }, }, mounted() { diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index e58fd19ea31..f2af87d476c 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,7 +1,8 @@ +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; -import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; -import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; @@ -10,3 +11,23 @@ export function getWorkItemQuery(isFetchedByIid) { export function getWorkItemNotesQuery(isFetchedByIid) { return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; } + +export const findHierarchyWidgetChildren = (workItem) => + workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY).children.nodes; + +const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => { + return `${ + gon.relative_url_root || '' + }/${fullPath}/-/autocomplete_sources/${autocompleteType}?type=WorkItem&type_id=${workItemIid}`; +}; + +export const autocompleteDataSources = (fullPath, iid) => ({ + labels: autocompleteSourcesPath('labels', fullPath, iid), + members: autocompleteSourcesPath('members', fullPath, iid), + commands: autocompleteSourcesPath('commands', fullPath, iid), +}); + +export const markdownPreviewPath = (fullPath, iid) => + `${ + gon.relative_url_root || '' + }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0bc920b1f73..cc7a45e1c82 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -301,12 +301,6 @@ img.emoji { height: 4px; } -.project-item-select-holder { - .project-item-select { - min-width: 250px; - } -} - .gl-accessibility { &:focus { display: flex; diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index f7cd5d7e183..4eb26d533c2 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -450,6 +450,16 @@ } } +.code .diff-grid-row.line_holder.diff-tr .diff-td.commented:not(.hll) { + --highlight-border-color: #{$blue-300}; + background-color: $blue-50; + + .gl-dark & { + --highlight-border-color: #{$blue-600}; + background-color: $blue-900; + } +} + .diff-table.code, table.code { width: 100%; @@ -461,6 +471,21 @@ table.code { table-layout: fixed; border-radius: 0 0 $border-radius-default $border-radius-default; + .diff-td.highlight-top { + box-shadow: 0 -1px var(--highlight-border-color, $blue-300); + z-index: 1; + } + + .diff-td.highlight-bottom { + box-shadow: 0 1px var(--highlight-border-color, $blue-300); + z-index: 1; + } + + .diff-td.highlight-top.highlight-bottom { + box-shadow: 0 -1px var(--highlight-border-color, $blue-300), 0 1px var(--highlight-border-color, $blue-300); + z-index: 2; + } + .diff-tr.line_holder .diff-td, tr.line_holder td { line-height: $code-line-height; @@ -485,13 +510,16 @@ table.code { user-select: none; margin: 0; padding: 0 10px 0 5px; - border-right-width: 1px; - border-right-style: solid; text-align: right; width: 50px; position: relative; white-space: nowrap; + &:nth-of-type(2) { + border-right-width: 1px; + border-right-style: solid; + } + a { transition: none; float: left; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 65d7eafb8b8..ebb9466eb15 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -632,21 +632,16 @@ display: block; width: 100%; min-height: 30px; - padding: 0 7px; + @include gl-py-0; + @include gl-px-3; color: $gray-700; line-height: 30px; - border: 1px solid $dropdown-divider-bg; - border-radius: 2px; + border: 1px solid $gray-400; + @include gl-rounded-base; outline: 0; &:focus { - color: $gray-700; - border-color: $blue-300; - box-shadow: 0 0 4px $dropdown-input-focus-shadow; - - ~ .dropdown-input-clear { - color: $gray-700; - } + @include gl-focus; } &:hover { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b28302f29ef..9ea5a66b3bc 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -142,106 +142,6 @@ padding: 100px 0; } - /** - * Blame file - */ - &.blame { - table { - border: 0; - margin: 0; - } - - // - // IMPORTANT PERFORMANCE OPTIMIZATION - // - // When viewinng a blame with many commits a lot of content is rendered on the page. - // content-visibility rule below ensure that we only render what is visible to the user, - // thus reducing TBT in the browser. - // Grid is used instead of table layout because content-visibility performs better with it. - tr { - content-visibility: auto; - display: grid; - grid-template-columns: 400px max-content auto; - border-bottom: 1px solid $gray-darker; - - &:last-child { - border-bottom: 0; - } - } - - td { - border-top: 0; - border-bottom: 0; - - &:first-child { - border-left: 0; - } - - &:last-child { - border-right: 0; - } - - &.blame-commit { - padding: 5px 10px; - min-width: 400px; - max-width: 400px; - background: $gray-light; - border-left: 3px solid; - - .commit-row-title { - display: flex; - } - - .item-title { - flex: 1; - margin-right: 0.5em; - } - } - - &.line-numbers { - float: none; - border-left: 1px solid $gray-100; - - .file-line-num { - @include gl-min-w-9; - } - - i { - float: none; - margin-right: 0; - } - } - - &.lines { - padding: 0; - } - - .code { - height: 100%; - } - } - - @for $i from 0 through 5 { - td.blame-commit-age-#{$i} { - border-left-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%); - } - } - - @for $i from 1 through 4 { - td.blame-commit-age-#{$i + 5} { - border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%); - } - } - - .doc-versions { - color: $gray-400; - - &:hover { - color: $gray-900; - } - } - } - &.logs { background: $gray-darker; max-height: 700px; @@ -271,14 +171,6 @@ } } - /** - * Code file - */ - &.code { - padding: 0; - border-radius: 0 0 $border-radius-default $border-radius-default; - } - .list-inline.previews { display: flex; flex-wrap: wrap; @@ -591,3 +483,100 @@ span.idiff { .file-holder [data-loading] .file-content *:nth-of-type(1n+30) { @include gl-display-none; } + +.tree-list-scroll:not(.tree-list-blobs) { + .tree-list-parent::before { + @include gl-content-empty; + @include gl-absolute; + @include gl-z-index-1; + @include gl-pointer-events-none; + + top: 28px; + left: calc(14px + (var(--level) * 16px)); + width: 1px; + height: calc(100% - 24px); + background-color: var(--gray-100, $gray-100); + } +} + +.blame-table { + margin: 0; +} + +.blame-table-wrapper { + overflow-x: auto; + min-width: max-content; +} + +.blame { + position: relative; + + .tr { + display: flex; + border-bottom: 1px solid $gray-darker; + + &.last-row { + border-bottom: 0; + } + } + + .blame-commit { + padding: 5px 10px; + width: 400px; + flex: none; + background: $gray-light; + border-left: 3px solid; + + .commit-row-title { + display: flex; + } + + .item-title { + flex: 1; + margin-right: 0.5em; + } + } + + .lines { + flex: 1; + } + + .code { + height: 100%; + } + + @for $i from 0 through 5 { + .blame-commit-age-#{$i} { + border-left-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%); + } + } + + @for $i from 1 through 4 { + .blame-commit-age-#{$i + 5} { + border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%); + } + } + + .doc-versions { + color: $gray-400; + + &:hover { + color: $gray-900; + } + } +} + +.blame.file-content .td.line-numbers { + float: none; + border-left: 1px solid $gray-100; + border-radius: 0; + + .file-line-num { + @include gl-min-w-9; + } +} + +.code { + padding: 0; + border-radius: 0 0 $border-radius-default $border-radius-default; +} diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index e86edff3f13..c0fe8ca6f76 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -36,6 +36,24 @@ input[type='number'].hide-spinners { } /* stylelint-enable property-no-vendor-prefix */ +/** + * When form input type is search, browsers add a clear input button inside + * the input field. This overlaps with the input field we have already added. + */ + +/* stylelint-disable property-no-vendor-prefix */ +input[type='search'] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; + + &::-webkit-search-cancel-button, + &::-webkit-search-results-button { + @include gl-display-none; + } +} +/* stylelint-enable property-no-vendor-prefix */ + .datetime-controls { select { width: 100px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 4b1efcc1e9a..7baf84198e4 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,5 +1,4 @@ $search-input-field-min-width: 320px; -$search-input-field-max-width: 640px; $search-input-field-x-min-width: 200px; .navbar-gitlab { @@ -64,11 +63,6 @@ $search-input-field-x-min-width: 200px; @include gl-focus($focus-ring: $focus-ring-dark); } } - - .project-item-select { - right: auto; - left: 0; - } } .dropdown.open { @@ -80,16 +74,6 @@ $search-input-field-x-min-width: 200px; .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } - - .header-search-new { - max-width: $search-input-field-max-width; - } - - &.header-search-is-active { - .global-search-container { - flex-grow: 1; - } - } } .header-search { @@ -446,18 +430,6 @@ $search-input-field-x-min-width: 200px; margin-left: auto; } -.btn-sign-in { - background-color: $indigo-100; - color: $indigo-900; - font-weight: $gl-font-weight-bold; - line-height: 18px; - margin: 4px 0 4px 2px; - - &:hover { - background-color: $white; - } -} - @include media-breakpoint-down(xs) { .navbar-gitlab .container-fluid { font-size: 18px; diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 195a66bf9e5..3ac63b8f608 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -1,4 +1,5 @@ -.appearance-logo-preview { +.appearance-logo-preview, +.appearance-pwa-icon-preview { max-width: 400px; margin-bottom: 20px; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 628406d5889..c5e50299e6d 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -21,20 +21,6 @@ } } -@keyframes blinking-scroll-button { - 0% { - opacity: 0.2; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0.2; - } -} - @mixin str-truncated($max-width: 82%) { display: inline-block; overflow: hidden; @@ -308,27 +294,6 @@ margin-right: 0; } } - - .btn-scroll.animate { - .scroll-arrow { - animation: blinking-scroll-button 1.5s ease-in-out infinite; - } - - .scroll-dot { - animation: blinking-scroll-button 1.5s ease-in-out infinite; - animation-delay: 0.3s; - } - - &:disabled { - opacity: 1; - } - } - - .btn-scroll:disabled, - .btn-refresh:disabled { - opacity: 0.35; - cursor: not-allowed; - } } @mixin build-loader-animation { diff --git a/app/assets/stylesheets/framework/page_header.scss b/app/assets/stylesheets/framework/page_header.scss index c8b4e306a2e..c2bd475ab90 100644 --- a/app/assets/stylesheets/framework/page_header.scss +++ b/app/assets/stylesheets/framework/page_header.scss @@ -42,8 +42,4 @@ margin: 0 2px 0 3px; } } - - .ci-status { - margin-right: 10px; - } } diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 7e0a601223d..5ba0b1d0828 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -179,11 +179,6 @@ display: inline-block; } - .project-item-select-holder { - margin: 0; - width: 100%; - } - &.inline { display: flex; flex-flow: row wrap; @@ -367,13 +362,3 @@ } } } - -.project-item-select-holder.btn-group { - .new-project-item-select-button { - max-width: 32px; - } -} - -.empty-state .project-item-select-holder.btn-group { - max-width: 320px; -} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index eb34d91476b..a07a57f40f7 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -741,20 +741,6 @@ top: calc(#{$header-height} + #{$performance-bar-height}); } -.sidebar-move-issue-confirmation-button { - width: 100%; - - &.is-loading { - .sidebar-move-issue-confirmation-loading-icon { - display: inline-block; - } - } -} - -.sidebar-move-issue-confirmation-loading-icon { - display: none; -} - .issuable-show-labels { .gl-label { margin-bottom: 5px; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index 14971e3b2ee..9f8d5d25cb8 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -17,7 +17,6 @@ border-radius: 3px; .file-content { - max-height: 500px; overflow-y: auto; } diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 59a9df9ede0..4b55b39d6f3 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -1,6 +1,7 @@ .super-sidebar { top: 0; width: $contextual-sidebar-width; + z-index: 600; .user-bar { background-color: $t-gray-a-04; @@ -10,11 +11,53 @@ } } + .counter .gl-icon { + color: var(--gray-500, $gray-500); + } + + .counter:hover, + .counter:focus, + .gl-dropdown-custom-toggle:hover .counter, + .gl-dropdown-custom-toggle:focus .counter, + .gl-dropdown-custom-toggle[aria-expanded='true'] .counter { + background-color: $gray-50; + border-color: transparent; + mix-blend-mode: multiply; + + .gl-icon { + color: var(--gray-700, $gray-700); + } + } + .context-switcher-toggle { &[aria-expanded='true'] { background-color: $t-gray-a-08; } } + + .btn-with-notification { + mix-blend-mode: unset !important; // Our tertiary buttons otherwise use another mix-blend mode, making border-color semi-transparent. + position: relative; + + .notification { + background-color: $blue-500; + border: 2px solid $gray-10; // Same as the sidebar's background color. + position: absolute; + height: 9px; + width: 9px; + top: 5px; + left: 22px; + border-radius: 50%; + transition: background-color 100ms linear, border-color 100ms linear; + } + + &:hover, + &:focus { + .notification { + border-color: $gray-50; // Same as the button's hover background color. + } + } + } } .with-performance-bar .super-sidebar { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 0a475845fd3..9b5897b7df9 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -688,8 +688,7 @@ code { } .commit-sha, -.ref-name, -.pipeline-number { +.ref-name { font-family: $monospace-font; font-size: 95%; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 539e92eeca4..c616915073e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -634,9 +634,10 @@ $status-icon-size: 22px; /* * Social Icons */ -$twitter: #1d9bf0; -$skype: #0078d7; +$discord: #5865f2; $linkedin: #2867b2; +$skype: #0078d7; +$twitter: #1d9bf0; /* * Award emoji @@ -787,12 +788,6 @@ $stat-graph-selection-fill: #333; $stat-graph-selection-stroke: #333; /* -* Selects -*/ -$select2-drop-shadow1: rgba(76, 86, 103, 0.247059); -$select2-drop-shadow2: rgba(31, 37, 50, 0.317647); - -/* * Typography */ $body-text-shadow: rgba(255, 255, 255, 0.01); diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 96df8487c0e..085e25a0cdc 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -57,6 +57,15 @@ } } +@mixin line-number-hover-dark { + background-color: $purple-800; + border-color: $purple-300; + + a { + color: $purple-50; + } +} + @mixin conflict-colors($theme) { .diff-line-num { &.conflict_marker_our, @@ -75,6 +84,8 @@ .line_holder { .line_content, .line-coverage { + position: relative; + &.conflict_marker_our { background-color: map-get($conflict-colors, #{$theme}-header-head-neutral); border-color: map-get($conflict-colors, #{$theme}-header-head-neutral); diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 3438a73eff6..02469cf5165 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -131,7 +131,7 @@ $dark-il: #de935f; @include hljs-override('title\\.class\\.inherited', $dark-no); @include hljs-override('variable\\.constant', $dark-no); @include hljs-override('title\\.function', $dark-nf); - + // Line numbers .file-line-num { @@ -174,6 +174,11 @@ $dark-il: #de935f; @include diff-expansion($gray-600, $gray-200, $gray-300, $white); } + .diff-grid-row.line_holder.diff-tr .diff-td.commented:not(.hll) { + --highlight-border-color: #{$blue-600}; + background-color: $blue-900; + } + // Diff line .line_holder { &.match .line_content, @@ -188,15 +193,15 @@ $dark-il: #de935f; @include dark-diff-expansion-line; } - .diff-td.diff-line-num.hll:not(.empty-cell), - .diff-td.line-coverage.hll:not(.empty-cell), - .diff-td.line-codequality.hll:not(.empty-cell), - .diff-td.line_content.hll:not(.empty-cell), - td.diff-line-num.hll:not(.empty-cell), - td.line-coverage.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $dark-diff-not-empty-bg; - border-color: darken($dark-diff-not-empty-bg, 15%); + .diff-td.diff-line-num.hll, + .diff-td.line-coverage.hll, + .diff-td.line-codequality.hll, + .diff-td.line_content.hll, + td.diff-line-num.hll, + td.line-coverage.hll, + td.line_content.hll { + --highlight-border-color: #{$orange-500}; + background-color: $orange-800; } .line-coverage { @@ -239,14 +244,14 @@ $dark-il: #de935f; &:not(.match) .diff-grid-right:hover, &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover; + @include line-number-hover-dark; } } .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover; + @include line-number-hover-dark; } } diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 75dd342393d..30d04b4002e 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -1,7 +1,7 @@ /* https://github.com/richleland/pygments-css/blob/master/monokai.css */ @import '../common'; -@import 'highlight.js/styles/base16/monokai.css'; +@import 'highlight.js/styles/base16/monokai'; /* * Monokai Colors @@ -148,6 +148,11 @@ $monokai-gh: #75715e; color: $monokai-line-num-color; } + .diff-grid-row.line_holder.diff-tr .diff-td.commented:not(.hll) { + --highlight-border-color: #{$blue-600}; + background-color: $blue-900; + } + // Code itself pre.code, .diff-line-num { @@ -179,15 +184,15 @@ $monokai-gh: #75715e; @include dark-diff-expansion-line; } - .diff-td.diff-line-num.hll:not(.empty-cell), - .diff-td.line-coverage.hll:not(.empty-cell), - .diff-td.line-codequality.hll:not(.empty-cell), - .diff-td.line_content.hll:not(.empty-cell), - td.diff-line-num.hll:not(.empty-cell), - td.line-coverage.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $monokai-line-empty-bg; - border-color: $monokai-line-empty-border; + .diff-td.diff-line-num.hll, + .diff-td.line-coverage.hll, + .diff-td.line-codequality.hll, + .diff-td.line_content.hll, + td.diff-line-num.hll, + td.line-coverage.hll, + td.line_content.hll { + --highlight-border-color: #{$orange-500}; + background-color: $orange-800; } .line-coverage { @@ -230,14 +235,14 @@ $monokai-gh: #75715e; &:not(.match) .diff-grid-right:hover, &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover; + @include line-number-hover-dark; } } .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover; + @include line-number-hover-dark; } } diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index fa1f7211b3e..8339d7eff80 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -55,7 +55,7 @@ &, pre.code, - .line_holder .line_content { + .line_holder .line_content:not(.hll) { background-color: $white; color: $gl-text-color; } @@ -84,8 +84,8 @@ @include line-coverage-border-color($green-500, $orange-500); } - .line-coverage, - .line-codequality { + .line-coverage:not(.hll), + .line-codequality:not(.hll) { &.old, &.new, &.new-nomappinginraw, @@ -119,11 +119,6 @@ &.hll:not(.empty-cell).is-over { @include line-number-hover; } - - &.hll:not(.empty-cell) { - background-color: $white; - border-color: $white-normal; - } } &:not(.diff-expanded) + .diff-expanded, @@ -158,7 +153,7 @@ } } - &.new, &.new-nomappinginraw { + &.new:not(.hll), &.new-nomappinginraw:not(.hll) { background-color: $white-normal; &::before { @@ -174,18 +169,9 @@ &.match { @include match-line; } - - &.hll:not(.empty-cell) { - background-color: $white-normal; - } } } - // highlight line via anchor - pre .hll { - background-color: $white-normal; - } - // Search result highlight span.highlight_word { background-color: $white-normal; @@ -197,7 +183,10 @@ text-decoration: underline; } - .hll { background-color: $white; } + .hll { + --highlight-border-color: #{$orange-200}; + background-color: $orange-50; + } .gd { color: $gl-text-color; diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index c0b2fb90aa0..075510e6e5f 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -1,7 +1,7 @@ /* https://gist.github.com/qguv/7936275 */ @import '../common'; -@import 'highlight.js/styles/base16/solarized-dark.css'; +@import 'highlight.js/styles/base16/solarized-dark'; /* * Solarized dark colors @@ -151,6 +151,11 @@ $solarized-dark-il: #2aa198; color: $solarized-dark-line-color; } + .diff-grid-row.line_holder.diff-tr .diff-td.commented:not(.hll) { + --highlight-border-color: #{$blue-600}; + background-color: $blue-900; + } + // Code itself pre.code, .diff-line-num { @@ -182,15 +187,15 @@ $solarized-dark-il: #2aa198; @include dark-diff-expansion-line; } - .diff-td.diff-line-num.hll:not(.empty-cell), - .diff-td.line-coverage.hll:not(.empty-cell), - .diff-td.line-codequality.hll:not(.empty-cell), - .diff-td.line_content.hll:not(.empty-cell), - td.diff-line-num.hll:not(.empty-cell), - td.line-coverage.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { - background-color: $solarized-dark-hll-bg; - border-color: darken($solarized-dark-hll-bg, 15%); + .diff-td.diff-line-num.hll, + .diff-td.line-coverage.hll, + .diff-td.line-codequality.hll, + .diff-td.line_content.hll, + td.diff-line-num.hll, + td.line-coverage.hll, + td.line_content.hll { + --highlight-border-color: #{$orange-500}; + background-color: $orange-800; } .line-coverage { @@ -201,7 +206,7 @@ $solarized-dark-il: #2aa198; &:not(.match) .diff-grid-right:hover, &.code-search-line:hover { .diff-line-num:not(.empty-cell) { - @include line-number-hover; + @include line-number-hover-dark; } } @@ -240,7 +245,7 @@ $solarized-dark-il: #2aa198; .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - @include line-number-hover; + @include line-number-hover-dark; } } diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 921b36dd610..4e244ed7420 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -1,7 +1,7 @@ /* https://gist.github.com/qguv/7936275 */ @import '../common'; -@import 'highlight.js/styles/base16/solarized-light.css'; +@import 'highlight.js/styles/base16/solarized-light'; /* * Solarized light syntax colors @@ -116,7 +116,7 @@ $solarized-light-il: #2aa198; @include hljs-override('variable\\.constant', $solarized-light-no); @include hljs-override('variable\\.language', $solarized-light-nb); @include hljs-override('params', $solarized-light-nb); - + // Line numbers .file-line-num { @include line-link($black, 'link'); @@ -174,13 +174,13 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-matchline-bg; } - .diff-td.diff-line-num.hll:not(.empty-cell), - .diff-td.line-coverage.hll:not(.empty-cell), - .diff-td.line-codequality.hll:not(.empty-cell), - .diff-td.line_content.hll:not(.empty-cell), - td.diff-line-num.hll:not(.empty-cell), - td.line-coverage.hll:not(.empty-cell), - td.line_content.hll:not(.empty-cell) { + .diff-td.diff-line-num.hll, + .diff-td.line-coverage.hll, + .diff-td.line-codequality.hll, + .diff-td.line_content.hll, + td.diff-line-num.hll, + td.line-coverage.hll, + td.line_content.hll { background-color: $solarized-light-hll-bg; border-color: darken($solarized-light-hll-bg, 15%); } diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss index f6cce25671f..0a283254a4c 100644 --- a/app/assets/stylesheets/highlight/themes/white.scss +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -19,4 +19,4 @@ :root { --default-diff-color-deletion: #eb919b; --default-diff-color-addition: #a0f5b4; -}
\ No newline at end of file +} diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 816aa88cfde..ccb5d96e966 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -125,13 +125,13 @@ $white-gc-bg: #eaf2f5; .diff-line-num, .diff-line-num a { - color: $black-transparent; + color: $gray-400; } // Code itself pre.code, .diff-line-num { - border-color: $white-normal; + border-color: rgba(0, 0, 0, 0.1); } &, @@ -173,7 +173,7 @@ pre.code, background-color: $line-number-old; a { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + color: scale-color($gray-300, $red: -30%, $green: -30%, $blue: -30%); } } @@ -182,7 +182,7 @@ pre.code, background-color: $line-number-new; a { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + color: scale-color($gray-200, $red: -30%, $green: -30%, $blue: -30%); } } @@ -191,9 +191,9 @@ pre.code, @include line-number-hover; } - &.hll:not(.empty-cell) { - background-color: $line-number-select; - border-color: $line-select-yellow-dark; + &.hll { + --highlight-border-color: #{$orange-200}; + background-color: $orange-50; } } @@ -246,8 +246,9 @@ pre.code, @include match-line; } - &.hll:not(.empty-cell) { - background-color: $line-select-yellow; + &.hll { + --highlight-border-color: #{$orange-200}; + background-color: $orange-50; } } @@ -267,8 +268,9 @@ pre.code, background-color: $line-added; } - &.hll:not(.empty-cell) { - background-color: $line-select-yellow; + &.hll { + --highlight-border-color: #{$orange-200}; + background-color: $orange-50; } } } diff --git a/app/assets/stylesheets/lazy_bundles/select2.scss b/app/assets/stylesheets/lazy_bundles/select2.scss deleted file mode 100644 index f2c372020ef..00000000000 --- a/app/assets/stylesheets/lazy_bundles/select2.scss +++ /dev/null @@ -1,654 +0,0 @@ -/* -Version: 3.5.2 Timestamp: Sat Nov 1 14:43:36 EDT 2014 -Updated 2020-10-05 by TimZ -*/ -.select2-container { - margin: 0; - position: relative; - display: inline-block; -} - -.select2-container, -.select2-drop, -.select2-search, -.select2-search input { - box-sizing: border-box; -} - -.select2-container .select2-choice { - display: block; - height: 26px; - padding: 0 0 0 8px; - overflow: hidden; - position: relative; - - border: 1px solid #aaa; - white-space: nowrap; - line-height: 26px; - color: #444; - text-decoration: none; - - border-radius: 4px; - - background-clip: padding-box; - - user-select: none; - - background-color: #fff; - background-image: linear-gradient(to top, #eee 0%, #fff 50%); -} - -html[dir='rtl'] .select2-container .select2-choice { - padding: 0 8px 0 0; -} - -.select2-container.select2-drop-above .select2-choice { - border-bottom-color: #aaa; - - border-radius: 0 0 4px 4px; - - background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); -} - -.select2-container.select2-allowclear .select2-choice .select2-chosen { - margin-right: 42px; -} - -.select2-container .select2-choice > .select2-chosen { - margin-right: 26px; - display: block; - overflow: hidden; - - white-space: nowrap; - - text-overflow: ellipsis; - float: none; - width: auto; -} - -html[dir='rtl'] .select2-container .select2-choice > .select2-chosen { - margin-left: 26px; - margin-right: 0; -} - -.select2-container .select2-choice abbr { - display: none; - width: 12px; - height: 12px; - position: absolute; - right: 24px; - top: 8px; - - font-size: 1px; - text-decoration: none; - - border: 0; - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path('select2.png')) right top no-repeat; - cursor: pointer; - outline: 0; -} - -.select2-container.select2-allowclear .select2-choice abbr { - display: inline-block; -} - -.select2-container .select2-choice abbr:hover { - background-position: right -11px; - cursor: pointer; -} - -.select2-drop-mask { - border: 0; - margin: 0; - padding: 0; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 9998; - /* styles required for IE to work */ - background-color: #fff; - filter: alpha(opacity=0); -} - -.select2-drop { - width: 100%; - margin-top: -1px; - position: absolute; - z-index: 9999; - top: 100%; - - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; - - border-radius: 0 0 4px 4px; - - box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); -} - -.select2-drop.select2-drop-above { - margin-top: 1px; - border-top: 1px solid #aaa; - border-bottom: 0; - - border-radius: 4px 4px 0 0; - - box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); -} - -.select2-drop-active { - border: 1px solid #5897fb; - border-top: 0; -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid #5897fb; -} - -.select2-drop-auto-width { - border-top: 1px solid #aaa; - width: auto; -} - -.select2-drop-auto-width .select2-search { - padding-top: 4px; -} - -.select2-container .select2-choice .select2-arrow { - display: inline-block; - width: 18px; - height: 100%; - position: absolute; - right: 0; - top: 0; - - border-left: 1px solid #aaa; - border-radius: 0 4px 4px 0; - - background-clip: padding-box; - - background: #ccc; - background-image: linear-gradient(to top, #ccc 0%, #eee 60%); -} - -html[dir='rtl'] .select2-container .select2-choice .select2-arrow { - left: 0; - right: auto; - - border-left: 0; - border-right: 1px solid #aaa; - border-radius: 4px 0 0 4px; -} - -.select2-container .select2-choice .select2-arrow b { - display: block; - width: 100%; - height: 100%; - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path("select2.png")) no-repeat 0 1px; -} - -html[dir='rtl'] .select2-container .select2-choice .select2-arrow b { - background-position: 2px 1px; -} - -.select2-search { - display: inline-block; - width: 100%; - min-height: 26px; - margin: 0; - padding-left: 4px; - padding-right: 4px; - - position: relative; - z-index: 10000; - - white-space: nowrap; -} - -.select2-search input { - width: 100%; - height: auto !important; - min-height: 26px; - padding: 4px 20px 4px 5px; - margin: 0; - - outline: 0; - font-family: sans-serif; - font-size: 1em; - - border: 1px solid #aaa; - border-radius: 0; - - box-shadow: none; - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path('select2.png')) no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; -} - -html[dir='rtl'] .select2-search input { - padding: 4px 5px 4px 20px; - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path('select2.png')) no-repeat -37px -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; -} - -.select2-drop.select2-drop-above .select2-search input { - margin-top: 4px; -} - -.select2-search input.select2-active { - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path('select2-spinner.gif')) no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; -} - -.select2-container-active .select2-choice, -.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; - - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); -} - -.select2-dropdown-open .select2-choice { - border-bottom-color: transparent; - box-shadow: 0 1px 0 #fff inset; - - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - - background-color: #eee; - background-image: linear-gradient(to top, #fff 0%, #eee 50%); -} - -.select2-dropdown-open.select2-drop-above .select2-choice, -.select2-dropdown-open.select2-drop-above .select2-choices { - border: 1px solid #5897fb; - border-top-color: transparent; - - background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); -} - -.select2-dropdown-open .select2-choice .select2-arrow { - background: transparent; - border-left: 0; - filter: none; -} - -html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow { - border-right: 0; -} - -.select2-dropdown-open .select2-choice .select2-arrow b { - background-position: -18px 1px; -} - -html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow b { - background-position: -16px 1px; -} - -.select2-hidden-accessible { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -/* results */ -.select2-results { - max-height: 200px; - padding: 0 0 0 4px; - margin: 4px 4px 4px 0; - position: relative; - overflow-x: hidden; - overflow-y: auto; -} - -html[dir='rtl'] .select2-results { - padding: 0 4px 0 0; - margin: 4px 0 4px 4px; -} - -.select2-results ul.select2-result-sub { - margin: 0; - padding-left: 0; -} - -.select2-results li { - list-style: none; - display: list-item; - background-image: none; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; -} - -.select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; - - min-height: 1em; - - user-select: none; -} - -.select2-results-dept-1 .select2-result-label { padding-left: 20px; } -.select2-results-dept-2 .select2-result-label { padding-left: 40px; } -.select2-results-dept-3 .select2-result-label { padding-left: 60px; } -.select2-results-dept-4 .select2-result-label { padding-left: 80px; } -.select2-results-dept-5 .select2-result-label { padding-left: 100px; } -.select2-results-dept-6 .select2-result-label { padding-left: 110px; } -.select2-results-dept-7 .select2-result-label { padding-left: 120px; } - -.select2-results .select2-highlighted { - background: #3875d7; - color: #fff; -} - -.select2-results li em { - background: #feffde; - font-style: normal; -} - -.select2-results .select2-highlighted em { - background: transparent; -} - -.select2-results .select2-highlighted ul { - background: #fff; - color: #000; -} - -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-ajax-error, -.select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; - padding-left: 5px; -} - -/* -disabled look for disabled choices in the results dropdown -*/ -.select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; -} - -.select2-results .select2-disabled { - background: #f4f4f4; - display: list-item; - cursor: default; -} - -.select2-results .select2-selected { - display: none; -} - -.select2-more-results.select2-active { - /* stylelint-disable-next-line function-url-quotes */ - background: #f4f4f4 url(image-path('select2-spinner.gif')) no-repeat 100%; -} - -.select2-results .select2-ajax-error { - background: rgba(255, 50, 50, 0.2); -} - -.select2-more-results { - background: #f4f4f4; - display: list-item; -} - -/* disabled styles */ - -.select2-container.select2-container-disabled .select2-choice { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container.select2-container-disabled .select2-choice .select2-arrow { - background-color: #f4f4f4; - background-image: none; - border-left: 0; -} - -.select2-container.select2-container-disabled .select2-choice abbr { - display: none; -} - - -/* multiselect */ - -.select2-container-multi .select2-choices { - height: auto !important; - height: 1%; - margin: 0; - padding: 0 5px 0 0; - position: relative; - - border: 1px solid #aaa; - cursor: text; - overflow: hidden; - - background-color: #fff; - background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); -} - -html[dir='rtl'] .select2-container-multi .select2-choices { - padding: 0 0 0 5px; -} - -.select2-locked { - padding: 3px 5px !important; -} - -.select2-container-multi .select2-choices { - min-height: 26px; -} - -.select2-container-multi.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; - - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); -} - -.select2-container-multi .select2-choices li { - float: left; - list-style: none; -} - -html[dir='rtl'] .select2-container-multi .select2-choices li { - float: right; -} - -.select2-container-multi .select2-choices .select2-search-field { - margin: 0; - padding: 0; - white-space: nowrap; -} - -.select2-container-multi .select2-choices .select2-search-field input { - padding: 5px; - margin: 1px 0; - - font-family: sans-serif; - font-size: 100%; - color: #666; - outline: 0; - border: 0; - - box-shadow: none; - background: transparent !important; -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - /* stylelint-disable-next-line function-url-quotes */ - background: #fff url(image-path('select2-spinner.gif')) no-repeat 100% !important; -} - -.select2-default { - color: #999 !important; -} - -.select2-container-multi .select2-choices .select2-search-choice { - padding: 3px 5px 3px 18px; - margin: 3px 0 3px 5px; - position: relative; - - line-height: 13px; - color: #333; - cursor: default; - border: 1px solid #aaa; - - border-radius: 3px; - - box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); - - background-clip: padding-box; - - user-select: none; - - background-color: #e4e4e4; - background-image: linear-gradient(to bottom, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); -} - -html[dir='rtl'] .select2-container-multi .select2-choices .select2-search-choice { - margin: 3px 5px 3px 0; - padding: 3px 18px 3px 5px; -} - -.select2-container-multi .select2-choices .select2-search-choice .select2-chosen { - cursor: default; -} - -.select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; -} - -.select2-search-choice-close { - display: block; - width: 12px; - height: 13px; - position: absolute; - right: 3px; - top: 4px; - - font-size: 1px; - outline: none; - /* stylelint-disable-next-line function-url-quotes */ - background: url(image-path('select2.png')) right top no-repeat; -} - -html[dir='rtl'] .select2-search-choice-close { - right: auto; - left: 3px; -} - -.select2-container-multi .select2-search-choice-close { - left: 3px; -} - -html[dir='rtl'] .select2-container-multi .select2-search-choice-close { - left: auto; - right: 2px; -} - -.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { - background-position: right -11px; -} - -.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { - background-position: right -11px; -} - -/* disabled styles */ -.select2-container-multi.select2-container-disabled .select2-choices { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - padding: 3px 5px; - border: 1px solid #ddd; - background-image: none; - background-color: #f4f4f4; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { - display: none; - background: none; -} -/* end multiselect */ - - -.select2-result-selectable .select2-match, -.select2-result-unselectable .select2-match { - text-decoration: underline; -} - -.select2-offscreen, -.select2-offscreen:focus { - clip: rect(0 0 0 0) !important; - width: 1px !important; - height: 1px !important; - border: 0 !important; - margin: 0 !important; - padding: 0 !important; - overflow: hidden !important; - position: absolute !important; - outline: 0 !important; - left: 0 !important; - top: 0 !important; -} - -.select2-display-none { - display: none; -} - -.select2-measure-scrollbar { - position: absolute; - top: -10000px; - left: -10000px; - width: 100px; - height: 100px; - overflow: scroll; -} - -@media only screen and (min-resolution: 120dpi) { - .select2-search input, - .select2-search-choice-close, - .select2-container .select2-choice abbr, - .select2-container .select2-choice .select2-arrow b { - /* stylelint-disable-next-line function-url-quotes */ - background-image: url(image-path("select2x2.png")) !important; - background-repeat: no-repeat !important; - background-size: 60px 40px !important; - } - - .select2-search input { - background-position: 100% -21px !important; - } -} - -/* End of select2.css */ - -@import './select2_overrides'; diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss deleted file mode 100644 index e3cec187fab..00000000000 --- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss +++ /dev/null @@ -1,341 +0,0 @@ -@import 'page_bundles/mixins_and_variables_and_functions'; -/** Select2 selectbox style override **/ -.select2-container { - width: 100% !important; - - &.input-md, - &.input-lg { - display: block; - } -} - -.select2-container, -.select2-container.select2-drop-above { - .select2-choice { - background: var(--white, $white); - color: var(--gl-text-color, $gl-text-color); - border-color: var(--gray-400, $gray-400); - height: 34px; - padding: $gl-vert-padding $gl-input-padding; - font-size: $gl-font-size; - line-height: 1.42857143; - border-radius: $gl-border-radius-base; - - .select2-arrow { - padding-top: 12px; - padding-right: 20px; - /* stylelint-disable-next-line function-url-quotes */ - background: url(asset_path('chevron-down.png')) no-repeat 2px 8px; - - .gl-dark & { - filter: invert(0.9); - } - - b { - display: none; - } - } - - .select2-chosen { - margin-right: 15px; - } - - &:hover { - border-color: var(--gray-400, $gray-400); - color: var(--gl-text-color, $gl-text-color); - } - } - - // Essentially we’re doing @include form-control-focus here (from - // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a - // `&:focus` selector and we’re never actually focusing the .select2-choice - // link nor the .select2-container, the Select2 library focuses an off-screen - // .select2-focusser element instead. - &.select2-container-active:not(.select2-dropdown-open) { - .select2-choice { - color: var(--gray-700, $gray-700); - background-color: var(--white, $white); - border-color: $input-focus-border-color; - outline: 0; - } - - // Reusable focus “glow” box-shadow - @mixin form-control-focus-glow { - @if $enable-shadows { - box-shadow: $input-box-shadow, $input-focus-box-shadow; - } @else { - box-shadow: $input-focus-box-shadow; - } - } - - // Apply the focus “glow” shadow to the .select2-container if it also has - // the .block-truncated class as that applies an overflow: hidden, thereby - // hiding the glow of the nested .select2-choice element. - &.block-truncated { - @include form-control-focus-glow; - } - - // Apply the glow directly to the .select2-choice link if we’re not - // block-truncating the container. - &:not(.block-truncated) .select2-choice { - @include form-control-focus-glow; - } - } - - &.is-invalid { - ~ .invalid-feedback { - display: block; - } - - .select2-choices, - .select2-choice { - border-color: var(--red-500, $red-500); - } - } -} - -.select2-drop, -.select2-drop.select2-drop-above { - background: var(--white, $white); - box-shadow: 0 2px 4px $dropdown-shadow-color; - border-radius: $gl-border-radius-base; - border: 1px solid var(--gray-400, $gray-400); - min-width: 175px; - color: var(--gl-text-color, $gl-text-color); - z-index: 999; - - .modal-open & { - z-index: $zindex-modal + 200; - } -} - -.select2-drop-mask { - z-index: 998; - - .modal-open & { - z-index: $zindex-modal + 100; - } -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid var(--gray-400, $gray-400); - margin-top: -6px; -} - -.select2-container-active { - .select2-choice, - .select2-choices { - box-shadow: none; - } -} - -.select2-dropdown-open, -.select2-dropdown-open.select2-drop-above { - .select2-choice { - border-color: var(--gray-400, $gray-400); - outline: 0; - } -} - -.select2-container-multi { - .select2-choices { - border-radius: $border-radius-default; - border-color: var(--gray-400, $gray-400); - background: none; - - .select2-search-field input { - padding: 5px $gl-input-padding; - height: auto; - font-family: inherit; - font-size: inherit; - } - - .select2-search-choice { - margin: 5px 0 0 8px; - box-shadow: none; - border-color: var(--gray-400, $gray-400); - color: var(--gl-text-color, $gl-text-color); - line-height: 15px; - background-color: var(--gray-50, $gray-50); - background-image: none; - padding: 3px 18px 3px 5px; - - .select2-search-choice-close { - top: 5px; - left: initial; - right: 3px; - } - - &.select2-search-choice-focus { - border-color: var(--gray-400, $gray-400); - } - } - } -} - -.select2-drop-active { - margin-top: $dropdown-vertical-offset; - font-size: 14px; - - .select2-results { - max-height: 350px; - } -} - -.select2-search { - padding: $grid-size; - - .select2-drop-auto-width & { - padding: $grid-size; - } - - input { - padding: $grid-size; - background: transparent image-url('select2.png'); - color: var(--gl-text-color, $gl-text-color); - background-clip: content-box; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 0 bottom 0 !important; - border: 1px solid var(--gray-400, $gray-400); - border-radius: $border-radius-default; - line-height: 16px; - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; - - &:focus { - border-color: var(--blue-300, $blue-300); - } - - &.select2-active { - background-color: var(--white, $white); - background-image: image-url('select2-spinner.gif') !important; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 6px center !important; - background-size: 16px 16px !important; - } - } - - + .select2-results { - padding-top: 0; - } -} - -.select2-results { - margin: 0; - padding: #{$gl-padding / 2} 0; - - .select2-no-results, - .select2-searching, - .select2-ajax-error, - .select2-selection-limit { - background: transparent; - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-result-label, - .select2-more-results { - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-highlighted { - background: transparent; - color: var(--gl-text-color, $gl-text-color); - - .select2-result-label { - background: var(--gray-50, $gray-50); - } - } - - .select2-result { - padding: 0 1px; - } - - li.select2-result-with-children > .select2-result-label { - font-weight: $gl-font-weight-bold; - color: var(--gl-text-color, $gl-text-color); - } -} - -.select2-highlighted { - .group-result { - .group-path { - color: var(--gray-700, $gray-700); - } - } -} - -.select2-result-selectable, -.select2-result-unselectable { - .select2-match { - font-weight: $gl-font-weight-bold; - text-decoration: none; - } -} - -.input-group { - .select2-container { - display: table-cell; - max-width: 180px; - } -} - -.file-editor { - .select2 { - float: right; - } -} - -.import-namespace-select { - > .select2-choice { - border-radius: $border-radius-default 0 0 $border-radius-default; - position: relative; - left: 1px; - } -} - -.issue-form { - .select2-container { - width: 250px !important; - } -} - -.new_project, -.edit-project, -.import-project { - .input-group { - .select2-container { - display: unset; - max-width: unset; - flex-grow: 1; - } - } - - .input-group-prepend, - .input-group-append { - + .select2 a { - border-radius: 0 $gl-border-radius-base $gl-border-radius-base 0; - } - } -} - -.project-path { - .select2-choice { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -} - -.right-sidebar { - .block { - .select2-container span { - margin-top: 0; - } - } -} - -.block-truncated { - > div:not(.block):not(.select2-display-none) { - display: inline; - } -} diff --git a/app/assets/stylesheets/page_bundles/admin/elasticsearch_form.scss b/app/assets/stylesheets/page_bundles/admin/elasticsearch_form.scss new file mode 100644 index 00000000000..b4e0047ad6d --- /dev/null +++ b/app/assets/stylesheets/page_bundles/admin/elasticsearch_form.scss @@ -0,0 +1,5 @@ +@import '../mixins_and_variables_and_functions'; + +.index-entities-list { + height: calc(#{$gl-spacing-scale-8} * 5.75); +} diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index bdbcf7ab58c..99e7f7ae0a4 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -103,13 +103,9 @@ } } -.board-card-title { - width: 95%; - - a { - @include media-breakpoint-down(md) { - font-size: $gl-font-size-sm; - } +.board-card-title a { + @include media-breakpoint-down(md) { + font-size: $gl-font-size-sm; } } diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index 7adbf10b83a..17886ab954a 100644 --- a/app/assets/stylesheets/page_bundles/ci_status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -27,7 +27,6 @@ } &.ci-canceled, - &.ci-skipped, &.ci-disabled, &.ci-scheduled, &.ci-manual { diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index c3688f4a138..2002b4d4dff 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -906,8 +906,7 @@ $ide-commit-header-height: 48px; padding-bottom: 0; } - .ide-right-sidebar-terminal, - .ide-right-sidebar-clientside { + .ide-right-sidebar-terminal { padding: 0; } } @@ -1083,43 +1082,6 @@ $ide-commit-header-height: 48px; } } -.ide-preview-header { - padding: 0 $grid-size; - border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); - background-color: var(--ide-highlight-background, $gray-light); - min-height: 44px; -} - -.ide-navigator-btn { - height: 24px; - min-width: 24px; - max-width: 24px; - padding: 0; - margin: 0 ($grid-size / 2); - color: var(--ide-text-color-secondary, $gray-600); - - &:first-child { - margin-left: 0; - } -} - -.ide-navigator-location { - padding-top: ($grid-size / 2); - padding-bottom: ($grid-size / 2); - - &:focus { - outline: 0; - box-shadow: none; - border-color: var(--ide-border-color, $gray-100); - } -} - -.ide-preview-loading-icon { - right: $grid-size; - top: 50%; - transform: translateY(-50%); -} - .ide-file-templates { padding: $grid-size $gl-padding; background-color: var(--ide-background, $gray-light); diff --git a/app/assets/stylesheets/page_bundles/incidents.scss b/app/assets/stylesheets/page_bundles/incidents.scss index e807c4c0bbb..493add1ea0f 100644 --- a/app/assets/stylesheets/page_bundles/incidents.scss +++ b/app/assets/stylesheets/page_bundles/incidents.scss @@ -7,7 +7,13 @@ } .timeline-event { - grid-template-columns: #{$gl-spacing-scale-9} minmax(0, 1fr) #{$gl-spacing-scale-7}; + grid-template-columns: auto minmax(0, 1fr) #{$gl-spacing-scale-7}; +} + +.create-timeline-event, +.edit-timeline-event, +.timeline-event { + gap: $gl-spacing-scale-5; } /** @@ -51,8 +57,10 @@ } .timeline-entry:not(:last-child) { + @include gl-pb-0; + .timeline-event-border { - @include gl-pb-5; + @include gl-pb-3; @include gl-border-gray-50; @include gl-border-1; @include gl-border-b-solid; @@ -68,3 +76,13 @@ } } } + +.timeline-event-note .md p { + @include gl-text-gray-700; +} + +.timeline-event-note-date { + @media (max-width: $breakpoint-md) { + width: 100%; + } +} diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss index 26d694f7421..23b210e3a8e 100644 --- a/app/assets/stylesheets/page_bundles/issues_show.scss +++ b/app/assets/stylesheets/page_bundles/issues_show.scss @@ -12,6 +12,21 @@ padding-inline-end: 1rem; width: 2rem; } + + .task-list-item-actions-wrapper { + position: absolute; + inset-block-start: 0; + inset-inline-end: -2rem; + } + + .task-list-item-actions-wrapper.show .task-list-item-actions, + .task-list-item-actions:is(:focus, :hover) { + opacity: 1; + } + } + + .md.has-task-list-item-actions > :is(ul, ol) > li { + margin-inline-end: 1.5rem; } ul.task-list > li.task-list-item { @@ -21,6 +36,10 @@ inset-inline-start: -0.6rem; } } + + .dropdown-item.text-danger p { + color: var(--red-500, $red-500); /* Override typography.scss making text black */ + } } .is-ghost { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 5c699dd81df..fe64e4f2fe8 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -812,7 +812,8 @@ $tabs-holder-z-index: 250; .mr-widget-body-ready-merge { @include media-breakpoint-down(sm) { - @include gl-p-3; + @include gl-py-3; + @include gl-pr-3; } } diff --git a/app/assets/stylesheets/page_bundles/pipeline_editor.scss b/app/assets/stylesheets/page_bundles/pipeline_editor.scss index e167052a3e1..13d158b08fe 100644 --- a/app/assets/stylesheets/page_bundles/pipeline_editor.scss +++ b/app/assets/stylesheets/page_bundles/pipeline_editor.scss @@ -20,3 +20,11 @@ @include gl-display-block; top: 2px; } + +.job-assistant-drawer { + width: 100%; + + @include media-breakpoint-up(sm) { + width: 560px; + } +} diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index ac1e9fb024b..fc745433f1b 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -240,6 +240,10 @@ color: $twitter; } +.discord-icon { + color: $discord; +} + .key-created-at { line-height: 42px; } diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index 10da541ed8d..cde570cfb0f 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -5,6 +5,7 @@ $search-avatar-size: 16px; $search-sidebar-min-width: 240px; $search-sidebar-max-width: 300px; $search-keyboard-shortcut: '/'; +$language-filter-max-height: 20rem; $border-radius-medium: 3px; @@ -25,6 +26,26 @@ $border-radius-medium: 3px; min-width: $search-sidebar-min-width; max-width: $search-sidebar-max-width; } + + .language-filter-checkbox { + .custom-control-label { + flex-grow: 1; + } + } + + .language-filter-max-height { + max-height: $language-filter-max-height; + } +} + +.search-max-w-inherit { + max-width: inherit; +} + +.search-wrap-f-md-down { + @include gl-media-breakpoint-down(md) { + white-space: normal !important; + } } .search { @@ -324,13 +345,20 @@ $border-radius-medium: 3px; } } -// Disable Webkit's search input styles +/** + * When form input type is search, browsers add a clear input button inside + * the input field. This overlaps with the input field we have already added. + */ + +/* stylelint-disable property-no-vendor-prefix */ input[type='search'] { - /* stylelint-disable-next-line property-no-vendor-prefix */ -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; &::-webkit-search-cancel-button, &::-webkit-search-results-button { @include gl-display-none; } } +/* stylelint-enable property-no-vendor-prefix */ diff --git a/app/assets/stylesheets/page_bundles/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss index 9037eb7ae62..8978b8d798b 100644 --- a/app/assets/stylesheets/page_bundles/settings.scss +++ b/app/assets/stylesheets/page_bundles/settings.scss @@ -71,6 +71,7 @@ animation: collapseMaxHeight 300ms ease-out; // Keep the section from expanding when we scroll over it pointer-events: none; + margin-bottom: $gl-spacing-scale-5; .settings.expanded & { max-height: none; @@ -101,7 +102,6 @@ display: block; height: 1px; overflow: hidden; - margin-top: 20px; } .sub-section { diff --git a/app/assets/stylesheets/page_bundles/terms.scss b/app/assets/stylesheets/page_bundles/terms.scss index 9dff3e9c99c..139627072be 100644 --- a/app/assets/stylesheets/page_bundles/terms.scss +++ b/app/assets/stylesheets/page_bundles/terms.scss @@ -8,7 +8,11 @@ } .terms-fade { - background: linear-gradient(0deg, $white 0%, rgba($white, 0.5) 100%); + background: linear-gradient(rgba($white, 0), $white); + + .gl-dark & { + background: linear-gradient(rgba($gray-900, 0), $gray-900); + } } .content { diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index d4b0b4169d3..beee219f51f 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -27,18 +27,19 @@ } &.todo-pending.done-reversible { - &:hover { - border-top: 1px solid transparent; + .todo-item, + .todo-timestamp { + @include gl-opacity-5; + } - .todo-avatar, - .todo-item { - opacity: 0.6; - } + .todo-avatar { + filter: grayscale(1); } - .todo-avatar, - .todo-item { - opacity: 0.2; + &:hover { + @include gl-border-t-1; + @include gl-border-t-transparent; + @include gl-border-t-solid; } } } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 4766f124e5b..07a0cf3f367 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -86,3 +86,10 @@ min-width: 0; } } + +.work-item-notes { + .discussion-notes ul.notes li.toggle-replies-widget { + // offset for .timeline-content padding + an extra 1px for border width + margin: -5px -9px; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index dd24e3fcb5d..7d465dbcc04 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -266,24 +266,9 @@ } } -.gpg-status-box { - padding: 2px 10px; - - &:empty { - display: none; - } - - &.valid { - @include green-status-color; - } - - &.invalid { - @include status-color($gray-dark, color('gray'), $gray-darkest); - border-color: $gray-darkest; - - &:not(span):hover { - color: color('gray'); - } +.signature-badge { + &:hover { + @include gl-text-decoration-none; } } @@ -297,7 +282,7 @@ } } -.gpg-popover-status { +.signature-popover { display: flex; align-items: center; font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index c2ac4f32480..75c81b74ba7 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -101,12 +101,6 @@ ul.related-merge-requests > li gl-emoji { } } -.issue-form { - .select2-container { - width: 250px !important; - } -} - .issues-nav-controls { .btn-group:empty { display: none; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6b662359a67..5b8b850ba35 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -185,14 +185,8 @@ $comparison-empty-state-height: 62px; margin-bottom: 0; } - .dropdown-menu-toggle { - width: 100%; - } - - .dropdown-menu { - left: 5px; - right: 5px; - width: auto; + .gl-dropdown-custom-toggle { + @include gl-w-full; } } @@ -384,6 +378,8 @@ $comparison-empty-state-height: 62px; } .mr-compare-dropdown { + @include gl-w-full; + .gl-button-text { @include gl-w-full; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 75d52424fd9..5d03281a30a 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -70,6 +70,9 @@ $system-note-svg-size: 1rem; align-items: center; gap: 0 0.25rem; flex-wrap: wrap; + line-height: $gl-line-height-20; + padding-top: $gl-padding-6; + padding-bottom: $gl-padding-6; } } @@ -217,6 +220,9 @@ $system-note-svg-size: 1rem; align-items: center; gap: 0 0.25rem; flex-wrap: wrap; + padding-top: $gl-padding-6; + padding-bottom: $gl-padding-6; + line-height: $gl-line-height-20; } } @@ -631,6 +637,11 @@ $system-note-svg-size: 1rem; &.new { border-right-width: 0; } + + .note-header { + flex-wrap: wrap; + align-items: center; + } } .notes { @@ -660,6 +671,7 @@ $system-note-svg-size: 1rem; } .discussion-reply-holder { + border-top: 0; border-radius: 0 0 $border-radius-default $border-radius-default; position: relative; @@ -772,8 +784,7 @@ $system-note-svg-size: 1rem; .note-header { display: flex; justify-content: space-between; - align-items: center; - flex-wrap: wrap; + align-items: flex-start; > .note-header-info, > .note-actions { diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index bb83a91bc57..3b28025053b 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -761,6 +761,9 @@ input { color: #ececef; background-color: #333238; } +input[type="search"] { + appearance: textfield; +} .form-control { border-radius: 4px; padding: 6px 10px; @@ -834,9 +837,6 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } -.navbar-gitlab .header-content .header-search-new { - max-width: 640px; -} .navbar-gitlab .header-search { min-width: 320px; } @@ -985,13 +985,6 @@ kbd { .header-new .dropdown-menu { margin-top: 4px; } -.btn-sign-in { - background-color: #ebebfa; - color: #292961; - font-weight: 600; - line-height: 18px; - margin: 4px 0 4px 2px; -} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1638,8 +1631,6 @@ svg.s16 { --gray-200: #535158; --gray-700: #bfbfc3; --gray-900: #ececef; - --green-100: #0d532a; - --green-700: #91d4a8; --gl-text-color: #ececef; --border-color: #434248; --white: #333238; @@ -1822,11 +1813,6 @@ body.gl-dark .navbar-gitlab .search form .search-input { .gl-display-none { display: none; } -@media (min-width: 576px) { - .gl-sm-display-none { - display: none; - } -} .gl-display-flex { display: flex; } @@ -1851,9 +1837,15 @@ body.gl-dark .navbar-gitlab .search form .search-input { .gl-align-items-stretch { align-items: stretch; } +.gl-flex-grow-0\! { + flex-grow: 0 !important; +} .gl-flex-grow-1 { flex-grow: 1; } +.gl-flex-basis-half\! { + flex-basis: 50% !important; +} .gl-justify-content-end { justify-content: flex-end; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 9e1c6b065a0..adafe719892 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -761,6 +761,9 @@ input { color: #333238; background-color: #fff; } +input[type="search"] { + appearance: textfield; +} .form-control { border-radius: 4px; padding: 6px 10px; @@ -834,9 +837,6 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } -.navbar-gitlab .header-content .header-search-new { - max-width: 640px; -} .navbar-gitlab .header-search { min-width: 320px; } @@ -985,13 +985,6 @@ kbd { .header-new .dropdown-menu { margin-top: 4px; } -.btn-sign-in { - background-color: #ebebfa; - color: #292961; - font-weight: 600; - line-height: 18px; - margin: 4px 0 4px 2px; -} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1651,11 +1644,6 @@ svg.s16 { .gl-display-none { display: none; } -@media (min-width: 576px) { - .gl-sm-display-none { - display: none; - } -} .gl-display-flex { display: flex; } @@ -1680,9 +1668,15 @@ svg.s16 { .gl-align-items-stretch { align-items: stretch; } +.gl-flex-grow-0\! { + flex-grow: 0 !important; +} .gl-flex-grow-1 { flex-grow: 1; } +.gl-flex-basis-half\! { + flex-basis: 50% !important; +} .gl-justify-content-end { justify-content: flex-end; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index d8afff1a200..3aace601c45 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -511,7 +511,7 @@ input.btn-block[type="button"] { .gl-form-checkbox.custom-control .custom-control-input[type="checkbox"]:indeterminate:disabled ~ .custom-control-label::after { - background-color: #626168; + background-color: #737278; } .gl-button { display: inline-flex; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 7d98a780e55..af98d59251f 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -4,19 +4,6 @@ to see the available utility classes. If you cannot find the class you need, consider adding it to Gitlab UI before adding it here. **/ - -@each $variant, $range in $color-ranges { - @each $suffix, $color in $range { - #{'.bg-#{$variant}-#{$suffix}'} { - background-color: $color; - } - - #{'.text-#{$variant}-#{$suffix}'} { - color: $color; - } - } -} - @each $index, $size in $type-scale { #{'.text-#{$index}'} { font-size: $size; @@ -290,11 +277,6 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 flex-flow: row wrap; } -// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2098 -.gl-max-w-0 { - max-width: 0; -} - .gl-isolate { isolation: isolate; } diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb index cf85e4b3d33..cf7ba0e5aaf 100644 --- a/app/channels/awareness_channel.rb +++ b/app/channels/awareness_channel.rb @@ -15,7 +15,7 @@ class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/Name private_class_method :refresh_interval # keep clients updated about session membership - periodically every: self.refresh_interval do + periodically every: refresh_interval do transmit payload end diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index eec56682300..f6b4fbac8d5 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -19,6 +19,13 @@ class AbuseReportsController < ApplicationController reported_from_url: report_params[:reported_from_url] ) + Gitlab::Tracking.event( + 'ReportAbuse', + 'select_abuse_category', + property: report_params[:category], + user: @user + ) + render :new end @@ -29,6 +36,13 @@ class AbuseReportsController < ApplicationController if @abuse_report.save @abuse_report.notify + Gitlab::Tracking.event( + 'ReportAbuse', + 'submit_form', + property: @abuse_report.category, + user: @abuse_report.user + ) + message = _("Thank you for your report. A GitLab administrator will look into it shortly.") redirect_to root_path, notice: message elsif report_params[:user_id].present? @@ -41,7 +55,7 @@ class AbuseReportsController < ApplicationController private def report_params - params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url) + params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url, links_to_spam: []) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/application_settings/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb index 3cb31ff756f..719e8e4a913 100644 --- a/app/controllers/admin/application_settings/appearances_controller.rb +++ b/app/controllers/admin/application_settings/appearances_controller.rb @@ -46,6 +46,15 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont redirect_to admin_application_settings_appearances_path, notice: _('Header logo was successfully removed.') end + def pwa_icon + @appearance.remove_pwa_icon! + + @appearance.save + + redirect_to admin_application_settings_appearances_path, + notice: _('Progressive Web App (PWA) icon was successfully removed.') + end + def favicon @appearance.remove_favicon! @appearance.save @@ -68,12 +77,16 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont def allowed_appearance_params %i[ title - pwa_short_name description + pwa_name + pwa_short_name + pwa_description logo logo_cache header_logo header_logo_cache + pwa_icon + pwa_icon_cache favicon favicon_cache new_project_guidelines diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb index ef9264d1615..5ea8c672993 100644 --- a/app/controllers/admin/jobs_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -6,6 +6,10 @@ class Admin::JobsController < Admin::ApplicationController feature_category :continuous_integration urgency :low + before_action do + push_frontend_feature_flag(:admin_jobs_vue) + end + def index # We need all builds for tabs counters @all_builds = Ci::JobsFinder.new(current_user: current_user).execute diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 96fe0c9331d..21a3a0aea0b 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -3,7 +3,11 @@ class Admin::RunnersController < Admin::ApplicationController include RunnerSetupScripts - before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] + before_action :runner, except: [:index, :new, :tag_list, :runner_setup_scripts] + + before_action only: [:index] do + push_frontend_feature_flag(:create_runner_workflow, current_user) + end feature_category :runner urgency :low @@ -18,6 +22,10 @@ class Admin::RunnersController < Admin::ApplicationController assign_projects end + def new + render_404 unless Feature.enabled?(:create_runner_workflow, current_user) + end + def update if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? respond_to do |format| @@ -29,28 +37,6 @@ class Admin::RunnersController < Admin::ApplicationController end end - def destroy - Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute - - redirect_to admin_runners_path, status: :found - end - - def resume - if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: true).success? - redirect_to admin_runners_path, notice: _('Runner was successfully updated.') - else - redirect_to admin_runners_path, alert: _('Runner was not updated.') - end - end - - def pause - if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: false).success? - redirect_to admin_runners_path, notice: _('Runner was successfully updated.') - else - redirect_to admin_runners_path, alert: _('Runner was not updated.') - end - end - def tag_list tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 4f379d8a75b..00b17bf381f 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -7,7 +7,6 @@ class Admin::UsersController < Admin::ApplicationController before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate before_action :ensure_destroy_prerequisites_met, only: [:destroy] - before_action :check_ban_user_feature_flag, only: [:ban] feature_category :user_management @@ -359,6 +358,7 @@ class Admin::UsersController < Admin::ApplicationController :skype, :theme_id, :twitter, + :discord, :username, :website_url, :note, @@ -377,10 +377,6 @@ class Admin::UsersController < Admin::ApplicationController access_denied! unless Gitlab.config.gitlab.impersonation_enabled end - def check_ban_user_feature_flag - access_denied! unless Feature.enabled?(:ban_user_feature_flag) - end - def log_impersonation_event Gitlab::AppLogger.info(format(_("User %{current_user_username} has started impersonating %{username}"), current_user_username: current_user.username, username: user.username)) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 36aae42e21f..353f9098b95 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -69,7 +69,7 @@ class ApplicationController < ActionController::Base :masked_page_url def self.endpoint_id_for_action(action_name) - "#{self.name}##{action_name}" + "#{name}##{action_name}" end rescue_from Encoding::CompatibilityError do |exception| @@ -510,8 +510,6 @@ class ApplicationController < ActionController::Base end def set_locale(&block) - return Gitlab::I18n.with_user_locale(current_user, &block) unless Feature.enabled?(:preferred_language_switcher) - if current_user Gitlab::I18n.with_user_locale(current_user, &block) else diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 668b2ebaf9e..01cc1ef21c6 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -6,7 +6,7 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] before_action :check_search_rate_limit!, only: [:users, :projects] - feature_category :users, [:users, :user] + feature_category :user_profile, [:users, :user] feature_category :projects, [:projects] feature_category :team_planning, [:award_emojis] feature_category :code_review_workflow, [:merge_request_target_branches] diff --git a/app/controllers/concerns/analytics/cycle_analytics/stage_actions.rb b/app/controllers/concerns/analytics/cycle_analytics/stage_actions.rb index eebc40f33f4..b0220b17cf9 100644 --- a/app/controllers/concerns/analytics/cycle_analytics/stage_actions.rb +++ b/app/controllers/concerns/analytics/cycle_analytics/stage_actions.rb @@ -7,9 +7,11 @@ module Analytics extend ActiveSupport::Concern included do + extend ::Gitlab::Utils::Override include CycleAnalyticsParams - before_action :validate_params, only: %i[median] + before_action :validate_params, except: %i[index] + before_action :authorize_stage, except: %i[index] end def index @@ -44,11 +46,11 @@ module Analytics private - def parent + def namespace raise NotImplementedError end - def value_stream_class + def authorize_stage raise NotImplementedError end @@ -64,7 +66,7 @@ module Analytics end def stage - @stage ||= ::Analytics::CycleAnalytics::StageFinder.new(parent: parent, stage_id: params[:id]).execute + @stage ||= ::Analytics::CycleAnalytics::StageFinder.new(parent: namespace, stage_id: params[:id]).execute end def data_collector @@ -75,7 +77,7 @@ module Analytics end def value_stream - @value_stream ||= value_stream_class.build_default_value_stream(parent) + @value_stream ||= Analytics::CycleAnalytics::ValueStream.build_default_value_stream(namespace) end def list_params @@ -83,7 +85,7 @@ module Analytics end def list_service - Analytics::CycleAnalytics::Stages::ListService.new(parent: parent, current_user: current_user, params: list_params) + Analytics::CycleAnalytics::Stages::ListService.new(parent: namespace, current_user: current_user, params: list_params) end def cycle_analytics_configuration(stages) @@ -94,3 +96,5 @@ module Analytics end end end + +Analytics::CycleAnalytics::StageActions.prepend_mod_with('Analytics::CycleAnalytics::StageActions') diff --git a/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb b/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb new file mode 100644 index 00000000000..f10b23d1664 --- /dev/null +++ b/app/controllers/concerns/analytics/cycle_analytics/value_stream_actions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module ValueStreamActions + extend ActiveSupport::Concern + + included do + before_action :authorize + end + + def index + # FOSS users can only see the default value stream + value_streams = [Analytics::CycleAnalytics::ValueStream.build_default_value_stream(namespace)] + + render json: Analytics::CycleAnalytics::ValueStreamSerializer.new.represent(value_streams) + end + + private + + def namespace + raise NotImplementedError + end + + def authorize + authorize_read_cycle_analytics! + end + end + end +end + +Analytics::CycleAnalytics::ValueStreamActions.prepend_mod_with('Analytics::CycleAnalytics::ValueStreamActions') diff --git a/app/controllers/concerns/ci/auth_build_trace.rb b/app/controllers/concerns/ci/auth_build_trace.rb new file mode 100644 index 00000000000..0370a382eb8 --- /dev/null +++ b/app/controllers/concerns/ci/auth_build_trace.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ci + module AuthBuildTrace + extend ActiveSupport::Concern + + def authorize_read_build_trace! + return if can?(current_user, :read_build_trace, build) + + if build.debug_mode? + access_denied!( + _('You must have developer or higher permissions in the associated project to view job logs when debug ' \ + "trace is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' and 'CI_DEBUG_SERVICES' variables to " \ + "'false' in your pipeline configuration or CI/CD settings. If you must view this job log, " \ + 'a project maintainer or owner must add you to the project with developer permissions or higher.') + ) + else + access_denied!(_('The current user is not authorized to access the job log.')) + end + end + end +end diff --git a/app/controllers/concerns/clientside_preview_csp.rb b/app/controllers/concerns/clientside_preview_csp.rb deleted file mode 100644 index 6892c441b67..00000000000 --- a/app/controllers/concerns/clientside_preview_csp.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module ClientsidePreviewCSP - extend ActiveSupport::Concern - - included do - content_security_policy do |p| - next if p.directives.blank? - next unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? - - default_frame_src = p.directives['frame-src'] || p.directives['default-src'] - frame_src_values = Array.wrap(default_frame_src) | [Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url].compact - - p.frame_src(*frame_src_values) - end - end -end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 0669f051457..e1381b4173f 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -90,7 +90,7 @@ module IssuableActions end def destroy - Issuable::DestroyService.new(project: issuable.project, current_user: current_user).execute(issuable) + Issuable::DestroyService.new(container: issuable.project, current_user: current_user).execute(issuable) name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." @@ -246,7 +246,21 @@ module IssuableActions end def bulk_update_params - params.require(:update).permit(bulk_update_permitted_keys) + clean_bulk_update_params( + params.require(:update).permit(bulk_update_permitted_keys) + ) + end + + def clean_bulk_update_params(permitted_params) + permitted_params.delete_if do |k, v| + next if k == :issuable_ids + + if v.is_a?(Array) + v.compact.empty? + else + v.blank? + end + end end def bulk_update_permitted_keys @@ -254,7 +268,6 @@ module IssuableActions :issuable_ids, :assignee_id, :milestone_id, - :sprint_id, :state_event, :subscription_event, assignee_ids: [], diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 5060ce69d9c..a202808e2c3 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -14,18 +14,8 @@ module IssuableCollections private - def show_alert_if_search_is_disabled - if current_user || params[:search].blank? || !html_request? || Feature.disabled?(:disable_anonymous_search, type: :ops) - return - end - - flash.now[:notice] = _('You must sign in to search for specific terms.') - end - # rubocop:disable Gitlab/ModuleWithInstanceVariables def set_issuables_index - show_alert_if_search_is_disabled - @issuables = issuables_collection set_pagination diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index b8249345a54..31445eb3eca 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -7,14 +7,12 @@ module IssuableCollectionsAction included do before_action :check_search_rate_limit!, only: [:issues, :merge_requests], if: -> { - params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches) + params[:search].present? } end # rubocop:disable Gitlab/ModuleWithInstanceVariables def issues - show_alert_if_search_is_disabled - @issues = issuables_collection .non_archived .page(params[:page]) @@ -28,11 +26,13 @@ module IssuableCollectionsAction end def merge_requests - show_alert_if_search_is_disabled - @merge_requests = issuables_collection.page(params[:page]) @issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @merge_requests).data + rescue ActiveRecord::QueryCanceled => exception # rubocop:disable Database/RescueQueryCanceled + log_exception(exception) + + @search_timeout_occurred = true end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 7c6e449b509..773e4c15d6e 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -6,7 +6,7 @@ module MembershipActions def update update_params = params.require(root_params_key).permit(:access_level, :expires_at) - member = membershipable.members_and_requesters.find(params[:id]) + member = members_and_requesters.find(params[:id]) result = Members::UpdateService .new(current_user, update_params) .execute(member) @@ -30,7 +30,7 @@ module MembershipActions end def destroy - member = membershipable.members_and_requesters.find(params[:id]) + member = members_and_requesters.find(params[:id]) skip_subresources = !ActiveRecord::Type::Boolean.new.cast(params.delete(:remove_sub_memberships)) # !! is used in case unassign_issuables contains empty string which would result in nil unassign_issuables = !!ActiveRecord::Type::Boolean.new.cast(params.delete(:unassign_issuables)) @@ -71,7 +71,7 @@ module MembershipActions end def approve_access_request - access_requester = membershipable.requesters.find(params[:id]) + access_requester = requesters.find(params[:id]) Members::ApproveAccessRequestService .new(current_user, params) .execute(access_requester) @@ -81,7 +81,7 @@ module MembershipActions # rubocop: disable CodeReuse/ActiveRecord def leave - member = membershipable.members_and_requesters.find_by!(user_id: current_user.id) + member = members_and_requesters.find_by!(user_id: current_user.id) Members::DestroyService.new(current_user).execute(member) notice = @@ -140,6 +140,14 @@ module MembershipActions raise NotImplementedError end + def members_and_requesters + membershipable.members_and_requesters + end + + def requesters + membershipable.requesters + end + def requested_relations(inherited_permissions = :with_inherited_permissions) case params[inherited_permissions].presence when 'exclude' diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index d4e8e95e016..338c3af235b 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -37,7 +37,7 @@ module MetricsDashboard def all_dashboards dashboard_finder .find_all_paths(project_for_dashboard) - .map(&method(:amend_dashboard)) + .map { |dashboard| amend_dashboard(dashboard) } end def amend_dashboard(dashboard) diff --git a/app/controllers/concerns/preferred_language_switcher.rb b/app/controllers/concerns/preferred_language_switcher.rb index 00cd0f9d1d5..872652100c9 100644 --- a/app/controllers/concerns/preferred_language_switcher.rb +++ b/app/controllers/concerns/preferred_language_switcher.rb @@ -6,8 +6,6 @@ module PreferredLanguageSwitcher private def init_preferred_language - return unless Feature.enabled?(:preferred_language_switcher) - cookies[:preferred_language] = preferred_language end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index b01320ce3ec..5696e441ad0 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -5,6 +5,8 @@ module ProductAnalyticsTracking include RedisTracking extend ActiveSupport::Concern + MIGRATED_EVENTS = ['g_analytics_valuestream'].freeze + class_methods do # TODO: Remove once all the events are migrated to #track_custom_event # during https://gitlab.com/groups/gitlab-org/-/epics/8641 @@ -63,9 +65,9 @@ module ProductAnalyticsTracking end def event_enabled?(event) - events_to_ff = { - g_analytics_valuestream: '', + return true if MIGRATED_EVENTS.include?(event) + events_to_ff = { i_search_paid: :_phase2, i_search_total: :_phase2, i_search_advanced: :_phase2, diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb index 6ac87d8f27b..501590d33d9 100644 --- a/app/controllers/concerns/record_user_last_activity.rb +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -20,6 +20,7 @@ module RecordUserLastActivity return if Gitlab::Database.read_only? return unless current_user && current_user.last_activity_on != Date.today - Users::ActivityService.new(current_user).execute + # TODO: add namespace & project - https://gitlab.com/gitlab-org/gitlab/-/issues/387952 + Users::ActivityService.new(author: current_user).execute end end diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index e98c1a30887..ef3d281589a 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -22,7 +22,7 @@ module RequiresWhitelistedMonitoringClient end def ip_whitelist - @ip_whitelist ||= Settings.monitoring.ip_whitelist.map(&IPAddr.method(:new)) + @ip_whitelist ||= Settings.monitoring.ip_whitelist.map { |ip| IPAddr.new(ip) } end def valid_token? diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index c91edb74d6b..2141b257b40 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -63,21 +63,32 @@ module SendFileUpload private def image_scaling_request?(file_upload) - avatar_safe_for_scaling?(file_upload) && - scaling_allowed_by_feature_flags?(file_upload) && - valid_image_scaling_width? + (avatar_safe_for_scaling?(file_upload) || pwa_icon_safe_for_scaling?(file_upload)) && + scaling_allowed_by_feature_flags?(file_upload) + end + + def pwa_icon_safe_for_scaling?(file_upload) + file_upload.try(:image_safe_for_scaling?) && + mounted_as_pwa_icon?(file_upload) && + valid_image_scaling_width?(Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS) end def avatar_safe_for_scaling?(file_upload) - file_upload.try(:image_safe_for_scaling?) && mounted_as_avatar?(file_upload) + file_upload.try(:image_safe_for_scaling?) && + mounted_as_avatar?(file_upload) && + valid_image_scaling_width?(Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS) end def mounted_as_avatar?(file_upload) file_upload.try(:mounted_as)&.to_sym == :avatar end - def valid_image_scaling_width? - Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS.include?(params[:width]&.to_i) + def mounted_as_pwa_icon?(file_upload) + file_upload.try(:mounted_as)&.to_sym == :pwa_icon + end + + def valid_image_scaling_width?(allowed_scalar_widths) + allowed_scalar_widths.include?(params[:width]&.to_i) end def scaling_allowed_by_feature_flags?(file_upload) diff --git a/app/controllers/concerns/sends_blob.rb b/app/controllers/concerns/sends_blob.rb index 3cf260c9f1b..3303b704ebe 100644 --- a/app/controllers/concerns/sends_blob.rb +++ b/app/controllers/concerns/sends_blob.rb @@ -27,12 +27,7 @@ module SendsBlob private def cached_blob?(blob, allow_caching: false) - stale = - if Feature.enabled?(:improve_blobs_cache_headers) - stale?(strong_etag: blob.id) - else - stale?(etag: blob.id) - end + stale = stale?(strong_etag: blob.id) max_age = if @ref && @commit && @ref == @commit.id # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -47,14 +42,9 @@ module SendsBlob end # Because we are opinionated we set the cache headers ourselves. - if Feature.enabled?(:improve_blobs_cache_headers) - expires_in(max_age, - public: allow_caching, must_revalidate: true, stale_if_error: 5.minutes, - stale_while_revalidate: 1.minute, 's-maxage': 1.minute) - else - response.cache_control[:public] = allow_caching - response.cache_control[:max_age] = max_age - end + expires_in(max_age, + public: allow_caching, must_revalidate: true, stale_if_error: 5.minutes, + stale_while_revalidate: 1.minute, 's-maxage': 1.minute) !stale end diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index 82388090350..fb48c0d8ba5 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -163,6 +163,7 @@ module VerifiesWithEmail end def require_email_verification_enabled?(user) - Feature.enabled?(:require_email_verification, user) + Feature.enabled?(:require_email_verification, user) && + Feature.disabled?(:skip_require_email_verification, user, type: :ops) end end diff --git a/app/controllers/concerns/zuora_csp.rb b/app/controllers/concerns/zuora_csp.rb deleted file mode 100644 index 5f9be11d7b9..00000000000 --- a/app/controllers/concerns/zuora_csp.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module ZuoraCSP - extend ActiveSupport::Concern - - ZUORA_URL = 'https://*.zuora.com' - - included do - content_security_policy do |policy| - next if policy.directives.blank? - - default_script_src = policy.directives['script-src'] || policy.directives['default-src'] - script_src_values = Array.wrap(default_script_src) | ["'self'", "'unsafe-eval'", ZUORA_URL] - - default_frame_src = policy.directives['frame-src'] || policy.directives['default-src'] - frame_src_values = Array.wrap(default_frame_src) | ["'self'", ZUORA_URL] - - default_child_src = policy.directives['child-src'] || policy.directives['default-src'] - child_src_values = Array.wrap(default_child_src) | ["'self'", ZUORA_URL] - - policy.script_src(*script_src_values) - policy.frame_src(*frame_src_values) - policy.child_src(*child_src_values) - end - end -end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 3d9184979d7..b003ca564f3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -14,7 +14,7 @@ class DashboardController < Dashboard::ApplicationController respond_to :html - feature_category :users, [:activity] + feature_category :user_profile, [:activity] feature_category :team_planning, [:issues, :issues_calendar] feature_category :code_review_workflow, [:merge_requests] @@ -36,17 +36,20 @@ class DashboardController < Dashboard::ApplicationController def load_events @events = - if params[:filter] == "followed" - load_user_events - else + case params[:filter] + when "projects", "starred" load_project_events + when "followed" + load_user_events(current_user.followees) + else + load_user_events(current_user) end Events::RenderService.new(current_user).execute(@events) end - def load_user_events - UserRecentEventsFinder.new(current_user, current_user.followees, event_filter, params).execute + def load_user_events(user) + UserRecentEventsFinder.new(current_user, user, event_filter, params).execute end def load_project_events diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 942cb9beed4..2f01bdecd23 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -144,7 +144,8 @@ class GraphqlController < ApplicationController def set_user_last_activity return unless current_user - Users::ActivityService.new(current_user).execute + # TODO: add namespace & project - https://gitlab.com/gitlab-org/gitlab/-/issues/387951 + Users::ActivityService.new(author: current_user).execute end def track_vs_code_usage diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 6936733c4f7..3cad9e1fbad 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -46,6 +46,8 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def target + # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/388541 + # type_id is a misnomer. QuickActions::TargetService actually requires an iid. QuickActions::TargetService .new(nil, current_user, group: @group) .execute(params[:type], params[:type_id]) diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb index 38087e3fc11..8ae429de490 100644 --- a/app/controllers/groups/email_campaigns_controller.rb +++ b/app/controllers/groups/email_campaigns_controller.rb @@ -3,7 +3,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0' - feature_category :navigation + feature_category :experimentation_activation urgency :low before_action :check_params @@ -44,7 +44,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController when :team, :team_short group_group_members_url(group) when :admin_verify - project_settings_ci_cd_path(group.projects.first, ci_runner_templates: true, anchor: 'js-runners-settings') + project_settings_ci_cd_path(group.projects.first, anchor: 'js-runners-settings') end end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 18b055b3f05..859bb0adb4e 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -9,8 +9,6 @@ class Groups::RunnersController < Groups::ApplicationController urgency :low def index - finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }) - @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000) @group_runner_registration_token = @group.runners_token if can?(current_user, :register_group_runners, group) Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group) diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb index b660eb3af99..4f858cd130a 100644 --- a/app/controllers/groups/usage_quotas_controller.rb +++ b/app/controllers/groups/usage_quotas_controller.rb @@ -4,6 +4,7 @@ module Groups class UsageQuotasController < Groups::ApplicationController before_action :authorize_read_usage_quotas! before_action :verify_usage_quotas_enabled! + before_action :push_frontend_feature_flags feature_category :subscription_cost_management urgency :low @@ -15,6 +16,10 @@ module Groups private + def push_frontend_feature_flags + push_frontend_feature_flag(:usage_quotas_for_all_editions, @group) + end + def verify_usage_quotas_enabled! render_404 unless group.usage_quotas_enabled? end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index e440b60ad1f..8f7a2c177b7 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -36,9 +36,14 @@ class GroupsController < Groups::ApplicationController before_action only: :issues do push_frontend_feature_flag(:or_issuable_queries, group) + push_frontend_feature_flag(:frontend_caching, group) push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) end + before_action only: :show do + push_frontend_feature_flag(:show_group_readme, group) + end + helper_method :captcha_required? skip_cross_project_access_check :index, :new, :create, :edit, :update, diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index bedeae3cf54..d0e14000d8e 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -2,7 +2,6 @@ class IdeController < ApplicationController include VSCodeCDNCSP - include ClientsidePreviewCSP include StaticObjectExternalStorageCSP include Gitlab::Utils::StrongMemoize diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index e9705c45116..f4eea3abd32 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -53,6 +53,7 @@ class Import::BulkImportsController < ApplicationController end def create + return render json: { success: false }, status: :too_many_requests if throttled_request? return render json: { success: false }, status: :unprocessable_entity unless valid_create_params? responses = create_params.map do |entry| @@ -204,4 +205,8 @@ class Import::BulkImportsController < ApplicationController def current_user_bulk_imports current_user.bulk_imports.gitlab end + + def throttled_request? + ::Gitlab::ApplicationRateLimiter.throttled_request?(request, current_user, :bulk_import, scope: current_user) + end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 9a8f6a74653..0bee1faccf5 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -79,13 +79,7 @@ class Import::GithubController < Import::BaseController def realtime_changes Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: already_added_projects.map { |project| - { - id: project.id, - import_status: project.import_status, - stats: ::Gitlab::GithubImport::ObjectCounter.summary(project) - } - } + render json: Import::GithubRealtimeRepoSerializer.new.represent(already_added_projects) end def cancel @@ -99,6 +93,23 @@ class Import::GithubController < Import::BaseController end end + def cancel_all + projects_to_cancel = Project.imported_from(provider_name).created_by(current_user).is_importing + + canceled = projects_to_cancel.map do |project| + # #reset is called to make sure project was not finished/canceled brefore calling service + result = Import::Github::CancelProjectImportService.new(project.reset, current_user).execute + + { + id: project.id, + status: result[:status], + error: result[:message] + }.compact + end + + render json: canceled + end + protected override :importable_repos diff --git a/app/controllers/jira_connect/public_keys_controller.rb b/app/controllers/jira_connect/public_keys_controller.rb index 09003f8478f..4505ab16926 100644 --- a/app/controllers/jira_connect/public_keys_controller.rb +++ b/app/controllers/jira_connect/public_keys_controller.rb @@ -10,9 +10,7 @@ module JiraConnect skip_before_action :authenticate_user! def show - if Feature.disabled?(:jira_connect_oauth_self_managed) || !Gitlab.config.jira_connect.enable_public_keys_storage - return render_404 - end + return render_404 unless public_key_storage_enabled? render plain: public_key.key end @@ -22,5 +20,11 @@ module JiraConnect def public_key JiraConnect::PublicKey.find(params[:id]) end + + def public_key_storage_enabled? + return true if Gitlab.config.jira_connect.enable_public_keys_storage + + Gitlab::CurrentSettings.jira_connect_public_key_storage_enabled? + end end end diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index ff7477a94d6..a206e7fbbd8 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -21,7 +21,6 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController before_action do push_frontend_feature_flag(:jira_connect_oauth, @user) - push_frontend_feature_flag(:jira_connect_oauth_self_managed, @user) end before_action :allow_rendering_in_iframe, only: :index diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 5bd3b74af1f..4046433f8ea 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -61,14 +61,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController handle_omniauth end - def authentiq - if params['sid'] - handle_service_ticket oauth['provider'], params['sid'] - end - - handle_omniauth - end - def auth0 if oauth['uid'].blank? fail_auth0_login diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb index 55a2904ce83..829a87b7d0a 100644 --- a/app/controllers/profiles/avatars_controller.rb +++ b/app/controllers/profiles/avatars_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Profiles::AvatarsController < Profiles::ApplicationController - feature_category :users + feature_category :user_profile def destroy @user = current_user diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb index 7e332d9a498..c88616b6d6c 100644 --- a/app/controllers/profiles/emails_controller.rb +++ b/app/controllers/profiles/emails_controller.rb @@ -7,7 +7,7 @@ class Profiles::EmailsController < Profiles::ApplicationController before_action -> { check_rate_limit!(:profile_resend_email_confirmation, scope: current_user, redirect_back: true) }, only: [:resend_confirmation_instructions] - feature_category :users + feature_category :user_profile urgency :low, [:index] def index diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb index 5962b10c44b..fdd76a7f7be 100644 --- a/app/controllers/profiles/groups_controller.rb +++ b/app/controllers/profiles/groups_controller.rb @@ -3,7 +3,7 @@ class Profiles::GroupsController < Profiles::ApplicationController include RoutableActions - feature_category :users + feature_category :user_profile def update group = find_routable!(Group, params[:id], request.fullpath) diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 39e8f6c500d..31c758ac763 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Profiles::KeysController < Profiles::ApplicationController - feature_category :users + feature_category :user_profile urgency :low, [:create, :index] def index @@ -34,6 +34,16 @@ class Profiles::KeysController < Profiles::ApplicationController end end + def revoke + @key = current_user.keys.find(params[:id]) + Keys::RevokeService.new(current_user).execute(@key) + + respond_to do |format| + format.html { redirect_to profile_keys_url, status: :found } + format.js { head :ok } + end + end + private def key_params diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 974e7104c07..7786bad4251 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -3,7 +3,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController before_action :user - feature_category :users + feature_category :user_profile urgency :low, [:show] urgency :medium, [:update] diff --git a/app/controllers/profiles/saved_replies_controller.rb b/app/controllers/profiles/saved_replies_controller.rb new file mode 100644 index 00000000000..5ac5d645efb --- /dev/null +++ b/app/controllers/profiles/saved_replies_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Profiles + class SavedRepliesController < Profiles::ApplicationController + feature_category :user_profile + + before_action do + render_404 unless Feature.enabled?(:saved_replies, current_user) + + @hide_search_settings = true + end + end +end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index c36f03d3e69..aded295bfab 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -3,7 +3,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController skip_before_action :check_two_factor_requirement before_action :ensure_verified_primary_email, only: [:show, :create] - before_action :validate_current_password, only: [:create, :codes, :destroy], if: :current_password_required? + before_action :validate_current_password, only: [:create, :codes, :destroy, :create_webauthn], if: :current_password_required? before_action :update_current_user_otp!, only: [:show] helper_method :current_password_required? @@ -21,8 +21,13 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def create otp_validation_result = ::Users::ValidateManualOtpService.new(current_user).execute(params[:pin_code]) + validated = (otp_validation_result[:status] == :success) - if otp_validation_result[:status] == :success + if validated && current_user.otp_backup_codes? && Feature.enabled?(:webauthn_without_totp) + ActiveSession.destroy_all_but_current(current_user, session) + Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! + redirect_to profile_two_factor_auth_path, notice: _("Your Time-based OTP device was registered!") + elsif validated ActiveSession.destroy_all_but_current(current_user, session) Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! do |user| @@ -64,10 +69,27 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def create_webauthn @webauthn_registration = Webauthn::RegisterService.new(current_user, device_registration_params, session[:challenge]).execute + + notice = _("Your WebAuthn device was registered!") if @webauthn_registration.persisted? session.delete(:challenge) - redirect_to profile_two_factor_auth_path, notice: s_("Your WebAuthn device was registered!") + if Feature.enabled?(:webauthn_without_totp) + + if current_user.otp_backup_codes? + redirect_to profile_two_factor_auth_path, notice: notice + else + + Users::UpdateService.new(current_user, user: current_user).execute! do |user| + @codes = current_user.generate_otp_backup_codes! + end + helpers.dismiss_two_factor_auth_recovery_settings_check + flash[:notice] = notice + render 'create' + end + else + redirect_to profile_two_factor_auth_path, notice: notice + end else @qr_code = build_qr_code @@ -119,11 +141,17 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController end def validate_current_password + return if Feature.disabled?(:webauthn_without_totp) && params[:action] == 'create_webauthn' return if current_user.valid_password?(params[:current_password]) current_user.increment_failed_attempts! - @error = { message: _('You must provide a valid current password') } + error_message = { message: _('You must provide a valid current password.') } + if params[:action] == 'create_webauthn' + @webauthn_error = error_message + else + @error = error_message + end setup_show_page diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index e3704b77adc..45b274fc920 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -14,7 +14,7 @@ class ProfilesController < Profiles::ApplicationController push_frontend_feature_flag(:webauthn) end - feature_category :users, [:show, :update, :reset_incoming_email_token, :reset_feed_token, + feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token, :reset_static_object_token, :update_username] feature_category :authentication_and_authorization, [:audit_log] @@ -127,6 +127,7 @@ class ProfilesController < Profiles::ApplicationController :commit_email, :skype, :twitter, + :discord, :username, :website_url, :organization, diff --git a/app/controllers/projects/airflow/dags_controller.rb b/app/controllers/projects/airflow/dags_controller.rb new file mode 100644 index 00000000000..9d1f0b0d63b --- /dev/null +++ b/app/controllers/projects/airflow/dags_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Projects + module Airflow + class DagsController < ::Projects::ApplicationController + before_action :check_feature_flag + before_action :authorize_read_airflow_dags! + + feature_category :dataops + + MAX_DAGS_PER_PAGE = 15 + def index + page = params[:page].to_i + page = 1 if page <= 0 + + @dags = ::Airflow::Dags.by_project_id(@project.id) + + return unless @dags.any? + + @dags = @dags.page(page).per(MAX_DAGS_PER_PAGE) + return redirect_to(url_for(page: @dags.total_pages)) if @dags.out_of_range? + + @pagination = { + page: page, + is_last_page: @dags.last_page?, + per_page: MAX_DAGS_PER_PAGE, + total_items: @dags.total_count + } + end + + private + + def check_feature_flag + render_404 unless Feature.enabled?(:airflow_dags, @project) + end + end + end +end diff --git a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb index ab2cf3abdde..a61b774f9c8 100644 --- a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb +++ b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb @@ -3,7 +3,6 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::ApplicationController include ::Analytics::CycleAnalytics::StageActions include Gitlab::Utils::StrongMemoize - extend ::Gitlab::Utils::Override respond_to :json @@ -11,20 +10,14 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat before_action :authorize_read_cycle_analytics! before_action :only_default_value_stream_is_allowed! - before_action :authorize_stage!, only: [:median, :count, :average, :records] urgency :low private - override :parent - def parent - @project - end - - override :value_stream_class - def value_stream_class - Analytics::CycleAnalytics::ProjectValueStream + override :namespace + def namespace + @project.project_namespace end override :cycle_analytics_configuration @@ -33,7 +26,9 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat end def only_default_value_stream_is_allowed! - render_404 if params[:value_stream_id] != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME + return if requests_default_value_stream? + + render_403 end def permitted_stage?(stage) @@ -42,11 +37,20 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat def permissions strong_memoize(:permissions) do - Gitlab::CycleAnalytics::Permissions.new(user: current_user, project: parent).get + Gitlab::CycleAnalytics::Permissions.new(user: current_user, project: @project).get end end - def authorize_stage! + def authorize_stage render_403 unless permitted_stage?(stage) end + + def requests_default_value_stream? + default_name = Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME + + params[:value_stream_id] == default_name + end end + +mod = 'Projects::Analytics::CycleAnalytics::StagesController' +Projects::Analytics::CycleAnalytics::StagesController.prepend_mod_with(mod) # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb index 60bcd1d7238..f58730f1d33 100644 --- a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb +++ b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb @@ -1,17 +1,16 @@ # frozen_string_literal: true class Projects::Analytics::CycleAnalytics::ValueStreamsController < Projects::ApplicationController + include ::Analytics::CycleAnalytics::ValueStreamActions + respond_to :json feature_category :planning_analytics urgency :low - before_action :authorize_read_cycle_analytics! - - def index - # FOSS users can only see the default value stream - value_streams = [Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project)] + private - render json: Analytics::CycleAnalytics::ValueStreamSerializer.new.represent(value_streams) + def namespace + project.project_namespace end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 25b83aed78a..62233c8c3c9 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -32,21 +32,6 @@ class Projects::ApplicationController < ApplicationController ->(project) { !project.pending_delete? } end - def authorize_read_build_trace! - return if can?(current_user, :read_build_trace, build) - - if build.debug_mode? - access_denied!( - _('You must have developer or higher permissions in the associated project to view job logs when debug trace ' \ - "is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' and 'CI_DEBUG_SERVICES' variables to 'false' " \ - 'in your pipeline configuration or CI/CD settings. If you must view this job log, a project maintainer ' \ - 'or owner must add you to the project with developer permissions or higher.') - ) - else - access_denied!(_('The current user is not authorized to access the job log.')) - end - end - def build_canonical_path(project) params[:namespace_id] = project.namespace.to_param params[:project_id] = project.to_param diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 3201538a393..5f8060ad756 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Projects::ArtifactsController < Projects::ApplicationController + include Ci::AuthBuildTrace include ExtractsPath include RendersBlob include SendFileUpload @@ -11,6 +12,7 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! before_action :authorize_read_build_trace!, only: [:download] + before_action :authorize_read_job_artifacts!, only: [:download] before_action :authorize_update_build!, only: [:keep] before_action :authorize_destroy_artifacts!, only: [:destroy] before_action :extract_ref_name_and_path @@ -40,10 +42,10 @@ class Projects::ArtifactsController < Projects::ApplicationController end def download - return render_404 unless artifacts_file + return render_404 unless artifact_file - log_artifacts_filesize(artifacts_file.model) - send_upload(artifacts_file, attachment: artifacts_file.filename, proxy: params[:proxy]) + log_artifacts_filesize(artifact_file.model) + send_upload(artifact_file, attachment: artifact_file.filename, proxy: params[:proxy]) end def browse @@ -82,11 +84,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def raw return render_404 unless zip_artifact? - return render_404 unless artifacts_file + return render_404 unless artifact_file path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path]) - send_artifacts_entry(artifacts_file, path) + send_artifacts_entry(artifact_file, path) end def keep @@ -153,8 +155,12 @@ class Projects::ArtifactsController < Projects::ApplicationController project.latest_successful_build_for_ref(params[:job], @ref_name) end - def artifacts_file - @artifacts_file ||= build&.artifacts_file_for_type(params[:file_type] || :archive) + def job_artifact + @job_artifact ||= build&.artifact_for_type(params[:file_type] || :archive) + end + + def artifact_file + @artifact_file ||= job_artifact&.file end def zip_artifact? @@ -175,4 +181,8 @@ class Projects::ArtifactsController < Projects::ApplicationController super end + + def authorize_read_job_artifacts! + return access_denied! unless can?(current_user, :read_job_artifacts, job_artifact) + end end diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 000203079cc..ffe6071ab3c 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -6,7 +6,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts] feature_category :code_review_workflow, [:merge_requests] - feature_category :users, [:members] + feature_category :user_profile, [:members] feature_category :source_code_management, [:snippets] urgency :low, [:merge_requests, :members] @@ -54,6 +54,8 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController # type_id is not required in general target_type = params.require(:type) + # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/388541 + # type_id is a misnomer. QuickActions::TargetService actually requires an iid. QuickActions::TargetService .new(project, current_user) .execute(target_type, params[:type_id]) diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 4eda76f4f21..59cea00e26b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -239,6 +239,8 @@ class Projects::BlobController < Projects::ApplicationController @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true) @code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path) + allow_lfs_direct_download + render 'show' end @@ -282,6 +284,30 @@ class Projects::BlobController < Projects::ApplicationController def visitor_id current_user&.id end + + def allow_lfs_direct_download + return unless directly_downloading_lfs_object? && content_security_policy_enabled? + return unless (lfs_object = @project.lfs_objects.find_by_oid(@blob.lfs_oid)) + + request.content_security_policy.directives['connect-src'] ||= [] + request.content_security_policy.directives['connect-src'] << lfs_src(lfs_object) + end + + def directly_downloading_lfs_object? + Gitlab.config.lfs.enabled && + !Gitlab.config.lfs.object_store.proxy_download && + @blob&.stored_externally? + end + + def content_security_policy_enabled? + Gitlab.config.gitlab.content_security_policy.enabled + end + + def lfs_src(lfs_object) + file = lfs_object.file + file = file.cdn_enabled_url(request.remote_ip) if file.respond_to?(:cdn_enabled_url) + file.url + end end Projects::BlobController.prepend_mod diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 7b01e4db42a..f19f143816f 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -19,8 +19,10 @@ class Projects::BranchesController < Projects::ApplicationController def index respond_to do |format| format.html do - @mode = params[:state].presence || 'overview' - @sort = sort_value_for_mode + @mode = fetch_mode + next render_404 unless @mode + + @sort = sort_param || default_sort @overview_max_branches = 5 # Fetch branches for the specified mode @@ -128,11 +130,7 @@ class Projects::BranchesController < Projects::ApplicationController private - def sort_value_for_mode - custom_sort || default_sort - end - - def custom_sort + def sort_param sort = params[:sort].presence unless sort.in?(supported_sort_options) @@ -144,11 +142,11 @@ class Projects::BranchesController < Projects::ApplicationController end def default_sort - 'stale' == @mode ? sort_value_oldest_updated : sort_value_recently_updated + 'stale' == @mode ? SORT_UPDATED_OLDEST : SORT_UPDATED_RECENT end def supported_sort_options - [nil, sort_value_name, sort_value_oldest_updated, sort_value_recently_updated] + [nil, SORT_NAME, SORT_UPDATED_OLDEST, SORT_UPDATED_RECENT] end # It can be expensive to calculate the diverging counts for each @@ -206,15 +204,23 @@ class Projects::BranchesController < Projects::ApplicationController limit = @overview_max_branches + 1 @active_branches = - BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated }) + BranchesFinder.new(@repository, { per_page: limit, sort: SORT_UPDATED_RECENT }) .execute(gitaly_pagination: true).select(&:active?) @stale_branches = - BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated }) + BranchesFinder.new(@repository, { per_page: limit, sort: SORT_UPDATED_OLDEST }) .execute(gitaly_pagination: true).select(&:stale?) @branches = @active_branches + @stale_branches end + def fetch_mode + state = params[:state].presence + + return 'overview' unless state + + state.presence_in(%w[active stale all overview]) + end + def confidential_issue_project return if params[:confidential_issue_project_id].blank? diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 1942a5fef7b..3a2bc445737 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -2,6 +2,9 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! + before_action do + push_frontend_feature_flag(:ci_job_assistant_drawer, @project) + end feature_category :pipeline_authoring diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 583b572d4b1..252b203b38a 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -31,6 +31,7 @@ class Projects::CommitController < Projects::ApplicationController respond_to do |format| format.html do + @ref = params[:id] render locals: { pagination_params: params.permit(:page) } end format.diff do diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index c006d56ae81..3acc71d5dd3 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -75,7 +75,7 @@ class Projects::CommitsController < Projects::ApplicationController search = permitted_params[:search] author = permitted_params[:author] - # fully_qualified_ref is available in some situations when the use_ref_type_parameter FF is enabled + # fully_qualified_ref is available in some situations from ExtractsRef ref = @fully_qualified_ref || @ref @commits = if search.present? diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 63c1378ad11..9fe44659250 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -48,7 +48,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController end def load_value_stream - @value_stream = Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project) + @value_stream = Analytics::CycleAnalytics::ValueStream.build_default_value_stream(@project.project_namespace) end def cycle_analytics_json diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index ea1288c0b20..9a88a8160b6 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -19,6 +19,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action only: [:show] do push_frontend_feature_flag(:environment_details_vue, @project) end + before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect] before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] @@ -57,6 +58,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController render json: { environments: serialize_environments(request, response, params[:nested]), review_app: serialize_review_app, + can_stop_stale_environments: can?(current_user, :stop_environment, @project), available_count: environments_count_by_state[:available], stopped_count: environments_count_by_state[:stopped] } diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb index b511a85b0b8..9c20f10809c 100644 --- a/app/controllers/projects/google_cloud/databases_controller.rb +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -51,7 +51,7 @@ module Projects if enable_response[:status] == :error track_event(:error_enable_cloudsql_services) - flash[:error] = error_message(enable_response[:message]) + flash[:alert] = error_message(enable_response[:message]) else create_response = ::GoogleCloud::CreateCloudsqlInstanceService .new(project, current_user, create_service_params) diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index fae8dbd59c7..92c99ad4271 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -22,7 +22,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base if enable_cloud_run_response[:status] == :error track_event(:error_enable_services) - flash[:error] = enable_cloud_run_response[:message] + flash[:alert] = enable_cloud_run_response[:message] redirect_to project_google_cloud_deployments_path(project) else params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } @@ -31,7 +31,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base if generate_pipeline_response[:status] == :error track_event(:error_generate_cloudrun_pipeline) - flash[:error] = 'Failed to generate pipeline' + flash[:alert] = 'Failed to generate pipeline' redirect_to project_google_cloud_deployments_path(project) else cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name]) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 06c16297ce8..21227d62023 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -28,8 +28,7 @@ class Projects::IssuesController < Projects::ApplicationController SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request? } before_action :check_search_rate_limit!, if: ->(c) { - SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request? && - params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches) + SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request? && params[:search].present? } # Allow write(create) issue @@ -47,6 +46,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:preserve_unchanged_markdown, project) push_frontend_feature_flag(:content_editor_on_issues, project) + push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project) end before_action only: [:index, :show] do @@ -55,6 +55,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action only: :index do push_frontend_feature_flag(:or_issuable_queries, project) + push_frontend_feature_flag(:frontend_caching, project&.group) end before_action only: :show do @@ -64,7 +65,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(:use_iid_in_work_items_path, project&.group) - push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?) + push_frontend_feature_flag(:incident_event_tags, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -127,7 +128,7 @@ class Projects::IssuesController < Projects::ApplicationController discussion_to_resolve: params[:discussion_to_resolve], confidential: !!Gitlab::Utils.to_boolean(issue_params[:confidential]) ) - service = ::Issues::BuildService.new(project: project, current_user: current_user, params: build_params) + service = ::Issues::BuildService.new(container: project, current_user: current_user, params: build_params) @issue = @noteable = service.execute @@ -155,7 +156,7 @@ class Projects::IssuesController < Projects::ApplicationController ) spam_params = ::Spam::SpamParams.new_from_request(request: request) - service = ::Issues::CreateService.new(project: project, current_user: current_user, params: create_params, spam_params: spam_params) + service = ::Issues::CreateService.new(container: project, current_user: current_user, params: create_params, spam_params: spam_params) result = service.execute # Only irrecoverable errors such as unauthorized user won't contain an issue in the response @@ -190,7 +191,7 @@ class Projects::IssuesController < Projects::ApplicationController new_project = Project.find(params[:move_to_project_id]) return render_404 unless issue.can_move?(current_user, new_project) - @issue = ::Issues::MoveService.new(project: project, current_user: current_user).execute(issue, new_project) + @issue = ::Issues::MoveService.new(container: project, current_user: current_user).execute(issue, new_project) end respond_to do |format| @@ -204,7 +205,7 @@ class Projects::IssuesController < Projects::ApplicationController end def reorder - service = ::Issues::ReorderService.new(project: project, current_user: current_user, params: reorder_params) + service = ::Issues::ReorderService.new(container: project, current_user: current_user, params: reorder_params) if service.execute(issue) head :ok @@ -215,7 +216,7 @@ class Projects::IssuesController < Projects::ApplicationController def related_branches @related_branches = ::Issues::RelatedBranchesService - .new(project: project, current_user: current_user) + .new(container: project, current_user: current_user) .execute(issue) .map { |branch| branch.merge(link: branch_link(branch)) } @@ -370,7 +371,7 @@ class Projects::IssuesController < Projects::ApplicationController def update_service spam_params = ::Spam::SpamParams.new_from_request(request: request) - ::Issues::UpdateService.new(project: project, current_user: current_user, params: issue_params, spam_params: spam_params) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: issue_params, spam_params: spam_params) end def finder_type diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index c6d442a6f27..3fea5c694f7 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true class Projects::JobsController < Projects::ApplicationController + include Ci::AuthBuildTrace include SendFileUpload include ContinueParams include ProjectStatsRefreshConflictsGuard urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw] - before_action :find_job_as_build, except: [:index, :play, :show] - before_action :find_job_as_processable, only: [:play, :show] + before_action :find_job_as_build, except: [:index, :play, :show, :retry] + before_action :find_job_as_processable, only: [:play, :show, :retry] before_action :authorize_read_build_trace!, only: [:trace, :raw] before_action :authorize_read_build! before_action :authorize_update_build!, @@ -76,7 +77,11 @@ class Projects::JobsController < Projects::ApplicationController response = Ci::RetryJobService.new(project, current_user).execute(@build) if response.success? - redirect_to build_path(response[:job]) + if @build.is_a?(::Ci::Build) + redirect_to build_path(response[:job]) + else + head :ok + end else respond_422 end diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb deleted file mode 100644 index 6fe009c8a28..00000000000 --- a/app/controllers/projects/learn_gitlab_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class Projects::LearnGitlabController < Projects::ApplicationController - before_action :authenticate_user! - before_action :check_experiment_enabled? - before_action :enable_invite_for_help_continuous_onboarding_experiment - before_action :enable_video_tutorials_continuous_onboarding_experiment - - feature_category :users - urgency :low, [:index] - - def index - end - - private - - def check_experiment_enabled? - return access_denied! unless helpers.learn_gitlab_enabled?(project) - end - - def enable_invite_for_help_continuous_onboarding_experiment - return unless current_user.can?(:admin_group_member, project.namespace) - - experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e| - e.candidate {} - end - end - - def enable_video_tutorials_continuous_onboarding_experiment - experiment(:video_tutorials_continuous_onboarding, namespace: project&.namespace).publish - end -end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index cba0056ccd5..3b399e3294e 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -20,10 +20,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap :branch_to ] - before_action do - push_frontend_feature_flag(:mr_compare_dropdowns, project) - end - def new define_new_vars end @@ -97,7 +93,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def target_projects projects = MergeRequestTargetProjectFinder .new(current_user: current_user, source_project: @project, project_feature: :repository) - .execute(include_routes: true).limit(20).search(params[:search]) + .execute(include_routes: false, search: params[:search]).limit(20) render json: ProjectSerializer.new.represent(projects) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 1c546d70df9..6ca885cee4c 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -61,6 +61,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options[:merge_ref_head_diff] ] + expires_in(1.day) if cache_with_max_age? + return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) Gitlab::Metrics.measure(:diffs_unfold) do @@ -238,4 +240,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter .track_mr_diffs_single_file_action(merge_request: @merge_request, user: current_user) end + + def cache_with_max_age? + @merge_request.diffs_batch_cache_with_max_age? && + params[:ck].present? && + render_merge_ref_head_diff? + end end diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index 74bb3ad1a63..ca6ab83b877 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -49,24 +49,22 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli def publish result = DraftNotes::PublishService.new(merge_request, current_user).execute(draft_note(allow_nil: true)) - if Feature.enabled?(:mr_review_submit_comment, @project) - if create_note_params[:note] - ::Notes::CreateService.new(@project, current_user, create_note_params).execute + if create_note_params[:note] + ::Notes::CreateService.new(@project, current_user, create_note_params).execute - merge_request_activity_counter.track_submit_review_comment(user: current_user) - end + merge_request_activity_counter.track_submit_review_comment(user: current_user) + end - if Gitlab::Utils.to_boolean(approve_params[:approve]) - unless merge_request.approved_by?(current_user) - success = ::MergeRequests::ApprovalService.new(project: @project, current_user: current_user, params: approve_params).execute(merge_request) + if Gitlab::Utils.to_boolean(approve_params[:approve]) + unless merge_request.approved_by?(current_user) + success = ::MergeRequests::ApprovalService.new(project: @project, current_user: current_user, params: approve_params).execute(merge_request) - unless success - return render json: { message: _('An error occurred while approving, please try again.') }, status: :internal_server_error - end + unless success + return render json: { message: _('An error occurred while approving, please try again.') }, status: :internal_server_error end - - merge_request_activity_counter.track_submit_review_approve(user: current_user) end + + merge_request_activity_counter.track_submit_review_approve(user: current_user) end if result[:status] == :success @@ -145,7 +143,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli user_ids = notes.map(&:author_id) project.team.max_member_access_for_user_ids(user_ids) - notes.map(&method(:render_draft_note)) + notes.map { |note| render_draft_note(note) } end def render_draft_note(note) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index b0920b3fbdb..d92ef3de6d9 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -28,9 +28,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo :codequality_mr_diff_reports ] before_action :set_issuables_index, only: [:index] - before_action :check_search_rate_limit!, only: [:index], if: -> { - params[:search].present? && Feature.enabled?(:rate_limit_issuable_searches) - } + before_action :check_search_rate_limit!, only: [:index], if: -> { params[:search].present? } before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] @@ -40,9 +38,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:refactor_security_extension, @project) push_frontend_feature_flag(:refactor_code_quality_inline_findings, project) push_frontend_feature_flag(:moved_mr_sidebar, project) - push_frontend_feature_flag(:mr_review_submit_comment, project) push_frontend_feature_flag(:mr_experience_survey, project) - push_frontend_feature_flag(:realtime_reviewers, project) push_frontend_feature_flag(:realtime_mr_status_change, project) end @@ -282,11 +278,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo case result[:count] when 0 - flash[:error] = "Failed to assign you issues related to the merge request" - when 1 - flash[:notice] = "1 issue has been assigned to you" + flash[:alert] = _("Failed to assign you issues related to the merge request.") else - flash[:notice] = "#{result[:count]} issues have been assigned to you" + flash[:notice] = n_("An issue has been assigned to you.", "%d issues have been assigned to you.", result[:count]) end redirect_to(merge_request_path(@merge_request)) @@ -356,10 +350,20 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private + # NOTE: Remove this disable with add_prepared_state_to_mr FF removal + # rubocop: disable Metrics/AbcSize def show_merge_request close_merge_request_if_no_source_project @merge_request.check_mergeability(async: true) + # NOTE: Remove the created_at check when removing the FF check + if ::Feature.enabled?(:add_prepared_state_to_mr, @merge_request.project) && + @merge_request.created_at < 5.minutes.ago && + !@merge_request.prepared? + + @merge_request.prepare + end + respond_to do |format| format.html do # use next to appease Rubocop @@ -401,6 +405,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end end + # rubocop: enable Metrics/AbcSize def render_html_page preload_assignees_for_render(@merge_request) @@ -419,6 +424,7 @@ 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? set_pipeline_variables @@ -576,6 +582,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def endpoint_diff_batch_url(project, merge_request) per_page = current_user&.view_diffs_file_by_file ? '1' : '5' 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? diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params) end diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index 1e1c4b1587c..00b965542f6 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -3,6 +3,8 @@ module Projects module Ml class ExperimentsController < ::Projects::ApplicationController + include Projects::Ml::ExperimentsHelper + before_action :check_feature_flag feature_category :mlops @@ -11,7 +13,12 @@ module Projects MAX_CANDIDATES_PER_PAGE = 30 def index - @experiments = ::Ml::Experiment.by_project_id(@project.id).page(params[:page]).per(MAX_EXPERIMENTS_PER_PAGE) + paginator = ::Ml::Experiment.by_project_id(@project.id) + .with_candidate_count + .keyset_paginate(cursor: params[:cursor], per_page: MAX_EXPERIMENTS_PER_PAGE) + + @experiments = paginator.records + @page_info = page_info(paginator) end def show @@ -19,26 +26,17 @@ module Projects return redirect_to project_ml_experiments_path(@project) unless @experiment.present? - page = params[:page].to_i - page = 1 if page == 0 - - @candidates = @experiment.candidates - .including_relationships - .page(page) - .per(MAX_CANDIDATES_PER_PAGE) - - return unless @candidates - - return redirect_to(url_for(page: @candidates.total_pages)) if @candidates.out_of_range? + find_params = params + .transform_keys(&:underscore) + .permit(:name, :order_by, :sort, :order_by_type) - @pagination = { - page: page, - is_last_page: @candidates.last_page?, - per_page: MAX_CANDIDATES_PER_PAGE, - total_items: @candidates.total_count - } + paginator = CandidateFinder + .new(@experiment, find_params) + .execute + .keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE) - @candidates.each(&:artifact_lazy) + @candidates = paginator.records.each(&:artifact_lazy) + @page_info = page_info(paginator) end private diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index aa0838752e2..f3c63b1b97b 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -14,11 +14,7 @@ class Projects::NetworkController < Projects::ApplicationController urgency :low, [:show] def show - @url = if Feature.enabled?(:use_ref_type_parameter, @project) - project_network_path(@project, @ref, @options.merge(format: :json, ref_type: ref_type)) - else - project_network_path(@project, @ref, @options.merge(format: :json)) - end + @url = project_network_path(@project, @ref, @options.merge(format: :json, ref_type: ref_type)) @ref_type = ref_type @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s") diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 9d3506d49b0..054e8c302c9 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -12,7 +12,8 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_resolve_note!, only: [:resolve, :unresolve] feature_category :team_planning - urgency :low + urgency :medium, [:index] + urgency :low, [:create, :update, :destroy, :resolve, :unresolve, :toggle_award_emoji, :outdated_line_change] def delete_attachment note.remove_attachment! diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 31030d958df..19d031bd59b 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -41,7 +41,9 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def update - if schedule.update(schedule_params) + response = Ci::PipelineSchedules::UpdateService.new(schedule, current_user, schedule_params).execute + + if response.success? redirect_to project_pipeline_schedules_path(@project) else render :edit @@ -63,7 +65,9 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def take_ownership - if schedule.update(owner: current_user) + response = Ci::PipelineSchedules::TakeOwnershipService.new(schedule, current_user).execute + + if response.success? redirect_to pipeline_schedules_path(@project) else redirect_to pipeline_schedules_path(@project), alert: _("Failed to change the owner") diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index db77127cb0a..10f58a9f479 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -5,7 +5,6 @@ class Projects::PipelinesController < Projects::ApplicationController include RedisTracking include ProductAnalyticsTracking include ProjectStatsRefreshConflictsGuard - include ZuoraCSP urgency :low, [ :index, :new, :builds, :show, :failures, :create, @@ -220,6 +219,8 @@ class Projects::PipelinesController < Projects::ApplicationController def config_variables respond_to do |format| format.json do + # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065 result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha]) result.nil? ? head(:no_content) : render(json: result) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cd9c6efb106..543ffa637e1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -47,7 +47,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def membershipable_members - project.members + query_members_via_project_namespace_enabled? ? project.namespace_members : project.members end def plain_source_type @@ -65,6 +65,18 @@ class Projects::ProjectMembersController < Projects::ApplicationController def root_params_key :project_member end + + def members_and_requesters + query_members_via_project_namespace_enabled? ? project.namespace_members_and_requesters : super + end + + def requesters + query_members_via_project_namespace_enabled? ? project.namespace_requesters : super + end + + def query_members_via_project_namespace_enabled? + Feature.enabled?(:project_members_index_by_project_namespace, project) + end end Projects::ProjectMembersController.prepend_mod_with('Projects::ProjectMembersController') diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index 924de0ee7ea..895a9a00624 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -10,7 +10,7 @@ class Projects::RawController < Projects::ApplicationController prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) } - before_action :set_ref_and_path + before_action :assign_ref_vars before_action :require_non_empty_project before_action :authorize_read_code! before_action :check_show_rate_limit!, only: [:show], unless: :external_storage_request? diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 8ac6d872aae..4c2bd2a9d42 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -24,17 +24,9 @@ class Projects::RefsController < Projects::ApplicationController when "blob" project_blob_path(@project, @id) when "graph" - if Feature.enabled?(:use_ref_type_parameter, @project) - project_network_path(@project, @id, ref_type: ref_type) - else - project_network_path(@project, @id, @options) - end + project_network_path(@project, @id, ref_type: ref_type) when "graphs" - if Feature.enabled?(:use_ref_type_parameter, @project) - project_graph_path(@project, @id, ref_type: ref_type) - else - project_graph_path(@project, @id) - end + project_graph_path(@project, @id, ref_type: ref_type) when "find_file" project_find_file_path(@project, @id) when "graphs_commits" @@ -42,11 +34,7 @@ class Projects::RefsController < Projects::ApplicationController when "badges" project_settings_ci_cd_path(@project, ref: @id) else - if Feature.enabled?(:use_ref_type_parameter, @project) - project_commits_path(@project, @id, ref_type: ref_type) - else - project_commits_path(@project, @id) - end + project_commits_path(@project, @id, ref_type: ref_type) end redirect_to new_path diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index da414d068a6..7c569df7267 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -9,6 +9,10 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_create_release!, only: :new before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink + prepend_before_action(only: [:downloads]) do + authenticate_sessionless_user!(:download) + end + feature_category :release_orchestration urgency :low diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 33ce37ef4fb..1cd4c5b6137 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -47,8 +47,13 @@ class Projects::RepositoriesController < Projects::ApplicationController end def set_cache_headers - expires_in cache_max_age(archive_metadata['CommitId']), public: Guest.can?(:download_code, project) - fresh_when(etag: archive_metadata['ArchivePath']) + commit_id = archive_metadata['CommitId'] + + expires_in(cache_max_age(commit_id), + public: Guest.can?(:download_code, project), must_revalidate: true, stale_if_error: 5.minutes, + stale_while_revalidate: 1.minute, 's-maxage': 1.minute) + + fresh_when(strong_etag: [commit_id, archive_metadata['ArchivePath']]) end def archive_not_modified? diff --git a/app/controllers/projects/service_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb index cfc322b47e7..8c16b6b230e 100644 --- a/app/controllers/projects/service_ping_controller.rb +++ b/app/controllers/projects/service_ping_controller.rb @@ -5,24 +5,6 @@ class Projects::ServicePingController < Projects::ApplicationController feature_category :web_ide - def web_ide_clientside_preview - return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? - - Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_count - - head(:ok) - end - - def web_ide_clientside_preview_success - return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? - - Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_success_count - Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user, - project: project) - - head(:ok) - end - def web_ide_pipelines_count Gitlab::UsageDataCounters::WebIdeCounter.increment_pipelines_count diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index f8133c5836d..4ca665679c0 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -4,7 +4,6 @@ module Projects module Settings class CiCdController < Projects::ApplicationController include RunnerSetupScripts - include ZuoraCSP NUMBER_OF_RUNNERS_PER_PAGE = 20 @@ -13,6 +12,10 @@ module Projects before_action :check_builds_available! before_action :define_variables + before_action do + push_frontend_feature_flag(:ci_inbound_job_token_scope, @project) + end + helper_method :highlight_badge feature_category :continuous_integration diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 6d099aa8b3d..74d730db026 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -88,25 +88,20 @@ module Projects # rubocop: disable CodeReuse/ActiveRecord def define_protected_refs - @protected_branches = @project.protected_branches.order(:name).page(params[:page]) + @protected_branches = fetch_protected_branches(@project) @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new @protected_tag = @project.protected_tags.new @protected_tags_count = @protected_tags.reduce(0) { |sum, tag| sum + tag.matching(@project.repository.tag_names).size } - - if Feature.enabled?(:group_protected_branches) - @protected_group_branches = if @project.root_namespace.is_a?(Group) - @project.root_namespace.protected_branches.order(:name).page(params[:page]) - else - [] - end - end - load_gon_index end # rubocop: enable CodeReuse/ActiveRecord + def fetch_protected_branches(project) + project.protected_branches.sorted_by_name.page(params[:page]) + end + def remote_mirror @remote_mirror = project.remote_mirrors.first_or_initialize end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ee2c268ff33..71ad747b6b1 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -45,7 +45,6 @@ class ProjectsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) - push_frontend_feature_flag(:package_registry_access_level) end layout :determine_layout @@ -223,7 +222,22 @@ class ProjectsController < Projects::ApplicationController end def housekeeping - ::Repositories::HousekeepingService.new(@project, :gc).execute + task = if params[:prune].present? + :prune + else + :eager + end + + ::Repositories::HousekeepingService.new(@project, task).execute do + ::Gitlab::Audit::Auditor.audit( + name: 'manually_trigger_housekeeping', + author: current_user, + scope: @project, + target: @project, + message: "Housekeeping task: #{task}", + created_at: DateTime.current + ) + end redirect_to( project_path(@project), diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0800d635d92..ed0e019d02b 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -115,6 +115,7 @@ class RegistrationsController < Devise::RegistrationsController def after_request_hook(user) return unless user.persisted? + track_creation user: user Gitlab::Tracking.event(self.class.name, 'successfully_submitted_form', user: user) end @@ -145,6 +146,11 @@ class RegistrationsController < Devise::RegistrationsController users_sign_up_welcome_path(glm_tracking_params) end + def track_creation(user:) + label = user_invited? ? 'invited' : 'signup' + Gitlab::Tracking.event(self.class.name, 'create_user', label: label, user: user) + end + def ensure_destroy_prerequisites_met if current_user.solo_owned_groups.present? redirect_to profile_account_path, @@ -252,9 +258,15 @@ class RegistrationsController < Devise::RegistrationsController end end - def after_pending_invitations_hook - member_id = session.delete(:originating_member_id) + def user_invited? + !!member_id + end + def member_id + @member_id ||= session.delete(:originating_member_id) + end + + def after_pending_invitations_hook return unless member_id # if invited multiple times to different projects, only the email clicked will be counted as accepted diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 144ec4c0de9..bd3461d8331 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -116,7 +116,7 @@ module Repositories end def log_user_activity - Users::ActivityService.new(user).execute + Users::ActivityService.new(author: user, project: project, namespace: project&.namespace).execute end end end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 97b6671a82a..71da9bdcbc4 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -41,8 +41,10 @@ class RootController < Dashboard::ProjectsController when 'stars' flash.keep redirect_to(starred_dashboard_projects_path) - when 'project_activity' + when 'your_activity' redirect_to(activity_dashboard_path) + when 'project_activity' + redirect_to(activity_dashboard_path(filter: 'projects')) when 'starred_project_activity' redirect_to(activity_dashboard_path(filter: 'starred')) when 'followed_user_activity' diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 38c773fa31d..1ca34dee3d6 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -24,6 +24,8 @@ class SearchController < ApplicationController before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch skip_before_action :authenticate_user! + skip_before_action :default_cache_headers, only: :count + requires_cross_project_access if: -> do search_term_present = params[:search].present? || params[:term].present? search_term_present && !params[:project_id].present? @@ -31,7 +33,7 @@ class SearchController < ApplicationController before_action :check_search_rate_limit!, only: search_rate_limited_endpoints before_action only: :show do - push_frontend_feature_flag(:search_page_vertical_nav, current_user) + push_frontend_feature_flag(:search_blobs_language_aggregation, current_user) end before_action only: :show do update_scope_for_code_search @@ -65,6 +67,8 @@ class SearchController < ApplicationController @search_highlight = @search_service_presenter.search_highlight end + return if @search_results.respond_to?(:failed?) && @search_results.failed? + Gitlab::Metrics::GlobalSearchSlis.record_apdex( elapsed: @global_search_duration_s, search_type: @search_type, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 699dcf1adac..b6aba04c877 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -289,7 +289,7 @@ class SessionsController < Devise::SessionsController def log_user_activity(user) login_counter.increment - Users::ActivityService.new(user).execute + Users::ActivityService.new(author: user).execute end def load_recaptcha diff --git a/app/controllers/users/unsubscribes_controller.rb b/app/controllers/users/unsubscribes_controller.rb index 9ac07083cd5..73388b16006 100644 --- a/app/controllers/users/unsubscribes_controller.rb +++ b/app/controllers/users/unsubscribes_controller.rb @@ -4,7 +4,7 @@ module Users class UnsubscribesController < ApplicationController skip_before_action :authenticate_user! - feature_category :users + feature_category :user_profile def show @user = get_user diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f23e513e419..9546f71cd37 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -26,8 +26,11 @@ class UsersController < ApplicationController before_action only: [:exists] do check_rate_limit!(:username_exists, scope: request.ip) end + before_action only: [:show] do + push_frontend_feature_flag(:profile_tabs_vue, current_user) + end - feature_category :users, [:show, :activity, :groups, :projects, :contributed, :starred, + feature_category :user_profile, [:show, :activity, :groups, :projects, :contributed, :starred, :followers, :following, :calendar, :calendar_activities, :exists, :activity, :follow, :unfollow, :ssh_keys] diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb index 4decd7f1bee..03b9c49de96 100644 --- a/app/controllers/whats_new_controller.rb +++ b/app/controllers/whats_new_controller.rb @@ -8,7 +8,7 @@ class WhatsNewController < ApplicationController before_action :check_whats_new_enabled before_action :check_valid_page_param, :set_pagination_headers - feature_category :navigation + feature_category :onboarding urgency :low def index diff --git a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb b/app/experiments/video_tutorials_continuous_onboarding_experiment.rb deleted file mode 100644 index 2c5790f83d1..00000000000 --- a/app/experiments/video_tutorials_continuous_onboarding_experiment.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class VideoTutorialsContinuousOnboardingExperiment < ApplicationExperiment - control {} - candidate {} -end diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb index 65e1934a39f..7b98df68f29 100644 --- a/app/finders/access_requests_finder.rb +++ b/app/finders/access_requests_finder.rb @@ -9,8 +9,8 @@ class AccessRequestsFinder @source = source end - def execute(*args) - execute!(*args) + def execute(...) + execute!(...) rescue Gitlab::Access::AccessDeniedError [] end diff --git a/app/finders/bulk_imports/entities_finder.rb b/app/finders/bulk_imports/entities_finder.rb index 78446f104d0..2b6507cc7b5 100644 --- a/app/finders/bulk_imports/entities_finder.rb +++ b/app/finders/bulk_imports/entities_finder.rb @@ -12,9 +12,9 @@ module BulkImports ::BulkImports::Entity .preload(:failures) # rubocop: disable CodeReuse/ActiveRecord .by_user_id(user.id) - .then(&method(:filter_by_bulk_import)) - .then(&method(:filter_by_status)) - .then(&method(:sort)) + .then { |entities| filter_by_bulk_import(entities) } + .then { |entities| filter_by_status(entities) } + .then { |entities| sort(entities) } end private diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index 1627e41a02d..8620dff6973 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -75,7 +75,7 @@ module Ci def filter_by_with_artifacts(builds) if params[:with_artifacts] - builds.with_erasable_artifacts + builds.with_any_artifacts else builds end diff --git a/app/finders/ci/pipeline_schedules_finder.rb b/app/finders/ci/pipeline_schedules_finder.rb index 2544c8c3254..e5ee65a02c8 100644 --- a/app/finders/ci/pipeline_schedules_finder.rb +++ b/app/finders/ci/pipeline_schedules_finder.rb @@ -9,20 +9,39 @@ module Ci @pipeline_schedules = project.pipeline_schedules end - # rubocop: disable CodeReuse/ActiveRecord - def execute(scope: nil) - scoped_schedules = - case scope - when 'active' - pipeline_schedules.active - when 'inactive' - pipeline_schedules.inactive - else - pipeline_schedules - end - - scoped_schedules.order(id: :desc) + def execute(scope: nil, ids: nil) + items = pipeline_schedules + items = by_ids(items, ids) + items = by_scope(items, scope) + + sort_items(items) + end + + private + + def by_ids(items, ids) + if ids.present? + items.id_in(ids) + else + items + end + end + + def by_scope(items, scope) + case scope + when 'active' + items.active + when 'inactive' + items.inactive + else + items + end + end + + # rubocop:disable CodeReuse/ActiveRecord + def sort_items(items) + items.order(id: :desc) end - # rubocop: enable CodeReuse/ActiveRecord + # rubocop:enable CodeReuse/ActiveRecord end end diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb index ce6001a01d7..a9124e5c56c 100644 --- a/app/finders/concerns/finder_methods.rb +++ b/app/finders/concerns/finder_methods.rb @@ -2,20 +2,20 @@ module FinderMethods # rubocop: disable CodeReuse/ActiveRecord - def find_by!(*args) - raise_not_found_unless_authorized execute.reorder(nil).find_by!(*args) + def find_by!(...) + raise_not_found_unless_authorized execute.reorder(nil).find_by!(...) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def find_by(*args) - if_authorized execute.reorder(nil).find_by(*args) + def find_by(...) + if_authorized execute.reorder(nil).find_by(...) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def find(*args) - raise_not_found_unless_authorized execute.reorder(nil).find(*args) + def find(...) + raise_not_found_unless_authorized execute.reorder(nil).find(...) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/concerns/finder_with_group_hierarchy.rb b/app/finders/concerns/finder_with_group_hierarchy.rb index 86ccac19b63..4ced544ba2c 100644 --- a/app/finders/concerns/finder_with_group_hierarchy.rb +++ b/app/finders/concerns/finder_with_group_hierarchy.rb @@ -27,6 +27,12 @@ module FinderWithGroupHierarchy # we can preset root group for all of them to optimize permission checks Group.preset_root_ancestor_for(groups) + # Preloading the max access level for the given groups to avoid N+1 queries + # during the access check. + if !skip_authorization && current_user && Feature.enabled?(:preload_max_access_levels_for_labels_finder, group) + Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute + end + groups_user_can_read_items(groups).map(&:id) end end diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb index e129fde3748..c1769ea28f9 100644 --- a/app/finders/fork_targets_finder.rb +++ b/app/finders/fork_targets_finder.rb @@ -7,8 +7,6 @@ class ForkTargetsFinder end def execute(options = {}) - return previous_execute(options) unless Feature.enabled?(:searchable_fork_targets) - items = fork_targets(options) by_search(items, options) @@ -31,14 +29,6 @@ class ForkTargetsFinder user.forkable_namespaces.sort_by_type end end - - # rubocop: disable CodeReuse/ActiveRecord - def previous_execute(options = {}) - return ::Namespace.where(id: user.forkable_namespaces).sort_by_type unless options[:only_groups] - - ::Group.where(id: user.manageable_groups(include_groups_with_developer_maintainer_access: true)) - end - # rubocop: enable CodeReuse/ActiveRecord end ForkTargetsFinder.prepend_mod_with('ForkTargetsFinder') diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb new file mode 100644 index 00000000000..c4963fcc352 --- /dev/null +++ b/app/finders/groups/accepting_project_shares_finder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# AcceptingProjectSharesFinder +# +# Used to filter Shareable Groups by a set of params +# +# Arguments: +# current_user - which user is requesting groups +# params: +# search: string +module Groups + class AcceptingProjectSharesFinder < Base + def initialize(current_user, project_to_be_shared, params = {}) + @current_user = current_user + @params = params + @project_to_be_shared = project_to_be_shared + end + + def execute + return Group.none unless can_share_project? + + groups = if has_admin_access? + Group.all + else + groups_with_guest_access_plus + end + + groups = groups.search(params[:search]) if params[:search].present? + + sort(groups).with_route + end + + private + + attr_reader :current_user, :project_to_be_shared, :params + + def has_admin_access? + current_user&.can_read_all_resources? + end + + # rubocop: disable CodeReuse/Finder + def groups_with_guest_access_plus + GroupsFinder.new(current_user, min_access_level: Gitlab::Access::GUEST).execute + end + # rubocop: enable CodeReuse/Finder + + def can_share_project? + Ability.allowed?(current_user, :admin_project, project_to_be_shared) && + project_to_be_shared.allowed_to_share_with_group? + end + end +end diff --git a/app/finders/groups/base.rb b/app/finders/groups/base.rb index d7f56b1a7a6..9d2f9f60a63 100644 --- a/app/finders/groups/base.rb +++ b/app/finders/groups/base.rb @@ -5,7 +5,7 @@ module Groups private def sort(items) - items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord + items.reorder(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord end def by_search(items) diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 61d79885001..24003111f88 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -13,6 +13,7 @@ # min_access_level: integer # search: string # exclude_group_ids: array of integers +# filter_group_ids: array of integers - only include groups from the specified list of ids # 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) @@ -34,6 +35,7 @@ class GroupsFinder < UnionFinder items = all_groups.map do |item| item = by_parent(item) item = by_custom_attributes(item) + item = filter_group_ids(item) item = exclude_group_ids(item) item = by_search(item) @@ -90,6 +92,12 @@ class GroupsFinder < UnionFinder groups.id_not_in(params[:exclude_group_ids]) end + def filter_group_ids(groups) + return groups unless params[:filter_group_ids] + + groups.id_in(params[:filter_group_ids]) + 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 13b7137da48..159836062cb 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -337,7 +337,6 @@ class IssuableFinder def by_search(items) return items unless search return items if items.is_a?(ActiveRecord::NullRelation) - return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil? return filter_by_full_text_search(items) if use_full_text_search? diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index de2a4ce3518..1641219a14c 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -22,8 +22,8 @@ class MembersFinder filter_members(members) end - def can?(*args) - Ability.allowed?(*args) + def can?(...) + Ability.allowed?(...) end private diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb index fdb3bac8935..ea1aa6d2e9e 100644 --- a/app/finders/merge_request_target_project_finder.rb +++ b/app/finders/merge_request_target_project_finder.rb @@ -11,9 +11,10 @@ class MergeRequestTargetProjectFinder @project_feature = project_feature end - def execute(include_routes: false) + def execute(search: nil, include_routes: false) if source_project.fork_network - include_routes ? projects.inc_routes : projects + items = include_routes ? projects.inc_routes : projects + by_search(items, search) else Project.id_in(source_project.id) end @@ -31,4 +32,10 @@ class MergeRequestTargetProjectFinder .non_archived .with_feature_available_for_user(project_feature, current_user) end + + # rubocop: disable CodeReuse/ActiveRecord + def by_search(items, search) + items.joins(:route).fuzzy_search(search, [Route.arel_table[:path], Route.arel_table[:name], :description]) + end + # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb index 589a9696ea6..c96f9527dd8 100644 --- a/app/finders/namespaces/projects_finder.rb +++ b/app/finders/namespaces/projects_finder.rb @@ -12,6 +12,8 @@ # search: string # include_subgroups: boolean # ids: int[] +# with_issues_enabled: boolean +# with_merge_requests_enabled: boolean # module Namespaces class ProjectsFinder @@ -30,7 +32,9 @@ module Namespaces namespace.projects.with_route end - filter_projects(collection) + collection = filter_projects(collection) + + sort(collection) end private @@ -39,7 +43,8 @@ module Namespaces def filter_projects(collection) collection = by_ids(collection) - by_similarity(collection) + collection = by_similarity(collection) + by_feature_availability(collection) end def by_ids(items) @@ -51,11 +56,26 @@ module Namespaces def by_similarity(items) return items unless params[:search].present? - if params[:sort] == :similarity - items = items.sorted_by_similarity_desc(params[:search], include_in_select: true) + items.merge(Project.search(params[:search])) + end + + def by_feature_availability(items) + items = items.with_issues_available_for_user(current_user) if params[:with_issues_enabled].present? + if params[:with_merge_requests_enabled].present? + items = items.with_merge_requests_available_for_user(current_user) end - items.merge(Project.search(params[:search])) + items + end + + def sort(items) + return items.projects_order_id_desc unless params[:sort] + + if params[:sort] == :similarity && params[:search].present? + return items.sorted_by_similarity_desc(params[:search], include_in_select: true) + end + + items.sort_by_attribute(params[:sort]) end end end diff --git a/app/finders/packages/tags_finder.rb b/app/finders/packages/tags_finder.rb index 020b3d8072a..dd104ea6f91 100644 --- a/app/finders/packages/tags_finder.rb +++ b/app/finders/packages/tags_finder.rb @@ -15,7 +15,7 @@ class Packages::TagsFinder .with_name(package_name) packages = packages.with_package_type(package_type) if package_type.present? - Packages::Tag.for_packages(packages) + Packages::Tag.for_package_ids(packages.select(:id)) end private diff --git a/app/finders/projects/ml/candidate_finder.rb b/app/finders/projects/ml/candidate_finder.rb new file mode 100644 index 00000000000..a543abc2c99 --- /dev/null +++ b/app/finders/projects/ml/candidate_finder.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Projects + module Ml + class CandidateFinder + VALID_ORDER_BY_TYPES = %w[column metric].freeze + VALID_ORDER_BY_COLUMNS = %w[name created_at id].freeze + VALID_SORT = %w[asc desc].freeze + + def initialize(experiment, params = {}) + @experiment = experiment + @params = params + end + + def execute + candidates = @experiment.candidates.including_relationships + + candidates = by_name(candidates) + order(candidates) + end + + private + + def by_name(candidates) + return candidates unless @params[:name].present? + + candidates.by_name(@params[:name]) + end + + def order(candidates) + return candidates.order_by_metric(metric_order_by, sort) if order_by_metric? + + candidates.order_by("#{column_order_by}_#{sort}").with_order_id_desc + end + + def order_by_metric? + order_by_type == 'metric' + end + + def order_by_type + valid_or_default(@params[:order_by_type], VALID_ORDER_BY_TYPES, 'column') + end + + def column_order_by + valid_or_default(@params[:order_by], VALID_ORDER_BY_COLUMNS, 'created_at') + end + + def metric_order_by + @params[:order_by] || '' + end + + def sort + valid_or_default(@params[:sort]&.downcase, VALID_SORT, 'desc') + end + + def valid_or_default(value, valid_values, default) + return value if valid_values.include?(value) + + default + end + end + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 1afd5adeada..401bc473216 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -56,11 +56,7 @@ class ProjectsFinder < UnionFinder collection = Project.wrap_with_cte(collection) if use_cte collection = filter_projects(collection) - if params[:sort] == 'similarity' && params[:search] - collection.sorted_by_similarity_desc(params[:search]) - else - sort(collection) - end + sort(collection) end private @@ -90,6 +86,7 @@ class ProjectsFinder < UnionFinder collection = by_last_activity_after(collection) collection = by_last_activity_before(collection) collection = by_language(collection) + collection = by_feature_availability(collection) by_repository_storage(collection) end @@ -239,7 +236,7 @@ class ProjectsFinder < UnionFinder end def by_language(items) - if Feature.enabled?(:project_language_search, current_user) && params[:language].present? + if params[:language].present? items.with_programming_language_id(params[:language]) else items @@ -247,11 +244,13 @@ class ProjectsFinder < UnionFinder end def sort(items) - if params[:sort].present? - items.sort_by_attribute(params[:sort]) - else - items.projects_order_id_desc + return items.projects_order_id_desc unless params[:sort] + + if params[:sort] == 'similarity' && params[:search].present? + return items.sorted_by_similarity_desc(params[:search], include_in_select: true) end + + items.sort_by_attribute(params[:sort]) end def by_archived(projects) @@ -270,6 +269,12 @@ class ProjectsFinder < UnionFinder end end + def by_feature_availability(items) + items = items.with_issues_available_for_user(current_user) if params[:with_issues_enabled] + items = items.with_merge_requests_available_for_user(current_user) if params[:with_merge_requests_enabled] + items + end + def finder_params return {} unless min_access_level? diff --git a/app/finders/protected_branches_finder.rb b/app/finders/protected_branches_finder.rb index a452a1f993b..dfc9a64737d 100644 --- a/app/finders/protected_branches_finder.rb +++ b/app/finders/protected_branches_finder.rb @@ -11,15 +11,21 @@ class ProtectedBranchesFinder LIMIT = 100 - attr_accessor :project, :params + attr_accessor :project_or_group, :params - def initialize(project, params = {}) - @project = project + def initialize(project_or_group, params = {}) + @project_or_group = project_or_group @params = params end def execute - protected_branches = project.limited_protected_branches(LIMIT) + protected_branches = if project_or_group.is_a?(Group) + project_or_group.protected_branches + else + project_or_group.all_protected_branches + end + + protected_branches = protected_branches.limit(LIMIT) by_name(protected_branches) end diff --git a/app/finders/security/jobs_finder.rb b/app/finders/security/jobs_finder.rb index 6c2090e0509..ef5b63ccd1d 100644 --- a/app/finders/security/jobs_finder.rb +++ b/app/finders/security/jobs_finder.rb @@ -20,7 +20,7 @@ module Security end def initialize(pipeline:, job_types: []) - if self.instance_of?(Security::JobsFinder) + if instance_of?(Security::JobsFinder) raise NotImplementedError, 'This is an abstract class, please instantiate its descendants' end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 0bf31ea33dd..2bb233e1906 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -24,7 +24,7 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w[Issue WorkItem MergeRequest DesignManagement::Design AlertManagement::Alert]).freeze + TODO_TYPES = Set.new(%w[Issue WorkItem MergeRequest DesignManagement::Design AlertManagement::Alert Namespace Project]).freeze attr_accessor :current_user, :params diff --git a/app/finders/work_items/work_items_finder.rb b/app/finders/work_items/work_items_finder.rb index 62cca06bf5e..07010adcf0d 100644 --- a/app/finders/work_items/work_items_finder.rb +++ b/app/finders/work_items/work_items_finder.rb @@ -19,7 +19,7 @@ module WorkItems end def by_widgets(items) - WorkItems::Type.available_widgets.each do |widget_class| + WorkItems::WidgetDefinition.available_widgets.each do |widget_class| widget_filter = widget_filter_for(widget_class) next unless widget_filter diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 7f83b62a2ff..89656f1e018 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -29,27 +29,33 @@ module GraphqlTriggers GitlabSchema.subscriptions.trigger('issuableMilestoneUpdated', { issuable_id: issuable.to_gid }, issuable) end + def self.work_item_note_created(work_item_gid, note_data) + GitlabSchema.subscriptions.trigger('workItemNoteCreated', { noteable_id: work_item_gid }, note_data) + end + + def self.work_item_note_deleted(work_item_gid, note_data) + GitlabSchema.subscriptions.trigger('workItemNoteDeleted', { noteable_id: work_item_gid }, note_data) + end + + def self.work_item_note_updated(work_item_gid, note_data) + GitlabSchema.subscriptions.trigger('workItemNoteUpdated', { noteable_id: work_item_gid }, note_data) + end + def self.merge_request_reviewers_updated(merge_request) GitlabSchema.subscriptions.trigger( - 'mergeRequestReviewersUpdated', - { issuable_id: merge_request.to_gid }, - merge_request + 'mergeRequestReviewersUpdated', { issuable_id: merge_request.to_gid }, merge_request ) end def self.merge_request_merge_status_updated(merge_request) GitlabSchema.subscriptions.trigger( - 'mergeRequestMergeStatusUpdated', - { issuable_id: merge_request.to_gid }, - merge_request + 'mergeRequestMergeStatusUpdated', { issuable_id: merge_request.to_gid }, merge_request ) end def self.merge_request_approval_state_updated(merge_request) GitlabSchema.subscriptions.trigger( - 'mergeRequestApprovalStateUpdated', - { issuable_id: merge_request.to_gid }, - merge_request + 'mergeRequestApprovalStateUpdated', { issuable_id: merge_request.to_gid }, merge_request ) end end diff --git a/app/graphql/mutations/achievements/create.rb b/app/graphql/mutations/achievements/create.rb index 6cfe6c0e643..310a653c705 100644 --- a/app/graphql/mutations/achievements/create.rb +++ b/app/graphql/mutations/achievements/create.rb @@ -28,10 +28,6 @@ module Mutations required: false, description: 'Description of or notes for the achievement.' - argument :revokeable, GraphQL::Types::Boolean, - required: true, - description: 'Revokeability for the achievement.' - authorize :admin_achievement def resolve(args) diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb index e16c08cb116..6f0f87b47a1 100644 --- a/app/graphql/mutations/ci/job_token_scope/add_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb @@ -18,18 +18,23 @@ module Mutations required: true, description: 'Project to be added to the CI job token scope.' + argument :direction, + ::Types::Ci::JobTokenScope::DirectionEnum, + required: false, + description: 'Direction of access, which defaults to outbound.' + field :ci_job_token_scope, - Types::Ci::JobTokenScopeType, - null: true, - description: "CI job token's scope of access." + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's access scope." - def resolve(project_path:, target_project_path:) + def resolve(project_path:, target_project_path:, direction: :outbound) project = authorized_find!(project_path) target_project = Project.find_by_full_path(target_project_path) result = ::Ci::JobTokenScope::AddProjectService .new(project, current_user) - .execute(target_project) + .execute(target_project, direction: direction) if result.success? { diff --git a/app/graphql/mutations/ci/job_token_scope/remove_project.rb b/app/graphql/mutations/ci/job_token_scope/remove_project.rb index f503b4f2f7a..20e991f5388 100644 --- a/app/graphql/mutations/ci/job_token_scope/remove_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/remove_project.rb @@ -18,18 +18,23 @@ module Mutations required: true, description: 'Project to be removed from the CI job token scope.' + argument :direction, + ::Types::Ci::JobTokenScope::DirectionEnum, + required: false, + description: 'Direction of access, which defaults to outbound.' + field :ci_job_token_scope, - Types::Ci::JobTokenScopeType, - null: true, - description: "CI job token's scope of access." + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's scope of access." - def resolve(project_path:, target_project_path:) + def resolve(project_path:, target_project_path:, direction: :outbound) project = authorized_find!(project_path) target_project = Project.find_by_full_path(target_project_path) result = ::Ci::JobTokenScope::RemoveProjectService .new(project, current_user) - .execute(target_project) + .execute(target_project, direction) if result.success? { diff --git a/app/graphql/mutations/ci/pipeline_schedule/update.rb b/app/graphql/mutations/ci/pipeline_schedule/update.rb new file mode 100644 index 00000000000..a0b5e793ecb --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/update.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class Update < Base + graphql_name 'PipelineScheduleUpdate' + + authorize :update_pipeline_schedule + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of the pipeline schedule.' + + argument :cron, GraphQL::Types::String, + required: false, + description: 'Cron expression of the pipeline schedule.' + + argument :cron_timezone, GraphQL::Types::String, + required: false, + description: + <<-STR + Cron time zone supported by ActiveSupport::TimeZone. + For example: "Pacific Time (US & Canada)" (default: "UTC"). + STR + + argument :ref, GraphQL::Types::String, + required: false, + description: 'Ref of the pipeline schedule.' + + argument :active, GraphQL::Types::Boolean, + required: false, + description: 'Indicates if the pipeline schedule should be active or not.' + + argument :variables, [Mutations::Ci::PipelineSchedule::VariableInputType], + required: false, + description: 'Variables for the pipeline schedule.' + + field :pipeline_schedule, + Types::Ci::PipelineScheduleType, + description: 'Updated pipeline schedule.' + + def resolve(id:, variables: [], **pipeline_schedule_attrs) + schedule = authorized_find!(id: id) + + params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) + + service_response = ::Ci::PipelineSchedules::UpdateService + .new(schedule, current_user, params) + .execute + + { + pipeline_schedule: schedule, + errors: service_response.errors + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb index 934d62e92cf..d214aa46cfc 100644 --- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb @@ -27,6 +27,10 @@ module Mutations description: 'Indicates CI/CD job tokens generated in other projects ' \ 'have restricted access to this project.' + argument :opt_in_jwt, GraphQL::Types::Boolean, + required: false, + description: 'When disabled, the JSON Web Token is always available in all jobs in the pipeline.' + field :ci_cd_settings, Types::Ci::CiCdSettingType, null: false, diff --git a/app/graphql/mutations/concerns/mutations/assignable.rb b/app/graphql/mutations/concerns/mutations/assignable.rb index 86f37207a2d..189c926fcc4 100644 --- a/app/graphql/mutations/concerns/mutations/assignable.rb +++ b/app/graphql/mutations/concerns/mutations/assignable.rb @@ -33,7 +33,7 @@ module Mutations def assign!(resource, users, operation_mode) update_service_class.new( - project: resource.project, + **update_service_class.constructor_container_arg(resource.project), current_user: current_user, params: { assignee_ids: assignee_ids(resource, users, operation_mode) } ).execute(resource) diff --git a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb index 508e1627032..3f32cd51ae7 100644 --- a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb +++ b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb @@ -7,7 +7,7 @@ module Mutations def extract_widget_params!(work_item_type, attributes) # Get the list of widgets for the work item's type to extract only the supported attributes - widget_keys = ::WorkItems::Type.available_widgets.map(&:api_symbol) + widget_keys = ::WorkItems::WidgetDefinition.available_widgets.map(&:api_symbol) widget_params = attributes.extract!(*widget_keys) not_supported_keys = widget_params.keys - work_item_type.widgets.map(&:api_symbol) diff --git a/app/graphql/mutations/issues/bulk_update.rb b/app/graphql/mutations/issues/bulk_update.rb new file mode 100644 index 00000000000..7f3d5f6ffb2 --- /dev/null +++ b/app/graphql/mutations/issues/bulk_update.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class BulkUpdate < BaseMutation + graphql_name 'IssuesBulkUpdate' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + MAX_ISSUES = 100 + + description 'Allows updating several properties for a set of issues. ' \ + 'Does nothing if the `bulk_update_issues_mutation` feature flag is disabled.' + + argument :parent_id, ::Types::GlobalIDType[::IssueParent], + required: true, + description: 'Global ID of the parent that the bulk update will be scoped to . ' \ + 'Example `IssueParentID` are `"gid://gitlab/Project/1"` and `"gid://gitlab/Group/1"`.' + + argument :ids, [::Types::GlobalIDType[::Issue]], + required: true, + description: 'Global ID array of the issues that will be updated. ' \ + "IDs that the user can\'t update will be ignored. A max of #{MAX_ISSUES} can be provided." + + argument :assignee_ids, [::Types::GlobalIDType[::User]], + required: false, + description: 'Global ID array of the users that will be assigned to the given issues. ' \ + 'Existing assignees will be replaced with the ones on this list.' + + argument :milestone_id, ::Types::GlobalIDType[::Milestone], + required: false, + description: 'Global ID of the milestone that will be assigned to the issues.' + + field :updated_issue_count, GraphQL::Types::Int, + null: true, + description: 'Number of issues that were successfully updated.' + + def ready?(**args) + if Feature.disabled?(:bulk_update_issues_mutation) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`bulk_update_issues_mutation` feature flag is disabled.' + end + + if args[:ids].size > MAX_ISSUES + raise Gitlab::Graphql::Errors::ArgumentError, + format(_('No more than %{max_issues} issues can be updated at the same time'), max_issues: MAX_ISSUES) + end + + super + end + + def resolve(ids:, parent_id:, **attributes) + parent = find_parent!(parent_id) + + result = Issuable::BulkUpdateService.new( + parent, + current_user, + prepared_params(attributes, ids) + ).execute('issue') + + if result.success? + { updated_issue_count: result.payload[:count], errors: [] } + else + { errors: result.errors } + end + end + + private + + def find_parent!(parent_id) + parent = GitlabSchema.find_by_gid(parent_id).sync + raise_resource_not_available_error! unless current_user.can?("read_#{parent.to_ability_name}", parent) + + parent + end + + def prepared_params(attributes, ids) + prepared = { issuable_ids: model_ids_from(ids).uniq } + + global_id_arguments.each do |argument| + next unless attributes.key?(argument) + + prepared[argument] = model_ids_from(attributes[argument]) + end + + prepared.transform_keys(param_mappings) + end + + def param_mappings + {} + end + + def global_id_arguments + %i[assignee_ids milestone_id] + end + + def model_ids_from(attributes) + return if attributes.nil? + return attributes.map(&:model_id) if attributes.is_a?(Array) + + attributes.model_id + end + end + end +end + +Mutations::Issues::BulkUpdate.prepend_mod diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 0389a482822..0c1acdf316e 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -83,7 +83,7 @@ module Mutations params = build_create_issue_params(attributes.merge(author_id: current_user.id), project) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - result = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute + result = ::Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: spam_params).execute check_spam_action_response!(result[:issue]) if result[:issue] diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb index 63bc9dabbf9..ef3f70c78b9 100644 --- a/app/graphql/mutations/issues/move.rb +++ b/app/graphql/mutations/issues/move.rb @@ -18,7 +18,7 @@ module Mutations target_project = resolve_project(full_path: target_project_path).sync begin - moved_issue = ::Issues::MoveService.new(project: source_project, current_user: current_user).execute(issue, target_project) + moved_issue = ::Issues::MoveService.new(container: source_project, current_user: current_user).execute(issue, target_project) rescue ::Issues::MoveService::MoveError => e errors = e.message end diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb index b795d66c16f..08578881a13 100644 --- a/app/graphql/mutations/issues/set_confidential.rb +++ b/app/graphql/mutations/issues/set_confidential.rb @@ -19,7 +19,7 @@ module Mutations # spam_params so a check can be performed. spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - ::Issues::UpdateService.new(project: project, current_user: current_user, params: { confidential: confidential }, spam_params: spam_params) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: { confidential: confidential }, spam_params: spam_params) .execute(issue) check_spam_action_response!(issue) diff --git a/app/graphql/mutations/issues/set_due_date.rb b/app/graphql/mutations/issues/set_due_date.rb index 70b76da4fcb..e361d241083 100644 --- a/app/graphql/mutations/issues/set_due_date.rb +++ b/app/graphql/mutations/issues/set_due_date.rb @@ -14,7 +14,7 @@ module Mutations issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project: project, current_user: current_user, params: { due_date: due_date }) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: { due_date: due_date }) .execute(issue) { diff --git a/app/graphql/mutations/issues/set_escalation_status.rb b/app/graphql/mutations/issues/set_escalation_status.rb index 4f3fcb4886d..13286034ada 100644 --- a/app/graphql/mutations/issues/set_escalation_status.rb +++ b/app/graphql/mutations/issues/set_escalation_status.rb @@ -17,7 +17,7 @@ module Mutations check_feature_availability!(issue) ::Issues::UpdateService.new( - project: project, + container: project, current_user: current_user, params: { escalation_status: { status: status } } ).execute(issue) diff --git a/app/graphql/mutations/issues/set_locked.rb b/app/graphql/mutations/issues/set_locked.rb index 93b31350bbf..86ad129f4cb 100644 --- a/app/graphql/mutations/issues/set_locked.rb +++ b/app/graphql/mutations/issues/set_locked.rb @@ -13,7 +13,7 @@ module Mutations def resolve(project_path:, iid:, locked:) issue = authorized_find!(project_path: project_path, iid: iid) - ::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: { discussion_locked: locked }) + ::Issues::UpdateService.new(container: issue.project, current_user: current_user, params: { discussion_locked: locked }) .execute(issue) { diff --git a/app/graphql/mutations/issues/set_severity.rb b/app/graphql/mutations/issues/set_severity.rb index 4a24bfd18ef..68d7fb7d0c0 100644 --- a/app/graphql/mutations/issues/set_severity.rb +++ b/app/graphql/mutations/issues/set_severity.rb @@ -15,7 +15,7 @@ module Mutations issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project: project, current_user: current_user, params: { severity: severity }) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: { severity: severity }) .execute(issue) { diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 6cab1214d24..b5af048dc07 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -31,6 +31,10 @@ module Mutations description: 'Close or reopen an issue.', required: false + argument :time_estimate, GraphQL::Types::String, + required: false, + description: 'Estimated time to complete the issue, or `0` to remove the current estimate.' + def resolve(project_path:, iid:, **args) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project @@ -38,7 +42,7 @@ module Mutations args = parse_arguments(args) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) - ::Issues::UpdateService.new(project: project, current_user: current_user, params: args, spam_params: spam_params).execute(issue) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: args, spam_params: spam_params).execute(issue) { issue: issue, @@ -46,11 +50,15 @@ module Mutations } end - def ready?(label_ids: [], add_label_ids: [], remove_label_ids: [], **args) + def ready?(label_ids: [], add_label_ids: [], remove_label_ids: [], time_estimate: nil, **args) if label_ids.any? && (add_label_ids.any? || remove_label_ids.any?) raise Gitlab::Graphql::Errors::ArgumentError, 'labelIds is mutually exclusive with any of addLabelIds or removeLabelIds' end + if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil? + raise Gitlab::Graphql::Errors::ArgumentError, 'timeEstimate must be formatted correctly, for example `1h 30m`' + end + super end @@ -61,6 +69,10 @@ module Mutations args[:remove_label_ids] = parse_label_ids(args[:remove_label_ids]) args[:label_ids] = parse_label_ids(args[:label_ids]) + unless args[:time_estimate].nil? + args[:time_estimate] = Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) + end + args end diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb index bf40c12aec5..320aa423ce3 100644 --- a/app/graphql/mutations/merge_requests/set_milestone.rb +++ b/app/graphql/mutations/merge_requests/set_milestone.rb @@ -17,7 +17,7 @@ module Mutations merge_request = authorized_find!(project_path: project_path, iid: iid) project = merge_request.project - ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { milestone: milestone }) + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { milestone_id: milestone&.id }) .execute(merge_request) { diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb index 0f4923e15a1..da4db7342a3 100644 --- a/app/graphql/mutations/merge_requests/update.rb +++ b/app/graphql/mutations/merge_requests/update.rb @@ -24,12 +24,16 @@ module Mutations as: :state_event, description: 'Action to perform to change the state.' + argument :time_estimate, GraphQL::Types::String, + required: false, + description: 'Estimated time to complete the merge request, or `0` to remove the current estimate.' + def resolve(project_path:, iid:, **args) merge_request = authorized_find!(project_path: project_path, iid: iid) - attributes = args.compact + args = parse_arguments(args) ::MergeRequests::UpdateService - .new(project: merge_request.project, current_user: current_user, params: attributes) + .new(project: merge_request.project, current_user: current_user, params: args) .execute(merge_request) errors = errors_on_object(merge_request) @@ -39,6 +43,25 @@ module Mutations errors: errors } end + + def ready?(time_estimate: nil, **args) + if !time_estimate.nil? && Gitlab::TimeTrackingFormatter.parse(time_estimate, keep_zero: true).nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'timeEstimate must be formatted correctly, for example `1h 30m`' + end + + super + end + + private + + def parse_arguments(args) + unless args[:time_estimate].nil? + args[:time_estimate] = Gitlab::TimeTrackingFormatter.parse(args[:time_estimate], keep_zero: true) + end + + args.compact + end end end end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index a4efffb69c1..9f124de7ab2 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -48,7 +48,7 @@ module Mutations widget_params = extract_widget_params!(type, params) create_result = ::WorkItems::CreateService.new( - project: project, + container: project, current_user: current_user, params: params, spam_params: spam_params, diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb index 4b0067d40d4..ec0244fa65e 100644 --- a/app/graphql/mutations/work_items/delete.rb +++ b/app/graphql/mutations/work_items/delete.rb @@ -20,7 +20,7 @@ module Mutations work_item = authorized_find!(id: id) result = ::WorkItems::DeleteService.new( - project: work_item.project, + container: work_item.project, current_user: current_user ).execute(work_item) diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 04c63d8e876..db6af38d82e 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -22,8 +22,10 @@ module Mutations spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) widget_params = extract_widget_params!(work_item.work_item_type, attributes) + interpret_quick_actions!(work_item, current_user, widget_params, attributes) + update_result = ::WorkItems::UpdateService.new( - project: work_item.project, + container: work_item.project, current_user: current_user, params: attributes, widget_params: widget_params, @@ -43,6 +45,37 @@ module Mutations def find_object(id:) GitlabSchema.find_by_gid(id) end + + def interpret_quick_actions!(work_item, current_user, widget_params, attributes = {}) + return unless work_item.work_item_type.widgets.include?(::WorkItems::Widgets::Description) + + description_param = widget_params[::WorkItems::Widgets::Description.api_symbol] + return unless description_param + + original_description = description_param.fetch(:description, work_item.description) + + description, command_params = QuickActions::InterpretService + .new(work_item.project, current_user, {}) + .execute(original_description, work_item) + + description_param[:description] = description if description && description != original_description + + # Widgets have a set of quick action params that they must process. + # Map them to widget_params so they can be picked up by widget services. + work_item.work_item_type.widgets + .filter { |widget| widget.respond_to?(:quick_action_params) } + .each do |widget| + widget.quick_action_params + .filter { |param_name| command_params.key?(param_name) } + .each do |param_name| + widget_params[widget.api_symbol] ||= {} + widget_params[widget.api_symbol][param_name] = command_params.delete(param_name) + end + end + + # The command_params not processed by widgets (e.g. title) should be placed in 'attributes'. + attributes.merge!(command_params || {}) + end end end end diff --git a/app/graphql/mutations/work_items/update_task.rb b/app/graphql/mutations/work_items/update_task.rb index aeb4f1d0f06..8dcc4c325ea 100644 --- a/app/graphql/mutations/work_items/update_task.rb +++ b/app/graphql/mutations/work_items/update_task.rb @@ -32,7 +32,7 @@ module Mutations spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) ::WorkItems::UpdateService.new( - project: task.project, + container: task.project, current_user: current_user, params: task_data_hash.except(:id), spam_params: spam_params diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql index c8353c738a5..01c312c567f 100644 --- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql +++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql @@ -34,6 +34,7 @@ fragment LinkedPipelineData on Pipeline { __typename id name + retried } project { __typename diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb index d70acdf7ca0..03b12dbb12e 100644 --- a/app/graphql/resolvers/board_list_issues_resolver.rb +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -13,7 +13,7 @@ module Resolvers alias_method :list, :object def resolve(**args) - filters = item_filters(args[:filters]) + filters = item_filters(args[:filters], list.board.resource_parent) mutually_exclusive_milestone_args!(filters) filter_params = filters.merge(board_id: list.board.id, id: list.id) diff --git a/app/graphql/resolvers/board_list_resolver.rb b/app/graphql/resolvers/board_list_resolver.rb index d853846b674..f9d3541cd5f 100644 --- a/app/graphql/resolvers/board_list_resolver.rb +++ b/app/graphql/resolvers/board_list_resolver.rb @@ -19,9 +19,8 @@ module Resolvers description: 'Filters applied when getting issue metadata in the board list.' def resolve(id: nil, issue_filters: {}) - context.scoped_set!(:issue_filters, item_filters(issue_filters)) - Gitlab::Graphql::Lazy.with_value(find_list(id: id)) do |list| + context.scoped_set!(:issue_filters, item_filters(issue_filters, list&.board&.resource_parent)) list if authorized_resource?(list) end end diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb index 4dae3b4a9d1..5fc813f4acb 100644 --- a/app/graphql/resolvers/board_lists_resolver.rb +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -22,7 +22,7 @@ module Resolvers def resolve_with_lookahead(id: nil, issue_filters: {}) lists = board_lists(id) - context.scoped_set!(:issue_filters, item_filters(issue_filters)) + context.scoped_set!(:issue_filters, item_filters(issue_filters, board.resource_parent)) List.preload_preferences_for_user(lists, current_user) if load_preferences? diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb index b818be3f018..467a3525867 100644 --- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb @@ -30,6 +30,7 @@ module Resolvers previous_stage_jobs_or_needs: [:needs, :pipeline], artifacts: [:job_artifacts], pipeline: [:user], + project: [{ project: [:route, { namespace: [:route] }] }], detailed_status: [ :metadata, { pipeline: [:merge_request] }, diff --git a/app/graphql/resolvers/ci/variables_resolver.rb b/app/graphql/resolvers/ci/variables_resolver.rb new file mode 100644 index 00000000000..71d420120b3 --- /dev/null +++ b/app/graphql/resolvers/ci/variables_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class VariablesResolver < BaseResolver + type Types::Ci::InstanceVariableType.connection_type, null: true + + argument :sort, ::Types::Ci::VariableSortEnum, + required: false, + description: 'Sort order of results.' + + def resolve(**args) + if parent.is_a?(Group) || parent.is_a?(Project) + parent.variables.order_by(args[:sort]) + elsif current_user&.can_admin_all_resources? + ::Ci::InstanceVariable.order_by(args[:sort]) + end + end + + private + + def parent + object.respond_to?(:sync) ? object.sync : object + end + end + end +end diff --git a/app/graphql/resolvers/concerns/board_item_filterable.rb b/app/graphql/resolvers/concerns/board_item_filterable.rb index 035cdbbd282..dcd2f265573 100644 --- a/app/graphql/resolvers/concerns/board_item_filterable.rb +++ b/app/graphql/resolvers/concerns/board_item_filterable.rb @@ -5,7 +5,7 @@ module BoardItemFilterable private - def item_filters(args) + def item_filters(args, resource_parent) filters = args.to_h set_filter_values(filters) @@ -45,10 +45,6 @@ module BoardItemFilterable def rewrite_param_name(filters, old_name, new_name) filters[new_name] = filters.delete(old_name) if filters[old_name].present? end - - def resource_parent - respond_to?(:board) ? board.resource_parent : list.board.resource_parent - end end ::BoardItemFilterable.prepend_mod_with('Resolvers::BoardItemFilterable') diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb index 1268e74fd58..86dda5cb1cb 100644 --- a/app/graphql/resolvers/concerns/resolves_groups.rb +++ b/app/graphql/resolvers/concerns/resolves_groups.rb @@ -5,8 +5,8 @@ module ResolvesGroups extend ActiveSupport::Concern include LooksAhead - def resolve_with_lookahead(**args) - apply_lookahead(resolve_groups(**args)) + def resolve_with_lookahead(...) + apply_lookahead(resolve_groups(...)) end private diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb index cc1a13fdf29..ac5b7beb5ef 100644 --- a/app/graphql/resolvers/concerns/search_arguments.rb +++ b/app/graphql/resolvers/concerns/search_arguments.rb @@ -17,7 +17,6 @@ module SearchArguments def ready?(**args) validate_search_in_params!(args) - validate_anonymous_search_access!(args) validate_search_rate_limit!(args) super @@ -25,14 +24,6 @@ module SearchArguments private - def validate_anonymous_search_access!(args) - return unless args[:search].present? - return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops) - - raise ::Gitlab::Graphql::Errors::ArgumentError, - "User must be authenticated to include the `search` argument." - end - def validate_search_in_params!(args) return unless args[:in].present? && args[:search].blank? @@ -41,7 +32,7 @@ module SearchArguments end def validate_search_rate_limit!(args) - return if args[:search].blank? || context[:request].nil? || Feature.disabled?(:rate_limit_issuable_searches) + return if args[:search].blank? || context[:request].nil? if current_user.present? rate_limiter_key = :search_rate_limit diff --git a/app/graphql/resolvers/data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer_resolver.rb new file mode 100644 index 00000000000..1a240d2811f --- /dev/null +++ b/app/graphql/resolvers/data_transfer_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + class DataTransferResolver < BaseResolver + argument :from, Types::DateType, + description: 'Retain egress data for 1 year. Current month will increase dynamically as egress occurs.', + required: false + argument :to, Types::DateType, + description: 'End date for the data.', + required: false + + type ::Types::DataTransfer::BaseType, null: false + + def self.source + raise NotImplementedError + end + + def self.project + Class.new(self) do + type Types::DataTransfer::ProjectDataTransferType, null: false + + def self.source + "Project" + end + end + end + + def self.group + Class.new(self) do + type Types::DataTransfer::GroupDataTransferType, null: false + + def self.source + "Group" + end + end + end + + def resolve(**_args) + return unless Feature.enabled?(:data_transfer_monitoring) + + start_date = Date.new(2023, 0o1, 0o1) + date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } + + nodes = 0.upto(3).map do |i| + { + date: date_for_index.call(i), + repository_egress: 250_000, + artifacts_egress: 250_000, + packages_egress: 250_000, + registry_egress: 250_000 + } + end + + { egress_nodes: nodes } + end + end +end diff --git a/app/graphql/resolvers/group_releases_resolver.rb b/app/graphql/resolvers/group_releases_resolver.rb new file mode 100644 index 00000000000..115289e1fca --- /dev/null +++ b/app/graphql/resolvers/group_releases_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + class GroupReleasesResolver < BaseResolver + type Types::ReleaseType.connection_type, null: true + + argument :sort, Types::GroupReleaseSortEnum, + required: false, default_value: :released_at_desc, + description: 'Sort group releases by given criteria.' + + alias_method :group, :object + + # GroupReleasesFinder only supports sorting by `released_at` + SORT_TO_PARAMS_MAP = { + released_at_desc: { sort: 'desc' }, + released_at_asc: { sort: 'asc' } + }.freeze + + def resolve(sort:) + releases = Releases::GroupReleasesFinder.new( + group, + current_user, + SORT_TO_PARAMS_MAP[sort] + ).execute + # fix ordering problem with GroupReleasesFinder and keyset pagination + # See more on https://gitlab.com/gitlab-org/gitlab/-/issues/378160 + offset_pagination(releases) + end + end +end diff --git a/app/graphql/resolvers/groups_resolver.rb b/app/graphql/resolvers/groups_resolver.rb index 6cfdba240f0..902b5279364 100644 --- a/app/graphql/resolvers/groups_resolver.rb +++ b/app/graphql/resolvers/groups_resolver.rb @@ -4,31 +4,18 @@ module Resolvers class GroupsResolver < BaseResolver include ResolvesGroups - type Types::GroupType, null: true - - argument :include_parent_descendants, GraphQL::Types::Boolean, - required: false, - description: 'List of descendant groups of the parent group.', - default_value: true - - argument :owned, GraphQL::Types::Boolean, - required: false, - description: 'Limit result to groups owned by authenticated user.' + type "Types::GroupConnection", null: true argument :search, GraphQL::Types::String, required: false, description: 'Search query for group name or group full path.' - alias_method :parent, :object - private # rubocop: disable CodeReuse/ActiveRecord - def resolve_groups(args) - return Group.none unless parent.present? - + def resolve_groups(**args) GroupsFinder - .new(context[:current_user], args.merge(parent: parent)) + .new(context[:current_user], args) .execute .reorder(name: :asc) end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 24009bf7e18..bbf45efa33e 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -17,6 +17,7 @@ module Resolvers before_connection_authorization do |nodes, current_user| projects = nodes.map(&:project) ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:group), current_user).execute end def ready?(**args) @@ -28,8 +29,6 @@ module Resolvers end def resolve_with_lookahead(**args) - return unless Feature.enabled?(:root_level_issues_query) - issues = apply_lookahead( IssuesFinder.new(current_user, prepare_finder_params(args)).execute ) diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb index c3c61d31e8d..726e78f9971 100644 --- a/app/graphql/resolvers/namespace_projects_resolver.rb +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -8,9 +8,9 @@ module Resolvers description: 'Include also subgroup projects.' argument :search, GraphQL::Types::String, - required: false, - default_value: nil, - description: 'Search project with most similar names or paths.' + required: false, + default_value: nil, + description: 'Search project with most similar names or paths.' argument :sort, Types::Projects::NamespaceProjectSortEnum, required: false, @@ -22,6 +22,14 @@ module Resolvers default_value: nil, description: 'Filter projects by IDs.' + argument :with_issues_enabled, GraphQL::Types::Boolean, + required: false, + description: "Return only projects with issues enabled." + + argument :with_merge_requests_enabled, GraphQL::Types::Boolean, + required: false, + description: "Return only projects with merge requests enabled." + type Types::ProjectType, null: true def resolve(args) @@ -54,7 +62,9 @@ module Resolvers include_subgroups: args.dig(:include_subgroups), sort: args.dig(:sort), search: args.dig(:search), - ids: parse_gids(args.dig(:ids)) + ids: parse_gids(args.dig(:ids)), + with_issues_enabled: args[:with_issues_enabled], + with_merge_requests_enabled: args[:with_merge_requests_enabled] } end diff --git a/app/graphql/resolvers/nested_groups_resolver.rb b/app/graphql/resolvers/nested_groups_resolver.rb new file mode 100644 index 00000000000..a2869b50cbb --- /dev/null +++ b/app/graphql/resolvers/nested_groups_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + class NestedGroupsResolver < BaseResolver + include ResolvesGroups + + type Types::GroupType, null: true + + argument :include_parent_descendants, GraphQL::Types::Boolean, + required: false, + description: 'List of descendant groups of the parent group.', + default_value: true + + argument :owned, GraphQL::Types::Boolean, + required: false, + description: 'Limit result to groups owned by authenticated user.' + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query for group name or group full path.' + + alias_method :parent, :object + + private + + # rubocop: disable CodeReuse/ActiveRecord + def resolve_groups(args) + return Group.none unless parent.present? + + GroupsFinder + .new(context[:current_user], args.merge(parent: parent)) + .execute + .reorder(name: :asc) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/graphql/resolvers/notes/synthetic_note_resolver.rb b/app/graphql/resolvers/notes/synthetic_note_resolver.rb new file mode 100644 index 00000000000..d4eafcd2c49 --- /dev/null +++ b/app/graphql/resolvers/notes/synthetic_note_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + module Notes + class SyntheticNoteResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_note + + type Types::Notes::NoteType, null: true + + argument :sha, GraphQL::Types::String, + required: true, + description: 'Global ID of the note.' + + argument :noteable_id, ::Types::GlobalIDType[::Noteable], + required: true, + description: 'Global ID of the resource to search synthetic note on.' + + def resolve(noteable_id:, sha:) + noteable = authorized_find!(id: noteable_id) + + synthetic_notes = ResourceEvents::MergeIntoNotesService.new( + noteable, current_user, paginated_notes: nil + ).execute + + synthetic_notes.find { |note| note.discussion_id == sha } + end + + def find_object(id:) + GitlabSchema.find_by_gid(id) + end + end + end +end diff --git a/app/graphql/resolvers/project_pipeline_schedules_resolver.rb b/app/graphql/resolvers/project_pipeline_schedules_resolver.rb index eb980f72717..32887385d26 100644 --- a/app/graphql/resolvers/project_pipeline_schedules_resolver.rb +++ b/app/graphql/resolvers/project_pipeline_schedules_resolver.rb @@ -10,8 +10,13 @@ module Resolvers required: false, description: 'Filter pipeline schedules by active status.' - def resolve(status: nil) - ::Ci::PipelineSchedulesFinder.new(project).execute(scope: status) + argument :ids, [GraphQL::Types::ID], + required: false, + default_value: nil, + description: 'Filter pipeline schedules by IDs.' + + def resolve(status: nil, ids: nil) + ::Ci::PipelineSchedulesFinder.new(project).execute(scope: status, ids: ids) end end end diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb index 99de4df945c..3eca0dfd83f 100644 --- a/app/graphql/resolvers/projects/services_resolver.rb +++ b/app/graphql/resolvers/projects/services_resolver.rb @@ -2,6 +2,7 @@ module Resolvers module Projects + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 class ServicesResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 0bdba53c7af..08981f2c441 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -15,14 +15,30 @@ module Resolvers description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \ "for example: `id_desc` or `name_asc`" + argument :with_issues_enabled, GraphQL::Types::Boolean, + required: false, + description: "Return only projects with issues enabled." + + argument :with_merge_requests_enabled, GraphQL::Types::Boolean, + required: false, + description: "Return only projects with merge requests enabled." + def resolve(**args) ProjectsFinder - .new(current_user: current_user, params: project_finder_params(args), project_ids_relation: parse_gids(args[:ids])) + .new(current_user: current_user, params: finder_params(args), project_ids_relation: parse_gids(args[:ids])) .execute end private + def finder_params(args) + { + **project_finder_params(args), + with_issues_enabled: args[:with_issues_enabled], + with_merge_requests_enabled: args[:with_merge_requests_enabled] + } + end + def parse_gids(gids) gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Project).model_id } end diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb index 358f3c33836..06f4ca2065c 100644 --- a/app/graphql/resolvers/releases_resolver.rb +++ b/app/graphql/resolvers/releases_resolver.rb @@ -6,7 +6,7 @@ module Resolvers argument :sort, Types::ReleaseSortEnum, required: false, default_value: :released_at_desc, - description: 'Sort releases by this criteria.' + description: 'Sort releases by given criteria.' alias_method :project, :object diff --git a/app/graphql/resolvers/saved_reply_resolver.rb b/app/graphql/resolvers/saved_reply_resolver.rb new file mode 100644 index 00000000000..96bbc139c96 --- /dev/null +++ b/app/graphql/resolvers/saved_reply_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class SavedReplyResolver < BaseResolver + type Types::SavedReplyType, null: true + + alias_method :target, :object + + argument :id, Types::GlobalIDType[::Users::SavedReply], + required: true, + description: 'ID of a saved reply.' + + def resolve(id:) + return unless Feature.enabled?(:saved_replies, current_user) + + saved_reply = ::Users::SavedReply.find_saved_reply(user_id: current_user.id, id: id.model_id) + + return unless saved_reply + + saved_reply + end + end +end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 83ed8c37250..0c9aac80274 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -7,6 +7,10 @@ module Resolvers type Types::WorkItemType.connection_type, null: true + argument :author_username, GraphQL::Types::String, + required: false, + description: 'Filter work items by author username.', + alpha: { milestone: '15.9' } argument :iid, GraphQL::Types::String, required: false, description: 'IID of the issue. For example, "1".' @@ -39,14 +43,19 @@ module Resolvers { work_item_type: :work_item_type, web_url: { project: { namespace: :route } }, - widgets: :work_item_type + widgets: { work_item_type: :enabled_widget_definitions } } end def nested_preloads { widgets: widget_preloads, - user_permissions: { update_work_item: :assignees } + user_permissions: { update_work_item: :assignees }, + project: { jira_import_status: { project: :jira_imports } }, + author: { + location: { author: :user_detail }, + gitpod_enabled: { author: :user_preference } + } } end @@ -55,9 +64,9 @@ module Resolvers last_edited_by: :last_edited_by, assignees: :assignees, parent: :work_item_parent, - children: { work_item_children_by_created_at: [:author, { project: :project_feature }] }, + children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] }, labels: :labels, - milestone: :milestone + milestone: { milestone: [:project, :group] } } end diff --git a/app/graphql/subscriptions/notes/base.rb b/app/graphql/subscriptions/notes/base.rb new file mode 100644 index 00000000000..3653c01e0e2 --- /dev/null +++ b/app/graphql/subscriptions/notes/base.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Subscriptions + module Notes + class Base < ::Subscriptions::BaseSubscription + include Gitlab::Graphql::Laziness + + argument :noteable_id, ::Types::GlobalIDType[::Noteable], + required: false, + description: 'ID of the noteable.' + + def subscribe(*args) + nil + end + + def authorized?(noteable_id:) + noteable = force(GitlabSchema.find_by_gid(noteable_id)) + + # unsubscribe if user cannot read the noteable anymore for any reason, e.g. issue was set confidential, + # in the meantime the read note permissions is checked within its corresponding returned type, i.e. NoteType + unauthorized! unless noteable && Ability.allowed?(current_user, :"read_#{noteable.to_ability_name}", noteable) + + true + end + end + end +end diff --git a/app/graphql/subscriptions/notes/created.rb b/app/graphql/subscriptions/notes/created.rb new file mode 100644 index 00000000000..07b7b308163 --- /dev/null +++ b/app/graphql/subscriptions/notes/created.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Subscriptions + module Notes + class Created < Base + payload_type ::Types::Notes::NoteType + + def update(*args) + case object + when ResourceEvent + object.work_item_synthetic_system_note + when Array + object.first.work_item_synthetic_system_note(events: object) + else + object + end + end + end + end +end diff --git a/app/graphql/subscriptions/notes/deleted.rb b/app/graphql/subscriptions/notes/deleted.rb new file mode 100644 index 00000000000..d931ef00d0d --- /dev/null +++ b/app/graphql/subscriptions/notes/deleted.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Subscriptions + module Notes + class Deleted < Base + payload_type ::Types::Notes::DeletedNoteType + + DeletedNote = Struct.new(:model_id, :model_name, :discussion_model_id, :last_discussion_note) do + def to_global_id + ::Gitlab::GlobalId.as_global_id(model_id, model_name: model_name) + end + + def discussion_id + ::Gitlab::GlobalId.as_global_id(discussion_model_id, model_name: Discussion.name) + end + end + + def update(*args) + DeletedNote.new(object[:id], object[:model_name], object[:discussion_id], object[:last_discussion_note]) + end + end + end +end diff --git a/app/graphql/subscriptions/notes/updated.rb b/app/graphql/subscriptions/notes/updated.rb new file mode 100644 index 00000000000..a4748a3361e --- /dev/null +++ b/app/graphql/subscriptions/notes/updated.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Subscriptions + module Notes + class Updated < Base + payload_type Types::Notes::NoteType + end + end +end diff --git a/app/graphql/types/achievements/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb index e2b9495c83d..67cc9778797 100644 --- a/app/graphql/types/achievements/achievement_type.rb +++ b/app/graphql/types/achievements/achievement_type.rb @@ -32,11 +32,6 @@ module Types null: true, description: 'Description or notes for the achievement.' - field :revokeable, - GraphQL::Types::Boolean, - null: false, - description: 'Revokeability of the achievement.' - field :created_at, Types::TimeType, null: false, diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb index 4086015dad6..d2bc1d55408 100644 --- a/app/graphql/types/base_argument.rb +++ b/app/graphql/types/base_argument.rb @@ -2,7 +2,7 @@ module Types class BaseArgument < GraphQL::Schema::Argument - include GitlabStyleDeprecations + include Gitlab::Graphql::Deprecations attr_reader :doc_reference diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index 11877b79e59..45e78b330fb 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -4,7 +4,7 @@ module Types class BaseEnum < GraphQL::Schema::Enum class CustomValue < GraphQL::Schema::EnumValue - include ::GitlabStyleDeprecations + include Gitlab::Graphql::Deprecations def initialize(name, desc = nil, **kwargs) init_gitlab_deprecation(kwargs) diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 615c143a0b9..caeb81c95cb 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -2,7 +2,7 @@ module Types class BaseField < GraphQL::Schema::Field - include GitlabStyleDeprecations + include Gitlab::Graphql::Deprecations argument_class ::Types::BaseArgument diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index 574791b79e6..dd6647b749d 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -30,6 +30,11 @@ module Types field :merge_trains_enabled, GraphQL::Types::Boolean, null: true, description: 'Whether merge trains are enabled.', method: :merge_trains_enabled? + field :opt_in_jwt, + GraphQL::Types::Boolean, + null: true, + description: 'When disabled, the JSON Web Token is always available in all jobs in the pipeline.', + method: :opt_in_jwt? field :project, Types::ProjectType, null: true, description: 'Project the CI/CD settings belong to.' end diff --git a/app/graphql/types/ci/code_quality_report_summary_type.rb b/app/graphql/types/ci/code_quality_report_summary_type.rb new file mode 100644 index 00000000000..0d560d9e9e8 --- /dev/null +++ b/app/graphql/types/ci/code_quality_report_summary_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + # This is presented through `PipelineType` that has its own authorization + class CodeQualityReportSummaryType < BaseObject + graphql_name 'CodeQualityReportSummary' + description 'Code Quality report for a pipeline' + + field :count, GraphQL::Types::Int, null: true, + description: 'Total number of Code Quality reports.' + ::Gitlab::Ci::Reports::CodequalityReports::SEVERITY_PRIORITIES.each_key do |status| + field status, GraphQL::Types::Int, null: true, + description: "Total number of #{status} status." + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/job_token_scope/direction_enum.rb b/app/graphql/types/ci/job_token_scope/direction_enum.rb new file mode 100644 index 00000000000..f52cf891af8 --- /dev/null +++ b/app/graphql/types/ci/job_token_scope/direction_enum.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + module JobTokenScope + class DirectionEnum < BaseEnum + graphql_name 'CiJobTokenScopeDirection' + description 'Direction of access.' + + value 'OUTBOUND', + value: :outbound, + description: 'Job token scope project can access target project in the outbound allowlist.' + + value 'INBOUND', + value: :inbound, + description: 'Target projects in the inbound allowlist can access the scope project ' \ + 'through their job tokens.' + end + end + end +end diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 37c0af944a7..639bbaa22af 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -11,7 +11,23 @@ module Types Types::ProjectType.connection_type, null: false, description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.', - method: :all_projects + method: :outbound_projects, + deprecated: { + reason: 'The `projects` attribute is being deprecated. Use `outbound_allowlist`', + milestone: '15.9' + } + + field :outbound_allowlist, + Types::ProjectType.connection_type, + null: false, + description: "Allow list of projects that are accessible using the current project's CI Job tokens.", + method: :outbound_projects + + field :inbound_allowlist, + Types::ProjectType.connection_type, + null: false, + description: "Allow list of projects that can access the current project through its CI Job tokens.", + method: :inbound_projects end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 4447a10a74e..a97e9cee4b1 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -37,6 +37,8 @@ module Types # Life-cycle timestamps: field :created_at, Types::TimeType, null: false, description: "When the job was created." + field :erased_at, Types::TimeType, null: true, + description: "When the job was erased." field :finished_at, Types::TimeType, null: true, description: 'When a job has finished running.' field :queued_at, Types::TimeType, null: true, @@ -97,6 +99,8 @@ module Types field :web_path, GraphQL::Types::String, null: true, description: 'Web path of the job.' + field :project, Types::ProjectType, null: true, description: 'Project that the job belongs to.' + def kind return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class) diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index cb561f48b3b..19d261853a7 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -178,6 +178,10 @@ module Types field :merge_request_event_type, Types::Ci::PipelineMergeRequestEventTypeEnum, null: true, description: "Event type of the pipeline associated with a merge request." + def commit + BatchLoader::GraphQL.wrap(object.commit) + end + def detailed_status object.detailed_status(current_user) end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 35339624e37..10d18f9ad2a 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -14,6 +14,9 @@ module Types JOB_COUNT_LIMIT = 1000 + # Only allow ephemeral_authentication_token to be visible for a short while + RUNNER_EPHEMERAL_TOKEN_AVAILABILITY_TIME = 3.hours + alias_method :runner, :object field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false, @@ -35,6 +38,10 @@ module Types description: 'Description of the runner.' field :edit_admin_url, GraphQL::Types::String, null: true, description: 'Admin form URL of the runner. Only available for administrators.' + field :ephemeral_authentication_token, GraphQL::Types::String, null: true, + description: 'Ephemeral authentication token used for runner machine registration.', + authorize: :read_ephemeral_token, + alpha: { milestone: '15.9' } field :executor_name, GraphQL::Types::String, null: true, description: 'Executor last advertised by the runner.', method: :executor_name @@ -134,6 +141,14 @@ module Types Gitlab::Routing.url_helpers.edit_admin_runner_url(runner) if can_admin_runners? end + def ephemeral_authentication_token + return unless runner.authenticated_user_registration_type? + return unless runner.created_at > RUNNER_EPHEMERAL_TOKEN_AVAILABILITY_TIME.ago + return if runner.runner_machines.any? + + runner.token + end + def project_count BatchLoader::GraphQL.for(runner.id).batch(key: :runner_project_count) do |ids, loader, args| counts = ::Ci::Runner.project_type diff --git a/app/graphql/types/ci/runner_upgrade_status_enum.rb b/app/graphql/types/ci/runner_upgrade_status_enum.rb index 34a931c8f79..668970aaff2 100644 --- a/app/graphql/types/ci/runner_upgrade_status_enum.rb +++ b/app/graphql/types/ci/runner_upgrade_status_enum.rb @@ -5,13 +5,13 @@ module Types class RunnerUpgradeStatusEnum < BaseEnum graphql_name 'CiRunnerUpgradeStatus' + MODEL_STATUS_TO_GRAPHQL_TRANSLATIONS = { + invalid_version: :invalid, + unavailable: :not_available + }.freeze + ::Ci::RunnerVersion::STATUS_DESCRIPTIONS.each do |status, description| - status_name_src = - if status == :invalid_version - :invalid - else - status - end + status_name_src = MODEL_STATUS_TO_GRAPHQL_TRANSLATIONS.fetch(status, status) value status_name_src.to_s.upcase, description: description, value: status end diff --git a/app/graphql/types/ci/variable_sort_enum.rb b/app/graphql/types/ci/variable_sort_enum.rb new file mode 100644 index 00000000000..3a60899ab5d --- /dev/null +++ b/app/graphql/types/ci/variable_sort_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class VariableSortEnum < BaseEnum + graphql_name 'CiVariableSort' + description 'Values for sorting variables' + + value 'KEY_ASC', 'Sorted by key in ascending order.', value: :key_asc + value 'KEY_DESC', 'Sorted by key in descending order.', value: :key_desc + end + end +end diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb deleted file mode 100644 index 859a27cac4c..00000000000 --- a/app/graphql/types/concerns/gitlab_style_deprecations.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -# Concern for handling GraphQL deprecations. -# https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items -module GitlabStyleDeprecations - extend ActiveSupport::Concern - - included do - attr_accessor :deprecation - end - - def visible?(ctx) - super && ctx[:remove_deprecated] == true ? deprecation.nil? : true - end - - private - - # Set deprecation, mutate the arguments - def init_gitlab_deprecation(kwargs) - if kwargs[:deprecation_reason].present? - raise ArgumentError, 'Use `deprecated` property instead of `deprecation_reason`. ' \ - 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-schema-items' - end - - # GitLab allows items to be marked as "alpha", which leverages GraphQL deprecations. - deprecation_args = kwargs.extract!(:alpha, :deprecated) - - self.deprecation = ::Gitlab::Graphql::Deprecation.parse(**deprecation_args) - return unless deprecation - - raise ArgumentError, "Bad deprecation. #{deprecation.errors.full_messages.to_sentence}" unless deprecation.valid? - - kwargs[:deprecation_reason] = deprecation.deprecation_reason - kwargs[:description] = deprecation.edit_description(kwargs[:description]) - end -end diff --git a/app/graphql/types/data_transfer/base_type.rb b/app/graphql/types/data_transfer/base_type.rb new file mode 100644 index 00000000000..e077612bfd5 --- /dev/null +++ b/app/graphql/types/data_transfer/base_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module DataTransfer + class BaseType < BaseObject + authorize + + field :egress_nodes, type: Types::DataTransfer::EgressNodeType.connection_type, + description: 'Data nodes.', + null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! + end + end +end diff --git a/app/graphql/types/data_transfer/egress_node_type.rb b/app/graphql/types/data_transfer/egress_node_type.rb new file mode 100644 index 00000000000..a050540999f --- /dev/null +++ b/app/graphql/types/data_transfer/egress_node_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module DataTransfer + class EgressNodeType < BaseObject + authorize + + field :date, GraphQL::Types::String, + description: 'First day of the node range. There is one node per month.', + null: false + + field :total_egress, GraphQL::Types::BigInt, + description: 'Total egress for that project in that period of time.', + null: false + + field :repository_egress, GraphQL::Types::BigInt, + description: 'Repository egress for that project in that period of time.', + null: false + + field :artifacts_egress, GraphQL::Types::BigInt, + description: 'Artifacts egress for that project in that period of time.', + null: false + + field :packages_egress, GraphQL::Types::BigInt, + description: 'Packages egress for that project in that period of time.', + null: false + + field :registry_egress, GraphQL::Types::BigInt, + description: 'Registery egress for that project in that period of time.', + null: false + + def total_egress + object.values.select { |x| x.is_a?(Integer) }.sum + end + end + end +end diff --git a/app/graphql/types/data_transfer/group_data_transfer_type.rb b/app/graphql/types/data_transfer/group_data_transfer_type.rb new file mode 100644 index 00000000000..a9a353e10e8 --- /dev/null +++ b/app/graphql/types/data_transfer/group_data_transfer_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module DataTransfer + class GroupDataTransferType < BaseType + graphql_name 'GroupDataTransfer' + authorize + end + end +end diff --git a/app/graphql/types/data_transfer/project_data_transfer_type.rb b/app/graphql/types/data_transfer/project_data_transfer_type.rb new file mode 100644 index 00000000000..f385aa20a7e --- /dev/null +++ b/app/graphql/types/data_transfer/project_data_transfer_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module DataTransfer + class ProjectDataTransferType < BaseType + graphql_name 'ProjectDataTransfer' + authorize + + field :total_egress, GraphQL::Types::BigInt, + description: 'Total egress for that project in that period of time.', + null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! + + def total_egress(**_) + return unless Feature.enabled?(:data_transfer_monitoring) + + 40_000_000 + end + end + end +end diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb index 1c23fd44ea1..6d895cc81cf 100644 --- a/app/graphql/types/deployment_type.rb +++ b/app/graphql/types/deployment_type.rb @@ -65,9 +65,9 @@ module Types field :tags, [Types::DeploymentTagType], description: 'Git tags that contain this deployment. ' \ - 'This field can only be resolved for one deployment in any single request.', + 'This field can only be resolved for two deployments in any single request.', calls_gitaly: true do - extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 2 end end end diff --git a/app/graphql/types/group_release_sort_enum.rb b/app/graphql/types/group_release_sort_enum.rb new file mode 100644 index 00000000000..7420e7a31ad --- /dev/null +++ b/app/graphql/types/group_release_sort_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + # Not inheriting from Types::SortEnum since we only want + # to implement a subset of the sort values it defines. + class GroupReleaseSortEnum < BaseEnum + graphql_name 'GroupReleaseSort' + description 'Values for sorting releases belonging to a group' + + # Borrowed from Types::ReleaseSortEnum and Types::SortEnum + # These values/descriptions should stay in-sync as much as possible. + value 'RELEASED_AT_DESC', 'Released at by descending order.', value: :released_at_desc + value 'RELEASED_AT_ASC', 'Released at by ascending order.', value: :released_at_asc + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 4e5ddbac8a2..3543ac29c17 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -193,14 +193,14 @@ module Types null: true, description: 'List of descendant groups of this group.', complexity: 5, - resolver: Resolvers::GroupsResolver + resolver: Resolvers::NestedGroupsResolver field :ci_variables, Types::Ci::GroupVariableType.connection_type, null: true, description: "List of the group's CI/CD variables.", authorize: :admin_group, - method: :variables + resolver: Resolvers::Ci::VariablesResolver field :runners, Types::Ci::RunnerType.connection_type, null: true, @@ -233,6 +233,17 @@ module Types resolver: Resolvers::WorkItems::TypesResolver, description: 'Work item types available to the group.' + field :releases, + Types::ReleaseType.connection_type, + null: true, + description: 'Releases belonging to projects in the group.', + resolver: Resolvers::GroupReleasesResolver + + field :data_transfer, Types::DataTransfer::GroupDataTransferType, + null: true, + resolver: Resolvers::DataTransferResolver.group, + description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' + def label(title:) BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4948063610a..1e5833a5cf0 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -54,19 +54,24 @@ module Types description: 'Indicates the issue is confidential.' field :discussion_locked, GraphQL::Types::Boolean, null: false, description: 'Indicates discussion is locked on the issue.' - field :due_date, Types::TimeType, null: true, - description: 'Due date of the issue.' - field :hidden, GraphQL::Types::Boolean, null: true, resolver_method: :hidden?, - description: 'Indicates the issue is hidden because the author has been banned. ' \ - 'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.' - field :downvotes, GraphQL::Types::Int, null: false, description: 'Number of downvotes the issue has received.', resolver: Resolvers::DownVotesCountResolver + field :due_date, Types::TimeType, null: true, + description: 'Due date of the issue.' + field :hidden, GraphQL::Types::Boolean, null: true, + description: 'Indicates the issue is hidden because the author has been banned.', method: :hidden? field :merge_requests_count, GraphQL::Types::Int, null: false, description: 'Number of merge requests that close the issue on merge.', resolver: Resolvers::MergeRequestsCountResolver + + field :related_merge_requests, Types::MergeRequestType.connection_type, + null: true, + description: 'Merge requests related to the issue. This field can only be resolved for one issue in any single request.' do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + field :relative_position, GraphQL::Types::Int, null: true, description: 'Relative position of the issue (used for positioning in epic tree and issue boards).' field :upvotes, GraphQL::Types::Int, @@ -182,6 +187,17 @@ module Types Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.duplicated_to_id).find end + def related_merge_requests + # rubocop: disable CodeReuse/ActiveRecord + MergeRequest.where( + id: ::Issues::ReferencedMergeRequestsService.new(container: object.project, current_user: current_user) + .execute(object) + .first + .map(&:id) + ) + # rubocop: enable CodeReuse/ActiveRecord + end + def discussion_locked !!object.discussion_locked end @@ -190,10 +206,6 @@ module Types object.creatable_note_email_address(context[:current_user]) end - def hidden? - object.hidden? if Feature.enabled?(:ban_user_feature_flag) - end - def escalation_status object.supports_escalation? ? object.escalation_status&.status_name : nil end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index abf7b3ad530..3c288c1d496 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -213,9 +213,9 @@ module Types field :security_auto_fix, GraphQL::Types::Boolean, null: true, description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' field :squash, GraphQL::Types::Boolean, null: false, - description: 'Indicates if squash on merge is enabled.' + description: 'Indicates if the merge request is set to be squashed when merged. [Project settings](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#configure-squash-options-for-a-project) may override this value. Use `squash_on_merge` instead to take project squash options into account.' field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?, - description: 'Indicates if squash on merge is enabled.' + description: 'Indicates if the merge request will be squashed when merged.' field :timelogs, Types::TimelogType.connection_type, null: false, description: 'Timelogs on the merge request.' diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 5a92ba754aa..e48e9deae96 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -66,6 +66,7 @@ module Types mount_mutation Mutations::Issues::Move mount_mutation Mutations::Issues::LinkAlerts mount_mutation Mutations::Issues::UnlinkAlert + mount_mutation Mutations::Issues::BulkUpdate, alpha: { milestone: '15.9' } mount_mutation Mutations::Labels::Create mount_mutation Mutations::Members::Groups::BulkUpdate mount_mutation Mutations::MergeRequests::Accept @@ -123,6 +124,7 @@ module Types mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership mount_mutation Mutations::Ci::PipelineSchedule::Play mount_mutation Mutations::Ci::PipelineSchedule::Create + mount_mutation Mutations::Ci::PipelineSchedule::Update mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: { reason: :renamed, replacement: 'ProjectCiCdSettingsUpdate', diff --git a/app/graphql/types/notes/deleted_note_type.rb b/app/graphql/types/notes/deleted_note_type.rb new file mode 100644 index 00000000000..f799fc01f6e --- /dev/null +++ b/app/graphql/types/notes/deleted_note_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Notes + # rubocop: disable Graphql/AuthorizeTypes + class DeletedNoteType < BaseObject + graphql_name 'DeletedNote' + + field :id, ::Types::GlobalIDType[::Note], + null: false, + description: 'ID of the deleted note.' + + field :discussion_id, ::Types::GlobalIDType[::Discussion], + null: true, + description: 'ID of the discussion for the deleted note.' + + field :last_discussion_note, GraphQL::Types::Boolean, + null: true, + description: 'Whether deleted note is the last note in the discussion.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb index 6c0d955ed77..f63b41b3c92 100644 --- a/app/graphql/types/packages/package_details_type.rb +++ b/app/graphql/types/packages/package_details_type.rb @@ -28,6 +28,8 @@ module Types field :last_downloaded_at, Types::TimeType, null: true, description: 'Last time that a file of this package was downloaded.' + field :public_package, GraphQL::Types::Boolean, null: true, description: 'Indicates if there is public access to the package.' + def versions object.versions end @@ -63,6 +65,10 @@ module Types def pypi_url pypi_registry_url(object.project.id) end + + def public_package + object.project.public? || object.project.project_feature.package_registry_access_level == ProjectFeature::PUBLIC + end end end end diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb index 0192af25d0f..d45c61f489b 100644 --- a/app/graphql/types/permission_types/base_permission_type.rb +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -28,7 +28,7 @@ module Types end def self.define_field_resolver_method(ability) - unless self.respond_to?(ability) + unless respond_to?(ability) define_method ability.to_sym do |*args| Ability.allowed?(context[:current_user], ability, object, args.to_h) end diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb index 73a2f820f79..88d8c38361a 100644 --- a/app/graphql/types/permission_types/merge_request.rb +++ b/app/graphql/types/permission_types/merge_request.rb @@ -21,10 +21,15 @@ module Types end permission_field :can_merge, calls_gitaly: true + permission_field :can_approve def can_merge object.can_be_merged_by?(context[:current_user]) end + + def can_approve + object.eligible_for_approval_by?(context[:current_user]) + end end end end diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb index bae1dae4834..f35f42001e0 100644 --- a/app/graphql/types/permission_types/work_item.rb +++ b/app/graphql/types/permission_types/work_item.rb @@ -6,7 +6,7 @@ module Types graphql_name 'WorkItemPermissions' description 'Check permissions for the current user on a work item' - abilities :read_work_item, :update_work_item, :delete_work_item + abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item end end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index fe13ee7ef3c..c105ab9814c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -345,7 +345,7 @@ module Types null: true, description: "List of the project's CI/CD variables.", authorize: :admin_build, - method: :variables + resolver: Resolvers::Ci::VariablesResolver field :ci_cd_settings, Types::Ci::CiCdSettingType, null: true, @@ -393,6 +393,10 @@ module Types field :services, Types::Projects::ServiceType.connection_type, null: true, + deprecated: { + reason: 'This will be renamed to `Project.integrations`', + milestone: '15.9' + }, description: 'Project services.', resolver: Resolvers::Projects::ServicesResolver @@ -562,6 +566,21 @@ module Types resolver: ::Resolvers::Ci::ProjectRunnersResolver, description: "Find runners visible to the current user." + field :data_transfer, Types::DataTransfer::ProjectDataTransferType, + null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! + resolver: Resolvers::DataTransferResolver.project, + description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.' + + field :visible_forks, Types::ProjectType.connection_type, + null: true, + alpha: { milestone: '15.10' }, + description: "Visible forks of the project." do + argument :minimum_access_level, + type: ::Types::AccessLevelEnum, + required: false, + description: 'Minimum access level.' + end + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end @@ -601,7 +620,11 @@ module Types end def open_issues_count - object.open_issues_count if object.feature_available?(:issues, context[:current_user]) + BatchLoader::GraphQL.wrap(object.open_issues_count) if object.feature_available?(:issues, context[:current_user]) + end + + def forks_count + BatchLoader::GraphQL.wrap(object.forks_count) end def statistics @@ -612,6 +635,8 @@ module Types project.container_repositories.size end + # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint. + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065 def ci_config_variables(sha:) result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(sha) @@ -630,6 +655,11 @@ module Types def sast_ci_configuration return unless Ability.allowed?(current_user, :read_code, object) + if project.repository.empty? + raise Gitlab::Graphql::Errors::MutationError, + _(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe + end + ::Security::CiConfiguration::SastParserService.new(object).configuration end @@ -643,11 +673,28 @@ module Types ::Projects::RepositoryLanguagesService.new(project, current_user).execute end + def visible_forks(minimum_access_level: nil) + if minimum_access_level.nil? + object.forks.public_or_visible_to_user(current_user) + else + object.forks.visible_to_user_and_access_level(current_user, minimum_access_level) + end + end + private def project @project ||= object.respond_to?(:sync) ? object.sync : object end + + def add_file_docs_link + ActionController::Base.helpers.link_to _('add at least one file to the repository'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/repository/index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' + end end end diff --git a/app/graphql/types/projects/namespace_project_sort_enum.rb b/app/graphql/types/projects/namespace_project_sort_enum.rb index bd7058196dd..7c7b54226d3 100644 --- a/app/graphql/types/projects/namespace_project_sort_enum.rb +++ b/app/graphql/types/projects/namespace_project_sort_enum.rb @@ -8,6 +8,7 @@ module Types value 'SIMILARITY', 'Most similar to the search query.', value: :similarity value 'STORAGE', 'Sort by storage size.', value: :storage + value 'ACTIVITY_DESC', 'Sort by latest activity, in descending order.', value: :latest_activity_desc end end end diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb index 1416d93d3b4..ec58e3254ae 100644 --- a/app/graphql/types/projects/service_type.rb +++ b/app/graphql/types/projects/service_type.rb @@ -2,6 +2,7 @@ module Types module Projects + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 module ServiceType include Types::BaseInterface graphql_name 'Service' diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb index d0cecbfea49..fd88fa957e7 100644 --- a/app/graphql/types/projects/service_type_enum.rb +++ b/app/graphql/types/projects/service_type_enum.rb @@ -2,6 +2,7 @@ module Types module Projects + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 class ServiceTypeEnum < BaseEnum graphql_name 'ServiceType' diff --git a/app/graphql/types/projects/services/base_service_type.rb b/app/graphql/types/projects/services/base_service_type.rb index 5341ae2a864..9a48aafa5a8 100644 --- a/app/graphql/types/projects/services/base_service_type.rb +++ b/app/graphql/types/projects/services/base_service_type.rb @@ -3,6 +3,7 @@ module Types module Projects module Services + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 class BaseServiceType < BaseObject graphql_name 'BaseService' diff --git a/app/graphql/types/projects/services/jira_project_type.rb b/app/graphql/types/projects/services/jira_project_type.rb index 1c5b97802e3..eb721d02b36 100644 --- a/app/graphql/types/projects/services/jira_project_type.rb +++ b/app/graphql/types/projects/services/jira_project_type.rb @@ -4,6 +4,7 @@ module Types module Projects module Services # rubocop:disable Graphql/AuthorizeTypes + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 class JiraProjectType < BaseObject graphql_name 'JiraProject' diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb index 425a283c674..ac274d7f890 100644 --- a/app/graphql/types/projects/services/jira_service_type.rb +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -3,6 +3,7 @@ module Types module Projects module Services + # TODO: Remove in 17.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108418 class JiraServiceType < BaseObject graphql_name 'JiraService' diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 990ba1fb7fc..fb906759ba4 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -17,7 +17,8 @@ module Types field :ci_variables, Types::Ci::InstanceVariableType.connection_type, null: true, - description: "List of the instance's CI/CD variables." + description: "List of the instance's CI/CD variables.", + resolver: Resolvers::Ci::VariablesResolver field :container_repository, Types::ContainerRepositoryDetailsType, null: true, description: 'Find a container repository.' do @@ -40,6 +41,10 @@ module Types null: true, resolver: Resolvers::GroupResolver, description: "Find a group." + field :groups, Types::GroupType.connection_type, + null: true, + resolver: Resolvers::GroupsResolver, + description: "Find groups." field :issue, Types::IssueType, null: true, description: 'Find an issue.' do @@ -50,8 +55,7 @@ module Types alpha: { milestone: '15.6' }, resolver: Resolvers::IssuesResolver, description: 'Find issues visible to the current user.' \ - ' At least one filter must be provided.' \ - ' Returns `null` if the `root_level_issues_query` feature flag is disabled.' + ' At least one filter must be provided.' field :jobs, ::Types::Ci::JobType.connection_type, null: true, @@ -76,6 +80,15 @@ module Types null: true, resolver: Resolvers::NamespaceResolver, description: "Find a namespace." + field :note, + ::Types::Notes::NoteType, + null: true, + description: 'Find a note.', + alpha: { milestone: '15.9' } do + argument :id, ::Types::GlobalIDType[::Note], + required: true, + description: 'Global ID of the note.' + end field :package, description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.', resolver: Resolvers::PackageDetailsResolver @@ -95,8 +108,10 @@ module Types resolver: Resolvers::Ci::RunnerResolver, extras: [:lookahead], description: "Find a runner." - field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver - field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver + field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver, + deprecated: { reason: 'No longer used, use gitlab-runner documentation to learn about supported platforms', milestone: '15.9' } + field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver, + deprecated: { reason: 'No longer used, use gitlab-runner documentation to learn about runner registration commands', milestone: '15.9' } field :runners, Types::Ci::RunnerType.connection_type, null: true, resolver: Resolvers::Ci::RunnersResolver, @@ -106,6 +121,12 @@ module Types null: true, resolver: Resolvers::SnippetsResolver, description: 'Find Snippets visible to the current user.' + field :synthetic_note, + Types::Notes::NoteType, + null: true, + description: 'Find a synthetic note', + resolver: ::Resolvers::Notes::SyntheticNoteResolver, + alpha: { milestone: '15.9' } field :timelogs, Types::TimelogType.connection_type, null: true, description: 'Find timelogs visible to the current user.', @@ -145,6 +166,10 @@ module Types GitlabSchema.find_by_gid(id) end + def note(id:) + GitlabSchema.find_by_gid(id) + end + def merge_request(id:) GitlabSchema.find_by_gid(id) end @@ -166,12 +191,6 @@ module Types application_settings end - def ci_variables - return unless current_user&.can_admin_all_resources? - - ::Ci::InstanceVariable.all - end - def application_settings Gitlab::CurrentSettings.current_application_settings end diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb index e171c683e7d..b2bc52c7745 100644 --- a/app/graphql/types/release_asset_link_type.rb +++ b/app/graphql/types/release_asset_link_type.rb @@ -9,8 +9,12 @@ module Types present_using Releases::LinkPresenter - field :external, GraphQL::Types::Boolean, null: true, method: :external?, - description: 'Indicates the link points to an external resource.' + field :external, GraphQL::Types::Boolean, + null: true, + method: :external?, + description: 'Indicates the link points to an external resource.', + deprecated: { reason: 'No longer used', milestone: '15.9' } + field :id, GraphQL::Types::ID, null: false, description: 'ID of the link.' field :link_type, diff --git a/app/graphql/types/saved_reply_type.rb b/app/graphql/types/saved_reply_type.rb index 329f431b10e..8c9f3d19810 100644 --- a/app/graphql/types/saved_reply_type.rb +++ b/app/graphql/types/saved_reply_type.rb @@ -4,6 +4,8 @@ module Types class SavedReplyType < BaseObject graphql_name 'SavedReply' + connection_type_class(Types::CountableConnectionType) + authorize :read_saved_replies field :id, Types::GlobalIDType[::Users::SavedReply], diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index f7f26ba4c5a..33fc0cbe20e 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -4,41 +4,60 @@ module Types class SubscriptionType < ::Types::BaseObject graphql_name 'Subscription' - field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the assignees of an issuable are updated.' + field :issuable_assignees_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the assignees of an issuable are updated.' - field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the crm contacts of an issuable are updated.' + field :issue_crm_contacts_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the crm contacts of an issuable are updated.' - field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the title of an issuable is updated.' + field :issuable_title_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the title of an issuable is updated.' - field :issuable_description_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the description of an issuable is updated.' + field :issuable_description_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the description of an issuable is updated.' - field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the labels of an issuable are updated.' + field :issuable_labels_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the labels of an issuable are updated.' - field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the due date or start date of an issuable is updated.' + field :issuable_dates_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the due date or start date of an issuable is updated.' - field :issuable_milestone_updated, subscription: Subscriptions::IssuableUpdated, null: true, - description: 'Triggered when the milestone of an issuable is updated.' + field :issuable_milestone_updated, + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the milestone of an issuable is updated.' + + field :work_item_note_created, + subscription: ::Subscriptions::Notes::Created, null: true, + description: 'Triggered when a note is created.', + alpha: { milestone: '15.9' } + + field :work_item_note_deleted, + subscription: ::Subscriptions::Notes::Deleted, null: true, + description: 'Triggered when a note is deleted.', + alpha: { milestone: '15.9' } + + field :work_item_note_updated, + subscription: ::Subscriptions::Notes::Updated, null: true, + description: 'Triggered when a note is updated.', + alpha: { milestone: '15.9' } field :merge_request_reviewers_updated, - subscription: Subscriptions::IssuableUpdated, - null: true, - description: 'Triggered when the reviewers of a merge request are updated.' + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the reviewers of a merge request are updated.' field :merge_request_merge_status_updated, - subscription: Subscriptions::IssuableUpdated, - null: true, - description: 'Triggered when the merge status of a merge request is updated.' + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the merge status of a merge request is updated.' field :merge_request_approval_state_updated, - subscription: Subscriptions::IssuableUpdated, - null: true, - description: 'Triggered when approval state of a merge request is updated.' + subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when approval state of a merge request is updated.' end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index a5bed3b9e19..9115b5a4760 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -137,6 +137,11 @@ module Types description: 'Saved replies authored by the user. ' \ 'Will not return saved replies if `saved_replies` feature flag is disabled.' + field :saved_reply, + resolver: Resolvers::SavedReplyResolver, + description: 'Saved reply authored by the user. ' \ + 'Will not return saved reply if `saved_replies` feature flag is disabled.' + field :gitpod_enabled, GraphQL::Types::Boolean, null: true, description: 'Whether Gitpod is enabled at the user level.' diff --git a/app/graphql/types/work_item_id_type.rb b/app/graphql/types/work_item_id_type.rb index bb01f865414..777edfad529 100644 --- a/app/graphql/types/work_item_id_type.rb +++ b/app/graphql/types/work_item_id_type.rb @@ -37,7 +37,11 @@ module Types def suitable?(gid) return false if gid&.model_name&.safe_constantize.blank? + # Using === operation doesn't work for model classes. + # See https://github.com/rails/rails/blob/v6.1.6.1/activerecord/lib/active_record/core.rb#L452 + # rubocop:disable Performance/RedundantEqualityComparisonBlock [::WorkItem, ::Issue].any? { |model_class| gid.model_class == model_class } + # rubocop:enable Performance/RedundantEqualityComparisonBlock end private diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 6a1a4f158be..b46362f66b8 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -8,6 +8,9 @@ module Types authorize :read_work_item + field :author, Types::UserType, null: true, + description: 'User that created the work item.', + alpha: { milestone: '15.9' } field :closed_at, Types::TimeType, null: true, description: 'Timestamp of when the work item was closed.' field :confidential, GraphQL::Types::Boolean, null: false, diff --git a/app/graphql/types/work_items/widget_type_enum.rb b/app/graphql/types/work_items/widget_type_enum.rb index 4e5933bff86..2ad951d421b 100644 --- a/app/graphql/types/work_items/widget_type_enum.rb +++ b/app/graphql/types/work_items/widget_type_enum.rb @@ -6,8 +6,8 @@ module Types graphql_name 'WorkItemWidgetType' description 'Type of a work item widget' - ::WorkItems::Type.available_widgets.each do |widget| - value widget.type.to_s.upcase, value: widget.type, description: "#{widget.type.to_s.titleize} widget." + ::WorkItems::WidgetDefinition.widget_classes.each do |cls| + value cls.type.to_s.upcase, value: cls.type, description: "#{cls.type.to_s.titleize} widget." end end end diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb index dc31c06477e..969c5d5a0b5 100644 --- a/app/helpers/admin/user_actions_helper.rb +++ b/app/helpers/admin/user_actions_helper.rb @@ -55,7 +55,6 @@ module Admin end def ban_actions - return unless ban_feature_available? return if @user.internal? if @user.banned? diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index ab743e27603..e9465e0db22 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -4,14 +4,40 @@ module AppearancesHelper include MarkupHelper include Gitlab::Utils::StrongMemoize - def appearance_short_name - Appearance.current&.pwa_short_name.presence || _('GitLab') + def appearance_pwa_icon_path_scaled(width) + return unless Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS.include?(width) + + append_root_path((current_appearance&.pwa_icon_path_scaled(width) || "/-/pwa-icons/logo-#{width}.png")) + end + + def appearance_maskable_logo + append_root_path('/-/pwa-icons/maskable-logo.png') + end + + def append_root_path(path) + Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path) end def brand_title current_appearance&.title.presence || default_brand_title end + def appearance_pwa_name + current_appearance&.pwa_name.presence || _('GitLab') + end + + def appearance_pwa_short_name + current_appearance&.pwa_short_name.presence || _('GitLab') + end + + def appearance_pwa_description + current_appearance&.pwa_description.presence || + _("The complete DevOps platform. " \ + "One application with endless possibilities. " \ + "Organizations rely on GitLab’s source code management, " \ + "CI/CD, security, and more to deliver software rapidly.") + end + def default_brand_title # This resides in a separate method so that EE can easily redefine it. _('GitLab Community Edition') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f4b34044e0e..93b7c8c0b94 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -232,7 +232,7 @@ module ApplicationHelper end def support_url - Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || "#{promo_url}/getting-help/" + Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || "#{promo_url}/get-help/" end def instance_review_permitted? @@ -284,10 +284,6 @@ module ApplicationHelper !params.has_key?(:no_startup_css) end - def use_new_fonts? - Feature.enabled?(:new_fonts, current_user) || request.params.has_key?(:new_fonts) - end - def outdated_browser? browser.ie? end @@ -368,6 +364,12 @@ module ApplicationHelper end end + def discord_url(user) + return '' if user.discord.blank? + + "https://discord.com/users/#{user.discord}" + end + def collapsed_sidebar? cookies["sidebar_collapsed"] == "true" end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 339938245a0..3abaae98c29 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -75,16 +75,38 @@ module ApplicationSettingsHelper end def restricted_level_checkboxes(form) - Gitlab::VisibilityLevel.values.map do |level| + restricted_visibility_levels_help_text = { + Gitlab::VisibilityLevel::PUBLIC => s_( + 'AdminSettings|If selected, only administrators are able to create public groups, projects, ' \ + 'and snippets. Also, profiles are only visible to authenticated users.' + ), + Gitlab::VisibilityLevel::INTERNAL => s_( + 'AdminSettings|If selected, only administrators are able to create internal groups, projects, and ' \ + 'snippets.' + ), + Gitlab::VisibilityLevel::PRIVATE => s_( + 'AdminSettings|If selected, only administrators are able to create private groups, projects, and ' \ + 'snippets.' + ) + } + + Gitlab::VisibilityLevel.options.map do |label, level| checked = restricted_visibility_levels(true).include?(level) form.gitlab_ui_checkbox_component( :restricted_visibility_levels, - "#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe, checkbox_options: { checked: checked, multiple: true, autocomplete: 'off' }, checked_value: level, unchecked_value: nil - ) + ) do |c| + c.label do + visibility_level_icon(level) + content_tag(:span, label, { class: 'gl-ml-2' }) + end + + c.help_text do + restricted_visibility_levels_help_text.fetch(level) + end + end end end @@ -145,6 +167,10 @@ module ApplicationSettingsHelper " using their classification label.") end + def external_authorization_allow_token_help_text + s_("ExternalAuthorization|Does not apply if service URL is specified.") + end + def external_authorization_timeout_help_text s_("ExternalAuthorization|Period GitLab waits for a response from the external "\ "service. If there is no response, access is denied. Default: 0.5 seconds.") @@ -197,6 +223,7 @@ module ApplicationSettingsHelper :allow_local_requests_from_hooks_and_services, :allow_local_requests_from_web_hooks_and_services, :allow_local_requests_from_system_hooks, + :allow_possible_spam, :dns_rebinding_protection_enabled, :archive_builds_in_human_readable, :asset_proxy_enabled, @@ -282,6 +309,7 @@ module ApplicationSettingsHelper :inactive_projects_send_warning_email_after_months, :invisible_captcha_enabled, :jira_connect_application_key, + :jira_connect_public_key_storage_enabled, :jira_connect_proxy_url, :max_artifacts_size, :max_attachment_size, @@ -383,7 +411,6 @@ module ApplicationSettingsHelper :user_default_internal_regex, :user_oauth_applications, :version_check_enabled, - :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, :diff_max_files, :diff_max_lines, @@ -450,7 +477,8 @@ module ApplicationSettingsHelper :can_create_group, :bulk_import_enabled, :allow_runner_registration_token, - :user_defaults_to_private_profile + :user_defaults_to_private_profile, + :deactivation_email_additional_text ].tap do |settings| next if Gitlab.com? @@ -475,7 +503,8 @@ module ApplicationSettingsHelper :external_authorization_service_default_label, :external_authorization_service_enabled, :external_authorization_service_timeout, - :external_authorization_service_url + :external_authorization_service_url, + :allow_deploy_tokens_and_keys_with_external_authn ] end diff --git a/app/helpers/artifacts_helper.rb b/app/helpers/artifacts_helper.rb new file mode 100644 index 00000000000..df0432105d5 --- /dev/null +++ b/app/helpers/artifacts_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ArtifactsHelper + def artifacts_app_data(project) + { + project_path: project.full_path, + can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s, + artifacts_management_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg') + } + end +end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index c41b5923d13..e2e89c9abca 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -5,7 +5,6 @@ module AuthHelper alicloud atlassian_oauth2 auth0 - authentiq azure_activedirectory_v2 azure_oauth2 bitbucket @@ -17,7 +16,6 @@ module AuthHelper jwt openid_connect salesforce - shibboleth twitter ).freeze LDAP_PROVIDER = /\Aldap/.freeze diff --git a/app/helpers/bizible_helper.rb b/app/helpers/bizible_helper.rb index 970cc6558da..4c8d3744ced 100644 --- a/app/helpers/bizible_helper.rb +++ b/app/helpers/bizible_helper.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true module BizibleHelper - def bizible_enabled? - Feature.enabled?(:ecomm_instrumentation, type: :ops) && + def bizible_enabled?(invite_email = nil) + invite_email.blank? && + Feature.enabled?(:ecomm_instrumentation, type: :ops) && Gitlab.config.extra.has_key?('bizible') && Gitlab.config.extra.bizible.present? && Gitlab.config.extra.bizible == true diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index c93c8dd8d76..823332c3d1d 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -101,7 +101,7 @@ module Ci has_gitlab_ci: has_gitlab_ci?(project).to_s, pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project), suggested_ci_templates: suggested_ci_templates.to_json, - ci_runner_settings_path: project_settings_ci_cd_path(project, ci_runner_templates: true, anchor: 'js-runners-settings') + ci_runner_settings_path: project_settings_ci_cd_path(project, anchor: 'js-runners-settings') } experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index ac36c867baf..41ef0bd20a8 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -63,6 +63,7 @@ module Ci # Runner install help page is external, located at # https://gitlab.com/gitlab-org/gitlab-runner runner_install_help_page: 'https://docs.gitlab.com/runner/install/', + new_runner_path: new_admin_runner_path, registration_token: Gitlab::CurrentSettings.runners_registration_token, online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i, stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i, diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb index 84572363a8d..a492c48e58c 100644 --- a/app/helpers/ci/variables_helper.rb +++ b/app/helpers/ci/variables_helper.rb @@ -47,6 +47,10 @@ module Ci ] end + def ci_variable_maskable_raw_regex + Ci::Maskable::MASK_AND_RAW_REGEX.inspect.sub('\\A', '^').sub('\\z', '$')[1...-1] + end + def ci_variable_maskable_regex Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(%r{^/}, '').sub(%r{/[a-z]*$}, '').gsub('\/', '/') end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 53781364af7..f75d3657986 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -131,10 +131,6 @@ module CommitsHelper } end - def commit_signature_badge_classes(additional_classes) - %w(btn gpg-status-box) + Array(additional_classes) - end - def conditionally_paginate_diff_files(diffs, paginate:, page:, per:) if paginate diff_files = diffs.diff_files.to_a diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index f6de405cecb..9f4ed6b8150 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -178,6 +178,10 @@ module EmailsHelper strip_tags(render_message(:footer_message, style: '')) end + def service_desk_email_additional_text + # overridden on EE + end + def say_hi(user) _('Hi %{username}!') % { username: sanitize_name(user.name) } end @@ -290,15 +294,27 @@ module EmailsHelper added_reviewers = new_reviewers - previous_reviewers removed_reviewers = previous_reviewers - new_reviewers - added_reviewers_template_text = added_reviewers.size > 1 ? "were added as reviewers.<br>" : "was added as a reviewer.<br>" - removed_reviewers_template_text = removed_reviewers.size > 1 ? "were removed from reviewers." : "was removed from reviewers." - - added = format_reviewers_string(added_reviewers, html_tag) - removed = format_reviewers_string(removed_reviewers, html_tag) - - added_reviewers_text = added ? "#{added} #{added_reviewers_template_text}".html_safe : '' - removed_reviewers_text = removed ? "#{removed} #{removed_reviewers_template_text}".html_safe : '' - s_('ChangeReviewer|%{added_reviewers_text}%{removed_reviewers_text}').html_safe % { added_reviewers_text: added_reviewers_text, removed_reviewers_text: removed_reviewers_text } + added_reviewers_text = if added_reviewers.any? + n_( + '%{reviewer_names} was added as a reviewer.', + '%{reviewer_names} were added as reviewers.', + added_reviewers.size) % { + reviewer_names: format_reviewers_string(added_reviewers, html_tag) + } + end + + removed_reviewers_text = if removed_reviewers.any? + n_( + '%{reviewer_names} was removed from reviewers.', + '%{reviewer_names} were removed from reviewers.', + removed_reviewers.size) % { + reviewer_names: format_reviewers_string(removed_reviewers, html_tag) + } + end + + line_delimiter = html_tag.present? ? '<br>' : "\n" + + [added_reviewers_text, removed_reviewers_text].compact.join(line_delimiter).html_safe end end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 1defe480059..2967501f628 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -15,6 +15,8 @@ module ExploreHelper namespace_id: params[:namespace_id] } + exist_opts[:language] = params[:language] + options = exist_opts.merge(options).delete_if { |key, value| value.blank? } request_path_with_options(options) end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 8f0d01c7d6d..a4d90716129 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -45,9 +45,7 @@ module FormHelper end end - def dropdown_max_select(data, feature_flag) - return data[:'max-select'] unless feature_flag.nil? || Feature.enabled?(feature_flag) - + def dropdown_max_select(data) if data[:'max-select'] && data[:'max-select'] < ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS data[:'max-select'] else @@ -179,11 +177,7 @@ module FormHelper new_options[:title] = _('Select reviewer(s)') new_options[:data][:'dropdown-header'] = _('Reviewer(s)') - if Feature.enabled?(:limit_reviewer_and_assignee_size) - new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS - else - new_options[:data].delete(:'max-select') - end + new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS new_options end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index e8fc6bc292f..129871ca3fd 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -113,7 +113,7 @@ module GroupsHelper end def render_setting_to_allow_project_access_token_creation?(group) - group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group) + group.root? && current_user.can?(:admin_setting_to_allow_resource_access_token_creation, group) end def show_thanks_for_purchase_alert?(quantity) diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 63544e28a0e..ac1e4456bc7 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -8,12 +8,12 @@ module HooksHelper } end - def link_to_test_hook(hook, trigger) - path = test_hook_path(hook, trigger) - trigger_human_name = integration_webhook_event_human_name(trigger) - - link_to path, rel: 'nofollow', method: :post do - content_tag(:span, trigger_human_name) + def webhook_test_items(hook, triggers) + triggers.map do |trigger| + { + href: test_hook_path(hook, trigger), + text: integration_webhook_event_human_name(trigger) + } end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 021b47ceab2..bba3fac7468 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -26,7 +26,7 @@ module IconsHelper # SVG Sprites currently don't work across domains, so in the case of a CDN # we have to set the current path deliberately to prevent addition of asset_host sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host - ActionController::Base.helpers.image_path('file_icons.svg', host: sprite_base_url) + ActionController::Base.helpers.image_path('file_icons/file_icons.svg', host: sprite_base_url) end def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil) diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 0e81cea8ac7..c5be044a27b 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -1,17 +1,20 @@ # frozen_string_literal: true module IdeHelper - def ide_data + def ide_data(project:, branch:, path:, merge_request:, fork_info:) { 'can-use-new-web-ide' => can_use_new_web_ide?.to_s, 'use-new-web-ide' => use_new_web_ide?.to_s, 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'), 'user-preferences-path' => profile_preferences_path, - 'branch-name' => @branch, - 'file-path' => @path, - 'fork-info' => @fork_info&.to_json, - 'merge-request' => @merge_request - }.merge(use_new_web_ide? ? new_ide_data : legacy_ide_data) + 'branch-name' => branch, + 'file-path' => path, + 'fork-info' => fork_info&.to_json, + 'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'), + 'editor-font-family' => 'JetBrains Mono', + 'editor-font-format' => 'woff2', + 'merge-request' => merge_request + }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) end def can_use_new_web_ide? @@ -24,16 +27,16 @@ module IdeHelper private - def new_ide_data + def new_ide_data(project:) { - 'project-path' => @project&.path_with_namespace, + 'project-path' => project&.path_with_namespace, 'csp-nonce' => content_security_policy_nonce, # We will replace these placeholders in the FE 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path') } end - def legacy_ide_data + def legacy_ide_data(project:) { 'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'), 'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'), @@ -43,13 +46,11 @@ module IdeHelper 'promotion-svg-path': image_path('illustrations/web-ide_promotion.svg'), 'ci-help-page-path' => help_page_path('ci/quick_start/index'), 'web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md'), - 'clientside-preview-enabled': Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?.to_s, 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, - 'codesandbox-bundler-url': Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url, - 'default-branch' => @project && @project.default_branch, - 'project' => convert_to_project_entity_json(@project), - 'enable-environments-guidance' => enable_environments_guidance?.to_s, - 'preview-markdown-path' => @project && preview_markdown_path(@project), + 'default-branch' => project && project.default_branch, + 'project' => convert_to_project_entity_json(project), + 'enable-environments-guidance' => enable_environments_guidance?(project).to_s, + 'preview-markdown-path' => project && preview_markdown_path(project), 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'), 'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'), 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'), @@ -63,8 +64,8 @@ module IdeHelper API::Entities::Project.represent(project, current_user: current_user).to_json end - def enable_environments_guidance? - experiment(:in_product_guidance_environments_webide, project: @project) do |e| + def enable_environments_guidance?(project) + experiment(:in_product_guidance_environments_webide, project: project) do |e| e.candidate { !has_dismissed_ide_environments_callout? } e.run diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 6fad1346426..e986b56fde4 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -45,7 +45,7 @@ module InviteMembersHelper full_path: source.full_path } - if show_invite_members_for_task?(source) + if current_user && show_invite_members_for_task?(source) dataset.merge!( tasks_to_be_done_options: tasks_to_be_done_options.to_json, projects: projects_for_source(source).to_json, @@ -71,11 +71,9 @@ module InviteMembersHelper {} end - def show_invite_members_for_task?(source) - return unless current_user - - invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).assigned.name == 'candidate' - params[:open_modal] == 'invite_members_for_task' || invite_for_help_continuous_onboarding + # Overridden in EE + def show_invite_members_for_task?(_source) + params[:open_modal] == 'invite_members_for_task' end def tasks_to_be_done_options diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index fb407aa7eed..46d2d2c42d9 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -6,7 +6,7 @@ module IssuablesHelper include ::Sidebars::Concerns::HasPill def sidebar_gutter_toggle_icon - content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do + content_tag(:span, class: 'js-sidebar-toggle-container gl-button-text', data: { is_expanded: !sidebar_gutter_collapsed? }) do sprite_icon('chevron-double-lg-left', css_class: "js-sidebar-expand #{'hidden' unless sidebar_gutter_collapsed?}") + sprite_icon('chevron-double-lg-right', css_class: "js-sidebar-collapse #{'hidden' if sidebar_gutter_collapsed?}") end @@ -152,7 +152,7 @@ module IssuablesHelper end if issuable.is_a?(Issue) && issuable.service_desk_reply_to - output << "#{html_escape(issuable.service_desk_reply_to)} via " + output << "#{html_escape(issuable.present(current_user: current_user).service_desk_reply_to)} via " end output << content_tag(:strong) do @@ -242,7 +242,6 @@ module IssuablesHelper updateEndpoint: "#{issuable_path(issuable)}.json", canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), - canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issuable), issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent, target_type: issuable.model_name, target_id: issuable.iid), markdownDocsPath: help_page_path('user/markdown'), @@ -272,7 +271,17 @@ module IssuablesHelper sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord iid: issuable.iid.to_s, isHidden: issue_hidden?(issuable), - canCreateIncident: create_issue_type_allowed?(issuable.project, :incident) + canCreateIncident: create_issue_type_allowed?(issuable.project, :incident), + **incident_only_initial_data(issuable) + } + end + + def incident_only_initial_data(issue) + return {} unless issue.incident? + + { + hasLinkedAlerts: issue.alert_management_alerts.any?, + canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issue) } end @@ -299,7 +308,7 @@ module IssuablesHelper end def issuables_count_for_state(issuable_type, state) - Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: true)[state] + Gitlab::IssuablesCountForState.new(finder, fast_fail: true, store_in_redis_cache: true)[state] end def close_issuable_path(issuable) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 362f1e65425..39399c2919b 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -57,7 +57,7 @@ module IssuesHelper end def issue_hidden?(issue) - Feature.enabled?(:ban_user_feature_flag) && issue.hidden? + issue.hidden? end def hidden_issue_icon(issue) @@ -195,7 +195,6 @@ module IssuesHelper empty_state_svg_path: image_path('illustrations/issues.svg'), full_path: namespace.full_path, initial_sort: current_user&.user_preference&.issues_sort, - is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s, is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s, is_public_visibility_restricted: Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s, diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index 0971fdae8dd..50e3c3cc5fe 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -11,7 +11,8 @@ module JiraConnectHelper subscriptions_path: jira_connect_subscriptions_path(format: :json), users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in gitlab_user_path: current_user ? user_path(current_user) : nil, - oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data(installation).to_json : nil + oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data(installation).to_json : nil, + public_key_storage_enabled: Gitlab.config.jira_connect.enable_public_keys_storage || Gitlab::CurrentSettings.jira_connect_public_key_storage_enabled? } end diff --git a/app/helpers/json_helper.rb b/app/helpers/json_helper.rb index e61c789fd08..2a1a6272cc9 100644 --- a/app/helpers/json_helper.rb +++ b/app/helpers/json_helper.rb @@ -4,11 +4,11 @@ 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(*args) - Gitlab::Json.generate(*args) + def json_generate(...) + Gitlab::Json.generate(...) end - def json_parse(*args) - Gitlab::Json.parse(*args) + def json_parse(...) + Gitlab::Json.parse(...) end end diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb deleted file mode 100644 index a07922e451a..00000000000 --- a/app/helpers/learn_gitlab_helper.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -module LearnGitlabHelper - IMAGE_PATH_PLAN = "learn_gitlab/section_plan.svg" - IMAGE_PATH_DEPLOY = "learn_gitlab/section_deploy.svg" - IMAGE_PATH_WORKSPACE = "learn_gitlab/section_workspace.svg" - LICENSE_SCANNING_RUN_URL = 'https://docs.gitlab.com/ee/user/compliance/license_compliance/index.html' - - def learn_gitlab_enabled?(project) - return false unless current_user - - learn_gitlab_onboarding_available?(project) - end - - def learn_gitlab_data(project) - { - actions: onboarding_actions_data(project).to_json, - sections: onboarding_sections_data.to_json, - project: onboarding_project_data(project).to_json - } - end - - def learn_gitlab_onboarding_available?(project) - Onboarding::Progress.onboarding?(project.namespace) && - Onboarding::LearnGitlab.new(current_user).available? - end - - private - - def onboarding_actions_data(project) - attributes = onboarding_progress(project).attributes.symbolize_keys - - action_urls(project).to_h do |action, url| - [ - action, - { - url: url, - completed: attributes[Onboarding::Progress.column_name(action)].present?, - svg: image_path("learn_gitlab/#{action}.svg"), - enabled: true - } - ] - end - end - - def onboarding_sections_data - { - workspace: { - svg: image_path(IMAGE_PATH_WORKSPACE) - }, - plan: { - svg: image_path(IMAGE_PATH_PLAN) - }, - deploy: { - svg: image_path(IMAGE_PATH_DEPLOY) - } - } - end - - def onboarding_project_data(project) - { name: project.name } - end - - def action_urls(project) - action_issue_urls.merge( - issue_created: project_issues_path(project), - git_write: project_path(project), - merge_request_created: project_merge_requests_path(project), - user_added: project_members_url(project), - **deploy_section_action_urls(project) - ) - end - - def action_issue_urls - Onboarding::Completion::ACTION_ISSUE_IDS.transform_values do |id| - project_issue_url(learn_gitlab_project, id) - end - end - - def deploy_section_action_urls(project) - experiment( - :security_actions_continuous_onboarding, - namespace: project.namespace, - user: current_user, - sticky_to: current_user - ) do |e| - e.control { { security_scan_enabled: project_security_configuration_path(project) } } - e.candidate do - { - license_scanning_run: LICENSE_SCANNING_RUN_URL, - secure_dependency_scanning_run: project_security_configuration_path(project, anchor: 'dependency-scanning'), - secure_dast_run: project_security_configuration_path(project, anchor: 'dast') - } - end - end.run - end - - def learn_gitlab_project - @learn_gitlab_project ||= Onboarding::LearnGitlab.new(current_user).project - end - - def onboarding_progress(project) - Onboarding::Progress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord - end -end - -LearnGitlabHelper.prepend_mod_with('LearnGitlabHelper') diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb index 0aaeb39c82d..6a7e09f75e4 100644 --- a/app/helpers/listbox_helper.rb +++ b/app/helpers/listbox_helper.rb @@ -23,7 +23,7 @@ module ListboxHelper # # The final parameter `html_options` applies arbitrary attributes to the # returned tag. Some of these are passed to the underlying Vue component as - # props, e.g., to right-align the menu of items, add `data: { right: true }`. + # props, e.g., to right-align the menu of items, add `data: { placement: 'right' }`. # # Examples: # # Create a listbox with two items, with the first item selected @@ -32,7 +32,7 @@ module ListboxHelper # = gl_redirect_listbox_tag items, 'foo' # # # Create the same listbox, right-align the menu and add margin styling - # = gl_redirect_listbox_tag items, 'foo', class: 'gl-ml-3', data: { right: true } + # = gl_redirect_listbox_tag items, 'foo', class: 'gl-ml-3', data: { placement: 'right' } def gl_redirect_listbox_tag(items, selected, html_options = {}) # Add script tag for app/assets/javascripts/entrypoints/behaviors/redirect_listbox.js content_for :page_specific_javascripts do diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 60796e628a3..01030690daf 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -5,42 +5,6 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) - groups ||= current_user.manageable_groups_with_routes - users = [current_user.namespace] - selected_id = selected - - unless extra_group.nil? || extra_group.is_a?(Group) - extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group' - end - - if extra_group && extra_group.is_a?(Group) - extra_group = dedup_extra_group(extra_group) - - if Ability.allowed?(current_user, :read_group, extra_group) - # Assign the value to an invalid primary ID so that the select box works - extra_group.id = -1 unless extra_group.persisted? - selected_id = extra_group.id if selected == :extra_group - groups |= [extra_group] - else - selected_id = current_user.namespace.id - end - end - - options = [] - options << options_for_group(groups, display_path: display_path, type: 'group') - - unless groups_only - options << options_for_group(users, display_path: display_path, type: 'user') - - if selected == :current_user && current_user.namespace - selected_id = current_user.namespace.id - end - end - - grouped_options_for_select(options, selected_id) - end - def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) group_icon_url(namespace) @@ -91,36 +55,13 @@ module NamespacesHelper } end - private - - # Many importers create a temporary Group, so use the real - # group if one exists by that name to prevent duplicates. - # rubocop: disable CodeReuse/ActiveRecord - def dedup_extra_group(extra_group) - unless extra_group.persisted? - existing_group = Group.find_by(path: extra_group.path) - extra_group = existing_group if existing_group&.persisted? - end - - extra_group - end - # rubocop: enable CodeReuse/ActiveRecord - - def options_for_group(namespaces, display_path:, type:) - group_label = type.pluralize - elements = namespaces.sort_by(&:human_name).map! do |n| - [display_path ? n.full_path : n.human_name, n.id, - data: { - options_parent: group_label, - visibility_level: n.visibility_level_value, - visibility: n.visibility, - name: n.name, - show_path: type == 'group' ? group_path(n) : user_path(n), - edit_path: type == 'group' ? edit_group_path(n) : nil - }] - end - - [group_label.camelize, elements] + def storage_usage_app_data(namespace) + { + namespace_id: namespace.id, + namespace_path: namespace.full_path, + user_namespace: namespace.user_namespace?.to_s, + default_per_page: page_size + } end end diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index b017c9a81d1..ddd6469a9e4 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -32,7 +32,7 @@ module Nav id: 'new_project', title: _('New project/repository'), href: new_project_path(namespace_id: group.id), - data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top' } ) ) end @@ -43,7 +43,7 @@ module Nav id: 'new_subgroup', title: _('New subgroup'), href: new_group_path(parent_id: group.id, anchor: 'create-group-pane'), - data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown', track_property: 'navigation_top' } ) ) end @@ -59,7 +59,7 @@ module Nav end { - title: _('This group'), + title: _('In this group'), menu_items: menu_items.compact } end @@ -74,7 +74,7 @@ module Nav id: 'new_issue', title: _('New issue'), href: new_project_issue_path(project), - data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } + data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'new_issue_link' } ) ) end @@ -85,7 +85,7 @@ module Nav id: 'new_mr', title: _('New merge request'), href: project_new_merge_request_path(merge_project), - data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown', track_property: 'navigation_top' } ) ) end @@ -96,7 +96,7 @@ module Nav id: 'new_snippet', title: _('New snippet'), href: new_project_snippet_path(project), - data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top' } ) ) end @@ -110,7 +110,7 @@ module Nav end { - title: _('This project'), + title: _('In this project'), menu_items: menu_items } end @@ -124,7 +124,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', qa_selector: 'global_new_project_link' } + data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_project_link' } ) ) end @@ -135,7 +135,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', qa_selector: 'global_new_group_link' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_group_link' } ) ) end @@ -146,13 +146,13 @@ 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', qa_selector: 'global_new_snippet_link' } + data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_snippet_link' } ) ) end { - title: _('GitLab'), + title: _('In GitLab'), menu_items: menu_items } end @@ -165,7 +165,8 @@ module Nav href: href, data: { track_action: 'click_link_invite_members', - track_label: 'plus_menu_dropdown' + track_label: 'plus_menu_dropdown', + track_property: 'navigation_top' } ) end diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index bd4d661ab49..fb11c183aeb 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -101,7 +101,7 @@ module Nav builder.add_primary_menu_item_with_shortcut( header: top_nav_localized_headers[:switch_to], active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), - data: { track_label: "projects_dropdown", track_action: "click_dropdown", qa_selector: "projects_dropdown" }, + data: { track_label: "projects_dropdown", track_action: "click_dropdown", track_property: "navigation_top", qa_selector: "projects_dropdown" }, view: PROJECTS_VIEW, shortcut_href: dashboard_projects_path, **projects_menu_item_attrs @@ -115,7 +115,7 @@ module Nav builder.add_primary_menu_item_with_shortcut( header: top_nav_localized_headers[:switch_to], active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]), - data: { track_label: "groups_dropdown", track_action: "click_dropdown", qa_selector: "groups_dropdown" }, + data: { track_label: "groups_dropdown", track_action: "click_dropdown", track_property: "navigation_top", qa_selector: "groups_dropdown" }, view: GROUPS_VIEW, shortcut_href: dashboard_groups_path, **groups_menu_item_attrs @@ -233,7 +233,7 @@ module Nav tracking_attrs( "menu_#{label.underscore.parameterize(separator: '_')}", 'click_dropdown', - 'navigation' + 'navigation_top' )[:data] || {} end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 6f7b2877100..4a218984af1 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -175,7 +175,7 @@ module PageLayoutHelper current_emoji: user.status.emoji.to_s, current_message: user.status.message.to_s, current_availability: user.status.availability.to_s, - current_clear_status_after: user.status.clear_status_at.to_s + current_clear_status_after: user.status.clear_status_at&.to_s(:iso8601) }) end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 88e68a52199..2442856d7fe 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -30,6 +30,7 @@ module PreferencesHelper { projects: _("Your Projects (default)"), stars: _("Starred Projects"), + your_activity: _("Your Activity"), project_activity: _("Your Projects' Activity"), starred_project_activity: _("Starred Projects' Activity"), followed_user_activity: _("Followed Users' Activity"), diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index b9a219b3021..55216d412a5 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -5,6 +5,25 @@ module Projects require 'json' include ActionView::Helpers::NumberHelper + def show_candidate_view_model(candidate) + data = { + candidate: { + params: candidate.params, + metrics: candidate.latest_metrics, + info: { + iid: candidate.iid, + path_to_artifact: link_to_artifact(candidate), + experiment_name: candidate.experiment.name, + path_to_experiment: link_to_experiment(candidate.project, candidate.experiment), + status: candidate.status + }, + metadata: candidate.metadata + } + } + + Gitlab::Json.generate(data) + end + def candidates_table_items(candidates) items = candidates.map do |candidate| { @@ -25,24 +44,30 @@ module Projects Gitlab::Json.generate(candidates.flat_map(&selector).map(&:name).uniq) end - def candidate_as_data(candidate) - data = { - params: candidate.params, - metrics: candidate.latest_metrics, - info: { - iid: candidate.iid, - path_to_artifact: link_to_artifact(candidate), - experiment_name: candidate.experiment.name, - path_to_experiment: link_to_experiment(candidate), - status: candidate.status - }, - metadata: candidate.metadata - } + def experiments_as_data(project, experiments) + data = experiments.map do |exp| + { + name: exp.name, + path: link_to_experiment(project, exp), + candidate_count: exp.candidate_count + } + end Gitlab::Json.generate(data) end - private + def page_info(paginator) + { + has_next_page: paginator.has_next_page?, + has_previous_page: paginator.has_previous_page?, + start_cursor: paginator.cursor_for_previous_page, + end_cursor: paginator.cursor_for_next_page + } + end + + def formatted_page_info(page_info) + Gitlab::Json.generate(page_info) + end def link_to_artifact(candidate) artifact = candidate.artifact @@ -56,10 +81,8 @@ module Projects project_ml_candidate_path(candidate.project, candidate.iid) end - def link_to_experiment(candidate) - experiment = candidate.experiment - - project_ml_experiment_path(experiment.project, experiment.iid) + def link_to_experiment(project, experiment) + project_ml_experiment_path(project, experiment.iid) end def user_info(candidate) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 507e05b9967..04190bc442b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -123,6 +123,25 @@ module ProjectsHelper end end + def vue_fork_divergence_data(project, ref) + source_project = visible_fork_source(project) + + return {} unless source_project + + source_default_branch = source_project.default_branch + + { + source_name: source_project.full_name, + source_path: project_path(source_project), + ahead_compare_path: project_compare_path( + project, from: source_default_branch, to: ref, from_project_id: source_project.id + ), + behind_compare_path: project_compare_path( + source_project, from: ref, to: source_default_branch, from_project_id: project.id + ) + } + end + def remove_fork_project_warning_message(project) _("You are going to remove the fork relationship from %{project_full_name}. Are you ABSOLUTELY sure?") % { project_full_name: project.full_name } @@ -285,11 +304,7 @@ module ProjectsHelper current_page?(starred_explore_projects_path) end - def show_merge_request_count?(disabled: false, compact_mode: false) - !disabled && !compact_mode - end - - def show_issue_count?(disabled: false, compact_mode: false) + def show_count?(disabled: false, compact_mode: false) !disabled && !compact_mode end @@ -420,6 +435,10 @@ module ProjectsHelper project.merge_requests_enabled? && can?(user, :read_merge_request, project) end + def able_to_see_forks_count?(project, user) + project.forking_enabled? && can?(user, :read_code, project) + end + def fork_button_disabled_tooltip(project) return unless current_user diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index 91adc36749b..1724e11a6f1 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -10,6 +10,10 @@ module RegistrationsHelper qa_selector: 'new_user_username_field' } end + + def arkose_labs_challenge_enabled? + false + end end RegistrationsHelper.prepend_mod_with('RegistrationsHelper') diff --git a/app/helpers/routing/snippets_helper.rb b/app/helpers/routing/snippets_helper.rb index 19450c1d033..a815204a216 100644 --- a/app/helpers/routing/snippets_helper.rb +++ b/app/helpers/routing/snippets_helper.rb @@ -114,16 +114,16 @@ module Routing end end - def toggle_award_emoji_personal_snippet_path(*args) - toggle_award_emoji_snippet_path(*args) + def toggle_award_emoji_personal_snippet_path(...) + toggle_award_emoji_snippet_path(...) end - def toggle_award_emoji_project_project_snippet_path(*args) - toggle_award_emoji_project_snippet_path(*args) + def toggle_award_emoji_project_project_snippet_path(...) + toggle_award_emoji_project_snippet_path(...) end - def toggle_award_emoji_project_project_snippet_url(*args) - toggle_award_emoji_project_snippet_url(*args) + def toggle_award_emoji_project_project_snippet_url(...) + toggle_award_emoji_project_snippet_url(...) end private diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb deleted file mode 100644 index 7ee40c28bad..00000000000 --- a/app/helpers/selects_helper.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module SelectsHelper - def project_select_tag(id, opts = {}) - opts[:class] = [*opts[:class], 'ajax-project-select'].join(' ') - - unless opts.delete(:scope) == :all - if @group - opts['data-group-id'] = @group.id - end - end - - with_feature_enabled_data_attribute = - case opts.delete(:with_feature_enabled) - when 'issues' then 'data-with-issues-enabled' - when 'merge_requests' then 'data-with-merge-requests-enabled' - end - - opts[with_feature_enabled_data_attribute] = true - - hidden_field_tag(id, opts[:selected], opts) - end - - def select2_tag(id, opts = {}) - klass_opts = [opts[:class]] - klass_opts << 'multiselect' if opts[:multiple] - - opts[:class] = klass_opts.join(' ') - value = opts[:selected] || '' - - hidden_field_tag(id, value, opts) - end -end - -SelectsHelper.prepend_mod_with('SelectsHelper') diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 56138ba95c2..8251e1cba8a 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -45,10 +45,7 @@ module SessionsHelper end def obfuscated_email(email) - regex = ::Gitlab::UntrustedRegexp.new('^(..?)(.*)(@.?)(.*)(\..*)$') - match = regex.match(email) - return email unless match - - match[1] + '*' * (match[2] || '').length + match[3] + '*' * (match[4] || '').length + match[5] + # Moved to Gitlab::Utils::Email in 15.9 + Gitlab::Utils::Email.obfuscated_email(email) end end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index f6257c92f3f..27020738515 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true module SidebarsHelper + include MergeRequestsHelper + include Nav::NewDropdownHelper + def sidebar_tracking_attributes_by_object(object) sidebar_attributes_for_object(object).fetch(:tracking_attrs, {}) end @@ -31,20 +34,65 @@ module SidebarsHelper Sidebars::Groups::Context.new(**context_data) end - def super_sidebar_context(user) + def super_sidebar_context(user, group:, project:) { name: user.name, username: user.username, avatar_url: user.avatar_url, assigned_open_issues_count: user.assigned_open_issues_count, - assigned_open_merge_requests_count: user.assigned_open_merge_requests_count, todos_pending_count: user.todos_pending_count, - issues_dashboard_path: issues_dashboard_path(assignee_username: user.username) + issues_dashboard_path: issues_dashboard_path(assignee_username: user.username), + total_merge_requests_count: user_merge_requests_counts[:total], + create_new_menu_groups: create_new_menu_groups(group: group, project: project), + merge_request_menu: create_merge_request_menu(user), + support_path: support_url, + display_whats_new: display_whats_new?, + whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count, + whats_new_version_digest: whats_new_version_digest, + show_version_check: show_version_check?, + gitlab_version: Gitlab.version_info, + gitlab_version_check: gitlab_version_check } end private + def create_new_menu_groups(group:, project:) + new_dropdown_sections = new_dropdown_view_model(group: group, project: project)[:menu_sections] + show_headers = new_dropdown_sections.length > 1 + new_dropdown_sections.map do |section| + { + name: show_headers ? section[:title] : '', + items: section[:menu_items].map do |item| + { + text: item[:title], + href: item[:href] + } + end + } + end + end + + def create_merge_request_menu(user) + [ + { + name: _('Merge requests'), + items: [ + { + text: _('Assigned'), + href: merge_requests_dashboard_path(assignee_username: user.username), + count: user_merge_requests_counts[:assigned] + }, + { + text: _('Review requests'), + href: merge_requests_dashboard_path(reviewer_username: user.username), + count: user_merge_requests_counts[:review_requested] + } + ] + } + ] + end + def sidebar_attributes_for_object(object) case object when Project @@ -98,7 +146,6 @@ module SidebarsHelper { current_user: user, container: project, - learn_gitlab_enabled: learn_gitlab_enabled?(project), current_ref: current_ref, ref_type: ref_type, jira_issues_integration: project_jira_issues_integration?, diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb index b49cb617d80..db779a9d8c1 100644 --- a/app/helpers/sorting_titles_values_helper.rb +++ b/app/helpers/sorting_titles_values_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true module SortingTitlesValuesHelper + SORT_NAME = 'name_asc' + SORT_UPDATED_OLDEST = 'updated_asc' + SORT_UPDATED_RECENT = 'updated_desc' + # Titles. def sort_title_created_date s_('SortOptions|Created date') diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb index 4cd40836335..13d6851f3cd 100644 --- a/app/helpers/ssh_keys_helper.rb +++ b/app/helpers/ssh_keys_helper.rb @@ -23,6 +23,28 @@ module SshKeysHelper } end + def ssh_key_revoke_modal_data(key, path) + title = _('Revoke Key') + + { + 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', + okTitle: _('Revoke') + }, + toggle: 'tooltip', + placement: 'top', + container: 'body' + } + end + def ssh_key_allowed_algorithms allowed_algorithms = Gitlab::CurrentSettings.allowed_key_types.flat_map do |ssh_key_type_name| Gitlab::SSHPublicKey.supported_algorithms_for_name(ssh_key_type_name) diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 04619ad3bda..137b24102e0 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -9,7 +9,7 @@ module TabHelper # the <gl-tabs/> component. Can be populated by # gl_tab_link_to elements. # - # See more at: https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-tabs-tab--default + # See more at: https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-tabs--default def gl_tabs_nav(html_options = {}, &block) gl_tabs_classes = %w[nav gl-tabs-nav] diff --git a/app/helpers/timeboxes_routing_helper.rb b/app/helpers/timeboxes_routing_helper.rb deleted file mode 100644 index 6a5bef74dc9..00000000000 --- a/app/helpers/timeboxes_routing_helper.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module TimeboxesRoutingHelper - def milestone_path(milestone, *args) - if milestone.group_milestone? - group_milestone_path(milestone.group, milestone, *args) - elsif milestone.project_milestone? - project_milestone_path(milestone.project, milestone, *args) - end - end - - def milestone_url(milestone, *args) - if milestone.group_milestone? - group_milestone_url(milestone.group, milestone, *args) - elsif milestone.project_milestone? - project_milestone_url(milestone.project, milestone, *args) - end - end -end - -TimeboxesRoutingHelper.prepend_mod_with('TimeboxesRoutingHelper') diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index daaf86e55a0..4a9dd30a5a2 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -139,6 +139,8 @@ module TodosHelper variant = 'info' if state == 'closed' when AlertManagement::Alert variant = 'info' if state == 'resolved' + else + variant = 'info' end content_tag(:span, class: 'todo-target-state') do diff --git a/app/helpers/tooling/visual_review_helper.rb b/app/helpers/tooling/visual_review_helper.rb index cd3b8be5aac..47afae49759 100644 --- a/app/helpers/tooling/visual_review_helper.rb +++ b/app/helpers/tooling/visual_review_helper.rb @@ -22,5 +22,9 @@ module Tooling 'id': 'review-app-toolbar-script', 'src': 'https://gitlab.com/assets/webpack/visual_review_toolbar.js' } end + + def review_apps_enabled? + Gitlab::Utils.to_boolean(ENV['REVIEW_APPS_ENABLED'], default: false) + end end end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 370dbb10462..0aeea323ddb 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -14,8 +14,8 @@ module TreeHelper end # Simple shortcut to File.join - def tree_join(*args) - File.join(*args) + def tree_join(...) + File.join(...) end def on_top_of_branch?(project = @project, ref = @ref) diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 6ab78b58457..2b8368dd29f 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -25,10 +25,6 @@ module Users !user_dismissed?(GCP_SIGNUP_OFFER) end - def render_flash_user_callout(flash_type, message, feature_name) - render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name - end - def render_dashboard_ultimate_trial(user) end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index a382b5e7b74..62b9eb2b506 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -120,10 +120,6 @@ module UsersHelper !user.confirmed? end - def ban_feature_available? - Feature.enabled?(:ban_user_feature_flag) - end - def confirm_user_data(user) message = if user.unconfirmed_email.present? _('This user has an unconfirmed email address (%{email}). You may force a confirmation.') % { email: user.unconfirmed_email } @@ -262,9 +258,11 @@ module UsersHelper if with_schema_markup job_title = '<span itemprop="jobTitle">'.html_safe + job_title + "</span>".html_safe organization = '<span itemprop="worksFor">'.html_safe + organization + "</span>".html_safe - end - html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization } + html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization } + else + s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization } + end end def user_table_headers @@ -302,6 +300,10 @@ module UsersHelper other: s_('User|Other') }.with_indifferent_access.freeze end + + def saved_replies_enabled? + Feature.enabled?(:saved_replies, current_user) + end end UsersHelper.prepend_mod_with('UsersHelper') diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb index bda9bf58fb7..514db6ba8a2 100644 --- a/app/helpers/web_hooks/web_hooks_helper.rb +++ b/app/helpers/web_hooks/web_hooks_helper.rb @@ -2,8 +2,6 @@ module WebHooks module WebHooksHelper - EXPIRY_TTL = 1.hour - def show_project_hook_failed_callout?(project:) return false if project_hook_page? return false unless current_user @@ -12,17 +10,11 @@ module WebHooks # Assumes include of Users::CalloutsHelper return false if web_hook_disabled_dismissed?(project) - any_project_hook_failed?(project) # Most expensive query last + project.fetch_web_hook_failure end private - def any_project_hook_failed?(project) - Rails.cache.fetch("any_web_hook_failed:#{project.id}", expires_in: EXPIRY_TTL) do - ProjectHook.for_projects(project).disabled.exists? - end - end - def project_hook_page? current_controller?('projects/hooks') || current_controller?('projects/hook_logs') end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 6a5680c080b..58843435fa0 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -105,7 +105,7 @@ module Emails @written_count = export_status.fetch(:rows_written) @truncated = export_status.fetch(:truncated) @size_limit = ActiveSupport::NumberHelper - .number_to_human_size(Issuable::ExportCsv::BaseService::TARGET_FILESIZE) + .number_to_human_size(ExportCsv::BaseService::TARGET_FILESIZE) filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv" attachments[filename] = { content: csv_data, mime_type: 'text/csv' } diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index fc944c34166..6678bb563ed 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -138,7 +138,7 @@ module Emails @written_count = export_status.fetch(:rows_written) @truncated = export_status.fetch(:truncated) @size_limit = ActiveSupport::NumberHelper - .number_to_human_size(Issuable::ExportCsv::BaseService::TARGET_FILESIZE) + .number_to_human_size(ExportCsv::BaseService::TARGET_FILESIZE) filename = "#{project.full_path.parameterize}_merge_requests_#{Date.current.iso8601}.csv" attachments[filename] = { content: csv_data, mime_type: 'text/csv' } diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index 2953ec6cbe5..1295f978049 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -17,24 +17,30 @@ module Emails email_sender = sender( @support_bot.id, send_from_user_email: false, - sender_name: @project.service_desk_setting&.outgoing_name + sender_name: @service_desk_setting&.outgoing_name, + sender_email: service_desk_sender_email_address ) options = service_desk_options(email_sender, 'thank_you', @issue.external_author) .merge(subject: "Re: #{subject_base}") - mail_new_thread(@issue, options) + inject_service_desk_custom_email(mail_new_thread(@issue, options)) end def service_desk_new_note_email(issue_id, note_id, recipient) @note = Note.find(note_id) setup_service_desk_mail(issue_id) - email_sender = sender(@note.author_id) + email_sender = sender( + @note.author_id, + send_from_user_email: false, + sender_email: service_desk_sender_email_address + ) + add_uploads_as_attachments if Feature.enabled?(:service_desk_new_note_email_native_attachments, @note.project) options = service_desk_options(email_sender, 'new_note', recipient) .merge(subject: subject_base) - mail_answer_thread(@issue, options) + inject_service_desk_custom_email(mail_answer_thread(@issue, options)) end private @@ -44,6 +50,8 @@ module Emails @project = @issue.project @support_bot = User.support_bot + @service_desk_setting = @project.service_desk_setting + @sent_notification = SentNotification.record(@issue, @support_bot.id, reply_key) end @@ -55,10 +63,26 @@ module Emails next unless template_body = template_content(email_type) options[:body] = template_body - options[:content_type] = 'text/html' + options[:content_type] = 'text/html' unless attachments.present? end end + def inject_service_desk_custom_email(mail) + return mail unless service_desk_custom_email_enabled? + + mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_delivery_options) + end + + def service_desk_custom_email_enabled? + Feature.enabled?(:service_desk_custom_email, @project) && @service_desk_setting&.custom_email_enabled? + end + + def service_desk_sender_email_address + return unless service_desk_custom_email_enabled? + + @service_desk_setting.custom_email + end + def template_content(email_type) template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project) text = substitute_template_replacements(template.content) @@ -77,6 +101,10 @@ module Emails .gsub(/%\{\s*ISSUE_ID\s*\}/, issue_id) .gsub(/%\{\s*ISSUE_PATH\s*\}/, issue_path) .gsub(/%\{\s*NOTE_TEXT\s*\}/, note_text) + .gsub(/%\{\s*SYSTEM_HEADER\s*\}/, text_header_message.to_s) + .gsub(/%\{\s*SYSTEM_FOOTER\s*\}/, text_footer_message.to_s) + .gsub(/%\{\s*UNSUBSCRIBE_URL\s*\}/, unsubscribe_sent_notification_url(@sent_notification)) + .gsub(/%\{\s*ADDITIONAL_TEXT\s*\}/, service_desk_email_additional_text.to_s) end def issue_id diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 31726563662..28ef6d8d6c6 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -68,14 +68,16 @@ class Notify < ApplicationMailer private # Return an email address that displays the name of the sender. - # Only the displayed name changes; the actual email address is always the same. - def sender(sender_id, send_from_user_email: false, sender_name: nil) + # Override sender_email if you want to hard replace the sender address (e.g. custom email for Service Desk) + def sender(sender_id, send_from_user_email: false, sender_name: nil, sender_email: nil) return unless sender = User.find(sender_id) address = default_sender_address address.display_name = sender_name.presence || "#{sender.name} (#{sender.to_reference})" - if send_from_user_email && can_send_from_user_email?(sender) + if sender_email + address.address = sender_email + elsif send_from_user_email && can_send_from_user_email?(sender) address.address = sender.email end diff --git a/app/models/ability.rb b/app/models/ability.rb index b15143c8c9c..eb645bcd653 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -29,7 +29,7 @@ class Ability # A list of users that can read confidential notes in a project def users_that_can_read_internal_notes(users, note_parent) DeclarativePolicy.subject_scope do - users.select { |u| allowed?(u, :reporter_access, note_parent) } + users.select { |u| allowed?(u, :read_internal_note, note_parent) } end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index ee0c23ef31e..dbcdfa5e946 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -4,6 +4,8 @@ class AbuseReport < ApplicationRecord include CacheMarkdownField include Sortable + MAX_CHAR_LIMIT_URL = 512 + cache_markdown_field :message, pipeline: :single_line belongs_to :reporter, class_name: 'User' @@ -23,13 +25,23 @@ class AbuseReport < ApplicationRecord validates :reported_from_url, allow_blank: true, - length: { maximum: 512 }, + length: { maximum: MAX_CHAR_LIMIT_URL }, addressable_url: { dns_rebind_protection: true, blocked_message: 'is an invalid URL. You can try reporting the abuse again, ' \ 'or contact a GitLab administrator for help.' } + validates :links_to_spam, + allow_blank: true, + length: { + maximum: 20, + message: N_("exceeds the limit of %{count} links") + } + + before_validation :filter_empty_strings_from_links_to_spam + validate :links_to_spam_contains_valid_urls + scope :by_user, ->(user) { where(user_id: user) } scope :with_users, -> { includes(:reporter, :user) } @@ -60,8 +72,38 @@ class AbuseReport < ApplicationRecord end def notify - return unless self.persisted? + return unless persisted? + + AbuseReportMailer.notify(id).deliver_later + end + + private + + def filter_empty_strings_from_links_to_spam + return if links_to_spam.blank? + + links_to_spam.reject!(&:empty?) + end + + def links_to_spam_contains_valid_urls + return if links_to_spam.blank? + + links_to_spam.each do |link| + Gitlab::UrlBlocker.validate!( + link, + schemes: %w[http https], + allow_localhost: true, + dns_rebind_protection: true + ) + + next unless link.length > MAX_CHAR_LIMIT_URL - AbuseReportMailer.notify(self.id).deliver_later + errors.add( + :links_to_spam, + format(_('contains URLs that exceed the %{limit} character limit'), limit: MAX_CHAR_LIMIT_URL) + ) + end + rescue ::Gitlab::UrlBlocker::BlockedUrlError + errors.add(:links_to_spam, _('only supports valid HTTP(S) URLs')) end end diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb index a436e32b35b..95606e50ad4 100644 --- a/app/models/achievements/achievement.rb +++ b/app/models/achievements/achievement.rb @@ -4,6 +4,9 @@ module Achievements class Achievement < ApplicationRecord include Avatarable include StripAttribute + include IgnorableColumns + + ignore_column :revokable, remove_with: '15.11', remove_after: '2023-04-22' belongs_to :namespace, inverse_of: :achievements, optional: false diff --git a/app/models/airflow.rb b/app/models/airflow.rb new file mode 100644 index 00000000000..2e5642a2639 --- /dev/null +++ b/app/models/airflow.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Airflow + def self.table_name_prefix + 'airflow_' + end +end diff --git a/app/models/airflow/dags.rb b/app/models/airflow/dags.rb new file mode 100644 index 00000000000..d17d4a4f3db --- /dev/null +++ b/app/models/airflow/dags.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Airflow + class Dags < ApplicationRecord + belongs_to :project + + validates :project, presence: true + validates :dag_name, length: { maximum: 255 }, presence: true + validates :schedule, length: { maximum: 255 } + validates :fileloc, length: { maximum: 255 } + + scope :by_project_id, ->(project_id) { where(project_id: project_id).order(id: :asc) } + end +end diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb index b432955ad88..fa165ae9600 100644 --- a/app/models/analytics/cycle_analytics/aggregation.rb +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -63,10 +63,13 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord group = group_or_project_namespace.is_a?(Group) ? group_or_project_namespace : group_or_project_namespace.parent top_level_group = group.root_ancestor aggregation = find_by(group_id: top_level_group.id) - return aggregation if aggregation.present? + return aggregation if aggregation&.enabled? - insert({ group_id: top_level_group.id }, unique_by: :group_id) - find_by(group_id: top_level_group.id) + # At this point we're sure that the group is licensed, we can always enable the aggregation. + # This re-enables the aggregation in case the group downgraded and later upgraded the license. + upsert({ group_id: top_level_group.id, enabled: true }) + + find(top_level_group.id) end private diff --git a/app/models/analytics/cycle_analytics/project_level.rb b/app/models/analytics/cycle_analytics/project_level.rb index d43793f60c9..813263fe833 100644 --- a/app/models/analytics/cycle_analytics/project_level.rb +++ b/app/models/analytics/cycle_analytics/project_level.rb @@ -33,8 +33,8 @@ module Analytics private def build_stage(stage_name) - stage_params = stage_params_by_name(stage_name).merge(project: project) - Analytics::CycleAnalytics::ProjectStage.new(stage_params) + stage_params = stage_params_by_name(stage_name).merge(namespace: project.project_namespace) + Analytics::CycleAnalytics::Stage.new(stage_params) end def stage_params_by_name(name) diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb deleted file mode 100644 index 8a80514333f..00000000000 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Analytics - module CycleAnalytics - class ProjectStage < ApplicationRecord - include Analytics::CycleAnalytics::Stageable - - belongs_to :project, optional: false - belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', foreign_key: :project_value_stream_id - - alias_attribute :parent, :project - alias_attribute :parent_id, :project_id - - alias_attribute :value_stream_id, :project_value_stream_id - - delegate :group, to: :project - - validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? } - - def self.relative_positioning_query_base(stage) - where(project_id: stage.project_id) - end - - def self.relative_positioning_parent_column - :project_id - end - - def self.distinct_stages_within_hierarchy(group) - with_preloaded_labels - .where(project_id: group.all_projects.select(:id)) - .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*") - end - - private - - # Project should belong to a group when the stage has Label based events since only GroupLabels are allowed. - def validate_project_group_for_label_events - errors.add(:project, s_('CycleAnalyticsStage|should be under a group')) unless project.group - end - end - end -end diff --git a/app/models/analytics/cycle_analytics/project_value_stream.rb b/app/models/analytics/cycle_analytics/project_value_stream.rb deleted file mode 100644 index 3eba7e87b17..00000000000 --- a/app/models/analytics/cycle_analytics/project_value_stream.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class Analytics::CycleAnalytics::ProjectValueStream < ApplicationRecord - belongs_to :project - - has_many :stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' - - validates :project, :name, presence: true - validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :project_id } - - def custom? - false - end - - def stages - [] - end - - def self.build_default_value_stream(project) - new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME, project: project) - end -end diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb new file mode 100644 index 00000000000..7e9a89975a3 --- /dev/null +++ b/app/models/analytics/cycle_analytics/stage.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class Stage < ApplicationRecord + self.table_name = :analytics_cycle_analytics_group_stages + + include DatabaseEventTracking + include Analytics::CycleAnalytics::Stageable + include Analytics::CycleAnalytics::Parentable + + validates :name, uniqueness: { scope: [:group_id, :group_value_stream_id] } + belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ValueStream', +foreign_key: :group_value_stream_id, inverse_of: :stages + + alias_attribute :parent, :namespace + alias_attribute :parent_id, :group_id + alias_attribute :value_stream_id, :group_value_stream_id + + def self.distinct_stages_within_hierarchy(namespace) + # Looking up the whole hierarchy including all kinds (type) of Namespace records. + # We're doing a custom traversal_ids query because: + # - The traversal_ids based `self_and_descendants` doesn't include the ProjectNamespace records. + # - The default recursive lookup also excludes the ProjectNamespace records. + # + # Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/386124 + all_namespace_ids = + Namespace + .select(Arel.sql('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)]').as('id')) + .where("traversal_ids @> ('{?}')", namespace.id) + + with_preloaded_labels + .where(parent_id: all_namespace_ids) + .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*") + end + + SNOWPLOW_ATTRIBUTES = %i[ + id + created_at + updated_at + relative_position + start_event_identifier + end_event_identifier + group_id + start_event_label_id + end_event_label_id + hidden + custom + name + group_value_stream_id + ].freeze + end + end +end diff --git a/app/models/analytics/cycle_analytics/stage_event_hash.rb b/app/models/analytics/cycle_analytics/stage_event_hash.rb index 0e1e9b3ef67..6443a970945 100644 --- a/app/models/analytics/cycle_analytics/stage_event_hash.rb +++ b/app/models/analytics/cycle_analytics/stage_event_hash.rb @@ -3,7 +3,7 @@ module Analytics module CycleAnalytics class StageEventHash < ApplicationRecord - has_many :cycle_analytics_project_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage', inverse_of: :stage_event_hash + has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::Stage', inverse_of: :stage_event_hash validates :hash_sha256, presence: true @@ -33,10 +33,10 @@ module Analytics end def self.unused_hashes_for(id) - exists_query = Analytics::CycleAnalytics::ProjectStage.where(stage_event_hash_id: id).select('1').limit(1) - where.not('EXISTS (?)', exists_query) + stage_exists_query = ::Analytics::CycleAnalytics::Stage.where(stage_event_hash_id: id).select('1').limit(1) + + where.not('EXISTS (?)', stage_exists_query) end end end end -Analytics::CycleAnalytics::StageEventHash.prepend_mod_with('Analytics::CycleAnalytics::StageEventHash') diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb new file mode 100644 index 00000000000..3d8a0a53f5e --- /dev/null +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ValueStream < ApplicationRecord + self.table_name = :analytics_cycle_analytics_group_value_streams + + include Analytics::CycleAnalytics::Parentable + + has_many :stages, -> { ordered }, + class_name: 'Analytics::CycleAnalytics::Stage', + foreign_key: :group_value_stream_id, + index_errors: true, + inverse_of: :value_stream + + validates :name, presence: true + validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :group_id } + + accepts_nested_attributes_for :stages, allow_destroy: true + + scope :preload_associated_models, -> { + includes(:namespace, + stages: [ + :namespace, + :end_event_label, + :start_event_label + ]) + } + + after_save :ensure_aggregation_record_presence + + def custom? + persisted? || name != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME + end + + def self.build_default_value_stream(namespace) + new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME, namespace: namespace) + end + + private + + def ensure_aggregation_record_presence + Analytics::CycleAnalytics::Aggregation.safe_create_for_namespace(namespace) + end + end + end +end diff --git a/app/models/analytics/usage_trends/measurement.rb b/app/models/analytics/usage_trends/measurement.rb index c1245d8dce7..ddadaf78c8f 100644 --- a/app/models/analytics/usage_trends/measurement.rb +++ b/app/models/analytics/usage_trends/measurement.rb @@ -24,8 +24,8 @@ module Analytics scope :order_by_latest, -> { order(recorded_at: :desc) } scope :with_identifier, ->(identifier) { where(identifier: identifier) } - scope :recorded_after, ->(date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? } - scope :recorded_before, ->(date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? } + scope :recorded_after, ->(date) { where(model.arel_table[:recorded_at].gteq(date)) if date.present? } + scope :recorded_before, ->(date) { where(model.arel_table[:recorded_at].lteq(date)) if date.present? } def self.identifier_query_mapping { diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 3a5e06e9a1c..b926c6abedc 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -5,9 +5,13 @@ class Appearance < ApplicationRecord include CacheMarkdownField include WithUploads + ALLOWED_PWA_ICON_SCALER_WIDTHS = [192, 512].freeze + attribute :title, default: '' - attribute :pwa_short_name, default: '' attribute :description, default: '' + attribute :pwa_name, default: '' + attribute :pwa_short_name, default: '' + attribute :pwa_description, default: '' attribute :new_project_guidelines, default: '' attribute :profile_image_guidelines, default: '' attribute :header_message, default: '' @@ -22,6 +26,24 @@ class Appearance < ApplicationRecord cache_markdown_field :header_message, pipeline: :broadcast_message cache_markdown_field :footer_message, pipeline: :broadcast_message + validates :pwa_name, + length: { maximum: 255, too_long: ->(object, data) { + N_("is too long (maximum is %{count} characters)") + } }, + allow_blank: true + + validates :pwa_short_name, + length: { maximum: 255, too_long: ->(object, data) { + N_("is too long (maximum is %{count} characters)") + } }, + allow_blank: true + + validates :pwa_description, + length: { maximum: 2048, too_long: ->(object, data) { + N_("is too long (maximum is %{count} characters)") + } }, + allow_blank: true + validates :logo, file_size: { maximum: 1.megabyte } validates :pwa_icon, file_size: { maximum: 1.megabyte } validates :header_logo, file_size: { maximum: 1.megabyte } @@ -47,6 +69,12 @@ class Appearance < ApplicationRecord end end + def pwa_icon_path_scaled(width) + return unless pwa_icon_path.present? + + pwa_icon_path + "?width=#{width}" + end + def logo_path logo_system_path(logo, 'logo') end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 198a3653cd3..291375f647c 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -36,7 +36,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.pluck_primary_key - where(nil).pluck(self.primary_key) + where(nil).pluck(primary_key) end def self.safe_ensure_unique(retries: 0) @@ -95,7 +95,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.underscore - Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore } + Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { to_s.underscore } end def self.where_exists(query) @@ -111,7 +111,7 @@ class ApplicationRecord < ActiveRecord::Base end def self.cached_column_list - self.column_names.map { |column_name| self.arel_table[column_name] } + column_names.map { |column_name| arel_table[column_name] } end def self.default_select_columns diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 59ad0650eb3..98adbd3ab06 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ApplicationSetting < ApplicationRecord +class ApplicationSetting < MainClusterwide::ApplicationRecord include CacheableAttributes include CacheMarkdownField include TokenAuthenticatable @@ -12,6 +12,8 @@ class ApplicationSetting < ApplicationRecord 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_column :clickhouse_connection_string, remove_with: '15.11', remove_after: '2023-04-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -415,6 +417,10 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 90, message: N_("'%{value}' days of inactivity must be greater than or equal to 90") }, if: :deactivate_dormant_users? + validates :allow_possible_spam, + allow_nil: false, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + Gitlab::SSHPublicKey.supported_types.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -676,6 +682,7 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :jitsu_administrator_password, 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) + attr_encrypted :product_analytics_clickhouse_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) validates :disable_feed_token, inclusion: { in: [true, false], message: N_('must be a boolean value') } diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 8ef7e9a92a8..a5f262f2e1e 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -33,7 +33,7 @@ module ApplicationSettingImplementation DEFAULT_MINIMUM_PASSWORD_LENGTH = 8 class_methods do - def defaults + def defaults # rubocop:disable Metrics/AbcSize { admin_mode: false, after_sign_up_text: nil, @@ -41,6 +41,7 @@ module ApplicationSettingImplementation akismet_api_key: nil, allow_local_requests_from_system_hooks: true, allow_local_requests_from_web_hooks_and_services: false, + allow_possible_spam: false, asset_proxy_enabled: false, authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand commit_email_hostname: default_commit_email_hostname, @@ -105,6 +106,7 @@ module ApplicationSettingImplementation invisible_captcha_enabled: false, issues_create_limit: 300, jira_connect_application_key: nil, + jira_connect_public_key_storage_enabled: false, jira_connect_proxy_url: nil, local_markdown_version: 0, login_recaptcha_protection_enabled: false, @@ -248,7 +250,13 @@ module ApplicationSettingImplementation bulk_import_enabled: false, allow_runner_registration_token: true, user_defaults_to_private_profile: false - } + }.tap do |hsh| + hsh.merge!(non_production_defaults) unless Rails.env.production? + end + end + + def non_production_defaults + {} end def default_commit_email_hostname @@ -296,11 +304,11 @@ module ApplicationSettingImplementation end def domain_allowlist_raw - array_to_string(self.domain_allowlist) + array_to_string(domain_allowlist) end def domain_denylist_raw - array_to_string(self.domain_denylist) + array_to_string(domain_denylist) end def domain_allowlist_raw=(values) @@ -316,7 +324,7 @@ module ApplicationSettingImplementation end def outbound_local_requests_allowlist_raw - array_to_string(self.outbound_local_requests_whitelist) + array_to_string(outbound_local_requests_whitelist) end def outbound_local_requests_allowlist_raw=(values) @@ -349,7 +357,7 @@ module ApplicationSettingImplementation end def protected_paths_raw - array_to_string(self.protected_paths) + array_to_string(protected_paths) end def protected_paths_raw=(values) @@ -357,7 +365,7 @@ module ApplicationSettingImplementation end def notes_create_limit_allowlist_raw - array_to_string(self.notes_create_limit_allowlist) + array_to_string(notes_create_limit_allowlist) end def notes_create_limit_allowlist_raw=(values) @@ -365,7 +373,7 @@ module ApplicationSettingImplementation end def users_get_by_id_limit_allowlist_raw - array_to_string(self.users_get_by_id_limit_allowlist) + array_to_string(users_get_by_id_limit_allowlist) end def users_get_by_id_limit_allowlist_raw=(values) @@ -516,12 +524,6 @@ module ApplicationSettingImplementation static_objects_external_storage_url.present? end - # This will eventually be configurable - # https://gitlab.com/gitlab-org/gitlab/issues/208161 - def web_ide_clientside_preview_bundler_url - 'https://sandbox-prod.gitlab-static.net' - end - def ensure_key_restrictions! return if Gitlab::Database.read_only? return unless Gitlab::FIPS.enabled? @@ -535,7 +537,7 @@ module ApplicationSettingImplementation def set_max_key_restriction!(key_type) attr_name = "#{key_type}_key_restriction" - current = self.attributes[attr_name].to_i + current = attributes[attr_name].to_i return if current == KeyRestrictionValidator::FORBIDDEN @@ -548,7 +550,7 @@ module ApplicationSettingImplementation [min_size, current].max end - self.assign_attributes({ attr_name => new_value }) + assign_attributes({ attr_name => new_value }) end def separate_allowlists(string_array) diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 5cc87be388f..3312216932b 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -55,7 +55,7 @@ class AuditEvent < ApplicationRecord end def initialize_details - return unless self.has_attribute?(:details) + return unless has_attribute?(:details) self.details = {} if details&.nil? end @@ -65,7 +65,9 @@ class AuditEvent < ApplicationRecord end def formatted_details - details.merge(details.slice(:from, :to).transform_values(&:to_s)) + details + .merge(details.slice(:from, :to).transform_values(&:to_s)) + .merge(author_email: author.try(:email)) end def author @@ -74,7 +76,7 @@ class AuditEvent < ApplicationRecord def lazy_author BatchLoader.for(author_id).batch do |author_ids, loader| - User.select(:id, :name, :username).where(id: author_ids).find_each do |user| + User.select(:id, :name, :username, :email).where(id: author_ids).find_each do |user| loader.call(user.id, user) end end @@ -82,7 +84,7 @@ class AuditEvent < ApplicationRecord def as_json(options = {}) super(options).tap do |json| - json['ip_address'] = self.ip_address.to_s + json['ip_address'] = ip_address.to_s end end @@ -114,10 +116,10 @@ class AuditEvent < ApplicationRecord def parallel_persist PARALLEL_PERSISTENCE_COLUMNS.each do |name| - original = self[name] || self.details[name] + original = self[name] || details[name] next unless original - self[name] = self.details[name] = original + self[name] = details[name] = original end end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index f41f0a8be84..dbc5c7a584e 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -55,11 +55,11 @@ class AwardEmoji < ApplicationRecord end def downvote? - self.name == DOWNVOTE_NAME + name == DOWNVOTE_NAME end def upvote? - self.name == UPVOTE_NAME + name == UPVOTE_NAME end def url diff --git a/app/models/board.rb b/app/models/board.rb index 8a7330e7320..2181b2f0545 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -18,7 +18,7 @@ class Board < ApplicationRecord # Sort by case-insensitive name, then ascending ids. This ensures that we will always # get the same list/first board no matter how many other boards are named the same scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) } - scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) } + scope :first_board, -> { where(id: order_by_name_asc.limit(1).select(:id)) } def project_needed? !group diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index ebca5e90313..6fc24c77f1d 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -39,9 +39,28 @@ class BulkImports::Entity < ApplicationRecord validates :project, absence: true, if: :group validates :group, absence: true, if: :project - validates :source_type, :source_full_path, :destination_name, presence: true - validates :destination_namespace, exclusion: [nil], if: :group - validates :destination_namespace, presence: true, if: :project + validates :source_type, presence: true + validates :source_full_path, + presence: true, + format: { with: Gitlab::Regex.bulk_import_source_full_path_regex, + message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message } + + validates :destination_name, + presence: true, + format: { with: Gitlab::Regex.group_path_regex, + message: Gitlab::Regex.group_path_regex_message } + + validates :destination_namespace, + exclusion: [nil], + format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex, + message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message }, + if: :group + + validates :destination_namespace, + presence: true, + format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex, + message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message }, + if: :project validate :validate_parent_is_a_group, if: :parent validate :validate_imported_entity_type @@ -57,6 +76,10 @@ class BulkImports::Entity < ApplicationRecord alias_attribute :destination_slug, :destination_name + delegate :default_project_visibility, + :default_group_visibility, + to: :'Gitlab::CurrentSettings.current_application_settings' + state_machine :status, initial: :created do state :created, value: 0 state :started, value: 1 @@ -156,6 +179,12 @@ class BulkImports::Entity < ApplicationRecord project? ? project&.full_path : group&.full_path end + def default_visibility_level + return default_group_visibility if group? + + default_project_visibility + end + private def validate_parent_is_a_group diff --git a/app/models/ci/application_record.rb b/app/models/ci/application_record.rb index ea7b1104e36..52f02bfb2fd 100644 --- a/app/models/ci/application_record.rb +++ b/app/models/ci/application_record.rb @@ -13,7 +13,7 @@ module Ci end def self.model_name - @model_name ||= ActiveModel::Name.new(self, nil, self.name.demodulize) + @model_name ||= ActiveModel::Name.new(self, nil, name.demodulize) end end end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 4af31fd37f2..697f06fbffd 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -55,7 +55,11 @@ module Ci end def retryable? - false + return false unless Feature.enabled?(:ci_recreate_downstream_pipeline, project) + + return false if failed? && (pipeline_loop_detected? || reached_max_descendant_pipelines_depth?) + + super end def self.with_preloads @@ -76,9 +80,9 @@ module Ci def inherit_status_from_downstream!(pipeline) case pipeline.status when 'success' - self.success! + success! when 'failed', 'canceled', 'skipped' - self.drop! + drop! else false end @@ -186,6 +190,10 @@ module Ci def persisted_environment end + def deployment_job? + false + end + def execute_hooks raise NotImplementedError end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 0139b025d98..f8b3777841d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -34,11 +34,11 @@ module Ci DEPLOYMENT_NAMES = %w[deploy release rollout].freeze has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable - has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build + 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 has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build - has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build + has_many :report_results, class_name: 'Ci::BuildReportResult', foreign_key: :build_id, inverse_of: :build has_one :namespace, through: :project # Projects::DestroyService destroys Ci::Pipelines, which use_fast_destroy on :job_artifacts @@ -49,16 +49,18 @@ module Ci has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id - has_many :pages_deployments, inverse_of: :ci_build + has_many :pages_deployments, foreign_key: :ci_build_id, inverse_of: :ci_build Ci::JobArtifact.file_types.each do |key, value| - has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id + has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', foreign_key: :job_id, inverse_of: :job end - has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build - has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build + has_one :runner_machine, through: :metadata, class_name: 'Ci::RunnerMachine' - has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', inverse_of: :build, foreign_key: :ci_build_id + has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, foreign_key: :build_id, inverse_of: :build + has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', foreign_key: :build_id, inverse_of: :build + + has_many :terraform_state_versions, class_name: 'Terraform::StateVersion', foreign_key: :ci_build_id, inverse_of: :build accepts_nested_attributes_for :runner_session, update_only: true accepts_nested_attributes_for :job_variables @@ -88,6 +90,12 @@ module Ci scope :unstarted, -> { where(runner_id: nil) } + scope :with_any_artifacts, -> do + where('EXISTS (?)', + Ci::JobArtifact.select(1).where("#{Ci::Build.quoted_table_name}.id = #{Ci::JobArtifact.quoted_table_name}.job_id") + ) + end + scope :with_downloadable_artifacts, -> do where('EXISTS (?)', Ci::JobArtifact.select(1) @@ -179,6 +187,8 @@ module Ci run_after_commit { build.execute_hooks } end + after_commit :track_ci_secrets_management_id_tokens_usage, on: :create, if: :id_tokens? + class << self # This is needed for url_for to work, # as the controller is JobsController @@ -382,21 +392,21 @@ module Ci def detailed_status(current_user) Gitlab::Ci::Status::Build::Factory - .new(self.present, current_user) + .new(present, current_user) .fabricate! end def other_manual_actions - pipeline.manual_actions.reject { |action| action.name == self.name } + pipeline.manual_actions.reject { |action| action.name == name } end def other_scheduled_actions - pipeline.scheduled_actions.reject { |action| action.name == self.name } + pipeline.scheduled_actions.reject { |action| action.name == name } end def pages_generator? Gitlab.config.pages.enabled && - self.name == 'pages' + name == 'pages' end def runnable? @@ -452,7 +462,7 @@ module Ci end def retries_count - pipeline.builds.retried.where(name: self.name).count + pipeline.builds.retried.where(name: name).count end override :all_met_to_become_pending? @@ -525,19 +535,19 @@ module Ci end def deployment_job? - has_environment_keyword? && self.environment_action == 'start' + has_environment_keyword? && environment_action == 'start' end def stops_environment? - has_environment_keyword? && self.environment_action == 'stop' + has_environment_keyword? && environment_action == 'stop' end def environment_action - self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options + options.fetch(:environment, {}).fetch(:action, 'start') if options end def environment_tier_from_options - self.options.dig(:environment, :deployment_tier) if self.options + options.dig(:environment, :deployment_tier) if options end def environment_tier @@ -827,7 +837,7 @@ module Ci end def erased? - !self.erased_at.nil? + !erased_at.nil? end def artifacts_expired? @@ -860,14 +870,14 @@ module Ci end def keep_artifacts! - self.update(artifacts_expire_at: nil) - self.job_artifacts.update_all(expire_at: nil) + update(artifacts_expire_at: nil) + job_artifacts.update_all(expire_at: nil) end - def artifacts_file_for_type(type) + def artifact_for_type(type) file_types = Ci::JobArtifact.associated_file_types_for(type) file_types_ids = file_types&.map { |file_type| Ci::JobArtifact.file_types[file_type] } - job_artifacts.find_by(file_type: file_types_ids)&.file + job_artifacts.find_by(file_type: file_types_ids) end def steps @@ -1092,11 +1102,11 @@ module Ci # without actually loading data. # def all_queuing_entries - ::Ci::PendingBuild.where(build_id: self.id) + ::Ci::PendingBuild.where(build_id: id) end def all_runtime_metadata - ::Ci::RunningBuild.where(build_id: self.id) + ::Ci::RunningBuild.where(build_id: id) end def shared_runner_build? @@ -1281,6 +1291,23 @@ module Ci .increment(status: status) end end + + def track_ci_secrets_management_id_tokens_usage + ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event('i_ci_secrets_management_id_tokens_build_created', values: user_id) + + Gitlab::Tracking.event( + self.class.to_s, + 'create_id_tokens', + namespace: namespace, + user: user, + label: 'redis_hll_counters.ci_secrets_management.i_ci_secrets_management_id_tokens_build_created_monthly', + ultimate_namespace_id: namespace.root_ancestor.id, + context: [Gitlab::Tracking::ServicePingContext.new( + data_source: :redis_hll, + event: 'i_ci_secrets_management_id_tokens_build_created' + ).to_context] + ) + end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 1dcb9190f11..b294afd405d 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -18,6 +18,7 @@ module Ci belongs_to :build, class_name: 'CommitStatus' belongs_to :project + belongs_to :runner_machine, class_name: 'Ci::RunnerMachine' before_create :set_build_project @@ -67,7 +68,7 @@ module Ci private def set_build_project - self.project_id ||= self.build.project_id + self.project_id ||= build.project_id end def timeout_with_highest_precedence diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 3fa17d6d286..03d1bd14bfb 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -4,6 +4,9 @@ module Ci class BuildNeed < Ci::ApplicationRecord include Ci::Partitionable include BulkInsertSafe + include IgnorableColumns + + ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-04-22' belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 20c0b04e228..5773b6132be 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -20,7 +20,7 @@ module Ci validates :url, public_url: { schemes: %w(https) } def terminal_specification - wss_url = Gitlab::UrlHelpers.as_wss(Addressable::URI.escape(self.url)) + wss_url = Gitlab::UrlHelpers.as_wss(Addressable::URI.escape(url)) return {} unless wss_url.present? parsed_wss_url = URI.parse(wss_url) @@ -33,7 +33,7 @@ module Ci port = port.presence || DEFAULT_PORT_NAME service = service.presence || DEFAULT_SERVICE_NAME - parsed_url = URI.parse(Addressable::URI.escape(self.url)) + parsed_url = URI.parse(Addressable::URI.escape(url)) parsed_url.path += "/proxy/#{service}/#{port}/#{path}" subprotocols = subprotocols.presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index c5f6e54c336..541a8b5bffa 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -108,7 +108,7 @@ module Ci raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 return if offset == size # Skip the following process as it doesn't affect anything - self.append(+"", offset) + append(+"", offset) end def append(new_data, offset) @@ -166,7 +166,7 @@ module Ci raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save? self.class.with_read_consistency(build) do - self.reset.then(&:unsafe_persist_data!) + reset.then(&:unsafe_persist_data!) end end rescue FailedToObtainLockError @@ -205,9 +205,9 @@ module Ci end def <=>(other) - return unless self.build_id == other.build_id + return unless build_id == other.build_id - self.chunk_index <=> other.chunk_index + chunk_index <=> other.chunk_index end protected diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb index d36646aba66..2b5452c803a 100644 --- a/app/models/ci/deleted_object.rb +++ b/app/models/ci/deleted_object.rb @@ -21,7 +21,7 @@ module Ci accumulator << record if record[:store_dir] && record[:file] end - self.insert_all(attributes) if attributes.any? + insert_all(attributes) if attributes.any? end def delete_file_from_storage diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 508aaa5a63c..b03c46a164f 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -3,9 +3,11 @@ module Ci class GroupVariable < Ci::ApplicationRecord include Ci::HasVariable - include Presentable include Ci::Maskable include Ci::RawVariable + include Limitable + include Presentable + prepend HasEnvironmentScope belongs_to :group, class_name: "::Group" @@ -21,6 +23,9 @@ module Ci scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } scope :for_groups, ->(group_ids) { where(group_id: group_ids) } + self.limit_name = 'group_ci_variables' + self.limit_scope = :group + def audit_details key end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 0dca5b18a24..89a3d269a43 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -134,15 +134,17 @@ module Ci belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id - # We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597 - ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22' - mount_file_store_uploader JobArtifactUploader, skip_store_file: true before_save :set_size, if: :file_changed? after_save :store_file_in_transaction!, unless: :store_after_commit? + + after_create_commit :log_create + after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit? + after_destroy_commit :log_destroy + validates :job, presence: true validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create @@ -384,6 +386,14 @@ module Ci # Use job.project to avoid extra DB query for project job.project.pending_delete? end + + def log_create + Gitlab::Ci::Artifacts::Logger.log_created(self) + end + + def log_destroy + Gitlab::Ci::Artifacts::Logger.log_deleted(self, __method__) + end end end diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb index 9e9a0a68ebd..618dc2da05c 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -17,6 +17,15 @@ module Ci Project.from_union(target_projects, remove_duplicates: false) end + def add!(target_project, user:) + Ci::JobToken::ProjectScopeLink.create!( + source_project: @source_project, + direction: @direction, + target_project: target_project, + added_by: user + ) + end + private def source_links diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index b784f93651a..96e370bba1e 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -1,24 +1,31 @@ # frozen_string_literal: true -# The connection between a source project (which defines the job token scope) -# and a target project which is the one allowed to be accessed by the job token. +# The connection between a source project (which the job token scope's allowlist applies too) +# and a target project which is added to the scope's allowlist. module Ci module JobToken class ProjectScopeLink < Ci::ApplicationRecord self.table_name = 'ci_job_token_project_scope_links' + PROJECT_LINK_DIRECTIONAL_LIMIT = 100 + belongs_to :source_project, class_name: 'Project' + # the project added to the scope's allowlist belongs_to :target_project, class_name: 'Project' belongs_to :added_by, class_name: 'User' - scope :with_source, ->(project) { where(source_project: project) } - scope :with_target, ->(project) { where(target_project: project) } + scope :with_access_direction, ->(direction) { where(direction: direction) } + scope :with_source, ->(project) { where(source_project: project) } + scope :with_target, ->(project) { where(target_project: project) } validates :source_project, presence: true validates :target_project, presence: true validate :not_self_referential_link + validate :source_project_under_link_limit, on: :create + # When outbound the target project is allowed to be accessed by the source job token. + # When inbound the source project is allowed to be accessed by the target job token. enum direction: { outbound: 0, inbound: 1 @@ -37,6 +44,16 @@ module Ci self.errors.add(:target_project, _("can't be the same as the source project")) end end + + def source_project_under_link_limit + return unless source_project + + existing_links_count = self.class.with_source(source_project).with_access_direction(direction).count + + if existing_links_count >= PROJECT_LINK_DIRECTIONAL_LIMIT + errors.add(:source_project, "exceeds the allowable number of project links in this direction") + end + end end end end diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index e320c0f92d1..20775077bd8 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -2,18 +2,17 @@ # This model represents the scope of access for a CI_JOB_TOKEN. # -# A scope is initialized with a project. +# A scope is initialized with a current project. # # Projects can be added to the scope by adding ScopeLinks to # create an allowlist of projects in either access direction (inbound, outbound). # -# Currently, projects in the outbound allowlist can be accessed via the token -# in the source project. +# Projects in the outbound allowlist can be accessed via the current project's job token. # -# TODO(Issue #346298) Projects in the inbound allowlist can use their token to access -# the source project. +# Projects in the inbound allowlist can use their project's job token to +# access the current project. # -# CI_JOB_TOKEN should be considered untrusted without these features enabled. +# CI_JOB_TOKEN should be considered untrusted without a scope enabled. # module Ci @@ -25,34 +24,70 @@ module Ci @current_project = current_project end - def allows?(accessed_project) - self_referential?(accessed_project) || outbound_allows?(accessed_project) + def accessible?(accessed_project) + self_referential?(accessed_project) || ( + outbound_accessible?(accessed_project) && + inbound_accessible?(accessed_project) + ) end def outbound_projects outbound_allowlist.projects end - # Deprecated: use outbound_projects, TODO(Issue #346298) remove references to all_project - def all_projects - outbound_projects + def inbound_projects + inbound_allowlist.projects + end + + def add!(added_project, user:, direction:) + case direction + when :inbound + inbound_allowlist.add!(added_project, user: user) + when :outbound + outbound_allowlist.add!(added_project, user: user) + end end private - def outbound_allows?(accessed_project) + def outbound_accessible?(accessed_project) # if the setting is disabled any project is considered to be in scope. - return true unless @current_project.ci_outbound_job_token_scope_enabled? + return true unless current_project.ci_outbound_job_token_scope_enabled? outbound_allowlist.includes?(accessed_project) end + def inbound_accessible?(accessed_project) + # if the flag or setting is disabled any project is considered to be in scope. + return true unless Feature.enabled?(:ci_inbound_job_token_scope, accessed_project) + return true unless accessed_project.ci_inbound_job_token_scope_enabled? + + inbound_linked_as_accessible?(accessed_project) + end + + # We don't check the inbound allowlist here. That is because + # the access check starts from the current project but the inbound + # allowlist contains projects that can access the current project. + def inbound_linked_as_accessible?(accessed_project) + inbound_accessible_projects(accessed_project).includes?(current_project) + end + + def inbound_accessible_projects(accessed_project) + Ci::JobToken::Allowlist.new(accessed_project, direction: :inbound) + end + + # User created list of projects allowed to access the current project + def inbound_allowlist + Ci::JobToken::Allowlist.new(current_project, direction: :inbound) + end + + # User created list of projects that can be accessed from the current project def outbound_allowlist - Ci::JobToken::Allowlist.new(@current_project, direction: :outbound) + Ci::JobToken::Allowlist.new(current_project, direction: :outbound) end def self_referential?(accessed_project) - @current_project.id == accessed_project.id + current_project.id == accessed_project.id end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index eab2ab69e44..bd426e02b9c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -355,7 +355,7 @@ module Ci scope :for_name, -> (name) do name_column = Ci::PipelineMetadata.arel_table[:name] - joins(:pipeline_metadata).where(name_column.lower.eq(name.downcase)) + joins(:pipeline_metadata).where(name_column.eq(name)) end scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } @@ -498,6 +498,10 @@ module Ci 100 end + def self.object_hierarchy(relation, options = {}) + ::Gitlab::Ci::PipelineObjectHierarchy.new(relation, options: options) + end + def uses_needs? processables.where(scheduling_type: :dag).any? end @@ -841,97 +845,6 @@ module Ci end end - def predefined_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) - variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - variables.append(key: 'CI_PIPELINE_CREATED_AT', value: created_at&.iso8601) - - variables.concat(predefined_commit_variables) - variables.concat(predefined_merge_request_variables) - - if open_merge_requests_refs.any? - variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(',')) - end - - variables.append(key: 'CI_GITLAB_FIPS_MODE', value: 'true') if Gitlab::FIPS.enabled? - - variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active? - variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period? - - if external_pull_request_event? && external_pull_request - variables.concat(external_pull_request.predefined_variables) - end - end - end - - def predefined_commit_variables - strong_memoize(:predefined_commit_variables) do - Gitlab::Ci::Variables::Collection.new.tap do |variables| - next variables unless sha.present? - - variables.append(key: 'CI_COMMIT_SHA', value: sha) - variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) - variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref) - variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug) - variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch? - variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) - variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s) - variables.append(key: 'CI_COMMIT_TIMESTAMP', value: git_commit_timestamp.to_s) - variables.append(key: 'CI_COMMIT_AUTHOR', value: git_author_full_text.to_s) - - # legacy variables - variables.append(key: 'CI_BUILD_REF', value: sha) - variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha) - variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref) - variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug) - - variables.concat(predefined_commit_tag_variables) - end - end - end - - def predefined_merge_request_variables - strong_memoize(:predefined_merge_request_variables) do - Gitlab::Ci::Variables::Collection.new.tap do |variables| - next variables unless merge_request? - - variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s) - variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) - variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) - - diff = self.merge_request_diff - if diff.present? - variables.append(key: 'CI_MERGE_REQUEST_DIFF_ID', value: diff.id.to_s) - variables.append(key: 'CI_MERGE_REQUEST_DIFF_BASE_SHA', value: diff.base_commit_sha) - end - - variables.concat(merge_request.predefined_variables) - end - end - end - - def predefined_commit_tag_variables - strong_memoize(:predefined_commit_ref_variables) do - Gitlab::Ci::Variables::Collection.new.tap do |variables| - next variables unless tag? - - git_tag = project.repository.find_tag(ref) - - next variables unless git_tag - - variables.append(key: 'CI_COMMIT_TAG', value: ref) - variables.append(key: 'CI_COMMIT_TAG_MESSAGE', value: git_tag.message) - - # legacy variable - variables.append(key: 'CI_BUILD_TAG', value: ref) - end - end - end - def queued_duration return unless started_at @@ -1403,6 +1316,12 @@ module Ci (Time.current - created_at).ceil / 60 end + def merge_request_diff + return unless merge_request? + + merge_request.merge_request_diff_for(merge_request_diff_sha) + end + private def cancel_jobs(jobs, retries: 1, auto_canceled_by_pipeline_id: nil) @@ -1455,12 +1374,6 @@ module Ci end end - def merge_request_diff - return unless merge_request? - - merge_request.merge_request_diff_for(merge_request_diff_sha) - end - def push_details strong_memoize(:push_details) do Gitlab::Git::Push.new(project, before_sha, sha, git_ref) diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index bac85b6095e..09ac0fa69e7 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -15,6 +15,8 @@ module Ci include EachBatch include Ci::HasRunnerExecutor + extend ::Gitlab::Utils::Override + add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration enum access_level: { @@ -28,6 +30,14 @@ module Ci project_type: 3 } + enum registration_type: { + registration_token: 0, + authenticated_user: 1 + }, _suffix: true + + # Prefix assigned to runners created from the UI, instead of registered via the command line + CREATED_RUNNER_TOKEN_PREFIX = 'glrt-' + # This `ONLINE_CONTACT_TIMEOUT` needs to be larger than # `RUNNER_QUEUE_EXPIRY_TIME+UPDATE_CONTACT_COLUMN_EVERY` # @@ -179,6 +189,7 @@ module Ci validate :tag_constraints validates :access_level, presence: true validates :runner_type, presence: true + validates :registration_type, presence: true validate :no_projects, unless: :project_type? validate :no_groups, unless: :group_type? @@ -373,7 +384,10 @@ module Ci end def short_sha - token[0...8] if token + return unless token + + start_index = authenticated_user_registration_type? ? CREATED_RUNNER_TOKEN_PREFIX.length : 0 + token[start_index..start_index + 8] end def tag_list @@ -474,6 +488,17 @@ module Ci end end + override :format_token + def format_token(token) + return token if registration_token_registration_type? + + "#{CREATED_RUNNER_TOKEN_PREFIX}#{token}" + end + + def ensure_machine(system_xid, &blk) + RunnerMachine.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods + end + private scope :with_upgrade_status, ->(upgrade_status) do @@ -566,6 +591,9 @@ module Ci end end + # TODO Remove in 16.0 when runners are known to send a system_id + # For now, heartbeats with version updates might result in two Sidekiq jobs being queued if a runner has a system_id + # This is not a problem since the jobs are deduplicated on the version def schedule_runner_version_update return unless version diff --git a/app/models/ci/runner_machine.rb b/app/models/ci/runner_machine.rb index 1dd997a8ee1..e52659a011f 100644 --- a/app/models/ci/runner_machine.rb +++ b/app/models/ci/runner_machine.rb @@ -3,12 +3,24 @@ module Ci class RunnerMachine < Ci::ApplicationRecord include FromUnion + include RedisCacheable include Ci::HasRunnerExecutor + include IgnorableColumns + + ignore_column :machine_xid, remove_with: '15.11', remove_after: '2022-03-22' + + # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated + UPDATE_CONTACT_COLUMN_EVERY = 40.minutes..55.minutes belongs_to :runner + has_many :build_metadata, class_name: 'Ci::BuildMetadata' + has_many :builds, through: :build_metadata, class_name: 'Ci::Build' + belongs_to :runner_version, inverse_of: :runner_machines, primary_key: :version, foreign_key: :version, + class_name: 'Ci::RunnerVersion' + validates :runner, presence: true - validates :machine_xid, presence: true, length: { maximum: 64 } + validates :system_xid, presence: true, length: { maximum: 64 } validates :version, length: { maximum: 2048 } validates :revision, length: { maximum: 255 } validates :platform, length: { maximum: 255 } @@ -16,6 +28,8 @@ module Ci validates :ip_address, length: { maximum: 1024 } validates :config, json_schema: { filename: 'ci_runner_config' } + cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type + # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner machine # will be considered stale STALE_TIMEOUT = 7.days @@ -29,5 +43,46 @@ module Ci where(contacted_some_time_ago), remove_duplicates: false).where(created_some_time_ago) end + + def heartbeat(values) + ## + # We can safely ignore writes performed by a runner heartbeat. We do + # not want to upgrade database connection proxy to use the primary + # database after heartbeat write happens. + # + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {} + values[:contacted_at] = Time.current + if values.include?(:executor) + values[:executor_type] = Ci::Runner::EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown) + end + + version_changed = values.include?(:version) && values[:version] != version + + cache_attributes(values) + + schedule_runner_version_update if version_changed + + # We save data without validation, it will always change due to `contacted_at` + update_columns(values) if persist_cached_data? + end + end + + private + + def persist_cached_data? + # Use a random threshold to prevent beating DB updates. + contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY) + + real_contacted_at = read_attribute(:contacted_at) + real_contacted_at.nil? || + (Time.current - real_contacted_at) >= contacted_at_max_age + end + + def schedule_runner_version_update + return unless version + + Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(version) + end end end diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb index bbde98ee591..ec42f46b165 100644 --- a/app/models/ci/runner_version.rb +++ b/app/models/ci/runner_version.rb @@ -8,24 +8,23 @@ module Ci enum_with_nil status: { not_processed: nil, invalid_version: -1, - not_available: 1, + unavailable: 1, available: 2, recommended: 3 } STATUS_DESCRIPTIONS = { invalid_version: 'Runner version is not valid.', - not_available: 'Upgrade is not available for the runner.', + unavailable: 'Upgrade is not available for the runner.', available: 'Upgrade is available for the runner.', recommended: 'Upgrade is available and recommended for the runner.' }.freeze - # Override auto generated negative scope (from available) so the scope has expected behavior - scope :not_available, -> { where(status: :not_available) } + has_many :runner_machines, inverse_of: :runner_version, foreign_key: :version, class_name: 'Ci::RunnerMachine' # This scope returns all versions that might need recalculating. For instance, once a version is considered # :recommended, it normally doesn't change status even if the instance is upgraded - scope :potentially_outdated, -> { where(status: [nil, :not_available, :available]) } + scope :potentially_outdated, -> { where(status: [nil, :unavailable, :available]) } validates :version, length: { maximum: 2048 } end diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index 1e6c48bbef5..5e273e0fd4b 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -35,7 +35,7 @@ module Ci end def file_extension - File.extname(name).delete_prefix('.') + File.extname(name).delete_prefix('.').presence end def metadata_parsable? diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 1092b9c9564..1b2a7dc3fe4 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -21,8 +21,18 @@ module Ci validates :token, presence: true, uniqueness: true validates :owner, presence: true + attr_encrypted :encrypted_token_tmp, + attribute: :encrypted_token, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32, + encode: false, + encode_vi: false + before_validation :set_default_values + before_save :copy_token_to_encrypted_token + def set_default_values self.token = "#{TRIGGER_TOKEN_PREFIX}#{SecureRandom.hex(20)}" if self.token.blank? end @@ -42,6 +52,12 @@ module Ci def can_access_project? Ability.allowed?(self.owner, :create_build, project) end + + private + + def copy_token_to_encrypted_token + self.encrypted_token_tmp = token + end end end diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index f4e17b5d812..23fe89c38df 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -3,9 +3,11 @@ module Ci class Variable < Ci::ApplicationRecord include Ci::HasVariable - include Presentable include Ci::Maskable include Ci::RawVariable + include Limitable + include Presentable + prepend HasEnvironmentScope belongs_to :project @@ -20,6 +22,9 @@ module Ci scope :unprotected, -> { where(protected: false) } scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) } + self.limit_name = 'project_ci_variables' + self.limit_scope = :project + def audit_details key end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb deleted file mode 100644 index 11f84940c38..00000000000 --- a/app/models/clusters/applications/cert_manager.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - # DEPRECATED for removal in %14.0 - # See https://gitlab.com/groups/gitlab-org/-/epics/4280 - class CertManager < ApplicationRecord - VERSION = 'v0.10.1' - CRD_VERSION = '0.10' - - self.table_name = 'clusters_applications_cert_managers' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - attribute :version, default: VERSION - after_initialize :set_default_email, if: :new_record? - - validates :email, presence: true - - def chart - 'certmanager/cert-manager' - end - - def repository - 'https://charts.jetstack.io' - end - - def install_command - helm_command_module::InstallCommand.new( - name: 'certmanager', - repository: repository, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files.merge(cluster_issuer_file), - preinstall: pre_install_script, - postinstall: post_install_script - ) - end - - def uninstall_command - helm_command_module::DeleteCommand.new( - name: 'certmanager', - rbac: cluster.platform_kubernetes_rbac?, - files: files, - postdelete: post_delete_script - ) - end - - private - - def set_default_email - self.email ||= self.cluster&.user&.email - end - - def pre_install_script - [ - apply_file("https://raw.githubusercontent.com/jetstack/cert-manager/release-#{CRD_VERSION}/deploy/manifests/00-crds.yaml"), - "kubectl label --overwrite namespace #{Gitlab::Kubernetes::Helm::NAMESPACE} certmanager.k8s.io/disable-validation=true" - ] - end - - def post_install_script - [retry_command(apply_file('/data/helm/certmanager/config/cluster_issuer.yaml'))] - end - - def retry_command(command) - Gitlab::Kubernetes::PodCmd.retry_command(command, times: 90) - end - - def post_delete_script - [ - delete_private_key, - delete_crd('certificates.certmanager.k8s.io'), - delete_crd('certificaterequests.certmanager.k8s.io'), - delete_crd('challenges.certmanager.k8s.io'), - delete_crd('clusterissuers.certmanager.k8s.io'), - delete_crd('issuers.certmanager.k8s.io'), - delete_crd('orders.certmanager.k8s.io') - ].compact - end - - def private_key_name - @private_key_name ||= cluster_issuer_content.dig('spec', 'acme', 'privateKeySecretRef', 'name') - end - - def delete_private_key - return unless private_key_name.present? - - args = %W(secret -n #{Gitlab::Kubernetes::Helm::NAMESPACE} #{private_key_name} --ignore-not-found) - - Gitlab::Kubernetes::KubectlCmd.delete(*args) - end - - def delete_crd(definition) - Gitlab::Kubernetes::KubectlCmd.delete("crd", definition, "--ignore-not-found") - end - - def apply_file(filename) - Gitlab::Kubernetes::KubectlCmd.apply_file(filename) - end - - def cluster_issuer_file - { - 'cluster_issuer.yaml': cluster_issuer_yaml_content - } - end - - def cluster_issuer_yaml_content - YAML.dump(cluster_issuer_content.deep_merge(cluster_issue_overlay)) - end - - def cluster_issuer_content - YAML.safe_load(File.read(cluster_issuer_file_path)) - end - - def cluster_issue_overlay - { "spec" => { "acme" => { "email" => self.email } } } - end - - def cluster_issuer_file_path - Rails.root.join('vendor', 'cert_manager', 'cluster_issuer.yaml') - end - end - end -end diff --git a/app/models/clusters/applications/cilium.rb b/app/models/clusters/applications/cilium.rb deleted file mode 100644 index 7936b0b18de..00000000000 --- a/app/models/clusters/applications/cilium.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class Cilium < ApplicationRecord - self.table_name = 'clusters_applications_cilium' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - - # Cilium can only be installed and uninstalled through the - # cluster-applications project by triggering CI pipeline for a - # management project. UI operations are not available for such - # applications. More information: - # https://docs.gitlab.com/ee/user/clusters/management_project.html - def allowed_to_uninstall? - false - end - end - end -end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 25d41d68b9e..a35ea6ddb46 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -14,13 +14,11 @@ module Clusters APPLICATIONS = { Clusters::Applications::Helm.application_name => Clusters::Applications::Helm, Clusters::Applications::Ingress.application_name => Clusters::Applications::Ingress, - Clusters::Applications::CertManager.application_name => Clusters::Applications::CertManager, Clusters::Applications::Crossplane.application_name => Clusters::Applications::Crossplane, Clusters::Applications::Prometheus.application_name => Clusters::Applications::Prometheus, Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, - Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, - Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium + Clusters::Applications::Knative.application_name => Clusters::Applications::Knative }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' @@ -58,13 +56,11 @@ module Clusters has_one_cluster_application :helm has_one_cluster_application :ingress - has_one_cluster_application :cert_manager has_one_cluster_application :crossplane has_one_cluster_application :prometheus has_one_cluster_application :runner has_one_cluster_application :jupyter has_one_cluster_application :knative - has_one_cluster_application :cilium has_many :kubernetes_namespaces has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster @@ -91,15 +87,7 @@ module Clusters delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true - delegate :on_creation?, to: :provider, allow_nil: true - delegate :knative_pre_installed?, to: :provider, allow_nil: true - - delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true - delegate :rbac?, to: :platform_kubernetes, prefix: true, allow_nil: true - delegate :available?, to: :application_helm, prefix: true, allow_nil: true - delegate :available?, to: :application_ingress, prefix: true, allow_nil: true - delegate :available?, to: :application_knative, prefix: true, allow_nil: true - delegate :available?, to: :integration_prometheus, prefix: true, allow_nil: true + delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -245,7 +233,7 @@ module Clusters end def persisted_applications - APPLICATIONS_ASSOCIATIONS.map(&method(:public_send)).compact + APPLICATIONS_ASSOCIATIONS.filter_map { |association_name| public_send(association_name) } # rubocop:disable GitlabSecurity/PublicSend end def applications @@ -266,6 +254,38 @@ module Clusters integration_prometheus || build_integration_prometheus end + def on_creation? + !!provider&.on_creation? + end + + def knative_pre_installed? + !!provider&.knative_pre_installed? + end + + def platform_kubernetes_active? + !!platform_kubernetes&.active? + end + + def platform_kubernetes_rbac? + !!platform_kubernetes&.rbac? + end + + def application_helm_available? + !!application_helm&.available? + end + + def application_ingress_available? + !!application_ingress&.available? + end + + def application_knative_available? + !!application_knative&.available? + end + + def integration_prometheus_available? + !!integration_prometheus&.available? + end + def provider if gcp? provider_gcp diff --git a/app/models/commit.rb b/app/models/commit.rb index a95ab756600..4517b3ef216 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -501,8 +501,8 @@ class Commit end end - def raw_diffs(*args) - raw.diffs(*args) + def raw_diffs(...) + raw.diffs(...) end def raw_deltas diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 64e585bae14..333a176b8f3 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -108,6 +108,8 @@ class CommitStatus < Ci::ApplicationRecord # These are pages deployments and external statuses. # before_create unless: :importing? do + next if Feature.enabled?(:ci_remove_ensure_stage_service, project) + # rubocop: disable CodeReuse/ServiceClass Ci::EnsureStageService.new(project, user).execute(self) do |stage| self.run_after_commit { StageUpdateWorker.perform_async(stage.id) } diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb index 680d20b61cf..4d464f353ee 100644 --- a/app/models/commit_user_mention.rb +++ b/app/models/commit_user_mention.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true class CommitUserMention < UserMention + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + belongs_to :note end diff --git a/app/models/compare.rb b/app/models/compare.rb index f594a796987..f03390334f4 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -79,8 +79,8 @@ class Compare commit&.sha end - def raw_diffs(*args) - @compare.diffs(*args) + def raw_diffs(...) + @compare.diffs(...) end def diffs(diff_options = nil) diff --git a/app/models/concerns/analytics/cycle_analytics/stageable.rb b/app/models/concerns/analytics/cycle_analytics/stageable.rb index d1f948d1366..caac4f31e1a 100644 --- a/app/models/concerns/analytics/cycle_analytics/stageable.rb +++ b/app/models/concerns/analytics/cycle_analytics/stageable.rb @@ -4,7 +4,6 @@ module Analytics module CycleAnalytics module Stageable extend ActiveSupport::Concern - include RelativePositioning include Gitlab::Utils::StrongMemoize included do @@ -92,10 +91,6 @@ module Analytics end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s) end - def find_with_same_parent!(id) - parent.cycle_analytics_stages.find(id) - end - private def validate_stage_event_pairs diff --git a/app/models/concerns/ci/has_variable.rb b/app/models/concerns/ci/has_variable.rb index 3b437fbba16..77e7e5035a0 100644 --- a/app/models/concerns/ci/has_variable.rb +++ b/app/models/concerns/ci/has_variable.rb @@ -18,6 +18,7 @@ module Ci scope :by_key, -> (key) { where(key: key) } scope :order_key_asc, -> { reorder(key: :asc) } + scope :order_key_desc, -> { reorder(key: :desc) } attr_encrypted :value, mode: :per_attribute_iv_and_salt, @@ -30,6 +31,16 @@ module Ci end end + class_methods do + def order_by(method) + case method.to_s + when 'key_asc' then order_key_asc + when 'key_desc' then order_key_desc + else all + end + end + end + def to_runner_variable var_cache_key = to_runner_variable_cache_key diff --git a/app/models/concerns/ci/maskable.rb b/app/models/concerns/ci/maskable.rb index 62be0150ee0..e2cef0981d1 100644 --- a/app/models/concerns/ci/maskable.rb +++ b/app/models/concerns/ci/maskable.rb @@ -12,10 +12,28 @@ module Ci # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~' # * Absolutely no fun is allowed REGEX = %r{\A[a-zA-Z0-9_+=/@:.~-]{8,}\z}.freeze + # * Single line + # * No spaces + # * Minimal length of 8 characters + # * Some fun is allowed + MASK_AND_RAW_REGEX = %r{\A\S{8,}\z}.freeze included do validates :masked, inclusion: { in: [true, false] } - validates :value, format: { with: REGEX }, if: :masked? + validates :value, format: { with: REGEX }, if: :masked_and_expanded? + validates :value, format: { with: MASK_AND_RAW_REGEX }, if: :masked_and_raw? + end + + def masked_and_raw? + return false unless self.class.method_defined?(:raw) + + masked? && raw? + end + + def masked_and_expanded? + return masked? unless self.class.method_defined?(:raw) + + masked? && !raw? end def to_runner_variable diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index d93f4a150d5..d91f33452a0 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -22,7 +22,7 @@ module Ci delegate :set_cancel_gracefully, to: :metadata, prefix: false, allow_nil: false delegate :id_tokens, to: :metadata, allow_nil: true - before_create :ensure_metadata + before_validation :ensure_metadata, on: :create end def has_exposed_artifacts? @@ -34,7 +34,7 @@ module Ci end def ensure_metadata - metadata || build_metadata(project: project, partition_id: partition_id) + metadata || build_metadata(project: project) end def degenerated? diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 7f1fbbefd94..5dac3c7833a 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -4,6 +4,7 @@ module CommitSignature included do include ShaAttribute + include EachBatch sha_attribute :commit_sha @@ -14,7 +15,8 @@ module CommitSignature other_user: 3, unverified_key: 4, unknown_key: 5, - multiple_signatures: 6 + multiple_signatures: 6, + revoked_key: 7 } belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 784afd1f231..58ea57962c5 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -93,7 +93,7 @@ module CounterAttribute run_after_commit_or_now do new_value = counter(attribute).increment(increment) - log_increment_counter(attribute, increment.amount, new_value) + log_increment_counter(attribute, increment, new_value) end end @@ -101,7 +101,7 @@ module CounterAttribute run_after_commit_or_now do new_value = counter(attribute).bulk_increment(increments) - log_increment_counter(attribute, increments.sum(&:amount), new_value) + log_bulk_increment_counter(attribute, increments, new_value) end end @@ -198,7 +198,8 @@ module CounterAttribute message: 'Increment counter attribute', attribute: attribute, project_id: project_id, - increment: increment, + increment: increment.amount, + ref: increment.ref, new_counter_value: new_value, current_db_value: read_attribute(attribute) ) @@ -206,6 +207,16 @@ module CounterAttribute Gitlab::AppLogger.info(payload) end + def log_bulk_increment_counter(attribute, increments, new_value) + if Feature.enabled?(:split_log_bulk_increment_counter, type: :ops) + increments.each do |increment| + log_increment_counter(attribute, increment, new_value) + end + else + log_increment_counter(attribute, Gitlab::Counters::Increment.new(amount: increments.sum(&:amount)), new_value) + end + end + def log_clear_counter(attribute) payload = Gitlab::ApplicationContext.current.merge( message: 'Clear counter attribute', diff --git a/app/models/concerns/cross_database_modification.rb b/app/models/concerns/cross_database_modification.rb index 273d5f35e76..df4f4f0bfe1 100644 --- a/app/models/concerns/cross_database_modification.rb +++ b/app/models/concerns/cross_database_modification.rb @@ -102,6 +102,10 @@ module CrossDatabaseModification :gitlab_main when 'Ci::ApplicationRecord' :gitlab_ci + when 'MainClusterwide::ApplicationRecord' + :gitlab_main_clusterwide + when 'PackageMetadata::ApplicationRecord' + :gitlab_pm else Gitlab::Database::GitlabSchema.table_schema(table_name) if table_name end diff --git a/app/models/concerns/enums/package_metadata.rb b/app/models/concerns/enums/package_metadata.rb new file mode 100644 index 00000000000..e15fe758e69 --- /dev/null +++ b/app/models/concerns/enums/package_metadata.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Enums + class PackageMetadata + PURL_TYPES = { + composer: 1, + conan: 2, + gem: 3, + golang: 4, + maven: 5, + npm: 6, + nuget: 7, + pypi: 8 + }.with_indifferent_access.freeze + + def self.purl_types + PURL_TYPES + end + end +end diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb new file mode 100644 index 00000000000..066a44912be --- /dev/null +++ b/app/models/concerns/exportable.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Exportable + extend ActiveSupport::Concern + + def readable_records(association, current_user: nil) + association_records = try(association) + return unless association_records.present? + + if has_many_association?(association) + DeclarativePolicy.user_scope do + association_records.select { |record| readable_record?(record, current_user) } + end + else + readable_record?(association_records, current_user) ? association_records : nil + end + end + + def exportable_association?(association, current_user: nil) + return false unless respond_to?(association) + return true if has_many_association?(association) + + readable = try(association) + return true if readable.nil? + + readable_record?(readable, current_user) + end + + def restricted_associations(keys) + exportable_restricted_associations & keys + end + + def has_many_association?(association_name) + self.class.reflect_on_association(association_name)&.macro == :has_many + end + + private + + def exportable_restricted_associations + [] + end + + def readable_record?(record, user) + if record.respond_to?(:exportable_record?) + record.exportable_record?(user) + else + record.readable_by?(user) + end + end +end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index b376537a418..224ac8930b5 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -21,7 +21,7 @@ module GroupDescendant descendants = Array.wrap(descendants).uniq return [] if descendants.empty? - unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) } + unless descendants.all?(GroupDescendant) raise ArgumentError, _('element is not a hierarchy') end diff --git a/app/models/concerns/id_in_ordered.rb b/app/models/concerns/id_in_ordered.rb index b89409e6841..39067574520 100644 --- a/app/models/concerns/id_in_ordered.rb +++ b/app/models/concerns/id_in_ordered.rb @@ -5,7 +5,7 @@ module IdInOrdered included do scope :id_in_ordered, -> (ids) do - raise ArgumentError, "ids must be an array of integers" unless ids.is_a?(Enumerable) && ids.all? { |id| id.is_a?(Integer) } + raise ArgumentError, "ids must be an array of integers" unless ids.is_a?(Enumerable) && ids.all?(Integer) # No need to sort if no more than 1 and the sorting code doesn't work # with an empty array diff --git a/app/models/concerns/integrations/has_web_hook.rb b/app/models/concerns/integrations/has_web_hook.rb index e622faf4a51..dcf14a4c7dc 100644 --- a/app/models/concerns/integrations/has_web_hook.rb +++ b/app/models/concerns/integrations/has_web_hook.rb @@ -42,9 +42,9 @@ module Integrations end # Execute the webhook, creating it if necessary. - def execute_web_hook!(*args) + def execute_web_hook!(...) update_web_hook! - service_hook.execute(*args) + service_hook.execute(...) end end end diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb index c319d685362..7f29083d6c6 100644 --- a/app/models/concerns/issuable_link.rb +++ b/app/models/concerns/issuable_link.rb @@ -20,6 +20,12 @@ module IssuableLink def issuable_type raise NotImplementedError end + + # Used to get the available types for the API + # overriden in EE + def available_link_types + [TYPE_RELATES_TO] + end end included do diff --git a/app/models/concerns/issue_parent.rb b/app/models/concerns/issue_parent.rb new file mode 100644 index 00000000000..c1fcbdcfc12 --- /dev/null +++ b/app/models/concerns/issue_parent.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# == IssuParent +# +# Used as a common ancestor for Group and Project so we can allow a polymorphic +# Types::GlobalIDType[::IssueParent] in the GraphQL API +# +# Used by Project, Group +# +module IssueParent +end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index eed396f785b..7addcf9e2ec 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -106,9 +106,9 @@ module Noteable relations << discussion_notes.select( "'notes' AS table_name", - 'discussion_id', 'MIN(id) AS id', - 'MIN(created_at) AS created_at' + 'MIN(created_at) AS created_at', + 'ARRAY_AGG(id) AS ids' ).with_notes_filter(notes_filter) .group(:discussion_id) @@ -116,17 +116,19 @@ module Noteable relations += synthetic_note_ids_relations end - Note.from_union(relations, remove_duplicates: false).fresh + Note.from_union(relations, remove_duplicates: false) + .select(:table_name, :id, :created_at, :ids) + .fresh end def capped_notes_count(max) notes.limit(max).count end - def grouped_diff_discussions(*args) + def grouped_diff_discussions(...) # Doesn't use `discussion_notes`, because this may include commit diff notes # besides MR diff notes, that we do not want to display on the MR Changes tab. - notes.inc_relations_for_view(self).grouped_diff_discussions(*args) + notes.inc_relations_for_view(self).grouped_diff_discussions(...) end # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -223,15 +225,16 @@ module Noteable # currently multiple models include Noteable concern, but not all of them support # all resource events, so we check if given model supports given resource event. if respond_to?(:resource_label_events) - relations << resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at) + relations << resource_label_events.select("'resource_label_events'", 'MIN(id)', :created_at, 'ARRAY_AGG(id)') + .group(:created_at, :user_id) end if respond_to?(:resource_state_events) - relations << resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at) + relations << resource_state_events.select("'resource_state_events'", :id, :created_at, 'ARRAY_FILL(id, ARRAY[1])') end if respond_to?(:resource_milestone_events) - relations << resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at) + relations << resource_milestone_events.select("'resource_milestone_events'", :id, :created_at, 'ARRAY_FILL(id, ARRAY[1])') end relations diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index df297017119..b85ac9ad4a6 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -49,7 +49,9 @@ module PrometheusAdapter query_class = query_klass_for(query_name) query_args = build_query_args(*args) - with_reactive_cache(query_class.name, *query_args, &query_class.method(:transform_reactive_result)) + with_reactive_cache(query_class.name, *query_args) do |result| + query_class.transform_reactive_result(result) + end end # Cache metrics for specific environment diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 9ed2070d11c..aa0fced99c4 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -122,8 +122,8 @@ module ReactiveCaching worker_class.perform_async(self.class, id, *args) end - def keep_alive_reactive_cache!(*args) - Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) + def keep_alive_reactive_cache!(...) + Rails.cache.write(alive_reactive_cache_key(...), true, expires_in: self.class.reactive_cache_lifetime) end def full_reactive_cache_key(*qualifiers) @@ -145,8 +145,8 @@ module ReactiveCaching Gitlab::ExclusiveLease.cancel(full_reactive_cache_key(*args), uuid) end - def within_reactive_cache_lifetime?(*args) - Rails.cache.exist?(alive_reactive_cache_key(*args)) + def within_reactive_cache_lifetime?(...) + Rails.cache.exist?(alive_reactive_cache_key(...)) end def enqueuing_update(*args) diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb index cf6a31e6ebd..5ff4f520d24 100644 --- a/app/models/concerns/require_email_verification.rb +++ b/app/models/concerns/require_email_verification.rb @@ -45,8 +45,9 @@ module RequireEmailVerification private def override_devise_lockable? - strong_memoize(:override_devise_lockable) do - Feature.enabled?(:require_email_verification, self) && !two_factor_enabled? - end + Feature.enabled?(:require_email_verification, self) && + !two_factor_enabled? && + Feature.disabled?(:skip_require_email_verification, self, type: :ops) end + strong_memoize_attr :override_devise_lockable? end diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb index 794748483e4..5a9b75d4db8 100644 --- a/app/models/concerns/sensitive_serializable_hash.rb +++ b/app/models/concerns/sensitive_serializable_hash.rb @@ -24,12 +24,12 @@ module SensitiveSerializableHash options[:except].concat self.class.attributes_exempt_from_serializable_hash - if self.class.respond_to?(:encrypted_attributes) - options[:except].concat self.class.encrypted_attributes.keys + if self.class.respond_to?(:attr_encrypted_attributes) + options[:except].concat self.class.attr_encrypted_attributes.keys # Per https://github.com/attr-encrypted/attr_encrypted/blob/a96693e9a2a25f4f910bf915e29b0f364f277032/lib/attr_encrypted.rb#L413 - options[:except].concat self.class.encrypted_attributes.values.map { |v| v[:attribute] } - options[:except].concat self.class.encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" } + options[:except].concat self.class.attr_encrypted_attributes.values.map { |v| v[:attribute] } + options[:except].concat self.class.attr_encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" } end super(options) diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index 701d2fda5c5..35c48c15fb2 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -22,7 +22,7 @@ module ShaAttribute class_methods do def sha_attribute(name) - return if ENV['STATIC_VERIFICATION'] + return if Gitlab::Environment.static_verification? sha_attribute_fields << name @@ -34,7 +34,7 @@ module ShaAttribute end def sha256_attribute(name) - return if ENV['STATIC_VERIFICATION'] + return if Gitlab::Environment.static_verification? sha256_attribute_fields << name diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index d27b451892a..fba923e843a 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -123,6 +123,6 @@ module Spammable # Override in Spammable if differs def allow_possible_spam? - Feature.enabled?(:allow_possible_spam, project) + Gitlab::CurrentSettings.allow_possible_spam end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 05addcf83d2..f9eba4cc2fe 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -24,10 +24,28 @@ module Taskable (\s.+) # followed by whitespace and some text. }x.freeze + # ignore tasks in code or html comment blocks. HTML blocks + # are ok as we allow tasks inside <detail> blocks + REGEX = %r{ + #{::Gitlab::Regex.markdown_code_or_html_comments} + | + (?<task_item> + #{ITEM_PATTERN} + ) + }mx.freeze + def self.get_tasks(content) - content.to_s.scan(ITEM_PATTERN).map do |prefix, checkbox, label| - TaskList::Item.new("#{prefix} #{checkbox}", label.strip) + items = [] + + content.to_s.scan(REGEX) do + next unless $~[:task_item] + + $~[:task_item].scan(ITEM_PATTERN) do |prefix, checkbox, label| + items << TaskList::Item.new("#{prefix.strip} #{checkbox}", label.strip) + end end + + items end def self.get_updated_tasks(old_content:, new_content:) @@ -67,10 +85,10 @@ module Taskable checklist_item_noun = n_('checklist item', 'checklist items', sum.item_count) if short format(s_('Tasks|%{complete_count}/%{total_count} %{checklist_item_noun}'), -checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) else format(s_('Tasks|%{complete_count} of %{total_count} %{checklist_item_noun} completed'), -checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) end end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index d91ec161b84..cc3e8f174b3 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -86,7 +86,7 @@ module TokenAuthenticatable def token_authenticatable_module @token_authenticatable_module ||= - const_set(:TokenAuthenticatable, Module.new).tap(&method(:include)) + const_set(:TokenAuthenticatable, Module.new).tap { |mod| include mod } end end end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index b5d48260072..1e8a290c050 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -49,7 +49,7 @@ module VulnerabilityFindingHelpers finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures, :flags, :evidence) - identifiers = report_finding.identifiers.map do |identifier| + identifiers = report_finding.identifiers.uniq(&:fingerprint).map do |identifier| Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project })) end signatures = report_finding.signatures.map do |signature| diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb new file mode 100644 index 00000000000..2cc17a6f185 --- /dev/null +++ b/app/models/concerns/web_hooks/auto_disabling.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module WebHooks + module AutoDisabling + extend ActiveSupport::Concern + + included do + # A hook is disabled if: + # + # - we are no longer in the grace-perod (recent_failures > ?) + # - and either: + # - disabled_until is nil (i.e. this was set by WebHook#fail!) + # - or disabled_until is in the future (i.e. this was set by WebHook#backoff!) + scope :disabled, -> do + where('recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)', + WebHook::FAILURE_THRESHOLD, Time.current) + end + + # A hook is executable if: + # + # - we are still in the grace-period (recent_failures <= ?) + # - OR we have exceeded the grace period and neither of the following is true: + # - disabled_until is nil (i.e. this was set by WebHook#fail!) + # - disabled_until is in the future (i.e. this was set by WebHook#backoff!) + scope :executable, -> do + where('recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))', + WebHook::FAILURE_THRESHOLD, WebHook::FAILURE_THRESHOLD, Time.current) + end + end + + def executable? + !temporarily_disabled? && !permanently_disabled? + end + + def temporarily_disabled? + return false if recent_failures <= WebHook::FAILURE_THRESHOLD + + disabled_until.present? && disabled_until >= Time.current + end + + def permanently_disabled? + return false if disabled_until.present? + + recent_failures > WebHook::FAILURE_THRESHOLD + end + + def disable! + return if permanently_disabled? + + super + end + + def backoff! + return if permanently_disabled? || (backoff_count >= WebHook::MAX_FAILURES && temporarily_disabled?) + + super + end + + def alert_status + if temporarily_disabled? + :temporarily_disabled + elsif permanently_disabled? + :disabled + else + :executable + end + end + end +end diff --git a/app/models/concerns/web_hooks/has_web_hooks.rb b/app/models/concerns/web_hooks/has_web_hooks.rb new file mode 100644 index 00000000000..161ce106b9b --- /dev/null +++ b/app/models/concerns/web_hooks/has_web_hooks.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module WebHooks + module HasWebHooks + extend ActiveSupport::Concern + + WEB_HOOK_CACHE_EXPIRY = 1.hour + + def any_hook_failed? + hooks.disabled.exists? + end + + def web_hook_failure_redis_key + "any_web_hook_failed:#{id}" + end + + def last_failure_redis_key + "web_hooks:last_failure:project-#{id}" + end + + def get_web_hook_failure + Gitlab::Redis::SharedState.with do |redis| + current = redis.get(web_hook_failure_redis_key) + + Gitlab::Utils.to_boolean(current) if current + end + end + + def fetch_web_hook_failure + Gitlab::Redis::SharedState.with do |_redis| + current = get_web_hook_failure + next current unless current.nil? + + cache_web_hook_failure + end + end + + def cache_web_hook_failure(state = any_hook_failed?) + Gitlab::Redis::SharedState.with do |redis| + redis.set(web_hook_failure_redis_key, state.to_s, ex: WEB_HOOK_CACHE_EXPIRY) + + state + end + end + end +end diff --git a/app/models/concerns/web_hooks/unstoppable.rb b/app/models/concerns/web_hooks/unstoppable.rb new file mode 100644 index 00000000000..26284fe3c36 --- /dev/null +++ b/app/models/concerns/web_hooks/unstoppable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module WebHooks + module Unstoppable + extend ActiveSupport::Concern + + included do + scope :executable, -> { all } + + scope :disabled, -> { none } + end + + def executable? + true + end + + def temporarily_disabled? + false + end + + def permanently_disabled? + false + end + + def alert_status + :executable + end + end +end diff --git a/app/models/concerns/work_item_resource_event.rb b/app/models/concerns/work_item_resource_event.rb index d0323feb029..ddf39787f63 100644 --- a/app/models/concerns/work_item_resource_event.rb +++ b/app/models/concerns/work_item_resource_event.rb @@ -5,6 +5,18 @@ module WorkItemResourceEvent included do belongs_to :work_item, foreign_key: 'issue_id' + + scope :with_work_item, -> { preload(:work_item) } + + # These events are created also on non work items, e.g. MRs, Epic however system notes subscription + # is only implemented on work items, so we do check if this event is linked to an work item. This can be + # expanded to other issuables later on. + after_commit :trigger_note_subscription_create, on: :create, if: -> { work_item.present? } + end + + # System notes are not updated or deleted, so firing just the noteCreated event. + def trigger_note_subscription_create(events: self) + GraphqlTriggers.work_item_note_created(work_item.to_gid, events) end def work_item_synthetic_system_note(events: nil) diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index 9dc53859ac0..b65736b7924 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -5,7 +5,7 @@ module X509SerialNumberAttribute class_methods do def x509_serial_number_attribute(name) - return if ENV['STATIC_VERIFICATION'] + return if Gitlab::Environment.static_verification? validate_binary_column_exists!(name) unless Rails.env.production? diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index d4075e1ff1b..c4d06be8841 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -8,6 +8,21 @@ module ContainerRegistry PUSH_ACTION = 'push' DELETE_ACTION = 'delete' EVENT_TRACKING_CATEGORY = 'container_registry:notification' + EVENT_PREFIX = "i_container_registry" + + ALLOWED_ACTOR_TYPES = %w( + personal_access_token + build + gitlab_or_ldap + ).freeze + + TRACKABLE_ACTOR_EVENTS = %w( + push_tag + delete_tag + push_repository + delete_repository + create_repository + ).freeze attr_reader :event @@ -32,6 +47,9 @@ module ContainerRegistry end ::Gitlab::Tracking.event(EVENT_TRACKING_CATEGORY, tracking_action) + + event = usage_data_event_for(tracking_action) + ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event, values: originator.id) if event end private @@ -81,6 +99,29 @@ module ContainerRegistry container_registry_path&.repository_project end + # counter name for unique user tracking (for MAU) + def usage_data_event_for(tracking_action) + return unless originator + return unless TRACKABLE_ACTOR_EVENTS.include?(tracking_action) + + "#{EVENT_PREFIX}_#{tracking_action}_user" + end + + def originator_type + event.dig('actor', 'user_type') + end + + def originator + return unless ALLOWED_ACTOR_TYPES.include?(originator_type) + + username = event.dig('actor', 'name') + return unless username + + strong_memoize(:originator) do + User.find_by_username(username) + end + end + def update_project_statistics return unless supported? return unless target_tag? || (action_delete? && target_digest?) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index db0fcd915b3..98ce981ad8e 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -395,7 +395,7 @@ class ContainerRepository < ApplicationRecord end def migrated? - MIGRATION_PHASE_1_ENDED_AT < self.created_at || import_done? + (self.created_at && MIGRATION_PHASE_1_ENDED_AT < self.created_at) || import_done? end def last_import_step_done_at @@ -497,7 +497,7 @@ class ContainerRepository < ApplicationRecord digests = tags.map { |tag| tag.digest }.compact.to_set - digests.map(&method(:delete_tag_by_digest)).all? + digests.map { |digest| delete_tag_by_digest(digest) }.all? end def delete_tag_by_digest(digest) diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index aaafa396337..ef31bedc3a8 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -9,9 +9,10 @@ class DeployKey < Key has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :deploy_keys_projects - has_many :deploy_keys_projects_with_write_access, -> { with_write_access }, class_name: "DeployKeysProject" + has_many :deploy_keys_projects_with_write_access, -> { with_write_access }, class_name: "DeployKeysProject", inverse_of: :deploy_key has_many :projects_with_write_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_write_access, source: :project - has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel' + has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel', inverse_of: :deploy_key + has_many :protected_tag_create_access_levels, class_name: '::ProtectedTag::CreateAccessLevel', inverse_of: :deploy_key scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where(deploy_keys_projects: { project_id: projects }) } scope :with_write_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_write_access) } diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1ae7d9925a5..f8873d388a3 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -105,7 +105,11 @@ class Deployment < ApplicationRecord after_transition any => :running do |deployment, transition| deployment.run_after_commit do - Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) + perform_params = { deployment_id: id, status: transition.to, status_changed_at: Time.current } + + serialize_params_for_sidekiq!(perform_params) + + Deployments::HooksWorker.perform_async(perform_params) end end @@ -119,7 +123,11 @@ class Deployment < ApplicationRecord after_transition any => FINISHED_STATUSES do |deployment, transition| deployment.run_after_commit do - Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current) + perform_params = { deployment_id: id, status: transition.to, status_changed_at: Time.current } + + serialize_params_for_sidekiq!(perform_params) + + Deployments::HooksWorker.perform_async(perform_params) end end @@ -464,6 +472,11 @@ class Deployment < ApplicationRecord end end + def serialize_params_for_sidekiq!(perform_params) + perform_params[:status_changed_at] = perform_params[:status_changed_at].to_s + perform_params.stringify_keys! + end + def self.last_deployment_group_associations { deployable: { diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb index baf4db29a0f..87899f65cb1 100644 --- a/app/models/design_user_mention.rb +++ b/app/models/design_user_mention.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class DesignUserMention < UserMention + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + belongs_to :design, class_name: 'DesignManagement::Design' belongs_to :note end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 9eb3308b901..83c85f30178 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -10,7 +10,8 @@ class Discussion # Bump this if we need to refresh the cached versions of discussions CACHE_VERSION = 1 - attr_reader :notes, :context_noteable + attr_reader :context_noteable + attr_accessor :notes delegate :created_at, :project, @@ -183,4 +184,11 @@ class Discussion resolved_at ].join(':') end + + # Consolidate discussions GID. There is no need to have different GID for different class names as the discussion_id + # hash is already unique per discussion. This also fixes the issue where same discussion may return different GIDs + # depending on number of notes it has. + def to_global_id(options = {}) + GlobalID.new(::Gitlab::GlobalId.build(model_name: Discussion.to_s, id: id)) + end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 7d99f10822d..f1de41674c6 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -28,20 +28,18 @@ class Environment < ApplicationRecord has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment - # NOTE: - # 1) no-op arguments is to prevent accidental legacy preloading. See: https://gitlab.com/gitlab-org/gitlab/-/issues/369240 - # 2) If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. - has_one :last_deployment, -> (_env) { success.ordered }, class_name: 'Deployment', inverse_of: :environment - has_one :last_visible_deployment, -> (_env) { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' - has_one :upcoming_deployment, -> (_env) { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment + # NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. + has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment + has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' + has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment Deployment::FINISHED_STATUSES.each do |status| - has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered }, + has_one :"last_#{status}_deployment", -> { where(status: status).ordered }, class_name: 'Deployment', inverse_of: :environment end Deployment::UPCOMING_STATUSES.each do |status| - has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered_as_upcoming }, + has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming }, class_name: 'Deployment', inverse_of: :environment end @@ -74,7 +72,11 @@ class Environment < ApplicationRecord # Currently, the tier presence is validaed for newly created environments. # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`. # See https://gitlab.com/gitlab-org/gitlab/-/issues/385253. - validates :tier, presence: true, on: :create + # Todo: Remove along with FF `validate_environment_tier_presence`. + validates :tier, presence: true, on: :create, unless: :validate_environment_tier_present? + + validates :tier, presence: true, if: :validate_environment_tier_present? + validate :safe_external_url validate :merge_request_not_changed @@ -600,6 +602,10 @@ class Environment < ApplicationRecord self.class.tiers[:other] end end + + def validate_environment_tier_present? + Feature.enabled?(:validate_environment_tier_presence, self.project) + end end Environment.prepend_mod_with('Environment') diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb index 5cd5aa1b085..71abfd3f6da 100644 --- a/app/models/grafana_integration.rb +++ b/app/models/grafana_integration.rb @@ -45,7 +45,7 @@ class GrafanaIntegration < ApplicationRecord end def token - decrypt(:token, encrypted_token) + attr_decrypt(:token, encrypted_token) end def check_token_changes diff --git a/app/models/group.rb b/app/models/group.rb index c7ad4d61ddb..7e09280dfff 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -21,9 +21,12 @@ class Group < Namespace include ChronicDurationAttribute include RunnerTokenExpirationInterval include Todoable + include IssueParent extend ::Gitlab::Utils::Override + README_PROJECT_PATH = 'gitlab-profile' + def self.sti_name 'Group' end @@ -43,7 +46,10 @@ class Group < Namespace 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]) }, foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' + has_many :members_and_requesters, as: :source, class_name: 'GroupMember' + has_many :namespace_members_and_requesters, -> { unscope(where: %i[source_id source_type]) }, + foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' has_many :milestones has_many :integrations @@ -422,15 +428,14 @@ class Group < Namespace ) end - def add_member(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true) + 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, - blocking_refresh: blocking_refresh + ldap: ldap ) end @@ -539,7 +544,6 @@ class Group < Namespace # rubocop: disable CodeReuse/ServiceClass def refresh_members_authorized_projects( - blocking: true, priority: UserProjectAccessChangedService::HIGH_PRIORITY, direct_members_only: false ) @@ -552,7 +556,7 @@ class Group < Namespace UserProjectAccessChangedService .new(user_ids) - .execute(blocking: blocking, priority: priority) + .execute(priority: priority) end # rubocop: enable CodeReuse/ServiceClass @@ -748,7 +752,7 @@ class Group < Namespace end def refresh_project_authorizations - refresh_members_authorized_projects(blocking: false) + refresh_members_authorized_projects end # each existing group needs to have a `runners_token`. @@ -915,10 +919,6 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2) end - def work_items_create_from_markdown_feature_flag_enabled? - feature_flag_enabled_for_self_or_ancestor?(:work_items_create_from_markdown) - end - def usage_quotas_enabled? ::Feature.enabled?(:usage_quotas_for_all_editions, self) && root? end @@ -948,6 +948,16 @@ class Group < Namespace direct_and_indirect_members.find_each(&:update_two_factor_requirement) end + def readme_project + projects.find_by(path: README_PROJECT_PATH) + end + strong_memoize_attr :readme_project + + def group_readme + readme_project&.repository&.readme + end + strong_memoize_attr :group_readme + private def feature_flag_enabled_for_self_or_ancestor?(feature_flag) diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index dcba136d163..8e9a74a68d0 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -2,6 +2,7 @@ class ProjectHook < WebHook include TriggerableHooks + include WebHooks::AutoDisabling include Presentable include Limitable extend ::Gitlab::Utils::Override @@ -45,14 +46,18 @@ class ProjectHook < WebHook override :update_last_failure def update_last_failure - return if executable? + if executable? + project.cache_web_hook_failure if project.get_web_hook_failure # may need update + else + project.cache_web_hook_failure(true) # definitely failing, no need to check - key = "web_hooks:last_failure:project-#{project_id}" - time = Time.current.utc.iso8601 + Gitlab::Redis::SharedState.with do |redis| + last_failure_key = project.last_failure_redis_key + time = Time.current.utc.iso8601 + prev = redis.get(last_failure_key) - Gitlab::Redis::SharedState.with do |redis| - prev = redis.get(key) - redis.set(key, time) if !prev || prev < time + redis.set(last_failure_key, time) if !prev || prev < time + end end end end diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 94ced96bbde..6af70c249a0 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true class ServiceHook < WebHook + include WebHooks::Unstoppable include Presentable + extend ::Gitlab::Utils::Override belongs_to :integration @@ -13,9 +15,4 @@ class ServiceHook < WebHook override :parent delegate :parent, to: :integration - - override :executable? - def executable? - true - end end diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 3c7f0ef9ffc..eaffe83cab3 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -2,6 +2,7 @@ class SystemHook < WebHook include TriggerableHooks + include WebHooks::Unstoppable triggerable_hooks [ :repository_update_hooks, diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 49418cda3ac..819152a38c8 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -56,31 +56,6 @@ class WebHook < ApplicationRecord all_branches: 2 }, _prefix: true - scope :executable, -> do - where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) - end - - # Inverse of executable - scope :disabled, -> do - where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) - end - - def executable? - !temporarily_disabled? && !permanently_disabled? - end - - def temporarily_disabled? - return false if recent_failures <= FAILURE_THRESHOLD - - disabled_until.present? && disabled_until >= Time.current - end - - def permanently_disabled? - return false if disabled_until.present? - - recent_failures > FAILURE_THRESHOLD - end - # rubocop: disable CodeReuse/ServiceClass def execute(data, hook_name, force: false) # hook.executable? is checked in WebHookService#execute @@ -112,8 +87,6 @@ class WebHook < ApplicationRecord end def disable! - return if permanently_disabled? - update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD) end @@ -127,8 +100,6 @@ class WebHook < ApplicationRecord # Don't actually back-off until FAILURE_THRESHOLD failures have been seen # we mark the grace-period using the recent_failures counter def backoff! - return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?) - attrs = { recent_failures: next_failure_count } if recent_failures >= FAILURE_THRESHOLD @@ -137,7 +108,7 @@ class WebHook < ApplicationRecord end assign_attributes(attrs) - save(validate: false) + save(validate: false) if changed? end def failed! @@ -167,16 +138,6 @@ class WebHook < ApplicationRecord { related_class: type } end - def alert_status - if temporarily_disabled? - :temporarily_disabled - elsif permanently_disabled? - :disabled - else - :executable - end - end - # Exclude binary columns by default - they have no sensible JSON encoding def serializable_hash(options = nil) options = options.try(:dup) || {} diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 9de6f2a1b57..e08294058e4 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -9,6 +9,8 @@ class WebHookLog < ApplicationRecord OVERSIZE_REQUEST_DATA = { 'oversize' => true }.freeze + attr_accessor :interpolated_url + self.primary_key = :id partitioned_by :created_at, strategy: :monthly, retain_for: 3.months @@ -23,6 +25,7 @@ class WebHookLog < ApplicationRecord before_save :obfuscate_basic_auth before_save :redact_user_emails + before_save :set_url_hash, if: -> { interpolated_url.present? } def self.recent where(created_at: 2.days.ago.beginning_of_day..Time.zone.now) @@ -66,4 +69,8 @@ class WebHookLog < ApplicationRecord value.to_s =~ URI::MailTo::EMAIL_REGEXP ? _('[REDACTED]') : value end end + + def set_url_hash + self.url_hash = Gitlab::CryptoHelper.sha256(interpolated_url) + end end diff --git a/app/models/incident_management/timeline_event_tag.rb b/app/models/incident_management/timeline_event_tag.rb index d1e3fbc2a6a..97b896d369d 100644 --- a/app/models/incident_management/timeline_event_tag.rb +++ b/app/models/incident_management/timeline_event_tag.rb @@ -4,8 +4,14 @@ module IncidentManagement class TimelineEventTag < ApplicationRecord self.table_name = 'incident_management_timeline_event_tags' - START_TIME_TAG_NAME = 'Start time' - END_TIME_TAG_NAME = 'End time' + PREDEFINED_TAGS = [ + 'Start time', + 'End time', + 'Impact detected', + 'Response initiated', + 'Impact mitigated', + 'Cause identified' + ].freeze belongs_to :project, inverse_of: :incident_management_timeline_event_tags diff --git a/app/models/integration.rb b/app/models/integration.rb index 54eeab10360..8bef8b08c19 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -493,9 +493,9 @@ class Integration < ApplicationRecord def reencrypt_properties unless properties.nil? || properties.empty? - alg = self.class.encrypted_attributes[:properties][:algorithm] + alg = self.class.attr_encrypted_attributes[:properties][:algorithm] iv = generate_iv(alg) - ep = self.class.encrypt(:properties, properties, { iv: iv }) + ep = self.class.attr_encrypt(:properties, properties, { iv: iv }) end { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv } diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 8700b673370..963ba918089 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -23,6 +23,7 @@ module Integrations ].freeze SECRET_MASK = '************' + CHANNEL_LIMIT_PER_EVENT = 10 attribute :category, default: 'chat' @@ -37,7 +38,8 @@ module Integrations presence: true, public_url: true, if: -> (integration) { integration.activated? && integration.requires_webhook? } - validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true + validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true, if: :activated? + validate :validate_channel_limit, if: :activated? def initialize_properties super @@ -132,17 +134,15 @@ module Integrations return false unless message - event_type = data[:event_type] || object_kind - - channel_names = event_channel_value(event_type).presence || channel.presence - channels = channel_names&.split(',')&.map(&:strip) + event = data[:event_type] || object_kind + channels = channels_for_event(event) opts = {} opts[:channel] = channels if channels.present? opts[:username] = username if username if notify(message, opts) - log_usage(event_type, user_id_from_hook_data(data)) + log_usage(event, user_id_from_hook_data(data)) return true end @@ -297,6 +297,34 @@ module Integrations false end end + + def channels_for_event(event) + channel_names = event_channel_value(event).presence || channel.presence + return [] unless channel_names + + channel_names.split(',').map(&:strip).uniq + end + + def unique_channels + @unique_channels ||= supported_events.flat_map do |event| + channels_for_event(event) + end.uniq + end + + def validate_channel_limit + supported_events.each do |event| + count = channels_for_event(event).count + next unless count > CHANNEL_LIMIT_PER_EVENT + + errors.add( + event_channel_name(event).to_sym, + format( + s_('SlackIntegration|cannot have more than %{limit} channels'), + limit: CHANNEL_LIMIT_PER_EVENT + ) + ) + end + end end end diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb index 554b422c0fa..501b214a769 100644 --- a/app/models/integrations/chat_message/base_message.rb +++ b/app/models/integrations/chat_message/base_message.rb @@ -5,10 +5,6 @@ module Integrations class BaseMessage RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze - # Markup characters which are used for links in HTML, Markdown, - # and Slack "mrkdwn" syntax (`<http://example.com|Label>`). - UNSAFE_MARKUP_CHARACTERS = '<>[]|' - attr_reader :markdown attr_reader :user_full_name attr_reader :user_name @@ -85,7 +81,7 @@ module Integrations # - https://api.slack.com/reference/surfaces/formatting#escaping # - https://gitlab.com/gitlab-org/slack-notifier#escaping def strip_markup(string) - string&.delete(UNSAFE_MARKUP_CHARACTERS) + SlackMarkdownSanitizer.sanitize(string) end def attachment_color diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 45302a0bd09..d96a848c72e 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -48,21 +48,21 @@ module Integrations section: SECTION_TYPE_CONNECTION, required: true, title: -> { s_('JiraService|Web URL') }, - help: -> { s_('JiraService|Base URL of the Jira instance.') }, + help: -> { s_('JiraService|Base URL of the Jira instance') }, placeholder: 'https://jira.example.com', exposes_secrets: true field :api_url, section: SECTION_TYPE_CONNECTION, title: -> { s_('JiraService|Jira API URL') }, - help: -> { s_('JiraService|If different from Web URL.') }, + help: -> { s_('JiraService|If different from the Web URL') }, exposes_secrets: true field :username, section: SECTION_TYPE_CONNECTION, required: true, - title: -> { s_('JiraService|Username or Email') }, - help: -> { s_('JiraService|Use a username for server version and an email for cloud version.') } + title: -> { s_('JiraService|Username or email') }, + help: -> { s_('JiraService|Username for the server version or an email for the cloud version') } field :password, section: SECTION_TYPE_CONNECTION, @@ -70,7 +70,7 @@ module Integrations title: -> { s_('JiraService|Password or API token') }, non_empty_password_title: -> { s_('JiraService|Enter new password or API token') }, non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, - help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') } + help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') } field :jira_issue_transition_id, api_only: true diff --git a/app/models/issue.rb b/app/models/issue.rb index 6744ee230b0..bea86168c8d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -25,6 +25,7 @@ class Issue < ApplicationRecord include FromUnion include EachBatch include PgFullTextSearchable + include Exportable extend ::Gitlab::Utils::Override @@ -180,11 +181,7 @@ class Issue < ApplicationRecord scope :confidential_only, -> { where(confidential: true) } scope :without_hidden, -> { - if Feature.enabled?(:ban_user_feature_flag) - where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id')) - else - all - end + where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id')) } scope :counts_by_state, -> { reorder(nil).group(:state_id).count } @@ -328,13 +325,22 @@ class Issue < ApplicationRecord '#' end + # Alternative prefix for situations where the standard prefix would be + # interpreted as a comment, most notably to begin commit messages with + # (e.g. "GL-123: My commit") + def self.alternative_reference_prefix + 'GL-' + end + # Pattern used to extract `#123` issue references from text # # This pattern supports cross-project references. def self.reference_pattern @reference_pattern ||= %r{ - (#{Project.reference_pattern})? - #{Regexp.escape(reference_prefix)}#{Gitlab::Regex.issue} + (?: + (#{Project.reference_pattern})?#{Regexp.escape(reference_prefix)} | + #{Regexp.escape(alternative_reference_prefix)} + )#{Gitlab::Regex.issue} }x end @@ -672,6 +678,12 @@ class Issue < ApplicationRecord true end + # we want to have subscriptions working on work items only, legacy issues do not support graphql subscriptions, yet so + # we need sometimes GID of an issue instance to be represented as WorkItem GID. E.g. notes subscriptions. + def to_work_item_global_id + ::Gitlab::GlobalId.as_global_id(id, model_name: WorkItem.name) + end + private def due_date_after_start_date diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb index dd963bc9e7e..9d7e2afa1d9 100644 --- a/app/models/issue_email_participant.rb +++ b/app/models/issue_email_participant.rb @@ -2,6 +2,7 @@ class IssueEmailParticipant < ApplicationRecord include BulkInsertSafe + include Presentable belongs_to :issue diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb index 3eadd580f7f..bb13b83d3ba 100644 --- a/app/models/issue_user_mention.rb +++ b/app/models/issue_user_mention.rb @@ -3,4 +3,7 @@ class IssueUserMention < UserMention belongs_to :issue belongs_to :note + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' end diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index 0e88d1ceae9..f07f979a06d 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -31,7 +31,7 @@ class JiraConnectInstallation < ApplicationRecord end def oauth_authorization_url - return Gitlab.config.gitlab.url if instance_url.blank? || Feature.disabled?(:jira_connect_oauth_self_managed) + return Gitlab.config.gitlab.url if instance_url.blank? instance_url end diff --git a/app/models/key.rb b/app/models/key.rb index 1f2234129ed..596186276bb 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -11,6 +11,8 @@ class Key < ApplicationRecord belongs_to :user + has_many :ssh_signatures, class_name: 'CommitSignatures::SshSignature' + before_validation :generate_fingerprint validates :title, @@ -136,6 +138,10 @@ class Key < ApplicationRecord save if generate_fingerprint end + def signing? + super || auth_and_signing? + end + private def generate_fingerprint diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb index 7d78c580fa2..984205044a7 100644 --- a/app/models/legacy_diff_discussion.rb +++ b/app/models/legacy_diff_discussion.rb @@ -27,10 +27,10 @@ class LegacyDiffDiscussion < Discussion true end - def active?(*args) + def active?(...) return @active if @active.present? - @active = first_note.active?(*args) + @active = first_note.active?(...) end def collapsed? diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index e1f28c0e117..2619a7cca99 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -12,7 +12,6 @@ class LfsObject < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: LfsObjectUploader::Store::REMOTE) } scope :for_oids, -> (oids) { where(oid: oids) } - scope :for_oid_and_size, -> (oid, size) { find_by(oid: oid, size: size) } validates :oid, presence: true, uniqueness: true, format: { with: /\A\h{64}\z/ } @@ -20,6 +19,10 @@ class LfsObject < ApplicationRecord BATCH_SIZE = 3000 + def self.for_oid_and_size(oid, size) + find_by(oid: oid, size: size) + end + def self.not_linked_to_project(project) where('NOT EXISTS (?)', project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) diff --git a/app/models/main_clusterwide/application_record.rb b/app/models/main_clusterwide/application_record.rb new file mode 100644 index 00000000000..dc61ea695c8 --- /dev/null +++ b/app/models/main_clusterwide/application_record.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MainClusterwide + class ApplicationRecord < ::ApplicationRecord + self.abstract_class = true + + if Gitlab::Database.has_config?(:main_clusterwide) + connects_to database: { writing: :main_clusterwide, reading: :main_clusterwide } + end + end +end diff --git a/app/models/member.rb b/app/models/member.rb index ecf9013f197..e97c9e929ac 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -22,7 +22,6 @@ class Member < ApplicationRecord STATE_AWAITING = 1 attr_accessor :raw_invite_token - attr_writer :blocking_refresh belongs_to :created_by, class_name: "User" belongs_to :user @@ -279,12 +278,8 @@ class Member < ApplicationRecord after_save :log_invitation_token_cleanup after_commit :send_request, if: :request?, unless: :importing?, on: [:create] - after_commit on: [:create, :update], unless: :importing? do - refresh_member_authorized_projects(blocking: blocking_refresh) - end - - after_commit on: [:destroy], unless: :importing? do - refresh_member_authorized_projects(blocking: false) + after_commit on: [:create, :update, :destroy], unless: :importing? do + refresh_member_authorized_projects end attribute :notification_level, default: -> { NotificationSetting.levels[:global] } @@ -555,8 +550,8 @@ class Member < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass # This method is overridden in the test environment, see stubbed_member.rb - def refresh_member_authorized_projects(blocking:) - UserProjectAccessChangedService.new(user_id).execute(blocking: blocking) + def refresh_member_authorized_projects + UserProjectAccessChangedService.new(user_id).execute end # rubocop: enable CodeReuse/ServiceClass @@ -642,12 +637,6 @@ class Member < ApplicationRecord error = StandardError.new("Invitation token is present but invite was already accepted!") Gitlab::ErrorTracking.track_exception(error, attributes.slice(%w["invite_accepted_at created_at source_type source_id user_id id"])) end - - def blocking_refresh - return true if @blocking_refresh.nil? - - @blocking_refresh - end end Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 796b05b7fff..f23d7208b6e 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -64,7 +64,7 @@ class GroupMember < Member private override :refresh_member_authorized_projects - def refresh_member_authorized_projects(blocking:) + def refresh_member_authorized_projects # Here, `destroyed_by_association` will be present if the # GroupMember is being destroyed due to the `dependent: :destroy` # callback on Group. In this case, there is no need to refresh the diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb index 36cbc97d049..42ce228c318 100644 --- a/app/models/members/member_role.rb +++ b/app/models/members/member_role.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass +class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass include IgnorableColumns ignore_column :download_code, remove_with: '15.9', remove_after: '2023-01-22' @@ -15,6 +15,8 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass validates_associated :members + before_destroy :prevent_delete_after_member_associated + private def belongs_to_top_level_namespace @@ -35,4 +37,13 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass errors.add(:base, s_("MemberRole|cannot be changed because it is already assigned to a user. "\ "Please create a new Member Role instead")) end + + def prevent_delete_after_member_associated + return unless members.present? + + errors.add(:base, s_("MemberRole|cannot be deleted because it is already assigned to a user. "\ + "Please disassociate the member role from all users before deletion.")) + + throw :abort # rubocop:disable Cop/BanCatchThrow + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 6aa6afb595d..733b7c4bc87 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -109,28 +109,24 @@ class ProjectMember < Member end end + # This method is overridden in the test environment, see stubbed_member.rb override :refresh_member_authorized_projects - def refresh_member_authorized_projects(blocking:) + def refresh_member_authorized_projects return unless user - # rubocop:disable CodeReuse/ServiceClass - if blocking - blocking_project_authorizations_refresh - else - AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) - end + execute_project_authorizations_refresh + # rubocop:disable CodeReuse/ServiceClass # Until we compare the inconsistency rates of the new, specialized service and # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. UserProjectAccessChangedService.new(user_id) - .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY) + .execute(priority: UserProjectAccessChangedService::LOW_PRIORITY) # rubocop:enable CodeReuse/ServiceClass end - # This method is overridden in the test environment, see stubbed_member.rb - def blocking_project_authorizations_refresh - AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]]) + def execute_project_authorizations_refresh + AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id) end # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054 diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 0012f098ab2..485ca3a3850 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -194,9 +194,7 @@ class MergeRequest < ApplicationRecord end before_transition any => :merged do |merge_request| - if ::Feature.enabled?(:reset_merge_error_on_transition, merge_request.project) - merge_request.merge_error = nil - end + merge_request.merge_error = nil end after_transition any => :opened do |merge_request| @@ -289,7 +287,7 @@ class MergeRequest < ApplicationRecord validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?] validate :validate_fork, unless: :closed_or_merged_without_fork? - validate :validate_target_project, on: :create + validate :validate_target_project, on: :create, unless: :importing? validate :validate_reviewer_size_length, unless: :importing? scope :by_source_or_target_branch, ->(branch_name) do @@ -394,6 +392,7 @@ class MergeRequest < ApplicationRecord scope :order_closed_at_desc, -> { order_by_metric(:latest_closed_at, 'DESC') } scope :preload_source_project, -> { preload(:source_project) } scope :preload_target_project, -> { preload(:target_project) } + scope :preload_target_project_with_namespace, -> { preload(target_project: [:namespace]) } scope :preload_routables, -> do preload(target_project: [:route, { namespace: :route }], source_project: [:route, { namespace: :route }]) @@ -1017,7 +1016,6 @@ class MergeRequest < ApplicationRecord end def validate_reviewer_size_length - return true unless Feature.enabled?(:limit_reviewer_and_assignee_size) return true unless reviewers.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS errors.add :reviewers, @@ -2019,6 +2017,18 @@ class MergeRequest < ApplicationRecord Feature.enabled?(:hide_merge_requests_from_banned_users) && author&.banned? end + def diffs_batch_cache_with_max_age? + Feature.enabled?(:diffs_batch_cache_with_max_age, project) + end + + def prepared? + prepared_at.present? + end + + def prepare + NewMergeRequestWorker.perform_async(id, author_id) + end + private attr_accessor :skip_fetch_ref @@ -2070,7 +2080,11 @@ class MergeRequest < ApplicationRecord end def report_type_enabled?(report_type) - !!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) + if report_type == :license_scanning + ::Gitlab::LicenseScanning.scanner_for_pipeline(project, actual_head_pipeline).has_data? + else + !!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) + end end end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index c546a5a0025..87d8704561f 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequest::Metrics < ApplicationRecord + include IgnorableColumns + belongs_to :merge_request, inverse_of: :metrics belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' @@ -14,6 +16,8 @@ class MergeRequest::Metrics < ApplicationRecord scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) } scope :by_target_project, ->(project) { where(target_project_id: project) } + ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + class << self def time_to_merge_expression Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))') diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb index 222d9c1aa8c..d946fd14628 100644 --- a/app/models/merge_request_user_mention.rb +++ b/app/models/merge_request_user_mention.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class MergeRequestUserMention < UserMention + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + belongs_to :merge_request belongs_to :note end diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index 3ea46a8b703..f973b00c568 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -2,6 +2,8 @@ module Ml class Candidate < ApplicationRecord + include Sortable + PACKAGE_PREFIX = 'ml_candidate_' enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 } @@ -19,6 +21,30 @@ module Ml attribute :iid, default: -> { SecureRandom.uuid } scope :including_relationships, -> { includes(:latest_metrics, :params, :user) } + scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection + scope :order_by_metric, ->(metric, direction) do + subquery = Ml::CandidateMetric.latest.where(name: metric) + column_expression = Arel::Table.new('latest')[:value] + metric_order_expression = direction.to_sym == :desc ? column_expression.desc : column_expression.asc + + joins("INNER JOIN (#{subquery.to_sql}) latest ON latest.candidate_id = ml_candidates.id") + .select("ml_candidates.*", "latest.value as metric_value") + .order( + Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'metric_value', + order_expression: metric_order_expression, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].desc + ) + ]) + ) + end delegate :project_id, :project, to: :experiment diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 0a326b0e005..7bb80a170c5 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -12,6 +12,12 @@ module Ml has_many :candidates, class_name: 'Ml::Candidate' has_many :metadata, class_name: 'Ml::ExperimentMetadata' + scope :with_candidate_count, -> { + left_outer_joins(:candidates) + .select("ml_experiments.*, count(ml_candidates.id) as candidate_count") + .group(:id) + } + has_internal_id :iid, scope: :project class << self diff --git a/app/models/namespace.rb b/app/models/namespace.rb index cf638f9b16c..9d9b09e3562 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -35,8 +35,6 @@ class Namespace < ApplicationRecord SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE, SR_ENABLED].freeze URL_MAX_LENGTH = 255 - PATH_TRAILING_VIOLATIONS = %w[.git .atom .].freeze - # This date is just a placeholder until namespace storage enforcement timeline is confirmed at which point # this should be replaced, see https://about.gitlab.com/pricing/faq-efficient-free-tier/#user-limits-on-gitlab-saas-free-tier MIN_STORAGE_ENFORCEMENT_DATE = 3.months.from_now.to_date @@ -85,6 +83,8 @@ class Namespace < ApplicationRecord has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory' has_many :achievements, class_name: 'Achievements::Achievement' has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail' + has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::Stage', foreign_key: :group_id, inverse_of: :namespace + has_many :value_streams, class_name: 'Analytics::CycleAnalytics::ValueStream', foreign_key: :group_id, inverse_of: :namespace validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, @@ -141,12 +141,14 @@ class Namespace < ApplicationRecord :npm_package_requests_forwarding, to: :package_settings + before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? } before_create :sync_share_with_group_lock_with_parent before_update :sync_share_with_group_lock_with_parent, if: :parent_changed? after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? } after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) } after_destroy :rm_dir + after_save :reload_namespace_details after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } @@ -240,27 +242,9 @@ class Namespace < ApplicationRecord end def clean_path(path, limited_to: Namespace.all) - path = path.dup - # Get the email username by removing everything after an `@` sign. - path.gsub!(/@.*\z/, "") - # Remove everything that's not in the list of allowed characters. - path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") - # Remove trailing violations ('.atom', '.git', or '.') - loop do - orig = path - PATH_TRAILING_VIOLATIONS.each { |ext| path = path.chomp(ext) } - break if orig == path - end - - # Remove leading violations ('-') - path.gsub!(/\A\-+/, "") - - # Users with the great usernames of "." or ".." would end up with a blank username. - # Work around that by setting their username to "blank", followed by a counter. - path = "blank" if path.blank? - - uniquify = Uniquify.new - uniquify.string(path) { |s| limited_to.find_by_path_or_name(s) } + slug = Gitlab::Slug::Path.new(path).generate + path = Namespaces::RandomizedSuffixPath.new(slug) + Uniquify.new.string(path) { |s| limited_to.find_by_path_or_name(s) } end def clean_name(value) @@ -617,6 +601,17 @@ class Namespace < ApplicationRecord private + def update_new_emails_created_column + return if namespace_settings.nil? + return if namespace_settings.emails_enabled == !emails_disabled + + if namespace_settings.persisted? + namespace_settings.update!(emails_enabled: !emails_disabled) + elsif namespace_settings + namespace_settings.emails_enabled = !emails_disabled + end + end + def cluster_enabled_granted? (Gitlab.com? || Gitlab.dev_or_test_env?) && root_ancestor.cluster_enabled_grant.present? end @@ -678,7 +673,6 @@ class Namespace < ApplicationRecord groups_requiring_authorizations_refresh.find_each do |group| group.refresh_members_authorized_projects( - blocking: false, priority: priority ) end diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index a5643ab9f79..2660d11171e 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -11,3 +11,5 @@ class Namespace::Detail < ApplicationRecord self.primary_key = :namespace_id end + +Namespace::Detail.prepend_mod diff --git a/app/models/namespaces/randomized_suffix_path.rb b/app/models/namespaces/randomized_suffix_path.rb new file mode 100644 index 00000000000..586d7bff5c3 --- /dev/null +++ b/app/models/namespaces/randomized_suffix_path.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Namespaces + class RandomizedSuffixPath + MAX_TRIES = 4 + LEADING_ZEROS = /^0+/.freeze + + def initialize(path) + @path = path + end + + def call(new_count) + @count = new_count.to_i + to_s + end + + def to_s + "#{path}#{suffix}" + end + + private + + attr_reader :count, :path + + def randomized_suffix + Time.current.strftime('%L%M%V').sub(LEADING_ZEROS, '').to_i + offset + end + + def offset + count - MAX_TRIES - 1 + end + + def suffix + return if count.nil? + return randomized_suffix if count > MAX_TRIES + return count if count > 0 + end + end +end diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 16a9c20dfdc..0e9760832af 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -47,6 +47,9 @@ module Namespaces # This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed. # This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid before_commit :sync_traversal_ids, on: [:create] + after_commit :set_traversal_ids, + if: -> { traversal_ids.empty? || saved_change_to_parent_id? }, + on: [:create, :update] define_model_callbacks :sync_traversal_ids end @@ -78,6 +81,15 @@ module Namespaces end end + def traversal_ids=(ids) + super(ids) + self.transient_traversal_ids = nil + end + + def traversal_ids + read_attribute(:traversal_ids).presence || transient_traversal_ids || [] + end + def use_traversal_ids? return false unless Feature.enabled?(:use_traversal_ids) @@ -174,12 +186,11 @@ module Namespaces # we need to preserve those specific parameters for super. hierarchy_order ||= :desc - # Get all ancestor IDs inclusively between top and our parent. - top_index = top ? traversal_ids.find_index(top.id) : 0 - ids = traversal_ids[top_index...-1] - ids_string = ids.map { |id| Integer(id) }.join(',') + top_index = ancestors_upto_top_index(top) + ids = traversal_ids[top_index...-1].reverse # WITH ORDINALITY lets us order the result to match traversal_ids order. + ids_string = ids.map { |id| Integer(id) }.join(',') from_sql = <<~SQL unnest(ARRAY[#{ids_string}]::bigint[]) WITH ORDINALITY AS ancestors(id, ord) INNER JOIN namespaces ON namespaces.id = ancestors.id @@ -206,6 +217,8 @@ module Namespaces private + attr_accessor :transient_traversal_ids + # Update the traversal_ids for the full hierarchy. # # NOTE: self.traversal_ids will be stale. Reload for a fresh record. @@ -218,6 +231,27 @@ module Namespaces end end + def set_traversal_ids + # This is a temporary guard and will be removed. + return if is_a?(Namespaces::ProjectNamespace) + + return unless Feature.enabled?(:set_traversal_ids_on_save, root_ancestor) + + self.transient_traversal_ids = if parent_id + parent.traversal_ids + [id] + else + [id] + end + + # Clear root_ancestor memo if changed. + if read_attribute(traversal_ids)&.first != transient_traversal_ids.first + clear_memoization(:root_ancestor) + end + + # Update traversal_ids for any associated child objects. + children.each(&:reload) if children.loaded? + end + # Lock the root of the hierarchy we just left, and lock the root of the hierarchy # we just joined. In most cases the two hierarchies will be the same. def lock_both_roots @@ -266,6 +300,17 @@ module Namespaces skope end + + def ancestors_upto_top_index(top) + return 0 if top.nil? + + index = traversal_ids.find_index(top.id) + if index.nil? + 0 + else + index + 1 + end + end end end end diff --git a/app/models/note.rb b/app/models/note.rb index 73c8e72d8b0..a64f7311725 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -23,6 +23,9 @@ class Note < ApplicationRecord include FromUnion include Sortable include EachBatch + include IgnorableColumns + + ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze @@ -138,8 +141,7 @@ class Note < ApplicationRecord relations = [{ project: :group }, { author: :status }, :updated_by, :resolved_by, :award_emoji, { system_note_metadata: :description_version }, :suggestions] - if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name) || - Feature.disabled?(:skip_notes_diff_include) + if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name) relations += [:note_diff_file, :diff_note_positions] end @@ -183,6 +185,39 @@ class Note < ApplicationRecord after_commit :notify_after_create, on: :create after_commit :notify_after_destroy, on: :destroy + after_commit :trigger_note_subscription_create, on: :create + after_commit :trigger_note_subscription_update, on: :update + after_commit :trigger_note_subscription_destroy, on: :destroy + + def trigger_note_subscription_create + return unless trigger_note_subscription? + + GraphqlTriggers.work_item_note_created(noteable.to_work_item_global_id, self) + end + + def trigger_note_subscription_update + return unless trigger_note_subscription? + + GraphqlTriggers.work_item_note_updated(noteable.to_work_item_global_id, self) + end + + def trigger_note_subscription_destroy + return unless trigger_note_subscription? + + # when deleting a note, we cannot pass it on as a Note instance, as GitlabSchema.object_from_id + # would try to resolve the given Note and fetch it from DB which would raise NotFound exception. + # So instead we just pass over the string representations of the note and discussion IDs, + # so that the subscriber can identify the discussion and the note. + deleted_note_data = { + id: self.id, + model_name: self.class.name, + discussion_id: self.discussion_id, + last_discussion_note: discussion.notes == [self] + } + + GraphqlTriggers.work_item_note_deleted(noteable.to_work_item_global_id, deleted_note_data) + end + class << self extend Gitlab::Utils::Override @@ -711,8 +746,18 @@ class Note < ApplicationRecord confidential? ? :read_internal_note : :read_note end + def exportable_record?(user) + return true unless system? + + readable_by?(user) + end + private + def trigger_note_subscription? + for_issue? && noteable + end + def system_note_viewable_by?(user) return true unless system_note_metadata diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index 67a6d5d6d6b..4238de0a2f8 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -2,6 +2,9 @@ class NoteDiffFile < ApplicationRecord include DiffFile + include IgnorableColumns + + ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' scope :referencing_sha, -> (oids, project_id:) do joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids }) diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb index 49fdb102209..269283df826 100644 --- a/app/models/onboarding/completion.rb +++ b/app/models/onboarding/completion.rb @@ -6,13 +6,13 @@ module Onboarding include Gitlab::Experiment::Dsl ACTION_ISSUE_IDS = { - pipeline_created: 7, trial_started: 2, required_mr_approvals_enabled: 11, code_owners_enabled: 10 }.freeze ACTION_PATHS = [ + :pipeline_created, :issue_created, :git_write, :merge_request_created, diff --git a/app/models/onboarding/learn_gitlab.rb b/app/models/onboarding/learn_gitlab.rb deleted file mode 100644 index d7a189ed6e2..00000000000 --- a/app/models/onboarding/learn_gitlab.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Onboarding - class LearnGitlab - PROJECT_NAME = 'Learn GitLab' - PROJECT_NAME_ULTIMATE_TRIAL = 'Learn GitLab - Ultimate trial' - BOARD_NAME = 'GitLab onboarding' - LABEL_NAME = 'Novice' - - def initialize(current_user) - @current_user = current_user - end - - def available? - project && board && label - end - - def project - @project ||= current_user.projects.find_by_name([PROJECT_NAME, PROJECT_NAME_ULTIMATE_TRIAL]) - end - - def board - return unless project - - @board ||= project.boards.find_by_name(BOARD_NAME) - end - - def label - return unless project - - @label ||= project.labels.find_by_name(LABEL_NAME) - end - - private - - attr_reader :current_user - end -end diff --git a/app/models/package_metadata/application_record.rb b/app/models/package_metadata/application_record.rb new file mode 100644 index 00000000000..1bf0222ada4 --- /dev/null +++ b/app/models/package_metadata/application_record.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PackageMetadata + class ApplicationRecord < ::ApplicationRecord + self.abstract_class = true + + def self.table_name_prefix + 'pm_' + end + end +end diff --git a/app/models/packages/composer/metadatum.rb b/app/models/packages/composer/metadatum.rb index 363858a3ed1..8b0b71ca86f 100644 --- a/app/models/packages/composer/metadatum.rb +++ b/app/models/packages/composer/metadatum.rb @@ -10,8 +10,18 @@ module Packages validates :package, :target_sha, :composer_json, presence: true + validate :composer_package_type + scope :for_package, ->(name, project_id) { joins(:package).where(packages_packages: { name: name, project_id: project_id, package_type: Packages::Package.package_types[:composer] }) } scope :locked_for_update, -> { lock('FOR UPDATE') } + + private + + def composer_package_type + return if package&.composer? + + errors.add(:base, _('Package type must be Composer')) + end end end end diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb index 2daafe0ebcf..9c615c20250 100644 --- a/app/models/packages/debian.rb +++ b/app/models/packages/debian.rb @@ -2,6 +2,8 @@ module Packages module Debian + TEMPORARY_PACKAGE_NAME = 'debian-temporary-package' + DISTRIBUTION_REGEX = %r{[a-z0-9][a-z0-9.-]*}i.freeze COMPONENT_REGEX = DISTRIBUTION_REGEX.freeze ARCHITECTURE_REGEX = %r{[a-z0-9][-a-z0-9]*}.freeze diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb index b70b6c460d2..eb66f4acfa9 100644 --- a/app/models/packages/debian/file_entry.rb +++ b/app/models/packages/debian/file_entry.rb @@ -4,7 +4,6 @@ module Packages module Debian class FileEntry include ActiveModel::Model - include ::Packages::FIPS DIGESTS = %i[md5 sha1 sha256].freeze FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze @@ -32,8 +31,6 @@ module Packages private def valid_package_file_digests - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - DIGESTS.each do |digest| package_file_digest = package_file["file_#{digest}"] sum = public_send("#{digest}sum") # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/packages/debian/file_metadatum.rb b/app/models/packages/debian/file_metadatum.rb index af51f256e18..eb1b03a8e9d 100644 --- a/app/models/packages/debian/file_metadatum.rb +++ b/app/models/packages/debian/file_metadatum.rb @@ -13,10 +13,11 @@ class Packages::Debian::FileMetadatum < ApplicationRecord } validates :file_type, presence: true - validates :file_type, inclusion: { in: %w[unknown] }, if: -> { package_file&.package&.debian_incoming? } + validates :file_type, inclusion: { in: %w[unknown] }, + if: -> { package_file&.package&.debian_incoming? || package_file&.package&.processing? } validates :file_type, inclusion: { in: %w[source dsc deb udeb buildinfo changes] }, - if: -> { package_file&.package&.debian_package? } + if: -> { package_file&.package&.debian_package? && !package_file&.package&.processing? } validates :component, presence: true, diff --git a/app/models/packages/debian/group_distribution.rb b/app/models/packages/debian/group_distribution.rb index 01938f4a2ec..dba38c1b538 100644 --- a/app/models/packages/debian/group_distribution.rb +++ b/app/models/packages/debian/group_distribution.rb @@ -10,6 +10,7 @@ class Packages::Debian::GroupDistribution < ApplicationRecord def packages Packages::Package .for_projects(group.all_projects.public_only) + .debian .with_debian_codename(codename) end end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 966165f9ad7..970538b45e7 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -138,10 +138,12 @@ class Packages::Package < ApplicationRecord joins(:conan_metadatum).where(packages_conan_metadata: { package_username: package_username }) end - scope :with_debian_codename, -> (codename) do - debian - .joins(:debian_distribution) - .where(Packages::Debian::ProjectDistribution.table_name => { codename: codename }) + scope :with_debian_codename, ->(codename) do + joins(:debian_distribution).where(Packages::Debian::ProjectDistribution.table_name => { codename: codename }) + end + scope :with_debian_codename_or_suite, ->(codename_or_suite) do + joins(:debian_distribution).where(Packages::Debian::ProjectDistribution.table_name => { codename: codename_or_suite }) + .or(where(Packages::Debian::ProjectDistribution.table_name => { suite: codename_or_suite })) end scope :preload_debian_file_metadata, -> { preload(package_files: :debian_file_metadatum) } scope :with_composer_target, -> (target) do @@ -160,7 +162,8 @@ class Packages::Package < ApplicationRecord scope :preload_files, -> { preload(:installable_package_files) } scope :preload_nuget_files, -> { preload(:installable_nuget_package_files) } scope :preload_pipelines, -> { preload(pipelines: :user) } - scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } + scope :last_of_each_version, -> { where(id: all.last_of_each_version_ids) } + scope :last_of_each_version_ids, -> { select('MAX(id) AS id').unscope(where: :id).group(:version) } scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } scope :select_distinct_name, -> { select(:name).distinct } @@ -277,6 +280,7 @@ class Packages::Package < ApplicationRecord project.packages .preload_pipelines .including_tags + .displayable .with_name(name) .where.not(version: version) .with_package_type(package_type) diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb index 14a1ae98ed4..9c17a147bf4 100644 --- a/app/models/packages/tag.rb +++ b/app/models/packages/tag.rb @@ -10,8 +10,8 @@ class Packages::Tag < ApplicationRecord scope :preload_package, -> { preload(:package) } scope :with_name, -> (name) { where(name: name) } - def self.for_packages(packages) - where(package_id: packages.select(:id)) + def self.for_package_ids(package_ids) + where(package_id: package_ids) .order(updated_at: :desc) .limit(FOR_PACKAGES_TAGS_LIMIT) end diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index 37bf080ae49..6fea3abf3d9 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -58,7 +58,7 @@ module PerformanceMonitoring rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e [e.message] rescue ActiveModel::ValidationError => e - e.model.errors.map { |attr, error| "#{attr}: #{error}" } + e.model.errors.map { |error| "#{error.attribute}: #{error.message}" } end private diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 0da205f86a5..f99c4c6c39d 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -24,7 +24,7 @@ class PersonalAccessToken < ApplicationRecord # During the implementation of Admin Mode for API, tokens of # administrators should automatically get the `admin_mode` scope as well # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692 - before_create :add_admin_mode_scope, if: :user_admin? + before_create :add_admin_mode_scope, if: -> { Feature.disabled?(:admin_mode_for_api) && user_admin? } scope :active, -> { not_revoked.not_expired } scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } @@ -84,10 +84,8 @@ class PersonalAccessToken < ApplicationRecord protected def validate_scopes - # During the implementation of Admin Mode for API, - # the `admin_mode` scope is not yet part of `all_available_scopes` but still valid. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692 - valid_scopes = Gitlab::Auth.all_available_scopes + [Gitlab::Auth::ADMIN_MODE_SCOPE] + valid_scopes = Gitlab::Auth.all_available_scopes + valid_scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE] if Feature.disabled?(:admin_mode_for_api) unless revoked || scopes.all? { |scope| valid_scopes.include?(scope.to_sym) } errors.add :scopes, "can only contain available scopes" diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index bf08da6a1e1..bf69f425189 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -2,8 +2,8 @@ class PlanLimits < ApplicationRecord include IgnorableColumns - 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' LimitUndefinedError = Class.new(StandardError) diff --git a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb index 8df986b47a2..0c747ad9c84 100644 --- a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb @@ -32,11 +32,12 @@ module Preloaders end def preload_with_traversal_ids - max_access_levels = GroupMember.active_without_invites_and_requests - .where(user: @user) - .joins("INNER JOIN (#{traversal_join_sql}) as hierarchy ON members.source_id = hierarchy.traversal_id") - .group('hierarchy.id') - .maximum(:access_level) + # Diagrammatic representation of this step: + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111157#note_1271550140 + max_access_levels = GroupMember.from_union(all_memberships) + .joins("INNER JOIN (#{traversal_join_sql}) as hierarchy ON members.source_id = hierarchy.traversal_id") + .group('hierarchy.id') + .maximum(:access_level) @groups.each do |group| max_access_level = max_access_levels[group.id] || Gitlab::Access::NO_ACCESS @@ -44,6 +45,58 @@ module Preloaders end end + def all_memberships + if Feature.enabled?(:include_memberships_from_group_shares_in_preloader) + [ + direct_memberships.select(*GroupMember.cached_column_list), + memberships_from_group_shares + ] + else + [direct_memberships] + end + end + + def direct_memberships + GroupMember.active_without_invites_and_requests.where(user: @user) + end + + def memberships_from_group_shares + alter_direct_memberships_to_make_it_act_like_memberships_in_shared_groups + end + + def alter_direct_memberships_to_make_it_act_like_memberships_in_shared_groups + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + altered_columns = GroupMember.attribute_names.map do |column_name| + case column_name + when 'access_level' + # Consider the limiting effect of group share's access level + smallest_value_arel([group_group_link_table[:group_access], group_member_table[:access_level]], 'access_level') + when 'source_id' + # Alter the `source_id` of the `Member` record that is currently pointing to the `shared_with_group` + # such that this record would now behave like a `Member` record of this user pointing to the `shared_group` group. + Arel::Nodes::As.new(group_group_link_table[:shared_group_id], Arel::Nodes::SqlLiteral.new('source_id')) + else + group_member_table[column_name] + end + end + + direct_memberships_in_groups_that_have_been_shared_with_other_groups.select(*altered_columns) + end + + def direct_memberships_in_groups_that_have_been_shared_with_other_groups + direct_memberships.joins( + "INNER JOIN group_group_links ON members.source_id = group_group_links.shared_with_group_id" + ) + end + + def smallest_value_arel(args, column_alias) + Arel::Nodes::As.new( + Arel::Nodes::NamedFunction.new('LEAST', args), + Arel::Nodes::SqlLiteral.new(column_alias)) + end + def traversal_join_sql Namespace.select('id, unnest(traversal_ids) as traversal_id').where(id: @groups.map(&:id)).to_sql end diff --git a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb index c9fd5e7718a..09854ec5ff1 100644 --- a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb @@ -7,12 +7,10 @@ module Preloaders def initialize(projects, user) @projects = if projects.is_a?(Array) Project.where(id: projects) - elsif Feature.enabled?(:projects_preloader_fix) + else # Push projects base query in to a sub-select to avoid # table name clashes. Performs better than aliasing. Project.where(id: projects.subquery(:id)) - else - Project.where(id: projects.reselect(:id)) end @user = user diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 4156c672518..e3693046423 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -7,7 +7,7 @@ class ProgrammingLanguage < ApplicationRecord # Returns all programming languages which match any of the given names (case # insensitively). scope :with_name_case_insensitive, ->(*names) do - sanitized_names = names.map(&method(:sanitize_sql_like)) + sanitized_names = names.map { |name| sanitize_sql_like(name) } where(arel_table[:name].matches_any(sanitized_names)) end diff --git a/app/models/project.rb b/app/models/project.rb index 561a842f23a..43ec26be786 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -40,6 +40,8 @@ class Project < ApplicationRecord include RunnerTokenExpirationInterval include BlocksUnsafeSerialization include Subquery + include IssueParent + include WebHooks::HasWebHooks extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -118,6 +120,7 @@ class Project < ApplicationRecord before_validation :remove_leading_spaces_on_name after_validation :check_pending_delete before_save :ensure_runners_token + before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? } after_create -> { create_or_load_association(:project_feature) } after_create -> { create_or_load_association(:ci_cd_settings) } @@ -306,6 +309,9 @@ class Project < ApplicationRecord primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, class_name: 'ProjectMember' has_many :members_and_requesters, as: :source, class_name: 'ProjectMember' + has_many :namespace_members_and_requesters, -> { unscope(where: %i[source_id source_type]) }, + primary_key: :project_namespace_id, foreign_key: :member_namespace_id, inverse_of: :project, + class_name: 'ProjectMember' has_many :users, through: :project_members @@ -395,9 +401,6 @@ class Project < ApplicationRecord has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :remote_mirrors, inverse_of: :project - has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage', inverse_of: :project - has_many :value_streams, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', inverse_of: :project - has_many :external_pull_requests, inverse_of: :project has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id @@ -750,7 +753,7 @@ class Project < ApplicationRecord return public_to_user unless user if user.is_a?(DeployToken) - user.accessible_projects + where(id: user.accessible_projects) else where('EXISTS (?) OR projects.visibility_level IN (?)', user.authorizations_for_projects(min_access_level: min_access_level), @@ -824,6 +827,7 @@ class Project < ApplicationRecord scope :for_group, -> (group) { where(group: group) } scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) } scope :for_group_and_its_ancestor_groups, ->(group) { where(namespace_id: group.self_and_ancestors.select(:id)) } + scope :is_importing, -> { with_import_state.where(import_state: { status: %w[started scheduled] }) } class << self # Searches for a list of projects based on the query given in `query`. @@ -991,6 +995,13 @@ class Project < ApplicationRecord namespace.owner == user end + def invalidate_personal_projects_count_of_owner + return unless personal? + return unless namespace.owner + + namespace.owner.invalidate_personal_projects_count + end + def project_setting super.presence || build_project_setting end @@ -1249,6 +1260,10 @@ class Project < ApplicationRecord import_state&.status || 'none' end + def import_checksums + import_state&.checksums || {} + end + def jira_import_status latest_jira_import&.status || 'initial' end @@ -2789,6 +2804,18 @@ class Project < ApplicationRecord protected_branches.limit(limit) end + def group_protected_branches + root_namespace.is_a?(Group) ? root_namespace.protected_branches : ProtectedBranch.none + end + + def all_protected_branches + if Feature.enabled?(:group_protected_branches) + @all_protected_branches ||= ProtectedBranch.from_union([protected_branches, group_protected_branches]) + else + protected_branches + end + end + def self_monitoring? Gitlab::CurrentSettings.self_monitoring_project_id == id end @@ -3045,13 +3072,8 @@ class Project < ApplicationRecord group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2) end - def work_items_create_from_markdown_feature_flag_enabled? - group&.work_items_create_from_markdown_feature_flag_enabled? || Feature.enabled?(:work_items_create_from_markdown) - end - def enqueue_record_project_target_platforms return unless Gitlab.com? - return unless Feature.enabled?(:record_projects_target_platforms, self) Projects::RecordTargetPlatformsWorker.perform_async(id) end @@ -3368,6 +3390,17 @@ class Project < ApplicationRecord ProjectFeature::PRIVATE end end + + def update_new_emails_created_column + return if project_setting.nil? + return if project_setting.emails_enabled == !emails_disabled + + if project_setting.persisted? + project_setting.update!(emails_enabled: !emails_disabled) + elsif project_setting + project_setting.emails_enabled = !emails_disabled + end + end end Project.prepend_mod_with('Project') diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 3623b3be20d..cb578496f26 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -31,7 +31,7 @@ class ProjectAuthorization < ApplicationRecord def self.insert_all_in_batches(attributes, per_batch = BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: attributes.size, batch_size: per_batch) - log_details(entire_size: attributes.size) if add_delay + log_details(entire_size: attributes.size, batch_size: per_batch) if add_delay attributes.each_slice(per_batch) do |attributes_batch| insert_all(attributes_batch) @@ -41,7 +41,7 @@ class ProjectAuthorization < ApplicationRecord def self.delete_all_in_batches_for_project(project:, user_ids:, per_batch: BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: user_ids.size, batch_size: per_batch) - log_details(entire_size: user_ids.size) if add_delay + log_details(entire_size: user_ids.size, batch_size: per_batch) if add_delay user_ids.each_slice(per_batch) do |user_ids_batch| project.project_authorizations.where(user_id: user_ids_batch).delete_all @@ -51,7 +51,7 @@ class ProjectAuthorization < ApplicationRecord def self.delete_all_in_batches_for_user(user:, project_ids:, per_batch: BATCH_SIZE) add_delay = add_delay_between_batches?(entire_size: project_ids.size, batch_size: per_batch) - log_details(entire_size: project_ids.size) if add_delay + log_details(entire_size: project_ids.size, batch_size: per_batch) if add_delay project_ids.each_slice(per_batch) do |project_ids_batch| user.project_authorizations.where(project_id: project_ids_batch).delete_all @@ -64,14 +64,15 @@ class ProjectAuthorization < ApplicationRecord # catch up with the primary when large batches of records are being added/removed. # Hance, we add a delay only if the GitLab installation has a replica database configured. entire_size > batch_size && - !::Gitlab::Database::LoadBalancing.primary_only? && - Feature.enabled?(:enable_minor_delay_during_project_authorizations_refresh) + !::Gitlab::Database::LoadBalancing.primary_only? end - private_class_method def self.log_details(entire_size:) + private_class_method def self.log_details(entire_size:, batch_size:) Gitlab::AppLogger.info( entire_size: entire_size, - message: 'Project authorizations refresh performed with delay' + total_delay: (entire_size / batch_size.to_f).ceil * SLEEP_DELAY, + message: 'Project authorizations refresh performed with delay', + **Gitlab::ApplicationContext.current ) end diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index cc9003423be..8741a341ad3 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -20,6 +20,10 @@ class ProjectCiCdSetting < ApplicationRecord attribute :forward_deployment_enabled, default: true attribute :separated_caches, default: true + default_value_for :inbound_job_token_scope_enabled do |settings| + Feature.enabled?(:ci_inbound_job_token_scope, settings.project) + end + chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval def keep_latest_artifacts_available? diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 11f4a3f3b6f..168646bbe41 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -63,32 +63,23 @@ class ProjectFeature < ApplicationRecord validate :repository_children_level - default_value_for :builds_access_level, value: ENABLED, allows_nil: false - default_value_for :issues_access_level, value: ENABLED, allows_nil: false - default_value_for :forking_access_level, value: ENABLED, allows_nil: false - default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false - default_value_for :snippets_access_level, value: ENABLED, allows_nil: false - default_value_for :wiki_access_level, value: ENABLED, allows_nil: false - default_value_for :repository_access_level, value: ENABLED, allows_nil: false - default_value_for :analytics_access_level, value: ENABLED, allows_nil: false - default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false - default_value_for :operations_access_level, value: ENABLED, allows_nil: false - default_value_for :security_and_compliance_access_level, value: PRIVATE, allows_nil: false - default_value_for :monitor_access_level, value: ENABLED, allows_nil: false - default_value_for :infrastructure_access_level, value: ENABLED, allows_nil: false - default_value_for :feature_flags_access_level, value: ENABLED, allows_nil: false - default_value_for :environments_access_level, value: ENABLED, allows_nil: false - default_value_for :releases_access_level, value: ENABLED, allows_nil: false - - default_value_for(:pages_access_level, allows_nil: false) do |feature| - if ::Gitlab::Pages.access_control_is_forced? - PRIVATE - else - feature.project&.public? ? ENABLED : PRIVATE - end - end - - default_value_for(:package_registry_access_level) do |feature| + attribute :builds_access_level, default: ENABLED + attribute :issues_access_level, default: ENABLED + attribute :forking_access_level, default: ENABLED + attribute :merge_requests_access_level, default: ENABLED + attribute :snippets_access_level, default: ENABLED + attribute :wiki_access_level, default: ENABLED + attribute :repository_access_level, default: ENABLED + attribute :analytics_access_level, default: ENABLED + attribute :metrics_dashboard_access_level, default: PRIVATE + attribute :operations_access_level, default: ENABLED + attribute :security_and_compliance_access_level, default: PRIVATE + attribute :monitor_access_level, default: ENABLED + attribute :infrastructure_access_level, default: ENABLED + attribute :feature_flags_access_level, default: ENABLED + attribute :environments_access_level, default: ENABLED + + attribute :package_registry_access_level, default: -> do if ::Gitlab.config.packages.enabled ENABLED else @@ -96,7 +87,7 @@ class ProjectFeature < ApplicationRecord end end - default_value_for(:container_registry_access_level) do |feature| + attribute :container_registry_access_level, default: -> do if gitlab_config_features.container_registry ENABLED else @@ -104,6 +95,9 @@ class ProjectFeature < ApplicationRecord end end + after_initialize :set_pages_access_level, if: :new_record? + after_initialize :set_default_values, unless: :new_record? + # "enabled" here means "not disabled". It includes private features! scope :with_feature_enabled, ->(feature) { feature_access_level_attribute = arel_table[access_level_attribute(feature)] @@ -170,6 +164,23 @@ class ProjectFeature < ApplicationRecord private + def set_pages_access_level + self.pages_access_level ||= if ::Gitlab::Pages.access_control_is_forced? + PRIVATE + else + self.project&.public? ? ENABLED : PRIVATE + end + end + + def set_default_values + self.class.column_names.each do |column_name| + next unless has_attribute?(column_name) + next unless read_attribute(column_name).nil? + + write_attribute(column_name, self.class.column_defaults[column_name]) + end + end + # Validates builds and merge requests access level # which cannot be higher than repository access level def repository_children_level diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 7711c6d604a..f16d661d4bb 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -11,6 +11,7 @@ class ProjectImportState < ApplicationRecord belongs_to :project, inverse_of: :import_state validates :project, presence: true + validates :checksums, json_schema: { filename: "project_import_stats" } alias_attribute :correlation_id, :correlation_id_value @@ -68,6 +69,16 @@ class ProjectImportState < ApplicationRecord state.project.remove_import_data end + before_transition started: [:finished, :canceled, :failed] do |state, _| + project = state.project + + if project.github_import? + import_stats = ::Gitlab::GithubImport::ObjectCounter.summary(state.project) + + state.update_column(:checksums, import_stats) + end + end + after_transition started: :finished do |state, _| project = state.project diff --git a/app/models/projects/data_transfer.rb b/app/models/projects/data_transfer.rb new file mode 100644 index 00000000000..a93aea55781 --- /dev/null +++ b/app/models/projects/data_transfer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Tracks egress of various services per project +# This class ensures that we keep 1 record per project per month. +module Projects + class DataTransfer < ApplicationRecord + self.table_name = 'project_data_transfers' + + belongs_to :project + belongs_to :namespace + + scope :current_month, -> { where(date: beginning_of_month) } + + def self.beginning_of_month(time = Time.current) + time.utc.beginning_of_month + end + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 050db3b6870..b3331b99a6b 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -3,6 +3,7 @@ class ProtectedBranch < ApplicationRecord include ProtectedRef include Gitlab::SQL::Pattern + include FromUnion belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches @@ -11,6 +12,9 @@ class ProtectedBranch < ApplicationRecord scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) } scope :allowing_force_push, -> { where(allow_force_push: true) } scope :sorted_by_name, -> { order(name: :asc) } + scope :sorted_by_namespace_and_name, -> { order(:namespace_id, :name) } + + scope :for_group, ->(group) { where(group: group) } protected_ref_access_levels :merge, :push @@ -43,14 +47,12 @@ class ProtectedBranch < ApplicationRecord end def self.new_cache(project, ref_name, dry_run: true) - if Feature.enabled?(:hash_based_cache_for_protected_branches, project) - ProtectedBranches::CacheService.new(project).fetch(ref_name, dry_run: dry_run) do # rubocop: disable CodeReuse/ServiceClass - self.matching(ref_name, protected_refs: protected_refs(project)).present? - end + ProtectedBranches::CacheService.new(project).fetch(ref_name, dry_run: dry_run) do # rubocop: disable CodeReuse/ServiceClass + self.matching(ref_name, protected_refs: protected_refs(project)).present? end end - # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/368279 + # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/370608 # ---------------------------------------------------------------- CACHE_EXPIRE_IN = 1.hour @@ -66,7 +68,19 @@ class ProtectedBranch < ApplicationRecord # End of deprecation -------------------------------------------- def self.allow_force_push?(project, ref_name) - project.protected_branches.allowing_force_push.matching(ref_name).any? + if Feature.enabled?(:group_protected_branches) + protected_branches = project.all_protected_branches.matching(ref_name) + + project_protected_branches, group_protected_branches = protected_branches.partition(&:project_id) + + # Group owner can be able to enforce the settings + return group_protected_branches.any?(&:allow_force_push) if group_protected_branches.present? + return project_protected_branches.any?(&:allow_force_push) if project_protected_branches.present? + + false + else + project.protected_branches.allowing_force_push.matching(ref_name).any? + end end def self.any_protected?(project, ref_names) @@ -78,7 +92,11 @@ class ProtectedBranch < ApplicationRecord end def self.protected_refs(project) - project.protected_branches + if Feature.enabled?(:group_protected_branches) + project.all_protected_branches + else + project.protected_branches + end end # overridden in EE @@ -104,6 +122,14 @@ class ProtectedBranch < ApplicationRecord name == project.default_branch end + def group_level? + entity.is_a?(Group) + end + + def project_level? + entity.is_a?(Project) + end + def entity group || project end diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb index 5d8b1fb4f71..abb233d3800 100644 --- a/app/models/protected_tag/create_access_level.rb +++ b/app/models/protected_tag/create_access_level.rb @@ -4,9 +4,43 @@ class ProtectedTag::CreateAccessLevel < ApplicationRecord include Importable include ProtectedTagAccess + belongs_to :deploy_key + + validates :access_level, uniqueness: { scope: :protected_tag_id, if: :role?, + conditions: -> { where(user_id: nil, group_id: nil, deploy_key_id: nil) } } + validates :deploy_key_id, uniqueness: { scope: :protected_tag_id, allow_nil: true } + validate :validate_deploy_key_membership + + def type + if deploy_key.present? + :deploy_key + else + super + end + end + def check_access(user) return false if access_level == Gitlab::Access::NO_ACCESS + if user && deploy_key.present? + return user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user) + end + super end + + private + + def validate_deploy_key_membership + return unless deploy_key + + return if project.deploy_keys_projects.where(deploy_key: deploy_key).exists? + + errors.add(:deploy_key, 'is not enabled for this project') + end + + def enabled_deploy_key_for_user?(deploy_key, user) + deploy_key.user_id == user.id && + DeployKey.with_write_access_for_project(protected_tag.project, deploy_key: deploy_key).any? + end end diff --git a/app/models/release.rb b/app/models/release.rb index b770f3934ef..0f00732b62e 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -11,7 +11,6 @@ class Release < ApplicationRecord cache_markdown_field :description belongs_to :project, touch: true - # releases prior to 11.7 have no author belongs_to :author, class_name: 'User' has_many :links, class_name: 'Releases::Link' @@ -26,7 +25,7 @@ class Release < ApplicationRecord before_create :set_released_at validates :project, :tag, presence: true - validates :author_id, presence: true, if: :validate_release_with_author? + validates :author_id, presence: true, on: :create validates :tag, uniqueness: { scope: :project_id } @@ -119,10 +118,6 @@ class Release < ApplicationRecord end end - def validate_release_with_author? - Feature.enabled?(:validate_release_with_author, self.project) - end - def set_released_at self.released_at ||= created_at end diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index c2d498ecb13..7cead8a42cd 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -2,7 +2,6 @@ class ReleaseHighlight CACHE_DURATION = 1.hour - FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') FREE_PACKAGE = 'Free' PREMIUM_PACKAGE = 'Premium' @@ -48,13 +47,17 @@ class ReleaseHighlight nil end + def self.whats_new_path + Rails.root.join('data/whats_new/*.yml') + end + def self.file_paths @file_paths ||= self.relative_file_paths.map { |path| path.prepend(Rails.root.to_s) } end def self.relative_file_paths Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do - Dir.glob(FILES_PATH).sort.reverse.map { |path| path.delete_prefix(Rails.root.to_s) } + Dir.glob(whats_new_path).sort.reverse.map { |path| path.delete_prefix(Rails.root.to_s) } end end @@ -119,3 +122,5 @@ class ReleaseHighlight item['available_in']&.include?(current_package) end end + +ReleaseHighlight.prepend_mod diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index 347adbdf96a..e02486fbc5b 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -37,6 +37,7 @@ module Releases url.start_with?(release.project.web_url) end + # `external?` is deprecated in 15.9 and will be removed in 16.0. def external? !internal? end @@ -44,7 +45,7 @@ module Releases def hook_attrs { id: id, - external: external?, + external: external?, # `external` is deprecated in 15.9 and will be removed in 16.0. link_type: link_type, name: name, url: url diff --git a/app/models/repository.rb b/app/models/repository.rb index cedfed16b20..d15f2a430fa 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -189,9 +189,7 @@ class Repository return [] end - query = Feature.enabled?(:commit_search_trailing_spaces) ? query.strip : query - - commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c| + commits = raw_repository.find_commits_by_message(query.strip, ref, path, limit, offset).map do |c| commit(c) end CommitCollection.new(container, commits, ref) @@ -633,11 +631,7 @@ class Repository end def readme_path - if Feature.enabled?(:readme_from_gitaly) - readme_path_gitaly - else - head_tree&.readme_path - end + head_tree&.readme_path end cache_method :readme_path @@ -702,14 +696,14 @@ class Repository end def head_tree(skip_flat_paths: true) - if head_commit - @head_tree ||= Tree.new(self, head_commit.sha, nil, skip_flat_paths: skip_flat_paths) - end + return if empty? || root_ref.nil? + + @head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths) end def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil) if sha == :head - return unless head_commit + return if empty? || root_ref.nil? if path.nil? return head_tree(skip_flat_paths: skip_flat_paths) @@ -878,25 +872,45 @@ class Repository end def merge(user, source_sha, merge_request, message) + merge_to_branch(user, + source_sha: source_sha, + target_branch: merge_request.target_branch, + message: message) do |commit_id| + merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id) + nil # Return value does not matter. + end + end + + def merge_to_branch(user, source_sha:, target_branch:, message:, target_sha: nil) with_cache_hooks do - raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id| - merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id) - nil # Return value does not matter. + raw_repository.merge(user, + source_sha: source_sha, + target_branch: target_branch, + message: message, + target_sha: target_sha + ) do |commit_id| + yield commit_id if block_given? end end end - def delete_refs(*ref_names) - raw.delete_refs(*ref_names) + def delete_refs(...) + raw.delete_refs(...) end - def ff_merge(user, source, target_branch, merge_request: nil) + def ff_merge(user, source, target_branch, target_sha: nil, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? merge_request&.update_and_mark_in_progress_merge_commit_sha(their_commit_id) - with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) } + with_cache_hooks do + raw.ff_merge(user, + source_sha: their_commit_id, + target_branch: target_branch, + target_sha: target_sha + ) + end end def revert( @@ -1245,29 +1259,6 @@ class Repository container.full_path, container: container) end - - def readme_path_gitaly - return if empty? || root_ref.nil? - - # (?i) to enable case-insensitive mode - # - # Note: `Gitlab::FileDetector::PATTERNS[:readme]#to_s` won't work because of - # incompatibility of regex engines between Rails and Gitaly. - regex = "(?i)#{Gitlab::FileDetector::PATTERNS[:readme].source}" - - readmes = search_files_by_regexp(regex, root_ref) - - choose_readme_to_display(readmes) - end - - # Extracted from Tree#readme_path - def choose_readme_to_display(readmes) - previewable_readme = readmes.find { |name| Gitlab::MarkupHelper.previewable?(name) } - - return previewable_readme if previewable_readme - - readmes.find { |name| Gitlab::MarkupHelper.plain?(name) } - end end Repository.prepend_mod_with('Repository') diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 8fea0d6d993..1a0a65df6a3 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SentNotification < ApplicationRecord + include IgnorableColumns + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize belongs_to :project @@ -14,6 +16,8 @@ class SentNotification < ApplicationRecord validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid + ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + after_save :keep_around_commit, if: :for_commit? class << self diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index 738f18ca5e3..5152746abb4 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -3,6 +3,14 @@ class ServiceDeskSetting < ApplicationRecord include Gitlab::Utils::StrongMemoize + attribute :custom_email_enabled, default: false + attr_encrypted :custom_email_smtp_password, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_32, + encode: false, + encode_iv: false + belongs_to :project validates :project_id, presence: true validate :valid_issue_template @@ -13,8 +21,42 @@ class ServiceDeskSetting < ApplicationRecord allow_blank: true, format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } } + validates :custom_email, + length: { maximum: 255 }, + uniqueness: true, + allow_nil: true, + format: /\A[\w\-._]+@[\w\-.]+\.{1}[a-zA-Z]{2,}\z/ + validates :custom_email_smtp_address, length: { maximum: 255 } + validates :custom_email_smtp_username, length: { maximum: 255 } + + validates :custom_email, + presence: true, + devise_email: true, + if: :custom_email_enabled? + validates :custom_email_smtp_address, + presence: true, + hostname: { allow_numeric_hostname: true, require_valid_tld: true }, + if: :custom_email_enabled? + validates :custom_email_smtp_username, + presence: true, + if: :custom_email_enabled? + validates :custom_email_smtp_port, + presence: true, + numericality: { only_integer: true, greater_than: 0 }, + if: :custom_email_enabled? + scope :with_project_key, ->(key) { where(project_key: key) } + def custom_email_delivery_options + { + user_name: custom_email_smtp_username, + password: custom_email_smtp_password, + address: custom_email_smtp_address, + domain: Mail::Address.new(custom_email).domain, + port: custom_email_smtp_port || 587 + } + end + def issue_template_content strong_memoize(:issue_template_content) do next unless issue_template_key.present? diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index a959ad4d548..9139dc22a94 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -121,7 +121,7 @@ class SnippetRepository < ApplicationRecord def invalid_signature_error?(err) err.is_a?(ArgumentError) && - err.message.downcase.match?(/failed to parse signature/) + err.message.downcase.include?('failed to parse signature') end def only_rename_action?(action) diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb index 87ce77a5787..138feb6ab29 100644 --- a/app/models/snippet_user_mention.rb +++ b/app/models/snippet_user_mention.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class SnippetUserMention < UserMention + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + belongs_to :snippet belongs_to :note end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index ca2ad8bf88c..267be5fe5c2 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -3,6 +3,9 @@ class Suggestion < ApplicationRecord include Importable include Suggestible + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' belongs_to :note, inverse_of: :suggestions validates :note, presence: true, unless: :importing? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 36166bdbc9a..bb8527d8c01 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -2,6 +2,9 @@ class SystemNoteMetadata < ApplicationRecord include Importable + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' # 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 07c61f64f29..dc976816ad9 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -2,6 +2,9 @@ class Timelog < ApplicationRecord include Importable + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' before_save :set_project diff --git a/app/models/todo.rb b/app/models/todo.rb index 47dabc1533d..62252912c32 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -4,6 +4,9 @@ class Todo < ApplicationRecord include Sortable include FromUnion include EachBatch + include IgnorableColumns + + ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' # 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 @@ -72,7 +75,9 @@ class Todo < ApplicationRecord scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } scope :for_commit, -> (id) { where(commit_id: id) } - scope :with_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: [:route, :owner] }]) } + scope :with_entity_associations, -> do + preload(:target, :author, :note, group: :route, project: [:route, { namespace: [:route, :owner] }, :project_setting]) + end scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) } scope :for_internal_notes, -> { joins(:note).where(note: { confidential: true }) } @@ -169,6 +174,7 @@ class Todo < ApplicationRecord done = grouped_count.where(state: :done).select("'done' AS state") pending = grouped_count.where(state: :pending).select("'pending' AS state") union = unscoped.from_union([done, pending], remove_duplicates: false) + .select(:user_id, :count, :state) connection.select_all(union).each_with_object({}) do |row, counts| counts[[row['user_id'], row['state']]] = row['count'] @@ -249,7 +255,7 @@ class Todo < ApplicationRecord end def for_issue_or_work_item? - [Issue.name, WorkItem.name].any? { |klass_name| target_type == klass_name } + [Issue.name, WorkItem.name].any?(target_type) end # override to return commits, which are not active record diff --git a/app/models/user.rb b/app/models/user.rb index da6e1abad07..f3e8f14adf5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,7 +101,7 @@ class User < ApplicationRecord MINIMUM_DAYS_CREATED = 7 - ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.8', remove_after: '2023-01-22' + ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.10', remove_after: '2023-02-22' # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour @@ -337,7 +337,7 @@ class User < ApplicationRecord enum layout: { fixed: 0, fluid: 1 } # User's Dashboard preference - enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 } + enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 } # User's Project preference enum project_view: { readme: 0, activity: 1, files: 2 } @@ -380,6 +380,7 @@ class User < ApplicationRecord delegate :website_url, :website_url=, to: :user_detail, allow_nil: true delegate :location, :location=, to: :user_detail, allow_nil: true delegate :organization, :organization=, to: :user_detail, allow_nil: true + delegate :discord, :discord=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -406,6 +407,15 @@ class User < ApplicationRecord transition deactivated: :ldap_blocked end + # aliasing system_block to set ldap_blocked statuses + # ldap_blocked is used for LDAP, SAML, and SCIM blocked users + # Issue for improving this naming: + # https://gitlab.com/gitlab-org/gitlab/-/issues/388487 + event :system_block do + transition active: :ldap_blocked + transition deactivated: :ldap_blocked + end + event :activate do transition deactivated: :active transition blocked: :active @@ -1025,19 +1035,32 @@ class User < ApplicationRecord def disable_two_factor! transaction do - update( - otp_required_for_login: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, - otp_grace_period_started_at: nil, - otp_backup_codes: nil - ) - self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll - self.webauthn_registrations.destroy_all # rubocop: disable Cop/DestroyAll + self.u2f_registrations.destroy_all # rubocop:disable Cop/DestroyAll + self.disable_webauthn! + self.disable_two_factor_otp! + self.reset_backup_codes! end end + def disable_two_factor_otp! + update( + otp_required_for_login: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, + otp_grace_period_started_at: nil, + otp_secret_expires_at: nil + ) + end + + def disable_webauthn! + self.webauthn_registrations.destroy_all # rubocop:disable Cop/DestroyAll + end + + def reset_backup_codes! + update(otp_backup_codes: nil) + end + def two_factor_enabled? two_factor_otp_enabled? || two_factor_webauthn_u2f_enabled? end @@ -1719,12 +1742,6 @@ class User < ApplicationRecord end end - def manageable_groups_with_routes(include_groups_with_developer_maintainer_access: false) - manageable_groups(include_groups_with_developer_maintainer_access: include_groups_with_developer_maintainer_access) - .eager_load(:route) - .order('routes.path') - end - def namespaces(owned_only: false) user_groups = owned_only ? owned_groups : groups personal_namespace = Namespace.where(id: namespace.id) diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index b6765cb0285..9d3df3d6400 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -14,11 +14,13 @@ class UserDetail < ApplicationRecord DEFAULT_FIELD_LENGTH = 500 + validates :discord, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validate :discord_format validates :linkedin, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true - validates :twitter, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true - validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :twitter, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true, if: :website_url_changed? before_validation :sanitize_attrs @@ -27,7 +29,7 @@ class UserDetail < ApplicationRecord enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true def sanitize_attrs - %i[linkedin skype twitter website_url].each do |attr| + %i[discord linkedin skype twitter website_url].each do |attr| value = self[attr] self[attr] = Sanitize.clean(value) if value.present? end @@ -41,13 +43,20 @@ class UserDetail < ApplicationRecord def prevent_nil_fields self.bio = '' if bio.nil? + self.discord = '' if discord.nil? self.linkedin = '' if linkedin.nil? - self.twitter = '' if twitter.nil? - self.skype = '' if skype.nil? self.location = '' if location.nil? self.organization = '' if organization.nil? + self.skype = '' if skype.nil? + self.twitter = '' if twitter.nil? self.website_url = '' if website_url.nil? end end +def discord_format + return if discord.blank? || discord =~ %r{\A\d{17,20}\z} + + errors.add(:discord, _('must contain only a discord user ID.')) +end + UserDetail.prepend_mod_with('UserDetail') diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb index 5aacf11b1cb..4cceffda19e 100644 --- a/app/models/user_synced_attributes_metadata.rb +++ b/app/models/user_synced_attributes_metadata.rb @@ -14,7 +14,7 @@ class UserSyncedAttributesMetadata < ApplicationRecord def read_only_attributes return [] unless sync_profile_from_provider? - SYNCABLE_ATTRIBUTES.select { |key| synced?(key) } + self.class.syncable_attributes.select { |key| synced?(key) } end def synced?(attribute) @@ -25,6 +25,20 @@ class UserSyncedAttributesMetadata < ApplicationRecord write_attribute("#{attribute}_synced", value) end + class << self + def syncable_attributes + return SYNCABLE_ATTRIBUTES if sync_name? + + SYNCABLE_ATTRIBUTES - %i[name] + end + + private + + def sync_name? + Gitlab.config.ldap.sync_name + end + end + private def sync_profile_from_provider? diff --git a/app/models/users/saved_reply.rb b/app/models/users/saved_reply.rb index 7737d826b05..f0ae5445a46 100644 --- a/app/models/users/saved_reply.rb +++ b/app/models/users/saved_reply.rb @@ -9,11 +9,11 @@ module Users validates :user_id, :name, :content, presence: true validates :name, length: { maximum: 255 }, - uniqueness: { scope: [:user_id] }, - format: { - with: Gitlab::Regex.saved_reply_name_regex, - message: Gitlab::Regex.saved_reply_name_regex_message - } + uniqueness: { scope: [:user_id] } validates :content, length: { maximum: 10000 } + + def self.find_saved_reply(user_id:, id:) + ::Users::SavedReply.find_by(user_id: user_id, id: id) + end end end diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb index 3a2613e15d9..76fe664f23d 100644 --- a/app/models/wiki_directory.rb +++ b/app/models/wiki_directory.rb @@ -6,7 +6,7 @@ class WikiDirectory attr_accessor :slug, :entries validates :slug, presence: true - + alias_method :to_param, :slug # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects, # preserving the order of the passed pages. # @@ -25,6 +25,7 @@ class WikiDirectory parent = File.dirname(path) parent = '' if parent == '.' directories[parent].entries << directory + directories[parent].entries.delete_if { |item| item.is_a?(WikiPage) && item.slug == directory.slug } end end end @@ -48,6 +49,6 @@ class WikiDirectory # Relative path to the partial to be used when rendering collections # of this object. def to_partial_path - '../shared/wikis/wiki_directory' + 'shared/wikis/wiki_directory' end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 24b0b94eeb7..b04aa196883 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -259,7 +259,7 @@ class WikiPage # Relative path to the partial to be used when rendering collections # of this object. def to_partial_path - '../shared/wikis/wiki_page' + 'shared/wikis/wiki_page' end def sha diff --git a/app/models/work_item.rb b/app/models/work_item.rb index f94e831437a..5ae3fb6cf78 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -3,6 +3,10 @@ class WorkItem < Issue include Gitlab::Utils::StrongMemoize + COMMON_QUICK_ACTIONS_COMMANDS = [ + :title, :reopen, :close, :cc, :tableflip, :shrug + ].freeze + self.table_name = 'issues' self.inheritance_column = :_type_disabled @@ -13,11 +17,14 @@ class WorkItem < Issue 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', foreign_key: :work_item_id, source: :work_item - has_many :work_item_children_by_created_at, -> { order(:created_at) }, through: :child_links, class_name: 'WorkItem', - foreign_key: :work_item_id, source: :work_item + has_many :work_item_children_by_relative_position, -> { work_item_children_keyset_order }, + through: :child_links, class_name: 'WorkItem', + foreign_key: :work_item_id, source: :work_item scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) } + delegate :supports_assignee?, to: :work_item_type + class << self def assignee_association_name 'issue' @@ -26,6 +33,26 @@ class WorkItem < Issue def test_reports_join_column 'issues.id' end + + def work_item_children_keyset_order + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :relative_position, + column_expression: WorkItems::ParentLink.arel_table[:relative_position], + order_expression: WorkItems::ParentLink.arel_table[:relative_position].asc.nulls_last, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :created_at, + order_expression: WorkItem.arel_table[:created_at].asc, + nullable: :not_nullable, + distinct: false + ) + ]) + + includes(:child_links).order(keyset_order) + end end def noteable_target_type_name @@ -52,6 +79,12 @@ class WorkItem < Issue hierarchy(same_type: true).max_descendants_depth.to_i end + def supported_quick_action_commands + commands_for_widgets = work_item_type.widgets.flat_map(&:quick_action_commands).uniq + + COMMON_QUICK_ACTIONS_COMMANDS + commands_for_widgets + end + private override :parent_link_confidentiality diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index e1f6a13f7a7..6a619dbab21 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -35,56 +35,6 @@ module WorkItems key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only }.freeze - WIDGETS_FOR_TYPE = { - issue: [ - Widgets::Assignees, - Widgets::Labels, - Widgets::Description, - Widgets::Hierarchy, - Widgets::StartAndDueDate, - Widgets::Milestone, - Widgets::Notes - ], - incident: [ - Widgets::Description, - Widgets::Hierarchy, - Widgets::Notes - ], - test_case: [ - Widgets::Description, - Widgets::Notes - ], - requirement: [ - Widgets::Description, - Widgets::Notes - ], - task: [ - Widgets::Assignees, - Widgets::Labels, - Widgets::Description, - Widgets::Hierarchy, - Widgets::StartAndDueDate, - Widgets::Milestone, - Widgets::Notes - ], - objective: [ - Widgets::Assignees, - Widgets::Labels, - Widgets::Description, - Widgets::Hierarchy, - Widgets::Milestone, - Widgets::Notes - ], - key_result: [ - Widgets::Assignees, - Widgets::Labels, - Widgets::Description, - Widgets::Hierarchy, - Widgets::StartAndDueDate, - Widgets::Notes - ] - }.freeze - # A list of types user can change between - both original and new # type must be included in this list. This is needed for legacy issues # where it's possible to switch between issue and incident. @@ -98,6 +48,9 @@ module WorkItems belongs_to :namespace, optional: true has_many :work_items, class_name: 'Issue', foreign_key: :work_item_type_id, inverse_of: :work_item_type + 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' before_validation :strip_whitespace @@ -112,10 +65,6 @@ module WorkItems scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } scope :by_type, ->(base_type) { where(base_type: base_type) } - def self.available_widgets - WIDGETS_FOR_TYPE.values.flatten.uniq - end - def self.default_by_type(type) found_type = find_by(namespace_id: nil, base_type: type) return found_type if found_type @@ -138,7 +87,15 @@ module WorkItems end def widgets - WIDGETS_FOR_TYPE[base_type.to_sym] + enabled_widget_definitions.filter_map(&:widget_class) + end + + def supports_assignee? + widgets.include? ::WorkItems::Widgets::Assignees + end + + def default_issue? + name == WorkItems::Type::TYPE_NAMES[:issue] end private @@ -148,5 +105,3 @@ module WorkItems end end end - -WorkItems::Type.prepend_mod diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb new file mode 100644 index 00000000000..5d4414e95d8 --- /dev/null +++ b/app/models/work_items/widget_definition.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module WorkItems + class WidgetDefinition < ApplicationRecord + self.table_name = 'work_item_widget_definitions' + + belongs_to :namespace, optional: true + belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :widget_definitions + + validates :name, presence: true + validates :name, uniqueness: { case_sensitive: false, scope: [:namespace_id, :work_item_type_id] } + validates :name, length: { maximum: 255 } + + scope :enabled, -> { where(disabled: false) } + scope :global, -> { where(namespace: nil) } + + enum widget_type: { + assignees: 0, + description: 1, + hierarchy: 2, + labels: 3, + milestone: 4, + notes: 5, + start_and_due_date: 6, + health_status: 7, # EE-only + weight: 8, # EE-only + iteration: 9, # EE-only + progress: 10, # EE-only + status: 11, # EE-only + requirement_legacy: 12, # EE-only + test_reports: 13 # EE-only + } + + def self.available_widgets + global.enabled.filter_map(&:widget_class).uniq + end + + def self.widget_classes + WorkItems::WidgetDefinition.widget_types.keys.filter_map do |type| + WorkItems::Widgets.const_get(type.camelize, false) + rescue NameError + nil + end + end + + def widget_class + return unless widget_type + + WorkItems::Widgets.const_get(widget_type.camelize, false) + rescue NameError + nil + end + end +end diff --git a/app/models/work_items/widgets/assignees.rb b/app/models/work_items/widgets/assignees.rb index ecbbee1bcfb..0707b03e647 100644 --- a/app/models/work_items/widgets/assignees.rb +++ b/app/models/work_items/widgets/assignees.rb @@ -5,6 +5,14 @@ module WorkItems class Assignees < Base delegate :assignees, to: :work_item delegate :allows_multiple_assignees?, to: :work_item + + def self.quick_action_commands + [:assign, :unassign, :reassign] + end + + def self.quick_action_params + [:assignee_ids] + end end end end diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index e7075a7a0e8..3a5b03bd514 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -11,6 +11,10 @@ module WorkItems "#{type}_widget".to_sym end + def self.quick_action_commands + [] + end + def type self.class.type end diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index ee10c631bcc..8f54cb32f43 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -8,7 +8,7 @@ module WorkItems end def children - work_item.work_item_children_by_created_at + work_item.work_item_children_by_relative_position end end end diff --git a/app/models/work_items/widgets/labels.rb b/app/models/work_items/widgets/labels.rb index 4ad8319ffac..e8b36156fec 100644 --- a/app/models/work_items/widgets/labels.rb +++ b/app/models/work_items/widgets/labels.rb @@ -5,6 +5,14 @@ module WorkItems class Labels < Base delegate :labels, to: :work_item delegate :allows_scoped_labels?, to: :work_item + + def self.quick_action_commands + [:label, :labels, :relabel, :remove_label, :unlabel] + end + + def self.quick_action_params + [:add_label_ids, :remove_label_ids, :label_ids] + end end end end diff --git a/app/models/work_items/widgets/start_and_due_date.rb b/app/models/work_items/widgets/start_and_due_date.rb index 0b828c5b5a9..22ef262534e 100644 --- a/app/models/work_items/widgets/start_and_due_date.rb +++ b/app/models/work_items/widgets/start_and_due_date.rb @@ -4,6 +4,14 @@ module WorkItems module Widgets class StartAndDueDate < Base delegate :start_date, :due_date, to: :work_item + + def self.quick_action_commands + [:due, :remove_due_date] + end + + def self.quick_action_params + [:due_date] + end end end end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 1c23b367489..7b01dccff87 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -9,6 +9,10 @@ module Ci @user.owns_runner?(@subject) end + condition(:creator) do + @user == @subject.creator + end + with_options scope: :subject, score: 0 condition(:is_instance_runner) do @subject.instance_type? @@ -72,6 +76,8 @@ module Ci rule { ~admin & belongs_to_multiple_projects }.prevent :delete_runner rule { ~admin & locked }.prevent :assign_runner + + rule { creator }.enable :read_ephemeral_token end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index fa7b117f3cd..d028738ccc9 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -22,6 +22,10 @@ class GlobalPolicy < BasePolicy condition(:project_bot, scope: :user) { @user&.project_bot? } condition(:migration_bot, scope: :user) { @user&.migration_bot? } + condition(:create_runner_workflow_enabled) do + Feature.enabled?(:create_runner_workflow) + end + rule { anonymous }.policy do prevent :log_in prevent :receive_notifications @@ -115,6 +119,11 @@ class GlobalPolicy < BasePolicy enable :approve_user enable :reject_user enable :read_usage_trends_measurement + enable :create_instance_runners + end + + rule { ~create_runner_workflow_enabled }.policy do + prevent :create_instance_runners end # We can't use `read_statistics` because the user may have different permissions for different projects diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index b2325b7acac..6cc65248914 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -76,6 +76,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_scope :subject condition(:resource_access_token_feature_available) { resource_access_token_feature_available? } condition(:resource_access_token_creation_allowed) { resource_access_token_creation_allowed? } + condition(:resource_access_token_create_feature_available) { resource_access_token_create_feature_available? } with_scope :subject condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? } @@ -83,6 +84,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_scope :subject condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? } + condition(:create_runner_workflow_enabled) do + Feature.enabled?(:create_runner_workflow) + end + condition(:group_runner_registration_allowed, scope: :subject) do Gitlab::CurrentSettings.valid_runner_registrars.include?('group') && @subject.runner_registration_enabled? end @@ -199,6 +204,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_group_runners enable :admin_group_runners enable :register_group_runners + enable :create_group_runners enable :set_note_created_at enable :set_emails_disabled @@ -277,8 +283,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :destroy_resource_access_tokens end - rule { can?(:admin_group) & resource_access_token_creation_allowed }.policy do - enable :admin_setting_to_allow_project_access_token_creation + rule { can?(:admin_group) & resource_access_token_create_feature_available }.policy do + enable :admin_setting_to_allow_resource_access_token_creation end rule { resource_access_token_creation_allowed & can?(:read_resource_access_tokens) }.policy do @@ -307,6 +313,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy rule { ~admin & ~group_runner_registration_allowed }.policy do prevent :register_group_runners + prevent :create_group_runners end rule { migration_bot }.policy do @@ -318,6 +325,13 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :read_observability end + rule { ~create_runner_workflow_enabled }.policy do + prevent :create_group_runners + end + + # Should be matched with ProjectPolicy#read_internal_note + rule { admin | reporter }.enable :read_internal_note + def access_level(for_any_session: false) return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 52796ed1a1d..496708a9737 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -57,11 +57,6 @@ class IssuablePolicy < BasePolicy enable :read_issuable enable :read_issuable_participables end - - # This rule replicates permissions in NotePolicy#can_read_internal_note - rule { can?(:reporter_access) | admin }.policy do - enable :read_internal_note - end end IssuablePolicy.prepend_mod_with('IssuablePolicy') diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index ccc095f37da..189609c2600 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -26,12 +26,6 @@ class NotePolicy < BasePolicy @subject.noteable.work_item_type.widgets.include?(::WorkItems::Widgets::Notes) end - # Should be matched with IssuablePolicy#read_internal_note - # and EpicPolicy#read_internal_note - condition(:can_read_internal_note) do - access_level >= Gitlab::Access::REPORTER || admin? - end - rule { ~notes_widget_enabled }.prevent_all rule { ~editable }.prevent :admin_note @@ -67,11 +61,11 @@ class NotePolicy < BasePolicy enable :resolve_note end - rule { can_read_internal_note }.policy do + rule { can?(:read_internal_note) }.policy do enable :mark_note_as_internal end - rule { internal & ~can_read_internal_note }.policy do + rule { internal & ~can?(:read_internal_note) }.policy do prevent :read_note prevent :admin_note prevent :resolve_note diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb index 0fb5953f2aa..35161fd95f1 100644 --- a/app/policies/packages/policies/project_policy.rb +++ b/app/policies/packages/policies/project_policy.rb @@ -7,25 +7,10 @@ module Packages overrides(:read_package) - condition(:package_registry_access_level_feature_flag_enabled, scope: :subject) do - ::Feature.enabled?(:package_registry_access_level, @subject) - end - condition(:packages_enabled_for_everyone, scope: :subject) do @subject.package_registry_access_level == ProjectFeature::PUBLIC end - # This rule can be removed if the `package_registry_access_level` feature flag is removed. - # Reason: If the feature flag is globally enabled, this rule will never be executed. - rule { anonymous & ~project.public_project & ~package_registry_access_level_feature_flag_enabled }.prevent_all - - # This rule can be removed if the `package_registry_access_level` feature flag is removed. - # Reason: If the feature flag is globally enabled, this rule will never be executed. - rule do - ~project.public_project & ~project.internal_access & - ~project.project_allowed_for_job_token & ~package_registry_access_level_feature_flag_enabled - end.prevent_all - rule { project.packages_disabled }.policy do prevent(:read_package) end @@ -46,7 +31,7 @@ module Packages enable :read_package end - rule { package_registry_access_level_feature_flag_enabled & packages_enabled_for_everyone }.policy do + rule { packages_enabled_for_everyone }.policy do enable :read_package end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index b85a57f81cd..875520d24be 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -121,7 +121,7 @@ class ProjectPolicy < BasePolicy desc "If user is authenticated via CI job token then the target project should be in scope" condition(:project_allowed_for_job_token) do - !@user&.from_ci_job_token? || @user.ci_job_token_scope.allows?(project) + !@user&.from_ci_job_token? || @user.ci_job_token_scope.accessible?(project) end with_scope :subject @@ -234,6 +234,10 @@ class ProjectPolicy < BasePolicy Gitlab.config.packages.enabled end + condition(:create_runner_workflow_enabled) do + Feature.enabled?(:create_runner_workflow) + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -272,6 +276,7 @@ class ProjectPolicy < BasePolicy enable :set_warn_about_potentially_unwanted_characters enable :register_project_runners + enable :create_project_runners enable :manage_owners end @@ -301,6 +306,8 @@ class ProjectPolicy < BasePolicy rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident + rule { can?(:reporter_access) & can?(:read_environment) }.enable :read_freeze_period + rule { can?(:create_issue) }.enable :create_work_item rule { can?(:create_issue) }.enable :create_task @@ -344,6 +351,7 @@ class ProjectPolicy < BasePolicy enable :read_package enable :read_product_analytics enable :read_ci_cd_analytics + enable :read_external_emails enable :read_grafana end @@ -469,6 +477,7 @@ class ProjectPolicy < BasePolicy enable :update_escalation_status enable :read_secure_files enable :update_sentry_issue + enable :read_airflow_dags end rule { can?(:developer_access) & user_confirmed? }.policy do @@ -519,6 +528,7 @@ class ProjectPolicy < BasePolicy enable :destroy_freeze_period enable :admin_feature_flags_client enable :register_project_runners + enable :create_project_runners enable :update_runners_registration_token enable :admin_project_google_cloud enable :admin_secure_files @@ -823,6 +833,7 @@ class ProjectPolicy < BasePolicy rule { ~admin & ~project_runner_registration_allowed }.policy do prevent :register_project_runners + prevent :create_project_runners end rule { can?(:admin_project_member) }.policy do @@ -847,6 +858,13 @@ class ProjectPolicy < BasePolicy enable :read_code end + rule { ~create_runner_workflow_enabled }.policy do + prevent :create_project_runners + end + + # Should be matched with GroupPolicy#read_internal_note + rule { admin | can?(:reporter_access) }.enable :read_internal_note + private def user_is_user? diff --git a/app/presenters/issue_email_participant_presenter.rb b/app/presenters/issue_email_participant_presenter.rb new file mode 100644 index 00000000000..8688b9a2af1 --- /dev/null +++ b/app/presenters/issue_email_participant_presenter.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class IssueEmailParticipantPresenter < Gitlab::View::Presenter::Delegated + presents ::IssueEmailParticipant, as: :participant + + delegator_override :email + def email + return super if Ability.allowed?(current_user, :read_external_emails, participant.issue.project) + + Gitlab::Utils::Email.obfuscated_email(super, deform: true) + end +end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 9b4e7e22165..69d775d8125 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -15,6 +15,19 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated def project_emails_disabled? issue.project.emails_disabled? end + + delegator_override :service_desk_reply_to + def service_desk_reply_to + return unless super.present? + return super if Ability.allowed?(current_user, :read_external_emails, issue.project) + + Gitlab::Utils::Email.obfuscated_email(super, deform: true) + end + + delegator_override :issue_email_participants + def issue_email_participants + issue.issue_email_participants.present(current_user: current_user) + end end IssuePresenter.prepend_mod_with('IssuePresenter') diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 417a2f9c51f..353e0fad6fb 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -281,8 +281,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end # Avoid including ActionView::Helpers::UrlHelper - def link_to(*args) - ApplicationController.helpers.link_to(*args) + def link_to(...) + ApplicationController.helpers.link_to(...) end end diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb index b82b558f0cd..37d144d9e98 100644 --- a/app/presenters/packages/detail/package_presenter.rb +++ b/app/presenters/packages/detail/package_presenter.rb @@ -29,7 +29,7 @@ module Packages package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum package_detail[:conan_metadatum] = @package.conan_metadatum if @package.conan_metadatum - package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links)) + package_detail[:dependency_links] = @package.dependency_links.map { |link| build_dependency_links(link) } package_detail[:pipeline] = build_pipeline_info(@package.pipeline) if @package.pipeline package_detail[:pipelines] = build_pipeline_infos(@package.pipelines) if @package.pipelines.present? diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb index fabb0a36746..57bdd373309 100644 --- a/app/presenters/packages/npm/package_presenter.rb +++ b/app/presenters/packages/npm/package_presenter.rb @@ -83,7 +83,7 @@ module Packages end def package_tags - Packages::Tag.for_packages(packages) + Packages::Tag.for_package_ids(packages.last_of_each_version_ids) .preload_package end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 780740fd805..392a2fcd390 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -30,7 +30,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated branches_anchor_data, tags_anchor_data, storage_anchor_data, - releases_anchor_data + releases_anchor_data, + environments_anchor_data ].compact.select(&:is_link) end @@ -43,6 +44,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), kubernetes_cluster_anchor_data, gitlab_ci_anchor_data, + wiki_anchor_data, integrations_anchor_data ].compact.reject(&:is_link).sort_by.with_index { |item, idx| [item.class_modifier ? 0 : 1, idx] } end @@ -60,6 +62,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated changelog_anchor_data, contribution_guide_anchor_data, gitlab_ci_anchor_data, + wiki_anchor_data, integrations_anchor_data ].compact.reject { |item| item.is_link } end @@ -189,6 +192,22 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated project_releases_path(project)) end + def environments_anchor_data + return unless can?(current_user, :read_environment, project) + + environments_count = project.environments.available.count + return if environments_count == 0 + + AnchorData.new(true, + statistic_icon('environment') + + n_('%{strong_start}%{count}%{strong_end} Environment', '%{strong_start}%{count}%{strong_end} Environments', environments_count).html_safe % { + count: number_with_delimiter(environments_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, + project_environments_path(project)) + end + def commits_anchor_data AnchorData.new(true, statistic_icon('commit') + @@ -364,6 +383,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end end + def wiki_anchor_data + return unless project.wiki_enabled? && can_read_wiki? + + 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) + end + end + def topics_to_show project_topic_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord end @@ -450,8 +479,24 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end # Avoid including ActionView::Helpers::UrlHelper - def content_tag(*args) - ActionController::Base.helpers.content_tag(*args) + def content_tag(...) + ActionController::Base.helpers.content_tag(...) + end + + def can_create_wiki? + current_user && can?(current_user, :create_wiki, project) + end + + def can_read_wiki? + current_user && can?(current_user, :read_wiki, project) + end + + def project_wiki_path + wiki_path(project.wiki) + end + + def project_create_wiki_path + "#{wiki_path(project.wiki)}?view=create" end end diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 53c547cde9e..76cc8242da8 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -17,7 +17,7 @@ module Projects delegator_override :project_members def project_members - super + converted_group_members + super.preload(:user) + converted_group_members # rubocop:disable CodeReuse/ActiveRecord end delegator_override :description @@ -46,7 +46,7 @@ module Projects # invitee, it would make the following query return 0 rows since a NULL # user_id would be present in the subquery non_null_user_ids = project.project_members.connected_to_user.select(:user_id) - GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids) + GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids).preload(:user) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index b760786aa4c..14aa9f26677 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -68,10 +68,6 @@ module Projects } end - def to_partial_path - '../../shared/deploy_keys/index' - end - def form_partial_path 'shared/deploy_keys/project_group_form' end diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index 26b8169d2b0..43164cca9c9 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -29,8 +29,8 @@ class UserPresenter < Gitlab::View::Presenter::Delegated private - def can?(*args) - user.can?(*args) + def can?(...) + user.can?(...) end def should_be_private? diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb index c1d415dfb40..06b39fedec9 100644 --- a/app/serializers/analytics/cycle_analytics/stage_entity.rb +++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb @@ -48,8 +48,8 @@ module Analytics end # Avoid including ActionView::Helpers::UrlHelper - def link_to(*args) - ActionController::Base.helpers.link_to(*args) + def link_to(...) + ActionController::Base.helpers.link_to(...) end private diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 1caa9720c08..9b21fc57b9e 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -4,7 +4,7 @@ class BuildDetailsEntity < Ci::JobEntity expose :coverage, :erased_at, :finished_at, :duration expose :tag_list, as: :tags expose :has_trace?, as: :has_trace - expose :stage + expose :stage_name, as: :stage expose :stuck?, as: :stuck expose :user, using: UserEntity expose :runner, using: RunnerEntity diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index 5e6ae0986df..28baa64bc7c 100644 --- a/app/serializers/ci/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -42,7 +42,6 @@ class Ci::PipelineEntity < Grape::Entity expose :duration expose :finished_at expose :event_type_name - expose :event_type_name, as: :name # To be removed in 15.7 end expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline| diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb index 52945a753dc..15a26739c51 100644 --- a/app/serializers/codequality_degradation_entity.rb +++ b/app/serializers/codequality_degradation_entity.rb @@ -15,4 +15,6 @@ class CodequalityDegradationEntity < Grape::Entity end expose :web_url + + expose :engine_name end diff --git a/app/serializers/import/github_realtime_repo_entity.rb b/app/serializers/import/github_realtime_repo_entity.rb new file mode 100644 index 00000000000..c26ae5f668d --- /dev/null +++ b/app/serializers/import/github_realtime_repo_entity.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Import + class GithubRealtimeRepoEntity < Grape::Entity + expose :id, documentation: { type: 'integer', example: 1 } + expose :import_status, documentation: { type: 'string', example: 'importing' } + expose :stats, + documentation: { + type: 'object', example: '{"fetched":{"label":10},"imported":{"label":10}}' + } do |project| + ::Gitlab::GithubImport::ObjectCounter.summary(project) + end + + expose :import_error, if: ->(project) { project.import_state&.failed? } do |project| + project.import_failures.last&.exception_message + end + end +end diff --git a/app/serializers/import/github_realtime_repo_serializer.rb b/app/serializers/import/github_realtime_repo_serializer.rb new file mode 100644 index 00000000000..8d03f5ce002 --- /dev/null +++ b/app/serializers/import/github_realtime_repo_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Import + class GithubRealtimeRepoSerializer < BaseSerializer + entity ::Import::GithubRealtimeRepoEntity + end +end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index a38f345f617..340fd8803af 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -89,12 +89,13 @@ class IssueEntity < IssuableEntity end expose :issue_email_participants do |issue| - # TODO - This is a Temporary solution to avoid leaking participants' emails - # on public/internal projects when issue is not confidential. - # Should be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/383448 is implemented. - next [] unless issue.confidential? + presented_issue = issue.present(current_user: request.current_user) - issue.issue_email_participants.map { |x| { email: x.email } } + presented_issue.issue_email_participants.map do |participant| + { + email: participant.email + } + end end expose :issue_type, diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb index cf050b32d21..500dc435526 100644 --- a/app/serializers/merge_requests/pipeline_entity.rb +++ b/app/serializers/merge_requests/pipeline_entity.rb @@ -22,10 +22,6 @@ class MergeRequests::PipelineEntity < Grape::Entity pipeline.present.event_type_name end - expose :name do |pipeline| # To be removed in 15.7 - pipeline.present.event_type_name - end - expose :artifacts do |pipeline, options| rel = pipeline.downloadable_artifacts diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 58ad5812801..679f829e852 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -66,8 +66,8 @@ class NoteEntity < API::Entities::Note expose :emoji_awardable?, as: :emoji_awardable expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity - expose :report_abuse_path, if: -> (note, _) { note.author_id } do |note| - new_abuse_report_path(user_id: note.author_id, ref_url: Gitlab::UrlBuilder.build(note)) + expose :report_abuse_path do |note| # @deprecated To be removed in API version 5 + add_category_abuse_reports_path end expose :noteable_note_url do |note| diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb index a3dbff3dc0b..58360321f7c 100644 --- a/app/serializers/project_import_entity.rb +++ b/app/serializers/project_import_entity.rb @@ -12,4 +12,8 @@ class ProjectImportEntity < ProjectEntity expose :provider_link, documentation: { type: 'string', example: '/source/source-repo' } do |project, options| provider_project_link_url(options[:provider_url], project[:import_source]) end + + expose :import_error, if: ->(project) { project.import_state&.failed? } do |project| + project.import_failures.last&.exception_message + end end diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb index 9fdadb322bf..d8d0e576565 100644 --- a/app/serializers/triggered_pipeline_entity.rb +++ b/app/serializers/triggered_pipeline_entity.rb @@ -15,6 +15,9 @@ class TriggeredPipelineEntity < Grape::Entity expose :name do |pipeline| pipeline.source_job&.name end + expose :retried do |pipeline| + pipeline.source_job&.retried + end end expose :path do |pipeline| diff --git a/app/services/analytics/cycle_analytics/stages/base_service.rb b/app/services/analytics/cycle_analytics/stages/base_service.rb index b676eff0a0b..0f5415c9f9e 100644 --- a/app/services/analytics/cycle_analytics/stages/base_service.rb +++ b/app/services/analytics/cycle_analytics/stages/base_service.rb @@ -37,7 +37,7 @@ module Analytics end def value_stream - @value_stream ||= params[:value_stream] + @value_stream ||= params.fetch(:value_stream) end end end diff --git a/app/services/analytics/cycle_analytics/stages/list_service.rb b/app/services/analytics/cycle_analytics/stages/list_service.rb index a6b94ef8295..1cd7d3f5c6d 100644 --- a/app/services/analytics/cycle_analytics/stages/list_service.rb +++ b/app/services/analytics/cycle_analytics/stages/list_service.rb @@ -13,7 +13,7 @@ module Analytics private def allowed? - can?(current_user, :read_cycle_analytics, parent) + can?(current_user, :read_cycle_analytics, parent.project) end def success(stages) diff --git a/app/services/authorized_project_update/project_access_changed_service.rb b/app/services/authorized_project_update/project_access_changed_service.rb index dafec1fef59..ca039187c50 100644 --- a/app/services/authorized_project_update/project_access_changed_service.rb +++ b/app/services/authorized_project_update/project_access_changed_service.rb @@ -6,16 +6,12 @@ module AuthorizedProjectUpdate @project_ids = Array.wrap(project_ids) end - def execute(blocking: true) + def execute return if @project_ids.empty? bulk_args = @project_ids.map { |id| [id] } - if blocking - AuthorizedProjectUpdate::ProjectRecalculateWorker.bulk_perform_and_wait(bulk_args) - else - AuthorizedProjectUpdate::ProjectRecalculateWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext - end + AuthorizedProjectUpdate::ProjectRecalculateWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext end end end diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb index e3d4da7fb07..77e297b6b11 100644 --- a/app/services/boards/issues/create_service.rb +++ b/app/services/boards/issues/create_service.rb @@ -32,7 +32,7 @@ module Boards def create_issue(params) # NOTE: We are intentionally not doing a spam/CAPTCHA check for issues created via boards. # See https://gitlab.com/gitlab-org/gitlab/-/issues/29400#note_598479184 for more context. - ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: nil).execute + ::Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil).execute end end end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 4de4d7c8f69..e80ff9cf857 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -52,7 +52,7 @@ module Boards end def update(issue, issue_modification_params) - ::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: issue_modification_params).execute(issue) + ::Issues::UpdateService.new(container: issue.project, current_user: current_user, params: issue_modification_params).execute(issue) end def moving_to_list_items_relation diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index 35a35e7b7c9..ac019d9ec5b 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -70,33 +70,52 @@ module BulkImports ) bulk_import.create_configuration!(credentials.slice(:url, :access_token)) - Array.wrap(params).each do |entity| - track_access_level(entity) + Array.wrap(params).each do |entity_params| + track_access_level(entity_params) + + validate_destination_full_path(entity_params) BulkImports::Entity.create!( bulk_import: bulk_import, - source_type: entity[:source_type], - source_full_path: entity[:source_full_path], - destination_slug: entity[:destination_slug], - destination_namespace: entity[:destination_namespace], - migrate_projects: Gitlab::Utils.to_boolean(entity[:migrate_projects], default: true) + source_type: entity_params[:source_type], + source_full_path: entity_params[:source_full_path], + destination_slug: entity_params[:destination_slug] || entity_params[:destination_name], + destination_namespace: entity_params[:destination_namespace], + migrate_projects: Gitlab::Utils.to_boolean(entity_params[:migrate_projects], default: true) ) end - bulk_import end end - def track_access_level(entity) + def track_access_level(entity_params) Gitlab::Tracking.event( self.class.name, 'create', label: 'import_access_level', user: current_user, - extra: { user_role: user_role(entity[:destination_namespace]), import_type: 'bulk_import_group' } + extra: { user_role: user_role(entity_params[:destination_namespace]), import_type: 'bulk_import_group' } ) end + def validate_destination_full_path(entity_params) + source_type = entity_params[:source_type] + + full_path = [ + entity_params[:destination_namespace], + entity_params[:destination_slug] || entity_params[:destination_name] + ].reject(&:blank?).join('/') + + case source_type + when 'group_entity' + return if Namespace.find_by_full_path(full_path).nil? + when 'project_entity' + return if Project.find_by_full_path(full_path).nil? + end + + raise BulkImports::Error.destination_full_path_validation_failure(full_path) + end + def user_role(destination_namespace) namespace = Namespace.find_by_full_path(destination_namespace) # if there is no parent namespace we assume user will be group creator/owner diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 3d548c824c8..4b62580e670 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -2,6 +2,36 @@ module Ci class ArchiveTraceService + include ::Gitlab::ExclusiveLeaseHelpers + + EXCLUSIVE_LOCK_KEY = 'archive_trace_service:batch_execute:lock' + LOCK_TIMEOUT = 56.minutes + LOOP_TIMEOUT = 55.minutes + LOOP_LIMIT = 2000 + BATCH_SIZE = 100 + + # rubocop: disable CodeReuse/ActiveRecord + def batch_execute(worker_name:) + start_time = Time.current + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + Ci::Build.with_stale_live_trace.find_each(batch_size: BATCH_SIZE).with_index do |build, index| + break if Time.current - start_time > LOOP_TIMEOUT + + if index > LOOP_LIMIT + Sidekiq.logger.warn(class: worker_name, message: 'Loop limit reached.', job_id: build.id) + break + end + + begin + execute(build, worker_name: worker_name) + rescue StandardError + next + end + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + def execute(job, worker_name:) unless job.trace.archival_attempts_available? Sidekiq.logger.warn(class: worker_name, message: 'The job is out of archival attempts.', job_id: job.id) diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb new file mode 100644 index 00000000000..45abb415174 --- /dev/null +++ b/app/services/ci/components/fetch_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Ci + module Components + class FetchService + include Gitlab::Utils::StrongMemoize + + TEMPLATE_FILE = 'template.yml' + + COMPONENT_PATHS = [ + ::Gitlab::Ci::Components::InstancePath + ].freeze + + def initialize(address:, current_user:) + @address = address + @current_user = current_user + end + + def execute + unless component_path_class + return ServiceResponse.error( + message: "#{error_prefix} the component path is not supported", + reason: :unsupported_path) + end + + component_path = component_path_class.new(address: address, content_filename: TEMPLATE_FILE) + content = component_path.fetch_content!(current_user: current_user) + + if content.present? + ServiceResponse.success(payload: { content: content, path: component_path }) + else + ServiceResponse.error(message: "#{error_prefix} content not found", reason: :content_not_found) + end + rescue Gitlab::Access::AccessDeniedError + ServiceResponse.error( + message: "#{error_prefix} project does not exist or you don't have sufficient permissions", + reason: :not_allowed) + end + + private + + attr_reader :current_user, :address + + def component_path_class + COMPONENT_PATHS.find { |klass| klass.match?(address) } + end + strong_memoize_attr :component_path_class + + def error_prefix + "component '#{address}' -" + end + end + end +end diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 3d0a7fb99ea..b281f942a14 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -89,7 +89,7 @@ module Ci return false end - if Feature.enabled?(:ci_limit_complete_hierarchy_size) && pipeline_tree_too_large? + if pipeline_tree_too_large? @bridge.drop!(:reached_max_pipeline_hierarchy_size) return false end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index eb25aeaf5a5..390675ab80b 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -33,7 +33,6 @@ module Ci Gitlab::Ci::Pipeline::Chain::EnsureEnvironments, Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups, Gitlab::Ci::Pipeline::Chain::Create, - Gitlab::Ci::Pipeline::Chain::CreateDeployments, Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, Gitlab::Ci::Pipeline::Chain::Limit::Activity, Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines, diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 6e2ba76682f..3d19fec6617 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -132,8 +132,6 @@ module Ci job.update_column(:artifacts_expire_at, artifact.expire_at) end - Gitlab::Ci::Artifacts::Logger.log_created(artifact) - success(artifact: artifact) rescue ActiveRecord::RecordNotUnique => error track_exception(error, params) diff --git a/app/services/ci/job_token_scope/add_project_service.rb b/app/services/ci/job_token_scope/add_project_service.rb index d03ae434b69..15553ad6e92 100644 --- a/app/services/ci/job_token_scope/add_project_service.rb +++ b/app/services/ci/job_token_scope/add_project_service.rb @@ -5,10 +5,14 @@ module Ci class AddProjectService < ::BaseService include EditScopeValidations - def execute(target_project) + def execute(target_project, direction: :outbound) + direction = :outbound if Feature.disabled?(:ci_inbound_job_token_scope) + validate_edit!(project, target_project, current_user) - link = add_project!(target_project) + link = allowlist(direction) + .add!(target_project, user: current_user) + ServiceResponse.success(payload: { project_link: link }) rescue ActiveRecord::RecordNotUnique @@ -19,12 +23,10 @@ module Ci ServiceResponse.error(message: e.message) end - def add_project!(target_project) - ::Ci::JobToken::ProjectScopeLink.create!( - source_project: project, - target_project: target_project, - added_by: current_user - ) + private + + def allowlist(direction) + Ci::JobToken::Allowlist.new(project, direction: direction) end end end diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb index 15644e529d9..864f9318c68 100644 --- a/app/services/ci/job_token_scope/remove_project_service.rb +++ b/app/services/ci/job_token_scope/remove_project_service.rb @@ -5,14 +5,16 @@ module Ci class RemoveProjectService < ::BaseService include EditScopeValidations - def execute(target_project) + def execute(target_project, direction) validate_edit!(project, target_project, current_user) if project == target_project return ServiceResponse.error(message: "Source project cannot be removed from the job token scope") end - link = ::Ci::JobToken::ProjectScopeLink.for_source_and_target(project, target_project) + link = ::Ci::JobToken::ProjectScopeLink + .with_access_direction(direction) + .for_source_and_target(project, target_project) unless link return ServiceResponse.error(message: "Target project is not in the job token scope") diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb index df4963d1b33..dbea270b7c6 100644 --- a/app/services/ci/list_config_variables_service.rb +++ b/app/services/ci/list_config_variables_service.rb @@ -17,7 +17,9 @@ module Ci new(project, user) end - def execute(sha) + def execute(ref) + sha = project.commit(ref).try(:sha) + with_reactive_cache(sha) { |result| result } end diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index 14e8dc41cf5..d4d5acef44e 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -3,6 +3,7 @@ module Ci class ParseDotenvArtifactService < ::BaseService include ::Gitlab::Utils::StrongMemoize + include ::Gitlab::EncodingHelper SizeLimitError = Class.new(StandardError) ParserError = Class.new(StandardError) @@ -36,6 +37,10 @@ module Ci variables = {} artifact.each_blob do |blob| + # Windows powershell may output UTF-16LE files, so convert the whole file + # to UTF-8 before proceeding. + blob = strip_bom(encode_utf8_with_replacement_character(blob)) + blob.each_line do |line| key, value = scan_line!(line) diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb index 57b663dc293..f392681eb85 100644 --- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb +++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb @@ -13,7 +13,7 @@ module Ci return if pipeline.has_codequality_mr_diff_report? return unless new_errors_introduced? - pipeline.pipeline_artifacts.create!(**artifact_attributes) + Ci::PipelineArtifact.create_or_replace_for_pipeline!(**artifact_attributes) end private @@ -24,12 +24,10 @@ module Ci file = build_carrierwave_file! { - project_id: pipeline.project_id, + pipeline: pipeline, file_type: :code_quality_mr_diff, - file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_quality_mr_diff), size: file["tempfile"].size, file: file, - expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now, locked: pipeline.locked } end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb new file mode 100644 index 00000000000..48c3e6490ae --- /dev/null +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Ci + module PipelineCreation + class CancelRedundantPipelinesService + include Gitlab::Utils::StrongMemoize + + BATCH_SIZE = 25 + + def initialize(pipeline) + @pipeline = pipeline + @project = @pipeline.project + end + + # rubocop: disable CodeReuse/ActiveRecord + def execute + return if pipeline.parent_pipeline? # skip if child pipeline + return unless project.auto_cancel_pending_pipelines? + + Gitlab::OptimisticLocking + .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables| + cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch| + auto_cancel_interruptible_pipelines(cancelables_batch.ids) + end + end + end + + private + + attr_reader :pipeline, :project + + def parent_auto_cancelable_pipelines + project.all_pipelines + .created_after(1.week.ago) + .for_ref(pipeline.ref) + .where_not_sha(project.commit(pipeline.ref).try(:id)) + .where("created_at < ?", pipeline.created_at) + .ci_sources + end + + def parent_and_child_pipelines + Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines, project_condition: :same) + .base_and_descendants + .alive_or_scheduled + end + # rubocop: enable CodeReuse/ActiveRecord + + def auto_cancel_interruptible_pipelines(pipeline_ids) + ::Ci::Pipeline + .id_in(pipeline_ids) + .with_only_interruptible_builds + .each do |cancelable_pipeline| + Gitlab::AppLogger.info( + class: self.class.name, + message: "Pipeline #{pipeline.id} auto-canceling pipeline #{cancelable_pipeline.id}", + canceled_pipeline_id: cancelable_pipeline.id, + canceled_by_pipeline_id: pipeline.id, + canceled_by_pipeline_source: pipeline.source + ) + + # cascade_to_children not needed because we iterate through descendants here + cancelable_pipeline.cancel_running( + auto_canceled_by_pipeline_id: pipeline.id, + cascade_to_children: false + ) + end + end + end + end +end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 508d9c3f2e1..2b8eb104be5 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -42,13 +42,14 @@ module Ci end def update_stages! - pipeline.stages.ordered.each(&method(:update_stage!)) + pipeline.stages.ordered.each { |stage| update_stage!(stage) } end def update_stage!(stage) # Update processables for a given stage in bulk/slices - ids = @collection.created_processable_ids_for_stage_position(stage.position) - ids.in_groups_of(BATCH_SIZE, false, &method(:update_processables!)) + @collection + .created_processable_ids_for_stage_position(stage.position) + .in_groups_of(BATCH_SIZE, false) { |ids| update_processables!(ids) } status = @collection.status_for_stage_position(stage.position) stage.set_status(status) @@ -62,7 +63,7 @@ module Ci .ordered_by_stage .select_with_aggregated_needs(project) - created_processables.each(&method(:update_processable!)) + created_processables.each { |processable| update_processable!(processable) } end def update_pipeline! diff --git a/app/services/ci/pipeline_schedules/update_service.rb b/app/services/ci/pipeline_schedules/update_service.rb new file mode 100644 index 00000000000..2412b5cbd81 --- /dev/null +++ b/app/services/ci/pipeline_schedules/update_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedules + class UpdateService + def initialize(schedule, user, params) + @schedule = schedule + @user = user + @params = params + end + + def execute + return forbidden unless allowed? + + if schedule.update(@params) + ServiceResponse.success(payload: schedule) + else + ServiceResponse.error(message: schedule.errors.full_messages) + end + end + + private + + attr_reader :schedule, :user + + def allowed? + user.can?(:update_pipeline_schedule, schedule) + end + + def forbidden + ServiceResponse.error( + message: _('The current user is not authorized to update the pipeline schedule'), + reason: :forbidden + ) + end + end + end +end diff --git a/app/services/ci/prometheus_metrics/observe_histograms_service.rb b/app/services/ci/prometheus_metrics/observe_histograms_service.rb index 6bd3d2121ba..10b3d61247b 100644 --- a/app/services/ci/prometheus_metrics/observe_histograms_service.rb +++ b/app/services/ci/prometheus_metrics/observe_histograms_service.rb @@ -27,7 +27,7 @@ module Ci def execute params .fetch(:histograms, []) - .each(&method(:observe)) + .each { |data| observe(data) } ServiceResponse.success(http_status: :created) end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index cd879e9bc07..205da2632c2 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -6,7 +6,7 @@ module Ci class RegisterJobService include ::Gitlab::Ci::Artifacts::Logger - attr_reader :runner, :metrics + attr_reader :runner, :runner_machine, :metrics TEMPORARY_LOCK_TIMEOUT = 3.seconds @@ -18,8 +18,9 @@ module Ci # affect 5% of the worst case scenarios. MAX_QUEUE_DEPTH = 45 - def initialize(runner) + def initialize(runner, runner_machine) @runner = runner + @runner_machine = runner_machine @metrics = ::Gitlab::Ci::Queue::Metrics.new(runner) end @@ -243,6 +244,7 @@ module Ci def assign_runner!(build, params) build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? + build.ensure_metadata.runner_machine = runner_machine if runner_machine failure_reason, _ = pre_assign_runner_checks.find { |_, check| check.call(build, params) } @@ -260,7 +262,7 @@ module Ci end def acquire_temporary_lock(build_id) - return true unless Feature.enabled?(:ci_register_job_temporary_lock, runner) + return true if Feature.disabled?(:ci_register_job_temporary_lock, runner, type: :ops) key = "build/register/#{build_id}" diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb new file mode 100644 index 00000000000..2de9ee4d38e --- /dev/null +++ b/app/services/ci/runners/create_runner_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Ci + module Runners + class CreateRunnerService + RUNNER_CLASS_MAPPING = { + 'instance_type' => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy, + nil => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy + }.freeze + + attr_accessor :user, :type, :params, :strategy + + def initialize(user:, type:, params:) + @user = user + @type = type + @params = params + @strategy = RUNNER_CLASS_MAPPING[type].new(user: user, type: type, params: params) + end + + def execute + normalize_params + + return ServiceResponse.error(message: 'Validation error') unless strategy.validate_params + return ServiceResponse.error(message: 'Insufficient permissions') unless strategy.authorized_user? + + runner = ::Ci::Runner.new(params) + + return ServiceResponse.success(payload: { runner: runner }) if runner.save + + ServiceResponse.error(message: runner.errors.full_messages) + end + + def normalize_params + params[:registration_type] = :authenticated_user + params[:runner_type] = type + params[:active] = !params.delete(:paused) if params[:paused].present? + params[:creator] = user + + strategy.normalize_params + end + end + end +end diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb index abd32610cec..db16b86d5e6 100644 --- a/app/services/ci/runners/register_runner_service.rb +++ b/app/services/ci/runners/register_runner_service.rb @@ -46,10 +46,10 @@ module Ci # Create shared runner. Requires admin access { runner_type: :instance_type } elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token) - # Create a specific runner for the project + # Create a project runner { runner_type: :project_type, scope: project } elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token) - # Create a specific runner for the group + # Create a group runner { runner_type: :group_type, scope: group } end end diff --git a/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb new file mode 100644 index 00000000000..f195c3e88f9 --- /dev/null +++ b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + module Runners + module RunnerCreationStrategies + class InstanceRunnerStrategy + attr_accessor :user, :type, :params + + def initialize(user:, type:, params:) + @user = user + @type = type + @params = params + end + + def normalize_params + params[:runner_type] = :instance_type + end + + def validate_params + true + end + + def authorized_user? + user.present? && user.can?(:create_instance_runners) + end + end + end + end +end diff --git a/app/services/ci/runners/stale_machines_cleanup_service.rb b/app/services/ci/runners/stale_machines_cleanup_service.rb new file mode 100644 index 00000000000..3e5706d24a6 --- /dev/null +++ b/app/services/ci/runners/stale_machines_cleanup_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Ci + module Runners + class StaleMachinesCleanupService + MAX_DELETIONS = 1000 + + def execute + ServiceResponse.success(payload: { + # the `stale` relationship can return duplicates, so we don't try to return a precise count here + deleted_machines: delete_stale_runner_machines > 0 + }) + end + + private + + def delete_stale_runner_machines + total_deleted_count = 0 + loop do + sub_batch_limit = [100, MAX_DELETIONS].min + + # delete_all discards part of the `stale` scope query, so we expliclitly wrap it with a SELECT as a workaround + deleted_count = Ci::RunnerMachine.id_in(Ci::RunnerMachine.stale.limit(sub_batch_limit)).delete_all + total_deleted_count += deleted_count + + break if deleted_count == 0 || total_deleted_count >= MAX_DELETIONS + end + + total_deleted_count + end + end + end +end diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb index 53b14ab54da..23ececef6a1 100644 --- a/app/services/clusters/agents/refresh_authorization_service.rb +++ b/app/services/clusters/agents/refresh_authorization_service.rb @@ -58,7 +58,7 @@ module Clusters if project_entries allowed_projects.where_full_path_in(project_entries.keys).map do |project| - { project_id: project.id, config: project_entries[project.full_path] } + { project_id: project.id, config: project_entries[project.full_path.downcase] } end end end @@ -70,7 +70,7 @@ module Clusters if group_entries allowed_groups.where_full_path_in(group_entries.keys).map do |group| - { group_id: group.id, config: group_entries[group.full_path] } + { group_id: group.id, config: group_entries[group.full_path.downcase] } end end end @@ -79,7 +79,7 @@ module Clusters def extract_config_entries(entity:) config.dig('ci_access', entity) &.first(AUTHORIZED_ENTITY_LIMIT) - &.index_by { |config| config.delete('id') } + &.index_by { |config| config.delete('id').downcase } end def allowed_projects diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 281b2508090..1a03b444b68 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -38,7 +38,7 @@ module Users end def render_participants_as_hash(participants) - participants.map(&method(:participant_as_hash)) + participants.map { |participant| participant_as_hash(participant) } end def participant_as_hash(participant) 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 3bc30f62a81..8074a193bbf 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -128,9 +128,9 @@ module DesignManagement target_repository.raw.merge( git_user, - source_sha, - merge_branch, - 'CopyDesignCollectionService finalize merge' + source_sha: source_sha, + target_branch: merge_branch, + message: 'CopyDesignCollectionService finalize merge' ) { nil } target_design_collection.end_copy! diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 54fc452ac85..20b4ec0921f 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -16,7 +16,7 @@ module Discussions end def execute - discussions.each(&method(:resolve_discussion)) + discussions.each { |discussion| resolve_discussion(discussion) } after_resolve_cleanup end diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb index 774e3ffe273..fb14ee40c05 100644 --- a/app/services/environments/stop_service.rb +++ b/app/services/environments/stop_service.rb @@ -28,6 +28,8 @@ module Environments created_environments = merge_request.created_environments if created_environments.any? + # This log message can be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/372965 + Gitlab::AppJsonLogger.info(message: 'Running new dynamic environment stop logic', project_id: project.id) created_environments.each { |env| execute(env) } else environments_in_head_pipeline = merge_request.environments_in_head_pipeline(deployment_status: :success) diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb index ca5e8d656a6..f5ce7da0de7 100644 --- a/app/services/error_tracking/issue_update_service.rb +++ b/app/services/error_tracking/issue_update_service.rb @@ -37,7 +37,7 @@ module ErrorTracking def close_issue(issue) Issues::CloseService - .new(project: project, current_user: current_user) + .new(container: project, current_user: current_user) .execute(issue, system_note: false) end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index bf4a26400e1..d848f694598 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -229,23 +229,21 @@ class EventCreateService track_event(event_action: :pushed, event_target: Project, author_id: current_user.id) namespace = project.namespace - if Feature.enabled?(:route_hll_to_snowplow, namespace) - Gitlab::Tracking.event( - self.class.to_s, - :push, - label: 'usage_activity_by_stage_monthly.create.action_monthly_active_users_project_repo', - namespace: namespace, - user: current_user, - project: project, - property: 'project_action', - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'project_action').to_context] - ) - end + Gitlab::Tracking.event( + self.class.to_s, + :push, + label: 'usage_activity_by_stage_monthly.create.action_monthly_active_users_project_repo', + namespace: namespace, + user: current_user, + project: project, + property: 'project_action', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'project_action').to_context] + ) Users::LastPushEventService.new(current_user) .cache_last_push_event(event) - Users::ActivityService.new(current_user).execute + Users::ActivityService.new(author: current_user, namespace: namespace, project: project).execute end def create_event(resource_parent, current_user, status, attributes = {}) @@ -275,8 +273,8 @@ class EventCreateService { resource_parent_attr => resource_parent.id } end - def track_event(**params) - Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(**params) + def track_event(...) + Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(...) end def track_snowplow_event(action:, project:, user:, label:, property:) diff --git a/app/services/export_csv/base_service.rb b/app/services/export_csv/base_service.rb new file mode 100644 index 00000000000..84d44fd75fc --- /dev/null +++ b/app/services/export_csv/base_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ExportCsv + class BaseService + # Target attachment size before base64 encoding + TARGET_FILESIZE = 15.megabytes + + def initialize(relation, resource_parent, fields = []) + @objects = relation + @resource_parent = resource_parent + @fields = fields + end + + def csv_data + csv_builder.render(TARGET_FILESIZE) + end + + def email(user) + raise NotImplementedError + end + + def invalid_fields + ::ExportCsv::MapExportFieldsService.new(fields, header_to_value_hash).invalid_fields + end + + private + + attr_reader :resource_parent, :objects, :fields + + # rubocop: disable CodeReuse/ActiveRecord + def csv_builder + @csv_builder ||= begin + data_hash = MapExportFieldsService.new(fields, header_to_value_hash).execute + + if preload_associations_in_batches? + CsvBuilder.new(objects, data_hash, associations_to_preload) + else + CsvBuilder.new(objects.preload(associations_to_preload), data_hash, []) + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def associations_to_preload + [] + end + + def header_to_value_hash + raise NotImplementedError + end + + def preload_associations_in_batches? + false + end + end +end diff --git a/app/services/export_csv/map_export_fields_service.rb b/app/services/export_csv/map_export_fields_service.rb new file mode 100644 index 00000000000..d4f46c65328 --- /dev/null +++ b/app/services/export_csv/map_export_fields_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ExportCsv + class MapExportFieldsService < BaseService + attr_reader :fields, :data + + def initialize(fields, data) + @fields = fields + @data = data + end + + def execute + return data if fields.empty? + + selected_fields_to_hash + end + + def invalid_fields + fields.reject { |field| permitted_field?(field) } + end + + private + + def selected_fields_to_hash + data.select { |key| requested_field?(key) } + end + + def requested_field?(field) + field.downcase.in?(fields.map(&:downcase)) + end + + def permitted_field?(field) + field.downcase.in?(keys.map(&:downcase)) + end + + def keys + data.keys + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 68bb6427350..25a1e9a9873 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -39,7 +39,6 @@ module Groups if @group.save @group.add_owner(current_user) Integration.create_from_active_default_integrations(@group, :group_id) - Onboarding::Progress.onboard(@group) end end diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 02a760ccf29..45e8972213e 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -42,7 +42,7 @@ module Groups if user_ids_for_project_authorizations_refresh.present? UserProjectAccessChangedService .new(user_ids_for_project_authorizations_refresh) - .execute(blocking: true) + .execute end publish_event diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb index 52180c39972..9c1a003ff36 100644 --- a/app/services/groups/group_links/create_service.rb +++ b/app/services/groups/group_links/create_service.rb @@ -31,7 +31,7 @@ module Groups end def setup_authorizations - shared_with_group.refresh_members_authorized_projects(blocking: false, direct_members_only: true) + shared_with_group.refresh_members_authorized_projects(direct_members_only: true) end end end diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb index d1f16775ab3..dc3cab927be 100644 --- a/app/services/groups/group_links/destroy_service.rb +++ b/app/services/groups/group_links/destroy_service.rb @@ -18,7 +18,7 @@ module Groups groups_to_refresh.uniq.each do |group| next if Feature.enabled?(:skip_group_share_unlink_auth_refresh, group.root_ancestor) - group.refresh_members_authorized_projects(blocking: false, direct_members_only: true) + group.refresh_members_authorized_projects(direct_members_only: true) end else Gitlab::AppLogger.info( diff --git a/app/services/groups/group_links/update_service.rb b/app/services/groups/group_links/update_service.rb index 244ec2254a8..66d0d63cb9b 100644 --- a/app/services/groups/group_links/update_service.rb +++ b/app/services/groups/group_links/update_service.rb @@ -13,7 +13,7 @@ module Groups group_link.update!(group_link_params) if requires_authorization_refresh?(group_link_params) - group_link.shared_with_group.refresh_members_authorized_projects(blocking: false, direct_members_only: true) + group_link.shared_with_group.refresh_members_authorized_projects(direct_members_only: true) end end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb index e179a14c497..e30818cc5d2 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb @@ -16,10 +16,8 @@ module Import allow_local_network: allow_local_requests?, dns_rebind_protection: true } - validate :aws_s3, if: :validate_aws_s3? - # When removing the import_project_from_remote_file_s3 remove the - # whole condition of this validation: - validates_with RemoteFileValidator, if: -> { validate_aws_s3? || !s3_request? } + + validates_with RemoteFileValidator, if: -> { !s3_request? } def initialize(params:, current_user: nil) @params = params @@ -47,20 +45,10 @@ module Import attr_reader :params - def aws_s3 - if s3_request? - errors.add(:base, 'To import from AWS S3 use `projects/remote-import-s3`') - end - end - def s3_request? headers['Server'] == 'AmazonS3' && headers['x-amz-request-id'].present? end - def validate_aws_s3? - ::Feature.enabled?(:import_project_from_remote_file_s3) - end - def headers return {} if file_url.blank? diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb new file mode 100644 index 00000000000..feb76425fb4 --- /dev/null +++ b/app/services/import_csv/base_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module ImportCsv + class BaseService + def initialize(user, project, csv_io) + @user = user + @project = project + @csv_io = csv_io + @results = { success: 0, error_lines: [], parse_error: false } + end + + def execute + process_csv + email_results_to_user + + results + end + + def email_results_to_user + raise NotImplementedError + end + + private + + attr_reader :user, :project, :csv_io, :results + + def attributes_for(row) + raise NotImplementedError + end + + def validate_headers_presence!(headers) + raise NotImplementedError + end + + def create_object_class + raise NotImplementedError + end + + def process_csv + with_csv_lines.each do |row, line_no| + attributes = attributes_for(row) + + if create_object(attributes)&.persisted? + results[:success] += 1 + else + results[:error_lines].push(line_no) + end + end + rescue ArgumentError, CSV::MalformedCSVError + results[:parse_error] = true + end + + def with_csv_lines + csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) + validate_headers_presence!(csv_data.lines.first) + + CSV.new( + csv_data, + col_sep: detect_col_sep(csv_data.lines.first), + headers: true, + header_converters: :symbol + ).each.with_index(2) + end + + def detect_col_sep(header) + if header.include?(",") + "," + elsif header.include?(";") + ";" + elsif header.include?("\t") + "\t" + else + raise CSV::MalformedCSVError.new('Invalid CSV format', 1) + end + end + + def create_object(attributes) + # NOTE: CSV imports are performed by workers, so we do not have a request context in order + # to create a SpamParams object to pass to the issuable create service. + spam_params = nil + + # default_params can be extracted into a method if we need + # to support creation of objects that belongs to groups. + default_params = { container: project, + current_user: user, + params: attributes, + spam_params: spam_params } + + create_service = create_object_class.new(**default_params.merge(extra_create_service_params)) + + create_service.execute_without_rate_limiting + end + + # Overidden in subclasses to support specific parameters + def extra_create_service_params + {} + end + end +end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index 49019278871..a75c5d2e75c 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -16,7 +16,7 @@ module IncidentManagement def execute create_result = Issues::CreateService.new( - project: project, + container: project, current_user: current_user, params: { title: title, diff --git a/app/services/incident_management/timeline_events/base_service.rb b/app/services/incident_management/timeline_events/base_service.rb index e0ca4320091..e997d940ed4 100644 --- a/app/services/incident_management/timeline_events/base_service.rb +++ b/app/services/incident_management/timeline_events/base_service.rb @@ -5,8 +5,6 @@ module IncidentManagement class BaseService include Gitlab::Utils::UsageData - AUTOCREATE_TAGS = [TimelineEventTag::START_TIME_TAG_NAME, TimelineEventTag::END_TIME_TAG_NAME].freeze - def allowed? user&.can?(:admin_incident_management_timeline_event, incident) end @@ -47,7 +45,7 @@ module IncidentManagement def auto_create_predefined_tags(new_tags) new_tags = new_tags.map(&:downcase) - tags_to_create = AUTOCREATE_TAGS.select { |tag| tag.downcase.in?(new_tags) } + tags_to_create = TimelineEventTag::PREDEFINED_TAGS.select { |tag| tag.downcase.in?(new_tags) } tags_to_create.each do |name| project.incident_management_timeline_event_tags.create(name: name) diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb index 06e8fc32335..b2ea1f1b020 100644 --- a/app/services/incident_management/timeline_events/create_service.rb +++ b/app/services/incident_management/timeline_events/create_service.rb @@ -155,15 +155,14 @@ module IncidentManagement def validate_tags(project, tag_names) return [] unless tag_names&.any? - start_time_tag = AUTOCREATE_TAGS[0].downcase - end_time_tag = AUTOCREATE_TAGS[1].downcase + predefined_tags = TimelineEventTag::PREDEFINED_TAGS.map(&:downcase) tag_names_downcased = tag_names.map(&:downcase) tags = project.incident_management_timeline_event_tags.by_names(tag_names).pluck_names.map(&:downcase) # remove tags from given tag_names and also remove predefined tags which can be auto created - tag_names_downcased - tags - [start_time_tag, end_time_tag] + tag_names_downcased - tags - predefined_tags end end end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 30444fa3938..c01509bc4d1 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -13,9 +13,9 @@ module Issuable end def execute(type) - ids = params.delete(:issuable_ids).split(",") + model_ids = ids_from_params(params.delete(:issuable_ids)) set_update_params(type) - updated_issuables = update_issuables(type, ids) + updated_issuables = update_issuables(type, model_ids) if updated_issuables.present? && requires_count_cache_reset?(type) schedule_group_issues_count_reset(updated_issuables) @@ -28,9 +28,14 @@ module Issuable private + def ids_from_params(issuable_ids) + return issuable_ids if issuable_ids.is_a?(Array) + + issuable_ids.split(',') + end + def set_update_params(type) params.slice!(*permitted_attrs(type)) - params.delete_if { |k, v| v.blank? } if params[:assignee_ids] == [IssuableFinder::Params::NONE.to_s] params[:assignee_ids] = [] @@ -40,8 +45,6 @@ module Issuable def permitted_attrs(type) attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event) - attrs.push(:sprint_id) if type == 'issue' - if type == 'issue' || type == 'merge_request' attrs.push(:assignee_ids) else @@ -53,10 +56,12 @@ module Issuable model_class = type.classify.constantize update_class = type.classify.pluralize.constantize::UpdateService items = find_issuables(parent, model_class, ids) + authorized_issuables = [] items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) + authorized_issuables << issuable update_class.new( **update_class.constructor_container_arg(issuable.issuing_parent), current_user: current_user, @@ -64,23 +69,22 @@ module Issuable ).execute(issuable) end - items + authorized_issuables end def find_issuables(parent, model_class, ids) + issuables = model_class.id_in(ids) + case parent when Project - projects = parent + issuables = issuables.of_projects(parent) when Group - projects = parent.all_projects + issuables = issuables.of_projects(parent.all_projects) else - return + raise ArgumentError, _('A parent must be provided when bulk updating issuables') end - model_class - .id_in(ids) - .of_projects(projects) - .includes_for_bulk_update + issuables.includes_for_bulk_update end # Duplicates params and its top-level values diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 3c13944cfbc..02beaaf5d83 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -7,6 +7,11 @@ module Issuable alias_method :old_project, :project + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(original_entity, target_parent) @original_entity = original_entity @target_parent = target_parent @@ -77,7 +82,7 @@ module Issuable end def close_issue - close_service = Issues::CloseService.new(project: old_project, current_user: current_user) + close_service = Issues::CloseService.new(container: old_project, current_user: current_user) close_service.execute(original_entity, notifications: false, system_note: true) end diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 6aab56f0f68..4c3e518d62b 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -2,6 +2,11 @@ module Issuable class DestroyService < IssuableBaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(issuable) after_destroy(issuable) if issuable.destroy end diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb index 10e7660289b..cb9271de11d 100644 --- a/app/services/issuable/discussions_list_service.rb +++ b/app/services/issuable/discussions_list_service.rb @@ -25,7 +25,7 @@ module Issuable paginated_discussions_by_type = paginator.records.group_by(&:table_name) notes = if paginated_discussions_by_type['notes'].present? - notes.with_discussion_ids(paginated_discussions_by_type['notes'].map(&:discussion_id)) + notes.id_in(paginated_discussions_by_type['notes'].flat_map(&:ids)) else notes.none end diff --git a/app/services/issuable/export_csv/base_service.rb b/app/services/issuable/export_csv/base_service.rb deleted file mode 100644 index 49ff05935c9..00000000000 --- a/app/services/issuable/export_csv/base_service.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Issuable - module ExportCsv - class BaseService - # Target attachment size before base64 encoding - TARGET_FILESIZE = 15.megabytes - - def initialize(issuables_relation, project) - @issuables = issuables_relation - @project = project - end - - def csv_data - csv_builder.render(TARGET_FILESIZE) - end - - private - - attr_reader :project, :issuables - - # rubocop: disable CodeReuse/ActiveRecord - def csv_builder - @csv_builder ||= - CsvBuilder.new(issuables.preload(associations_to_preload), header_to_value_hash) - end - # rubocop: enable CodeReuse/ActiveRecord - - def associations_to_preload - [] - end - - def header_to_value_hash - raise NotImplementedError - 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 e84d1032e41..83cf5a67453 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -2,38 +2,13 @@ module Issuable module ImportCsv - class BaseService - def initialize(user, project, csv_io) - @user = user - @project = project - @csv_io = csv_io - @results = { success: 0, error_lines: [], parse_error: false } - end - - def execute - process_csv - email_results_to_user - - @results - end + class BaseService < ::ImportCsv::BaseService + extend ::Gitlab::Utils::Override private - def process_csv - with_csv_lines.each do |row, line_no| - attributes = issuable_attributes_for(row) - - if create_issuable(attributes)&.persisted? - @results[:success] += 1 - else - @results[:error_lines].push(line_no) - end - end - rescue ArgumentError, CSV::MalformedCSVError - @results[:parse_error] = true - end - - def issuable_attributes_for(row) + override :attributes_for + def attributes_for(row) { title: row[:title], description: row[:description], @@ -41,58 +16,13 @@ module Issuable } end - def with_csv_lines - csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) - validate_headers_presence!(csv_data.lines.first) - - CSV.new( - csv_data, - col_sep: detect_col_sep(csv_data.lines.first), - headers: true, - header_converters: :symbol - ).each.with_index(2) - end - + override :validate_headers_presence! def validate_headers_presence!(headers) headers.downcase! if headers return if headers && headers.include?('title') && headers.include?('description') raise CSV::MalformedCSVError end - - def detect_col_sep(header) - if header.include?(",") - "," - elsif header.include?(";") - ";" - elsif header.include?("\t") - "\t" - else - raise CSV::MalformedCSVError - end - end - - def create_issuable(attributes) - # NOTE: CSV imports are performed by workers, so we do not have a request context in order - # to create a SpamParams object to pass to the issuable create service. - spam_params = nil - create_service = create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params) - - # For now, if create_issuable_class prepends RateLimitedService let's bypass rate limiting - if create_issuable_class < RateLimitedService - create_service.execute_without_rate_limiting - else - create_service.execute - end - end - - def email_results_to_user - # defined in ImportCsvService - end - - def create_issuable_class - # defined in ImportCsvService - end end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e24ae8f59f0..911d04d6b7a 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -34,8 +34,9 @@ class IssuableBaseService < ::BaseProjectService end def filter_params(issuable) + params.delete(:milestone) + unless can_set_issuable_metadata?(issuable) - params.delete(:milestone) params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb index 5d10eca2979..011a78029c8 100644 --- a/app/services/issues/after_create_service.rb +++ b/app/services/issues/after_create_service.rb @@ -2,6 +2,11 @@ module Issues class AfterCreateService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(issue) todo_service.new_issue(issue, current_user) delete_milestone_total_issue_counter_cache(issue.milestone) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 75bd2b88e86..877ce09e065 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -4,6 +4,11 @@ module Issues class BuildService < Issues::BaseService include ResolveDiscussions + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute filter_resolve_discussion_params diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index 8b05a1c2acd..c2a724254a7 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -76,7 +76,7 @@ module Issues # The system notes of the old issue are copied over so we don't want to end up with duplicate notes. # When cloning without notes, we want to generate system notes for the attributes that were copied. create_result = CreateService.new( - project: target_project, + container: target_project, current_user: current_user, params: new_params, spam_params: spam_params diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 4f6a859e20e..9fde1cc2ac2 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -2,6 +2,11 @@ module Issues class CloseService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + # Closes the supplied issue if the current user is able to do so. def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false) return issue unless can_close?(issue, skip_authorization: skip_authorization) @@ -51,6 +56,11 @@ module Issues private + # TODO: remove once MergeRequests::CloseService or IssuableBaseService method is changed. + def self.constructor_container_arg(value) + { container: value } + end + def can_close?(issue, skip_authorization: false) skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index f6a1db2dcaa..fa5233da489 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -13,11 +13,11 @@ module Issues # spam_checking is likely to be necessary. However, if there is not a request available in scope # in the caller (for example, an issue created via email) and the required arguments to the # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. - def initialize(project:, spam_params:, current_user: nil, params: {}, build_service: nil) + def initialize(container:, spam_params:, current_user: nil, params: {}, build_service: nil) @extra_params = params.delete(:extra_params) || {} - super(project: project, current_user: current_user, params: params) + super(project: container, current_user: current_user, params: params) @spam_params = spam_params - @build_service = build_service || BuildService.new(project: project, current_user: current_user, params: params) + @build_service = build_service || BuildService.new(container: project, current_user: current_user, params: params) end def execute(skip_system_notes: false) @@ -100,6 +100,18 @@ module Issues private + def self.constructor_container_arg(value) + { container: value } + end + + def handle_quick_actions(issue) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue) + + super + end + def authorization_action :create_issue end diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb index 9547698d916..a3213c50f86 100644 --- a/app/services/issues/duplicate_service.rb +++ b/app/services/issues/duplicate_service.rb @@ -2,6 +2,11 @@ module Issues class DuplicateService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(duplicate_issue, canonical_issue) return if canonical_issue == duplicate_issue return unless can?(current_user, :update_issue, duplicate_issue) @@ -10,7 +15,7 @@ module Issues create_issue_duplicate_note(duplicate_issue, canonical_issue) create_issue_canonical_note(canonical_issue, duplicate_issue) - close_service.new(project: project, current_user: current_user).execute(duplicate_issue) + close_service.new(container: project, current_user: current_user).execute(duplicate_issue) duplicate_issue.update(duplicated_to: canonical_issue) relate_two_issues(duplicate_issue, canonical_issue) diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 46e4b865dc3..d7c1ea276de 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true module Issues - class ExportCsvService < Issuable::ExportCsv::BaseService + class ExportCsvService < ExportCsv::BaseService include Gitlab::Routing.url_helpers include GitlabRoutingHelper - def initialize(issuables_relation, project, user = nil) - super(issuables_relation, project) + def initialize(relation, resource_parent, user = nil) + super(relation, resource_parent) - @labels = @issuables.labels_hash.transform_values { |labels| labels.sort.join(',').presence } + @labels = objects.labels_hash.transform_values { |labels| labels.sort.join(',').presence } end def email(mail_to_user) - Notify.issues_csv_email(mail_to_user, project, csv_data, csv_builder.status).deliver_now + Notify.issues_csv_email(mail_to_user, resource_parent, csv_data, csv_builder.status).deliver_now end private @@ -55,6 +55,10 @@ module Issues issue.timelogs.sum(&:time_spent) end # rubocop: enable CodeReuse/ActiveRecord + + def preload_associations_in_batches? + Feature.enabled?(:export_csv_preload_in_batches, resource_parent) + end end end diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb index 83e550583f6..c3d6af952b4 100644 --- a/app/services/issues/import_csv_service.rb +++ b/app/services/issues/import_csv_service.rb @@ -9,21 +9,21 @@ module Issues end def email_results_to_user - Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_later + Notify.import_issues_csv_email(user.id, project.id, results).deliver_later end private - def create_issuable(attributes) + def create_object(attributes) super[:issue] end - def create_issuable_class + def create_object_class Issues::CreateService end def record_import_attempt - Issues::CsvImport.create!(user: @user, project: @project) + Issues::CsvImport.create!(user: user, project: project) end end end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index f7f7d85611b..a2180dabdea 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -97,7 +97,7 @@ module Issues # Skip creation of system notes for existing attributes of the issue. The system notes of the old # issue are copied over so we don't want to end up with duplicate notes. create_result = CreateService.new( - project: @target_project, + container: @target_project, current_user: @current_user, params: new_params, spam_params: spam_params diff --git a/app/services/issues/referenced_merge_requests_service.rb b/app/services/issues/referenced_merge_requests_service.rb index a69cd324b1e..ba03927136a 100644 --- a/app/services/issues/referenced_merge_requests_service.rb +++ b/app/services/issues/referenced_merge_requests_service.rb @@ -2,6 +2,11 @@ module Issues class ReferencedMergeRequestsService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + # rubocop: disable CodeReuse/ActiveRecord def execute(issue) referenced = referenced_merge_requests(issue) diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb index 2ecd3e561c9..3f4413fdfd7 100644 --- a/app/services/issues/related_branches_service.rb +++ b/app/services/issues/related_branches_service.rb @@ -4,6 +4,11 @@ # those with a merge request open referencing the current issue. module Issues class RelatedBranchesService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(issue) branch_names_with_mrs = branches_with_merge_request_for(issue) branches = branches_with_iid_of(issue).reject { |b| branch_names_with_mrs.include?(b[:name]) } @@ -27,7 +32,7 @@ module Issues def branches_with_merge_request_for(issue) Issues::ReferencedMergeRequestsService - .new(project: project, current_user: current_user) + .new(container: project, current_user: current_user) .referenced_merge_requests(issue) .map(&:source_branch) end diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index f4f81e9455a..ebcf2fb5c83 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -2,6 +2,11 @@ module Issues class ReopenService < Issues::BaseService + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(issue, skip_authorization: false) return issue unless can_reopen?(issue, skip_authorization: skip_authorization) @@ -22,6 +27,14 @@ module Issues private + # overriding this because IssuableBaseService#constructor_container_arg returns { project: value } + # Issues::ReopenService constructor signature is different now, it takes container instead of project also + # IssuableBaseService#change_state dynamically picks one of the `Issues::ReopenService`, `Epics::ReopenService` or + # MergeRequests::ReopenService, so we need this method to return { }container: value } for Issues::ReopenService + def self.constructor_container_arg(value) + { container: value } + end + def can_reopen?(issue, skip_authorization: false) skip_authorization || can?(current_user, :reopen_issue, issue) end diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb index 5443d41ac30..059b4196b23 100644 --- a/app/services/issues/reorder_service.rb +++ b/app/services/issues/reorder_service.rb @@ -4,6 +4,11 @@ module Issues class ReorderService < Issues::BaseService include Gitlab::Utils::StrongMemoize + # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def execute(issue) return false unless can?(current_user, :update_issue, issue) return false unless move_between_ids @@ -14,7 +19,7 @@ module Issues private def update(issue, attrs) - ::Issues::UpdateService.new(project: project, current_user: current_user, params: attrs).execute(issue) + ::Issues::UpdateService.new(container: project, current_user: current_user, params: attrs).execute(issue) rescue ActiveRecord::RecordNotFound false end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 71cc5581ae6..71324b3f044 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -5,8 +5,8 @@ module Issues # NOTE: For Issues::UpdateService, we default the spam_params to nil, because spam_checking is not # necessary in many cases, and we don't want to require every caller to explicitly pass it as nil # to disable spam checking. - def initialize(project:, current_user: nil, params: {}, spam_params: nil) - super(project: project, current_user: current_user, params: params) + def initialize(container:, current_user: nil, params: {}, spam_params: nil) + super(project: container, current_user: current_user, params: params) @spam_params = spam_params end @@ -96,7 +96,7 @@ module Issues canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id) if canonical_issue - Issues::DuplicateService.new(project: project, current_user: current_user).execute(issue, canonical_issue) + Issues::DuplicateService.new(container: project, current_user: current_user).execute(issue, canonical_issue) end end # rubocop: enable CodeReuse/ActiveRecord @@ -109,13 +109,30 @@ module Issues target_project != issue.project update(issue) - Issues::MoveService.new(project: project, current_user: current_user).execute(issue, target_project) + Issues::MoveService.new(container: project, current_user: current_user).execute(issue, target_project) end private attr_reader :spam_params + # TODO: remove this once MergeRequests::UpdateService#initialize is changed to take container as named argument. + # + # Issues::UpdateService is used together with MergeRequests::UpdateService in Mutations::Assignable#assign! method + # however MergeRequests::UpdateService#initialize still takes `project` as param and Issues::UpdateService is being + # changed to take `container` as param. So we are adding this workaround in the meantime. + def self.constructor_container_arg(value) + { container: value } + end + + def handle_quick_actions(issue) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return unless issue.work_item_type.default_issue? + + super + end + def handle_date_changes(issue) return unless issue.previous_changes.slice('due_date', 'start_date').any? @@ -131,7 +148,7 @@ module Issues # we've pre-empted this from running in #execute, so let's go ahead and update the Issue now. update(issue) - Issues::CloneService.new(project: project, current_user: current_user).execute(issue, target_project, with_notes: with_notes) + Issues::CloneService.new(container: project, current_user: current_user).execute(issue, target_project, with_notes: with_notes) end def create_merge_request_from_quick_action @@ -181,9 +198,9 @@ module Issues return if skip_milestone_email if issue.milestone.nil? - notification_service.async.removed_milestone_issue(issue, current_user) + notification_service.async.removed_milestone(issue, current_user) else - notification_service.async.changed_milestone_issue(issue, issue.milestone, current_user) + notification_service.async.changed_milestone(issue, issue.milestone, current_user) end end diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 1ce459aa7e6..4144c293990 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -2,8 +2,8 @@ module Issues class ZoomLinkService < Issues::BaseService - def initialize(project:, current_user:, params:) - super + def initialize(container:, current_user:, params:) + super(project: container, current_user: current_user, params: params) @issue = params.fetch(:issue) @added_meeting = ZoomMeeting.canonical_meeting(@issue) diff --git a/app/services/jira/requests/projects/list_service.rb b/app/services/jira/requests/projects/list_service.rb index ac9e9bf0be9..09cab3c659b 100644 --- a/app/services/jira/requests/projects/list_service.rb +++ b/app/services/jira/requests/projects/list_service.rb @@ -29,7 +29,9 @@ module Jira end def map_projects(response) - response.map { |v| JIRA::Resource::Project.build(client, v) }.select(&method(:match_query?)) + response + .map { |v| JIRA::Resource::Project.build(client, v) } + .select { |jira_project| match_query?(jira_project) } end def match_query?(jira_project) diff --git a/app/services/jira_connect_installations/update_service.rb b/app/services/jira_connect_installations/update_service.rb index b2b6f2a91f2..ff5b9671e2b 100644 --- a/app/services/jira_connect_installations/update_service.rb +++ b/app/services/jira_connect_installations/update_service.rb @@ -24,7 +24,7 @@ module JiraConnectInstallations end end - send_uninstalled_hook if instance_url_changed? + send_uninstalled_hook if instance_url_changed? && @installation.instance_url.blank? ServiceResponse.new(status: :success) end diff --git a/app/services/keys/revoke_service.rb b/app/services/keys/revoke_service.rb new file mode 100644 index 00000000000..42ea9ab73be --- /dev/null +++ b/app/services/keys/revoke_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Keys + class RevokeService < ::Keys::DestroyService + def execute(key) + key.transaction do + unverify_associated_signatures(key) + + raise ActiveRecord::Rollback unless super(key) + end + end + + private + + def unverify_associated_signatures(key) + return unless Feature.enabled?(:revoke_ssh_signatures) + + key.ssh_signatures.each_batch do |batch| + batch.update_all( + verification_status: CommitSignatures::SshSignature.verification_statuses[:revoked_key], + updated_at: Time.zone.now + ) + end + end + end +end + +Keys::DestroyService.prepend_mod diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb index 5e73d7a957b..20f96ac2949 100644 --- a/app/services/members/approve_access_request_service.rb +++ b/app/services/members/approve_access_request_service.rb @@ -15,6 +15,12 @@ module Members private + def after_execute(member:, skip_log_audit_event:) + super + + resolve_access_request_todos(current_user, member) + end + def validate_access!(access_requester) raise Gitlab::Access::AccessDeniedError unless can_approve_access_requester?(access_requester) diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb index 62b8fc5d6f7..801f77ae082 100644 --- a/app/services/members/base_service.rb +++ b/app/services/members/base_service.rb @@ -53,6 +53,10 @@ module Members end end + def resolve_access_request_todos(current_user, requester) + todo_service.resolve_access_request_todos(current_user, requester) + end + def enqueue_delete_todos(member) type = member.is_a?(GroupMember) ? 'Group' : 'Project' # don't enqueue immediately to prevent todos removal in case of a mistake diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index 2d378a64c02..3ce8390d07d 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -21,8 +21,7 @@ module Members expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil, - ldap: nil, - blocking_refresh: nil + ldap: nil ) return [] unless invitees.present? @@ -40,8 +39,7 @@ module Members expires_at: expires_at, tasks_to_be_done: tasks_to_be_done, tasks_project_id: tasks_project_id, - ldap: ldap, - blocking_refresh: blocking_refresh + ldap: ldap } members = emails.map do |email| @@ -62,16 +60,14 @@ module Members access_level, current_user: nil, expires_at: nil, - ldap: nil, - blocking_refresh: nil + ldap: nil ) add_members(source, [invitee], access_level, current_user: current_user, expires_at: expires_at, - ldap: ldap, - blocking_refresh: blocking_refresh).first + ldap: ldap).first end private @@ -250,8 +246,6 @@ module Members def find_or_build_member @member = builder.new(source, invitee, existing_members).execute - - @member.blocking_refresh = args[:blocking_refresh] end def ldap diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 24c5b12b335..dd84b890385 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -63,6 +63,7 @@ module Members delete_subresources(member) unless skip_subresources delete_project_invitations_by(member) unless skip_subresources + resolve_access_request_todos(current_user, member) enqueue_delete_todos(member) enqueue_unassign_issuables(member) if unassign_issuables diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 9e39aa94246..11251e56ee3 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -9,6 +9,8 @@ module MergeRequests prepare_for_mergeability(merge_request) prepare_merge_request(merge_request) + + mark_merge_request_as_prepared(merge_request) end private @@ -53,6 +55,10 @@ module MergeRequests merge_request.mark_as_unchecked merge_request.check_mergeability(async: true) end + + def mark_merge_request_as_prepared(merge_request) + merge_request.update!(prepared_at: Time.current) + end end end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index c107280efb1..54283ea0676 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -14,7 +14,7 @@ module MergeRequests def execute assignable_issues.each do |issue| - Issues::UpdateService.new(project: issue.project, current_user: current_user, params: { assignee_ids: [current_user.id] }).execute(issue) + Issues::UpdateService.new(container: issue.project, current_user: current_user, params: { assignee_ids: [current_user.id] }).execute(issue) end { diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 8fa80dc3513..75e1adec41b 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -39,11 +39,7 @@ module MergeRequests # open while the Gitaly RPC waits. To avoid an idle in transaction # timeout, we do this before we attempt to save the merge request. - if Feature.enabled?(:async_merge_request_diff_creation, merge_request.target_project) - merge_request.skip_ensure_merge_request_diff = true - else - merge_request.eager_fetch_ref! - end + merge_request.skip_ensure_merge_request_diff = true end def set_projects! diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb index 1f8dec69ef0..96b4cdd0fe5 100644 --- a/app/services/merge_requests/export_csv_service.rb +++ b/app/services/merge_requests/export_csv_service.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true module MergeRequests - class ExportCsvService < Issuable::ExportCsv::BaseService + class ExportCsvService < ExportCsv::BaseService include Gitlab::Routing.url_helpers include GitlabRoutingHelper def email(user) - Notify.merge_requests_csv_email(user, project, csv_data, csv_builder.status).deliver_now + Notify.merge_requests_csv_email(user, resource_parent, csv_data, csv_builder.status).deliver_now end private diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 6b4f9dbe509..e6b0ffbf716 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -98,6 +98,7 @@ module MergeRequests commit_id ensure merge_request.update_and_mark_in_progress_merge_commit_sha(nil) + log_info("Merge request marked in progress") end def update_merge_sha_metadata(commit_id) diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 9fca2b0d19e..e32895a3cb6 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -55,7 +55,7 @@ module MergeRequests merge_request.id ) else - Issues::CloseService.new(project: project, current_user: current_user).execute(issue, commit: merge_request) + Issues::CloseService.new(container: project, current_user: current_user).execute(issue, commit: merge_request) end end end diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index 711978dc3f7..235dc6678df 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -145,7 +145,7 @@ module MergeRequests if push_options[:milestone] milestone = Milestone.for_projects_and_groups(@project, @project.ancestors_upto)&.find_by_name(push_options[:milestone]) - params[:milestone] = milestone if milestone + params[:milestone_id] = milestone.id if milestone end if params.key?(:description) diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index ce49d5dd43c..61831a624c7 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -16,7 +16,7 @@ module MergeRequests def refresh_merge_requests! # n + 1: https://gitlab.com/gitlab-org/gitlab-foss/issues/60289 - Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits)) + Gitlab::GitalyClient.allow_n_plus_1_calls { find_new_commits } # Be sure to close outstanding MRs before reloading them to avoid generating an # empty diff during a manual merge @@ -229,7 +229,7 @@ module MergeRequests :source, @push.branch_name, presence) end - # Add comment about pushing new commits to merge requests and send nofitication emails + # Add comment about pushing new commits to merge requests and send notification emails # def notify_about_push(merge_request) return unless @commits.present? diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index a273b853c0d..255d96f4969 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -215,9 +215,9 @@ module MergeRequests delete_milestone_total_merge_requests_counter_cache(previous_milestone) if merge_request.milestone.nil? - notification_service.async.removed_milestone_merge_request(merge_request, current_user) + notification_service.async.removed_milestone(merge_request, current_user) else - notification_service.async.changed_milestone_merge_request(merge_request, merge_request.milestone, current_user) + notification_service.async.changed_milestone(merge_request, merge_request.milestone, current_user) delete_milestone_total_merge_requests_counter_cache(merge_request.milestone) end diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 2563f2f5390..191a8711cbd 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -4,10 +4,10 @@ module Milestones class DestroyService < Milestones::BaseService def execute(milestone) Milestone.transaction do - update_params = { milestone: nil, skip_milestone_email: true } + update_params = { milestone_id: nil, skip_milestone_email: true } milestone.issues.each do |issue| - Issues::UpdateService.new(project: parent, current_user: current_user, params: update_params).execute(issue) + Issues::UpdateService.new(container: parent, current_user: current_user, params: update_params).execute(issue) end milestone.merge_requests.each do |merge_request| diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 5f05b613288..f5efc480fef 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -137,8 +137,6 @@ module Notes end def invalid_reviewers?(update_params) - return false unless Feature.enabled?(:limit_reviewer_and_assignee_size) - if update_params.key?(:reviewer_ids) possible_reviewers = update_params[:reviewer_ids]&.uniq&.size @@ -167,6 +165,20 @@ module Notes if Feature.enabled?(:notes_create_service_tracking, project) Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note)) end + + if Feature.enabled?(:route_hll_to_snowplow_phase4, project&.namespace) && note.for_commit? + metric_key_path = 'counts.commit_comment' + + Gitlab::Tracking.event( + 'Notes::CreateService', + 'create_commit_comment', + project: project, + namespace: project&.namespace, + user: user, + label: metric_key_path, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context] + ) + end end def tracking_data_for(note) diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index eda8bbcbc2e..ccee94a5cea 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -10,6 +10,7 @@ module Notes clear_noteable_diffs_cache(note) track_note_removal_usage_for_issues(note) if note.for_issue? track_note_removal_usage_for_merge_requests(note) if note.for_merge_request? + track_note_removal_usage_for_design(note) if note.for_design? end private @@ -22,6 +23,13 @@ module Notes def track_note_removal_usage_for_merge_requests(note) Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_remove_comment_action(note: note) end + + def track_note_removal_usage_for_design(note) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_design_comment_removed_action( + author: note.author, + project: project + ) + end end end diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index bdeebc641b8..04563d180b5 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -17,24 +17,24 @@ module NotificationRecipients ::NotificationRecipients::Builder::Default.new(target, current_user, **args).notification_recipients end - def self.build_new_note_recipients(*args) - ::NotificationRecipients::Builder::NewNote.new(*args).notification_recipients + def self.build_new_note_recipients(...) + ::NotificationRecipients::Builder::NewNote.new(...).notification_recipients end - def self.build_merge_request_unmergeable_recipients(*args) - ::NotificationRecipients::Builder::MergeRequestUnmergeable.new(*args).notification_recipients + def self.build_merge_request_unmergeable_recipients(...) + ::NotificationRecipients::Builder::MergeRequestUnmergeable.new(...).notification_recipients end def self.build_project_maintainers_recipients(target, **args) ::NotificationRecipients::Builder::ProjectMaintainers.new(target, **args).notification_recipients end - def self.build_new_review_recipients(*args) - ::NotificationRecipients::Builder::NewReview.new(*args).notification_recipients + def self.build_new_review_recipients(...) + ::NotificationRecipients::Builder::NewReview.new(...).notification_recipients end - def self.build_requested_review_recipients(*args) - ::NotificationRecipients::Builder::RequestReview.new(*args).notification_recipients + def self.build_requested_review_recipients(...) + ::NotificationRecipients::Builder::RequestReview.new(...).notification_recipients end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 777d02c590d..47bc36fce70 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -212,14 +212,6 @@ class NotificationService relabeled_resource_email(issue, added_labels, current_user, :relabeled_issue_email) end - def removed_milestone_issue(issue, current_user) - removed_milestone_resource_email(issue, current_user, :removed_milestone_issue_email) - end - - def changed_milestone_issue(issue, new_milestone, current_user) - changed_milestone_resource_email(issue, new_milestone, current_user, :changed_milestone_issue_email) - end - # When create a merge request we should send an email to: # # * mr author @@ -366,14 +358,6 @@ class NotificationService relabeled_resource_email(merge_request, added_labels, current_user, :relabeled_merge_request_email) end - def removed_milestone_merge_request(merge_request, current_user) - removed_milestone_resource_email(merge_request, current_user, :removed_milestone_merge_request_email) - end - - def changed_milestone_merge_request(merge_request, new_milestone, current_user) - changed_milestone_resource_email(merge_request, new_milestone, current_user, :changed_milestone_merge_request_email) - end - def close_mr(merge_request, current_user) close_resource_email(merge_request, current_user, :closed_merge_request_email) end @@ -788,6 +772,44 @@ class NotificationService end end + def removed_milestone(target, current_user) + method = case target + when Issue + :removed_milestone_issue_email + when MergeRequest + :removed_milestone_merge_request_email + end + + recipients = NotificationRecipients::BuildService.build_recipients( + target, + current_user, + action: 'removed_milestone' + ) + + recipients.each do |recipient| + mailer.send(method, recipient.user.id, target.id, current_user.id).deliver_later + end + end + + def changed_milestone(target, milestone, current_user) + method = case target + when Issue + :changed_milestone_issue_email + when MergeRequest + :changed_milestone_merge_request_email + end + + recipients = NotificationRecipients::BuildService.build_recipients( + target, + current_user, + action: 'changed_milestone' + ) + + recipients.each do |recipient| + mailer.send(method, recipient.user.id, target.id, milestone, current_user.id).deliver_later + end + end + protected def new_resource_email(target, current_user, method) @@ -847,30 +869,6 @@ class NotificationService end end - def removed_milestone_resource_email(target, current_user, method) - recipients = NotificationRecipients::BuildService.build_recipients( - target, - current_user, - action: 'removed_milestone' - ) - - recipients.each do |recipient| - mailer.send(method, recipient.user.id, target.id, current_user.id).deliver_later - end - end - - def changed_milestone_resource_email(target, milestone, current_user, method) - recipients = NotificationRecipients::BuildService.build_recipients( - target, - current_user, - action: 'changed_milestone' - ) - - recipients.each do |recipient| - mailer.send(method, recipient.user.id, target.id, milestone, current_user.id).deliver_later - end - end - def reopen_resource_email(target, current_user, method, status) recipients = NotificationRecipients::BuildService.build_recipients(target, current_user, action: "reopen") @@ -941,12 +939,12 @@ class NotificationService NotificationRecipients::BuildService.build_project_maintainers_recipients(target, action: action) end - def notifiable?(*args) - NotificationRecipients::BuildService.notifiable?(*args) + def notifiable?(...) + NotificationRecipients::BuildService.notifiable?(...) end - def notifiable_users(*args) - NotificationRecipients::BuildService.notifiable_users(*args) + def notifiable_users(...) + NotificationRecipients::BuildService.notifiable_users(...) end def deliver_access_request_email(recipient, member) diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb index 8fed6e2def8..82c4292fca8 100644 --- a/app/services/packages/create_event_service.rb +++ b/app/services/packages/create_event_service.rb @@ -21,6 +21,17 @@ module Packages end end + def originator_type + case current_user + when User + :user + when DeployToken + :deploy_token + else + :guest + end + end + private def event_scope @@ -34,20 +45,5 @@ module Packages def event_name params[:event_name] end - - def originator_type - case current_user - when User - :user - when DeployToken - :deploy_token - else - :guest - end - end - - def guest? - originator_type == :guest - end end end diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb index 19e68183ea2..24e40b5c986 100644 --- a/app/services/packages/debian/create_package_file_service.rb +++ b/app/services/packages/debian/create_package_file_service.rb @@ -3,8 +3,6 @@ module Packages module Debian class CreatePackageFileService - include ::Packages::FIPS - def initialize(package:, current_user:, params: {}) @package = package @current_user = current_user @@ -12,7 +10,6 @@ module Packages end def execute - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? raise ArgumentError, "Invalid package" unless package.present? raise ArgumentError, "Invalid user" unless current_user.present? @@ -32,7 +29,13 @@ module Packages } ) - if params[:file_name].end_with? '.changes' + if params[:distribution].present? && params[:component].present? + ::Packages::Debian::ProcessPackageFileWorker.perform_async( + package_file.id, + params[:distribution], + params[:component] + ) + elsif params[:file_name].end_with? '.changes' ::Packages::Debian::ProcessChangesWorker.perform_async(package_file.id, current_user.id) end diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb index 30480834748..43a4db5bdfc 100644 --- a/app/services/packages/debian/extract_changes_metadata_service.rb +++ b/app/services/packages/debian/extract_changes_metadata_service.rb @@ -4,7 +4,6 @@ module Packages module Debian class ExtractChangesMetadataService include Gitlab::Utils::StrongMemoize - include ::Packages::FIPS ExtractionError = Class.new(StandardError) @@ -14,8 +13,6 @@ module Packages end def execute - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - { file_type: file_type, architecture: metadata[:architecture], diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb index 3b2be7b6874..cb765e956e7 100644 --- a/app/services/packages/debian/find_or_create_package_service.rb +++ b/app/services/packages/debian/find_or_create_package_service.rb @@ -10,7 +10,7 @@ module Packages .debian .with_name(params[:name]) .with_version(params[:version]) - .with_debian_codename(params[:distribution_name]) + .with_debian_codename_or_suite(params[:distribution_name]) .not_pending_destruction .first @@ -26,7 +26,10 @@ module Packages def distribution strong_memoize(:distribution) do - Packages::Debian::DistributionsFinder.new(project, codename: params[:distribution_name]).execute.last! + Packages::Debian::DistributionsFinder.new( + project, + codename_or_suite: params[:distribution_name] + ).execute.last! end end end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 9b313202400..12ae6c68918 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -4,7 +4,6 @@ module Packages module Debian class GenerateDistributionService include Gitlab::Utils::StrongMemoize - include ::Packages::FIPS include ExclusiveLeaseGuard ONE_HOUR = 1.hour.freeze @@ -66,13 +65,10 @@ module Packages def initialize(distribution) @distribution = distribution @oldest_kept_generated_at = nil - @md5sum = [] @sha256 = [] end def execute - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - try_obtain_lease do @distribution.transaction do # We consider `apt-get update` can take at most one hour @@ -106,7 +102,7 @@ module Packages .with_debian_architecture_name(architecture&.name) .with_debian_file_type(package_file_type) .find_each - .map(&method(:package_stanza_from_fields)) + .map { |package_file| package_stanza_from_fields(package_file) } reuse_or_create_component_file(component, component_file_type, architecture, paragraphs.join("\n")) end @@ -143,10 +139,10 @@ module Packages rfc822_field('Directory', package_dirname(package_file)) ] else + # NB: MD5sum was removed for FIPS compliance [ rfc822_field('Filename', "#{package_dirname(package_file)}/#{package_file.file_name}"), rfc822_field('Size', package_file.size), - rfc822_field('MD5sum', package_file.file_md5), rfc822_field('SHA256', package_file.file_sha256) ] end @@ -190,7 +186,6 @@ module Packages ) end - @md5sum.append(" #{file_md5} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") @sha256.append(" #{file_sha256} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") end @@ -234,7 +229,8 @@ module Packages end def release_sums - ["MD5Sum:", @md5sum, "SHA256:", @sha256].flatten.compact.join("\n") + "\n" + # NB: MD5Sum was removed for FIPS compliance + ["SHA256:", @sha256].flatten.compact.join("\n") + "\n" end def rfc822_field(name, value, condition = true) diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb index a29cbd3f65f..129f2e5c9bc 100644 --- a/app/services/packages/debian/process_changes_service.rb +++ b/app/services/packages/debian/process_changes_service.rb @@ -15,12 +15,12 @@ module Packages end def execute - try_obtain_lease do - # return if changes file has already been processed - break if package_file.debian_file_metadatum&.changes? + # return if changes file has already been processed + return if package_file.debian_file_metadatum&.changes? - validate! + validate! + try_obtain_lease do package_file.transaction do update_files_metadata update_changes_metadata @@ -38,6 +38,9 @@ module Packages raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum.unknown? raise ArgumentError, 'invalid package file' unless metadata[:file_type] == :changes + raise ArgumentError, 'missing Source field' unless metadata.dig(:fields, 'Source').present? + raise ArgumentError, 'missing Version field' unless metadata.dig(:fields, 'Version').present? + raise ArgumentError, 'missing Distribution field' unless metadata.dig(:fields, 'Distribution').present? end def update_files_metadata diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb index 59e8ac3425b..7d2d71184e6 100644 --- a/app/services/packages/debian/process_package_file_service.rb +++ b/app/services/packages/debian/process_package_file_service.rb @@ -10,19 +10,23 @@ module Packages # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze - def initialize(package_file, creator, distribution_name, component_name) + def initialize(package_file, distribution_name, component_name) @package_file = package_file - @creator = creator @distribution_name = distribution_name @component_name = component_name end def execute - try_obtain_lease do - validate! + return if @package_file.package.pending_destruction? + + validate! - @package_file.transaction do + try_obtain_lease do + package.transaction do + rename_package_and_set_version + update_package update_file_metadata + cleanup_temp_package end ::Packages::Debian::GenerateDistributionWorker.perform_async(:project, package.debian_distribution.id) @@ -32,6 +36,8 @@ module Packages private def validate! + raise ArgumentError, 'missing distribution name' unless @distribution_name.present? + raise ArgumentError, 'missing component name' unless @component_name.present? raise ArgumentError, 'package file without Debian metadata' unless @package_file.debian_file_metadatum raise ArgumentError, 'already processed package file' unless @package_file.debian_file_metadatum.unknown? @@ -40,6 +46,80 @@ module Packages raise ArgumentError, "invalid package file type: #{file_metadata[:file_type]}" end + def file_metadata + ::Packages::Debian::ExtractMetadataService.new(@package_file).execute + end + strong_memoize_attr :file_metadata + + def package + package = temp_package.project + .packages + .debian + .with_name(package_name) + .with_version(package_version) + .with_debian_codename_or_suite(@distribution_name) + .not_pending_destruction + .last + package || temp_package + end + strong_memoize_attr :package + + def temp_package + @package_file.package + end + strong_memoize_attr :temp_package + + def package_name + package_name_and_version[0] + end + + def package_version + package_name_and_version[1] + end + + def package_name_and_version + package_name = file_metadata[:fields]['Package'] + package_version = file_metadata[:fields]['Version'] + + if file_metadata[:fields]['Source'] + # "sample" or "sample (1.2.3~alpha2)" + source_field_parts = file_metadata[:fields]['Source'].split(SOURCE_FIELD_SPLIT_REGEX) + package_name = source_field_parts[0] + package_version = source_field_parts[2] || package_version + end + + [package_name, package_version] + end + strong_memoize_attr :package_name_and_version + + def rename_package_and_set_version + package.update!( + name: package_name, + version: package_version, + status: :default + ) + end + + def update_package + return unless using_temporary_package? + + package.update!( + debian_publication_attributes: { distribution_id: distribution.id } + ) + end + + def using_temporary_package? + package.id == temp_package.id + end + + def distribution + Packages::Debian::DistributionsFinder.new( + @package_file.package.project, + codename_or_suite: @distribution_name + ).execute.last! + end + strong_memoize_attr :distribution + def update_file_metadata ::Packages::UpdatePackageFileService.new(@package_file, package_id: package.id) .execute @@ -55,36 +135,8 @@ module Packages ) end - def package - strong_memoize(:package) do - package_name = file_metadata[:fields]['Package'] - package_version = file_metadata[:fields]['Version'] - - if file_metadata[:fields]['Source'] - # "sample" or "sample (1.2.3~alpha2)" - source_field_parts = file_metadata[:fields]['Source'].split(SOURCE_FIELD_SPLIT_REGEX) - package_name = source_field_parts[0] - package_version = source_field_parts[2] || package_version - end - - params = { - 'name': package_name, - 'version': package_version, - 'distribution_name': @distribution_name - } - response = Packages::Debian::FindOrCreatePackageService.new(project, @creator, params).execute - response.payload[:package] - end - end - - def file_metadata - strong_memoize(:metadata) do - ::Packages::Debian::ExtractMetadataService.new(@package_file).execute - end - end - - def project - @package_file.package.project + def cleanup_temp_package + temp_package.destroy unless using_temporary_package? end # used by ExclusiveLeaseGuard diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 03844c2dc7e..b3a9beabba5 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService private def quick_action_types - %w(Issue MergeRequest Commit) + %w(Issue MergeRequest Commit WorkItem) end def explain_quick_actions(text) diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index a3e533c670e..9378bb31360 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -22,8 +22,9 @@ module Projects private def delete_tags - delete_service.execute - .tap(&method(:log_response)) + delete_service + .execute + .tap { |response| log_response(response) } end def delete_service diff --git a/app/services/projects/container_repository/destroy_service.rb b/app/services/projects/container_repository/destroy_service.rb index 6db6b449671..6cb0d55aea4 100644 --- a/app/services/projects/container_repository/destroy_service.rb +++ b/app/services/projects/container_repository/destroy_service.rb @@ -10,12 +10,15 @@ module Projects }.freeze def execute(container_repository, disable_timeout: true) - return false unless can?(current_user, :update_container_image, project) + return error('Unauthorized access') unless can_destroy? # Delete tags outside of the transaction to avoid hitting an idle-in-transaction timeout - unless delete_tags(container_repository, disable_timeout) && + if delete_tags(container_repository, disable_timeout) && destroy_container_repository(container_repository) + success + else container_repository.delete_failed! + error('Deletion failed for container repository') end end @@ -40,9 +43,19 @@ module Projects false end + def can_destroy? + return true if skip_permission_check? + + can?(current_user, :destroy_container_image, project) + end + def error_message(container_repository, message) - "Container repository with ID: #{container_repository.id} and path: #{container_repository.path}" \ - " failed with message: #{message}" + "Container repository with ID: #{container_repository.id} and path: #{container_repository.path} " \ + "failed with message: #{message}" + end + + def skip_permission_check? + !!params[:skip_permission_check] end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index d3313526eaf..94cc4700a49 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -125,7 +125,7 @@ module Projects setup_authorizations - current_user.invalidate_personal_projects_count + project.invalidate_personal_projects_count_of_owner Projects::PostCreationWorker.perform_async(@project.id) @@ -160,7 +160,6 @@ module Projects # AuthorizedProjectsWorker but with some delay and lower urgency as a # safety net. @project.group.refresh_members_authorized_projects( - blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY ) else @@ -198,7 +197,7 @@ module Projects end def create_sast_commit - ::Security::CiConfiguration::SastCreateService.new(@project, current_user, {}, commit_on_default: true).execute + ::Security::CiConfiguration::SastCreateService.new(@project, current_user, { initialize_with_sast: true }, commit_on_default: true).execute end def readme_content diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 4e883f682fb..2279ab301dc 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -34,7 +34,7 @@ module Projects publish_project_deleted_event_for(project) - current_user.invalidate_personal_projects_count + project.invalidate_personal_projects_count_of_owner true rescue StandardError => error @@ -257,12 +257,12 @@ module Projects return true unless Gitlab.config.registry.enabled return false unless remove_legacy_registry_tags + results = [] project.container_repositories.find_each do |container_repository| - service = Projects::ContainerRepository::DestroyService.new(project, current_user) - service.execute(container_repository) + results << destroy_repository(project, container_repository) end - true + results.all? end ## @@ -272,9 +272,14 @@ module Projects def remove_legacy_registry_tags return true unless Gitlab.config.registry.enabled - ::ContainerRepository.build_root_repository(project).tap do |repository| - break repository.has_tags? ? repository.delete_tags! : true - end + root_repository = ::ContainerRepository.build_root_repository(project) + root_repository.has_tags? ? destroy_repository(project, root_repository) : true + end + + def destroy_repository(project, repository) + service = ContainerRepository::DestroyService.new(project, current_user, { skip_permission_check: true }) + response = service.execute(repository) + response[:status] == :success end def raise_error(message) diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 72036aaff35..f77bae71d63 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -36,7 +36,6 @@ module Projects # AuthorizedProjectsWorker but with some delay and lower urgency as a # safety net. shared_with_group.refresh_members_authorized_projects( - blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY ) end diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb index 19df0dc2c73..a2307bfebf0 100644 --- a/app/services/projects/group_links/destroy_service.rb +++ b/app/services/projects/group_links/destroy_service.rb @@ -19,7 +19,6 @@ module Projects # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. link.group.refresh_members_authorized_projects( - blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY ) end diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb index c271b0a2307..9b2565adaca 100644 --- a/app/services/projects/group_links/update_service.rb +++ b/app/services/projects/group_links/update_service.rb @@ -26,7 +26,6 @@ module Projects # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. group_link.group.refresh_members_authorized_projects( - blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY ) end diff --git a/app/services/projects/protect_default_branch_service.rb b/app/services/projects/protect_default_branch_service.rb index 03d1c49657d..5360902038b 100644 --- a/app/services/projects/protect_default_branch_service.rb +++ b/app/services/projects/protect_default_branch_service.rb @@ -45,7 +45,11 @@ module Projects end def protected_branch_exists? - project.protected_branches.find_by_name(default_branch).present? + if Feature.enabled?(:group_protected_branches) + project.all_protected_branches.find_by_name(default_branch).present? + else + project.protected_branches.find_by_name(default_branch).present? + end end def default_branch diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 3cb5a564ba5..ed99c69be07 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -32,9 +32,9 @@ module Projects raise TransferError, s_("TransferProject|You don't have permission to transfer projects into that namespace.") end - transfer(project) + @owner_of_personal_project_before_transfer = project.namespace.owner if project.personal? - current_user.invalidate_personal_projects_count + transfer(project) true rescue Projects::TransferService::TransferError => ex @@ -121,6 +121,7 @@ module Projects # Overridden in EE def post_update_hooks(project) ensure_personal_project_owner_membership(project) + invalidate_personal_projects_counts publish_event end @@ -129,6 +130,18 @@ module Projects def remove_paid_features end + def invalidate_personal_projects_counts + # If the project was moved out of a personal namespace, + # the cache of the namespace owner, before the transfer, should be cleared. + if @owner_of_personal_project_before_transfer.present? + @owner_of_personal_project_before_transfer.invalidate_personal_projects_count + end + + # If the project has now moved into a personal namespace, + # the cache of the target namespace owner should be cleared. + project.invalidate_personal_projects_count_of_owner + end + def transfer_missing_group_resources(group) Labels::TransferService.new(current_user, group, project).execute @@ -179,7 +192,6 @@ module Projects # the old approach, we still run AuthorizedProjectsWorker # but with some delay and lower urgency as a safety net. UserProjectAccessChangedService.new(user_ids).execute( - blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY ) end diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb index af8c9ce74bb..4a9fc335421 100644 --- a/app/services/protected_branches/cache_service.rb +++ b/app/services/protected_branches/cache_service.rb @@ -81,7 +81,11 @@ module ProtectedBranches end def metrics - @metrics ||= Gitlab::Cache::Metrics.new( + @metrics ||= Gitlab::Cache::Metrics.new(cache_metadata) + end + + def cache_metadata + Gitlab::Cache::Metadata.new( caller_id: Gitlab::ApplicationContext.current_context_attribute(:caller_id), cache_identifier: "#{self.class}#fetch", feature_category: :source_code_management, diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb index a32a867491e..011dbf3515d 100644 --- a/app/services/protected_branches/destroy_service.rb +++ b/app/services/protected_branches/destroy_service.rb @@ -5,7 +5,10 @@ module ProtectedBranches def execute(protected_branch) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch) - protected_branch.destroy.tap { refresh_cache } + protected_branch.destroy.tap do + refresh_cache + after_execute + end end end end diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb index 6eda3c89e6c..04ae5287302 100644 --- a/app/services/quick_actions/target_service.rb +++ b/app/services/quick_actions/target_service.rb @@ -2,37 +2,45 @@ module QuickActions class TargetService < BaseService - def execute(type, type_id) + def execute(type, type_iid) case type&.downcase + when 'workitem' + work_item(type_iid) when 'issue' - issue(type_id) + issue(type_iid) when 'mergerequest' - merge_request(type_id) + merge_request(type_iid) when 'commit' - commit(type_id) + commit(type_iid) end end private # rubocop: disable CodeReuse/ActiveRecord - def issue(type_id) - return project.issues.build if type_id.nil? + def work_item(type_iid) + WorkItems::WorkItemsFinder.new(current_user, project_id: project.id).find_by(iid: type_iid) + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def issue(type_iid) + return project.issues.build if type_iid.nil? - IssuesFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.issues.build + IssuesFinder.new(current_user, project_id: project.id).find_by(iid: type_iid) || project.issues.build end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def merge_request(type_id) - return project.merge_requests.build if type_id.nil? + def merge_request(type_iid) + return project.merge_requests.build if type_iid.nil? - MergeRequestsFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.merge_requests.build + MergeRequestsFinder.new(current_user, project_id: project.id).find_by(iid: type_iid) || project.merge_requests.build end # rubocop: enable CodeReuse/ActiveRecord - def commit(type_id) - project.commit(type_id) + def commit(type_iid) + project.commit(type_iid) end end end diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index 7fb59dad508..5d6cb372653 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -58,7 +58,7 @@ module Releases end def milestones - return [] unless param_for_milestone_titles_provided? + return [] unless param_for_milestones_exists? strong_memoize(:milestones) do MilestonesFinder.new( @@ -67,22 +67,44 @@ module Releases project_ids: Array(project.id), group_ids: Array(project_group_id), state: 'all', - title: params[:milestones] + title: params[:milestones], + ids: params[:milestone_ids] ).execute end end - def inexistent_milestones + def inexistent_milestone_titles return [] unless param_for_milestone_titles_provided? existing_milestone_titles = milestones.map(&:title) + Array(params[:milestones]) - existing_milestone_titles end + def inexistent_milestone_ids + return [] unless param_for_milestone_ids_provided? + + existing_milestone_ids = milestones.map(&:id) + + Array(params[:milestone_ids]) - existing_milestone_ids + end + def param_for_milestone_titles_provided? !!params[:milestones] end + def param_for_milestone_ids_provided? + !!params[:milestone_ids] + end + + def param_for_milestones_provided? + param_for_milestone_titles_provided? || param_for_milestone_ids_provided? + end + + def param_for_milestones_exists? + params[:milestones].present? || params[:milestone_ids].present? + end + def execute_hooks(release, action = 'create') release.execute_hooks(action) end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 01dd6323d94..a3289f9e552 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -6,7 +6,8 @@ module Releases return error(_('Access Denied'), 403) unless allowed? return error(_('You are not allowed to create this tag as it is protected.'), 403) unless can_create_tag? return error(_('Release already exists'), 409) if release - return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestones.join(', ')), 400) if inexistent_milestones.any? # rubocop:disable Layout/LineLength + return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestone_titles.join(', ')), 400) if inexistent_milestone_titles.any? # rubocop:disable Layout/LineLength + return error(format(_("Milestone id(s) not found: %{milestones}"), milestones: inexistent_milestone_ids.join(', ')), 400) if inexistent_milestone_ids.any? # rubocop:disable Layout/LineLength # should be found before the creation of new tag # because tag creation can spawn new pipeline diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb index b9b2aba9805..c11d9468814 100644 --- a/app/services/releases/update_service.rb +++ b/app/services/releases/update_service.rb @@ -7,8 +7,8 @@ module Releases return error end - if param_for_milestone_titles_provided? - previous_milestones = release.milestones.map(&:title) + if param_for_milestones_provided? + previous_milestones = release.milestones.map(&:id) params[:milestones] = milestones end @@ -35,7 +35,8 @@ module Releases return error(_('Release does not exist'), 404) unless release return error(_('Access Denied'), 403) unless allowed? return error(_('params is empty'), 400) if empty_params? - return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestones.join(', ')), 400) if inexistent_milestones.any? # rubocop:disable Layout/LineLength + return error(format(_("Milestone(s) not found: %{milestones}"), milestones: inexistent_milestone_titles.join(', ')), 400) if inexistent_milestone_titles.any? # rubocop:disable Layout/LineLength + return error(format(_("Milestone id(s) not found: %{milestones}"), milestones: inexistent_milestone_ids.join(', ')), 400) if inexistent_milestone_ids.any? # rubocop:disable Layout/LineLength end def allowed? @@ -47,9 +48,9 @@ module Releases end def milestones_updated?(previous_milestones) - return false unless param_for_milestone_titles_provided? + return false unless param_for_milestones_provided? - previous_milestones.to_set != release.milestones.map(&:title) + previous_milestones.to_set != release.milestones.map(&:id) end end end diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index 36de70dc291..e675bb61072 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -32,7 +32,7 @@ module ResourceEvents return events if params[:paginated_notes].nil? return events.none if params[:paginated_notes][table_name].blank? - events.id_in(params[:paginated_notes][table_name].map(&:id)) + events.id_in(params[:paginated_notes][table_name].flat_map(&:ids)) end def apply_last_fetched_at(events) diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 7e176f95db0..02182bc3a77 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -23,16 +23,22 @@ module ResourceEvents label_hash.merge(label_id: label.id, action: ResourceLabelEvent.actions['remove']) end - ApplicationRecord.legacy_bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert + ids = ApplicationRecord.legacy_bulk_insert(ResourceLabelEvent.table_name, labels, return_ids: true) # rubocop:disable Gitlab/BulkInsert - create_timeline_events_from(added_labels: added_labels, removed_labels: removed_labels) + if resource.is_a?(Issue) + events = ResourceLabelEvent.id_in(ids) + events.first.trigger_note_subscription_create(events: events.to_a) if events.any? + end + create_timeline_events_from(added_labels: added_labels, removed_labels: removed_labels) resource.expire_note_etag_cache return unless resource.is_a?(Issue) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user, - project: resource.project) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action( + author: user, project: resource.project) + + events end private diff --git a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb index 0e2b171e192..18c32ef1152 100644 --- a/app/services/resource_events/synthetic_milestone_notes_builder_service.rb +++ b/app/services/resource_events/synthetic_milestone_notes_builder_service.rb @@ -18,7 +18,7 @@ module ResourceEvents def milestone_change_events return [] unless resource.respond_to?(:resource_milestone_events) - events = resource.resource_milestone_events.includes(user: :status) # rubocop: disable CodeReuse/ActiveRecord + events = resource.resource_milestone_events.includes(:milestone, user: :status) # rubocop: disable CodeReuse/ActiveRecord apply_common_filters(events) end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 3e93346bfdf..6acc32ea0a8 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -8,9 +8,9 @@ module Search attr_accessor :project, :current_user, :params - def initialize(project_or_projects, user, params) - @project = project_or_projects + def initialize(user, project_or_projects, params) @current_user = user + @project = project_or_projects @params = params.dup end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index b4344a009b2..7fca6ed7a20 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -187,7 +187,7 @@ class SearchService def search_service @search_service ||= if project - Search::ProjectService.new(project, current_user, params) + Search::ProjectService.new(current_user, project, params) elsif show_snippets? Search::SnippetService.new(current_user, params) elsif group diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb index aaa850fde39..3e8865d3dff 100644 --- a/app/services/security/ci_configuration/base_create_service.rb +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -12,6 +12,16 @@ module Security end def execute + if project.repository.empty? && !(@params && @params[:initialize_with_sast]) + docs_link = ActionController::Base.helpers.link_to _('add at least one file to the repository'), + Rails.application.routes.url_helpers.help_page_url('user/project/repository/index.md', + anchor: 'add-files-to-a-repository'), + target: '_blank', + rel: 'noopener noreferrer' + raise Gitlab::Graphql::Errors::MutationError, + _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe + end + project.repository.add_branch(current_user, branch_name, project.default_branch) attributes_for_commit = attributes diff --git a/app/services/snippets/count_service.rb b/app/services/snippets/count_service.rb index 9a3d33c75cf..ba421c5777e 100644 --- a/app/services/snippets/count_service.rb +++ b/app/services/snippets/count_service.rb @@ -70,7 +70,7 @@ module Snippets count(case when visibility_level=#{Snippet::PUBLIC} OR visibility_level=#{Snippet::INTERNAL} then 1 else null end) as are_public_or_internal, count(*) as total ") - .first + .take end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 0dcb3546034..4ec07bb4c5f 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -42,7 +42,7 @@ module Spam # Favour the most restrictive result. verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] } - # The target can override the verdict via the `allow_possible_spam` feature flag + # The target can override the verdict via the `allow_possible_spam` application setting verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: verdict) logger.info(class: self.class.name, diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb index ee7784c127b..1f6d8ab2409 100644 --- a/app/services/system_notes/base_service.rb +++ b/app/services/system_notes/base_service.rb @@ -19,8 +19,8 @@ module SystemNotes Note.create(note_params) end - def content_tag(*args) - ActionController::Base.helpers.content_tag(*args) + def content_tag(...) + ActionController::Base.helpers.content_tag(...) end def url_helpers diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb index a5648ad10c4..5851a2cb9e5 100644 --- a/app/services/tasks_to_be_done/base_service.rb +++ b/app/services/tasks_to_be_done/base_service.rb @@ -4,22 +4,22 @@ module TasksToBeDone class BaseService < ::IssuableBaseService LABEL_PREFIX = 'tasks to be done' - def initialize(project:, current_user:, assignee_ids: []) + def initialize(container:, current_user:, assignee_ids: []) params = { assignee_ids: assignee_ids, title: title, description: description, add_labels: label_name } - super(project: project, current_user: current_user, params: params) + super(project: container, current_user: current_user, params: params) end def execute if (issue = existing_task_issue) - update_service = Issues::UpdateService.new(project: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] }) + update_service = Issues::UpdateService.new(container: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] }) update_service.execute(issue) else - build_service = Issues::BuildService.new(project: project, current_user: current_user, params: params) + build_service = Issues::BuildService.new(container: project, current_user: current_user, params: params) create(build_service.execute) end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index bfd1e55507c..42a8aca17d3 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -198,6 +198,23 @@ class TodoService current_user.update_todos_count_cache end + def resolve_access_request_todos(current_user, member) + return if current_user.nil? || member.nil? + + target = member.source + + finder_params = { + state: :pending, + author_id: member.user_id, + action_id: ::Todo::MEMBER_ACCESS_REQUESTED, + type: target.class.polymorphic_name, + target: target.id + } + + todos = TodosFinder.new(current_user, finder_params).execute + resolve_todos(todos, current_user) + end + def restore_todos(todos, current_user) todos_ids = todos.batch_update(state: :pending) diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index f7178ee9bb6..c282dcf176c 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -12,21 +12,19 @@ class UserProjectAccessChangedService @user_ids = Array.wrap(user_ids) end - def execute(blocking: true, priority: HIGH_PRIORITY) + def execute(priority: HIGH_PRIORITY) return if @user_ids.empty? bulk_args = @user_ids.map { |id| [id] } result = - if blocking - AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args) - else - case priority - when HIGH_PRIORITY - AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext - when MEDIUM_PRIORITY - AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in(MEDIUM_DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds) # rubocop:disable Scalability/BulkPerformWithContext - else + case priority + when HIGH_PRIORITY + AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + when MEDIUM_PRIORITY + AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in(MEDIUM_DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds) # rubocop:disable Scalability/BulkPerformWithContext + when LOW_PRIORITY + if Feature.disabled?(:do_not_run_safety_net_auth_refresh_jobs) with_related_class_context do # We wrap the execution in `with_related_class_context`so as to obtain # the location of the original caller diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 4978f778870..c8f9c28061f 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -4,38 +4,56 @@ module Users class ActivityService LEASE_TIMEOUT = 1.minute.to_i - def initialize(author) + def initialize(author:, namespace: nil, project: nil) @user = if author.respond_to?(:username) author elsif author.respond_to?(:user) author.user end - @user = nil unless @user.is_a?(User) + @user = nil unless user.is_a?(User) + @namespace = namespace + @project = project end def execute - return unless @user + return unless user ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes { record_activity } end private + attr_reader :user, :namespace, :project + def record_activity return if Gitlab::Database.read_only? today = Date.today - return if @user.last_activity_on == today + return if user.last_activity_on == today - lease = Gitlab::ExclusiveLease.new("activity_service:#{@user.id}", + lease = Gitlab::ExclusiveLease.new("activity_service:#{user.id}", timeout: LEASE_TIMEOUT) return unless lease.try_obtain - @user.update_attribute(:last_activity_on, today) + user.update_attribute(:last_activity_on, today) + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event('unique_active_user', values: user.id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase3) - Gitlab::UsageDataCounters::HLLRedisCounter.track_event('unique_active_user', values: @user.id) + Gitlab::Tracking.event( + 'Users::ActivityService', + 'perform_action', + user: user, + namespace: namespace, + project: project, + label: 'redis_hll_counters.manage.unique_active_users_monthly', + context: [ + Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'unique_active_user').to_context + ] + ) end end end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 064bf132d3d..934dccf2f76 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -163,6 +163,7 @@ module Users :skype, :theme_id, :twitter, + :discord, :username, :website_url, :private_profile, @@ -177,19 +178,17 @@ module Users # Allowed params for user signup def signup_params - signup_params = [ + [ :email, :name, :password, :password_automatically_set, + :preferred_language, :username, :user_type, :first_name, :last_name ] - signup_params << :preferred_language if ::Feature.enabled?(:preferred_language_switcher) - - signup_params end end end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index d32dcd73734..9ab6fcc9832 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -142,6 +142,7 @@ class WebHookService log_data = { trigger: hook_name, url: hook.url, + interpolated_url: hook.interpolated_url, execution_duration: execution_duration, request_headers: build_headers, request_data: data, diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index 351ebc14564..ae09e44b952 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -16,7 +16,7 @@ module WorkItems def execute create_result = CreateService.new( - project: @project, + container: @project, current_user: @current_user, params: @params.merge(title: @params[:title].strip).reverse_merge(confidential: confidential_parent), spam_params: @spam_params diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index c89ebc75b80..eff2132039f 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -4,13 +4,13 @@ module WorkItems class CreateService < Issues::CreateService include WidgetableService - def initialize(project:, spam_params:, current_user: nil, params: {}, widget_params: {}) + def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {}) super( - project: project, + container: container, current_user: current_user, params: params, spam_params: spam_params, - build_service: ::WorkItems::BuildService.new(project: project, current_user: current_user, params: params) + build_service: ::WorkItems::BuildService.new(container: container, current_user: current_user, params: params) ) @widget_params = widget_params end diff --git a/app/services/work_items/delete_task_service.rb b/app/services/work_items/delete_task_service.rb index 2a82a993b71..3d66716543a 100644 --- a/app/services/work_items/delete_task_service.rb +++ b/app/services/work_items/delete_task_service.rb @@ -25,7 +25,7 @@ module WorkItems break ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error? delete_result = ::WorkItems::DeleteService.new( - project: @task.project, + container: @task.project, current_user: @current_user ).execute(@task) diff --git a/app/services/work_items/export_csv_service.rb b/app/services/work_items/export_csv_service.rb new file mode 100644 index 00000000000..9bef75e2c40 --- /dev/null +++ b/app/services/work_items/export_csv_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module WorkItems + class ExportCsvService < ExportCsv::BaseService + NotAvailableError = StandardError.new('This feature is currently behind a feature flag and it is not available.') + + def csv_data + raise NotAvailableError unless Feature.enabled?(:import_export_work_items_csv, resource_parent) + + super + end + + def email(mail_to_user) + # TODO - will be implemented as part of https://gitlab.com/gitlab-org/gitlab/-/issues/379082 + end + + private + + def associations_to_preload + [:work_item_type, :author] + end + + def header_to_value_hash + { + 'Id' => 'iid', + 'Title' => 'title', + 'Type' => ->(work_item) { work_item.work_item_type.name }, + 'Author' => 'author_name', + 'Author Username' => ->(work_item) { work_item.author.username }, + 'Created At (UTC)' => ->(work_item) { work_item.created_at.to_s(:csv) } + } + 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 index 9152580bef0..843b03906ac 100644 --- a/app/services/work_items/task_list_reference_removal_service.rb +++ b/app/services/work_items/task_list_reference_removal_service.rb @@ -39,7 +39,7 @@ module WorkItems end ::WorkItems::UpdateService.new( - project: @work_item.project, + container: @work_item.project, current_user: @current_user, params: { description: source_lines.join("\n"), lock_version: @lock_version } ).execute(@work_item) diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb index b098d67561b..d81576909d9 100644 --- a/app/services/work_items/task_list_reference_replacement_service.rb +++ b/app/services/work_items/task_list_reference_replacement_service.rb @@ -34,7 +34,7 @@ module WorkItems remove_additional_lines!(source_lines) ::WorkItems::UpdateService.new( - project: @work_item.project, + container: @work_item.project, current_user: @current_user, params: { description: source_lines.join("\n"), lock_version: @lock_version } ).execute(@work_item) diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index 1351445f6f3..d4acadbc851 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -4,10 +4,10 @@ module WorkItems class UpdateService < ::Issues::UpdateService include WidgetableService - def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) + def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) params[:widget_params] = true if widget_params.present? - super(project: project, current_user: current_user, params: params, spam_params: nil) + super(container: container, current_user: current_user, params: params, spam_params: spam_params) @widget_params = widget_params end diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index 47de6fe0fbd..9b142dbe4b8 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -6,12 +6,6 @@ class AttachmentUploader < GitlabUploader prepend ObjectStorage::Extension::RecordsUploads include UploaderHelper - private - - def dynamic_segment - File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) - end - def mounted_as # Geo fails to sync attachments on Note, and LegacyDiffNotes with missing mount_point. # @@ -22,4 +16,10 @@ class AttachmentUploader < GitlabUploader super end end + + private + + def dynamic_segment + File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) + end end diff --git a/app/uploaders/object_storage/cdn.rb b/app/uploaders/object_storage/cdn.rb index 8c9ee8682f4..a117451b3f8 100644 --- a/app/uploaders/object_storage/cdn.rb +++ b/app/uploaders/object_storage/cdn.rb @@ -16,7 +16,7 @@ module ObjectStorage if use_cdn?(ip_address) UrlResult.new(cdn_signed_url(params), true) else - UrlResult.new(url, false) + UrlResult.new(url(query: params), false) end end diff --git a/app/uploaders/object_storage/s3.rb b/app/uploaders/object_storage/s3.rb new file mode 100644 index 00000000000..063af8b5389 --- /dev/null +++ b/app/uploaders/object_storage/s3.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ObjectStorage + module S3 + def self.signed_head_url(file) + fog_storage = ::Fog::Storage.new(file.fog_credentials) + fog_dir = fog_storage.directories.new(key: file.fog_directory) + fog_file = fog_dir.files.new(key: file.path) + expire_at = ::Fog::Time.now + file.fog_authenticated_url_expiration + + fog_file.collection.head_url(fog_file.key, expire_at) + end + end +end diff --git a/app/validators/feature_flag_strategies_validator.rb b/app/validators/feature_flag_strategies_validator.rb index a933a307626..6008074414b 100644 --- a/app/validators/feature_flag_strategies_validator.rb +++ b/app/validators/feature_flag_strategies_validator.rb @@ -15,7 +15,7 @@ class FeatureFlagStrategiesValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return unless value - if value.is_a?(Array) && value.all? { |s| s.is_a?(Hash) } + if value.is_a?(Array) && value.all?(Hash) value.each do |strategy| strategy_validations(record, attribute, strategy) end diff --git a/app/validators/json_schemas/project_import_stats.json b/app/validators/json_schemas/project_import_stats.json new file mode 100644 index 00000000000..0b601c7cc89 --- /dev/null +++ b/app/validators/json_schemas/project_import_stats.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Project import stats", + "type": "object", + "properties": { + "fetched": { + "type": "object", + "patternProperties": { + ".*": { + "type": "integer" + } + } + }, + "imported": { + "type": "object", + "patternProperties": { + ".*": { + "type": "integer" + } + } + } + }, + "additionalProperties": false +} diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 393021ed93c..8b9bbfd0a59 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -8,7 +8,9 @@ = _("A member of the abuse team will review your report as soon as possible.") = gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit'} do |f| - = form_errors(@abuse_report) + .row + .col-lg-8 + = form_errors(@abuse_report) = f.hidden_field :user_id = f.hidden_field :category @@ -23,6 +25,7 @@ .col-lg-8 = f.label :reported_from = f.text_field :reported_from_url, class: "form-control", readonly: true + #js-links-to-spam{ data: { links: Array(@abuse_report.links_to_spam) } } .form-group.row .col-lg-8 = f.label :reason diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 21f69f6700f..87c251aa10c 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -55,7 +55,9 @@ = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe } .form-group = f.label :deactivate_dormant_users_period, _('Days of inactivity before deactivation'), class: 'label-light' - = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '90', step: '1' + = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: 90, required: true + #application_setting_deactivate_dormant_users_period_error.form-text.gl-text-red-500.gl-display-none + = _('Please enter a value of 90 days or more') .form-text.text-muted = _('Must be 90 days or more.') diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index e0ff1f4be43..80a7d3607ef 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -21,4 +21,11 @@ .form-group = f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.') + - if Feature.enabled?(:deactivation_email_additional_text) + .form-group + = f.label :deactivation_email_additional_text, _('Additional text for deactivation email') + = f.text_area :deactivation_email_additional_text, class: 'form-control gl-form-input', rows: 4 + .form-text.text-muted + = _('Text added to the body of user deactivation email messages. 1000 character limit.') + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index a5e10846488..1b62083849b 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -17,6 +17,9 @@ = f.gitlab_ui_checkbox_component :external_authorization_service_enabled, s_('ExternalAuthorization|Enable classification control using an external service'), help_text: external_authorization_description + = f.gitlab_ui_checkbox_component :allow_deploy_tokens_and_keys_with_external_authn, + s_('ExternalAuthorization|Allow deploy tokens and deploy keys to be used with external authorization'), + help_text: external_authorization_allow_token_help_text .form-group = f.label :external_authorization_service_url, s_('ExternalAuthorization|Service URL'), class: 'label-bold' = f.text_field :external_authorization_service_url, class: 'form-control gl-form-input' diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 11ebad07e9a..e76a83662af 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -12,7 +12,7 @@ = f.gitlab_ui_checkbox_component :help_page_hide_commercial_content, _('Hide marketing-related entries from the Help page') .form-group = f.label :help_page_support_url, _('Support page URL'), class: 'label-bold' - = f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'https://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' + = f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'https://company.example.com/get-help', :'aria-describedby' => 'support_help_block' %span.form-text.text-muted#support_help_block= _('Alternate support URL for Help page and Help dropdown.') .form-group diff --git a/app/views/admin/application_settings/_jira_connect.html.haml b/app/views/admin/application_settings/_jira_connect.html.haml index ad0660797ee..235b6855123 100644 --- a/app/views/admin/application_settings/_jira_connect.html.haml +++ b/app/views/admin/application_settings/_jira_connect.html.haml @@ -8,7 +8,12 @@ = expanded ? _('Collapse') : _('Expand') %p = s_('JiraConnect|Configure your Jira Connect Application ID.') - = link_to sprite_icon('question-o'), 'https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud', target: '_blank', rel: "noopener noreferrer", class: 'has-tooltip', title: _('More information'), aria: { label: _('GitLab for Jira Cloud') } + = link_to sprite_icon('question-o'), + help_page_path('integration/jira/connect-app', + aria: { label: _('GitLab for Jira Cloud') }, + class: 'has-tooltip', + anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances'), + title: _('More information') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f| @@ -24,4 +29,8 @@ = f.label :jira_connect_proxy_url, s_('JiraConnect|Jira Connect Proxy URL'), class: 'label-bold' = f.text_field :jira_connect_proxy_url, class: 'form-control gl-form-input' + %fieldset + .form-group + = f.gitlab_ui_checkbox_component :jira_connect_public_key_storage_enabled, s_('JiraConnect|Enable public key storage') + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index 08486a808bf..baf7c5de7b9 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -4,7 +4,7 @@ %fieldset .gl-form-group %span.form-text.gl-mb-3.gl-mt-0 - = _('If no options are selected, only administrators can register runners.') + = s_('Runners|If both settings are disabled, new runners cannot be registered.') = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' = hidden_field_tag "application_setting[valid_runner_registrars][]", nil - ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES.each do |type| diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 3e2551d753a..50b5e797559 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -39,7 +39,7 @@ .form-group = f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold' - help_text = _('Notify users by email when sign-in location is not recognized.') - - help_link = link_to _('Learn more.'), help_page_path('user/profile/unknown_sign_in_notification.md'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('user/profile/notifications.md', anchor: 'notifications-for-unknown-sign-ins'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :notify_on_unknown_sign_in, _('Enable email notification'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index 96face44344..abc7abe92ad 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -47,6 +47,10 @@ help_text: _("Helps prevent bots from creating issues.") .form-group + = f.gitlab_ui_checkbox_component :allow_possible_spam, _('Allow possible spam'), + help_text: _('Only log Akismet verdict, do not prevent issue or snippet creation when spam is detected.') + + .form-group = f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold' = f.text_field :akismet_api_key, class: 'form-control gl-form-input' .form-text.text-muted diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index e01126e32e1..0305a9487ca 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -14,13 +14,15 @@ .form-group.visibility-level-setting = f.label :default_group_visibility, class: 'label-bold' = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) - .form-group - = f.label :restricted_visibility_levels, class: 'label-bold gl-mb-0' - %span.form-text.gl-mt-0.gl-mb-3#restricted-visibility-help - = _('Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users.') + %fieldset.form-group.gl-form-group{ data: { testid: 'restricted-visibility-levels' } } + %legend.col-form-label + = s_('AdminSettings|Restricted visibility levels') + %small.form-text.text-gl-muted + = s_('AdminSettings|Prevent non-administrators from using the selected visibility levels for groups, projects and snippets.') = hidden_field_tag 'application_setting[restricted_visibility_levels][]' - - restricted_level_checkboxes(f).each do |level| - = level + .gl-form-checkbox-group + - restricted_level_checkboxes(f).each do |checkbox| + = checkbox .form-group = f.label :import_sources, s_('AdminSettings|Import sources'), class: 'label-bold gl-mb-0' %span.form-text.gl-mt-0.gl-mb-3#import-sources-help diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 5f51e91436c..6c6334905ca 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -10,7 +10,7 @@ .col-lg-8 .form-group - = f.label :header_logo, _('Header logo'), class: 'col-form-label label-bold pt-0' + = f.label :header_logo, _('Header logo'), class: 'col-form-label gl-pt-0' %p - if @appearance.header_logo? = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' @@ -29,7 +29,7 @@ .col-lg-8 .form-group - = f.label :favicon, _('Favicon'), class: 'col-form-label label-bold pt-0' + = f.label :favicon, _('Favicon'), class: 'col-form-label gl-pt-0' %p - if @appearance.favicon? = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' @@ -53,15 +53,15 @@ .col-lg-8 .form-group - = f.label :title, class: 'col-form-label label-bold' + = f.label :title, class: 'col-form-label' = f.text_field :title, class: "form-control gl-form-input" .form-group - = f.label :description, class: 'col-form-label label-bold' + = f.label :description, class: 'col-form-label' = f.text_area :description, class: "form-control gl-form-input", rows: 10 .form-text.text-muted = parsed_with_gfm .form-group - = f.label :logo, class: 'col-form-label label-bold pt-0' + = f.label :logo, class: 'col-form-label gl-pt-0' %p - if @appearance.logo? = image_tag @appearance.logo_path, class: 'appearance-logo-preview' @@ -72,7 +72,38 @@ = f.hidden_field :logo_cache = f.file_field :logo, class: "", accept: 'image/*' .form-text.text-muted - = _('Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.') + = _('Maximum file size is 1 MB. Pages are optimized for a 128x128 px logo.') + + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0= _('Progressive Web App (PWA)') + + .col-lg-8 + .form-group + = f.label _("Name"), class: 'col-form-label' + = f.text_field :pwa_name, class: "form-control gl-form-input" + .form-group + = f.label _("Short name"), class: 'col-form-label' + = f.text_field :pwa_short_name, class: "form-control gl-form-input" + .form-group + = f.label _("Description"), class: 'col-form-label' + = f.text_area :pwa_description, class: "form-control gl-form-input", rows: 10 + .form-text.text-muted + = parsed_with_gfm + .form-group + = f.label :pwa_icon, class: 'col-form-label gl-pt-0' + %p + - if @appearance.pwa_icon? + = image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview' + - if @appearance.persisted? + %br + = link_to _('Remove icon'), pwa_icon_admin_application_settings_appearances_path, data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" + %hr + = f.hidden_field :pwa_icon_cache + = f.file_field :pwa_icon, class: "", accept: 'image/*' + .form-text.text-muted + = _('Maximum file size is 1MB.') %hr .row @@ -81,7 +112,7 @@ .col-lg-8 .form-group - = f.label :new_project_guidelines, class: 'col-form-label label-bold' + = f.label :new_project_guidelines, class: 'col-form-label' %p = f.text_area :new_project_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted @@ -94,7 +125,7 @@ .col-lg-8 .form-group - = f.label :profile_image_guidelines, class: 'col-form-label label-bold' + = f.label :profile_image_guidelines, class: 'col-form-label' %p = f.text_area :profile_image_guidelines, class: "form-control gl-form-input", rows: 10 .form-text.text-muted diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index dceee07019c..a4af1913d22 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -3,7 +3,7 @@ - add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout -%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'admin-visibility-access-settings' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Visibility and access controls') @@ -89,26 +89,6 @@ .settings-content = render 'terminal' -%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Web IDE') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Manage Web IDE features.') - .settings-content - = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f| - = form_errors(@application_setting) - - %fieldset - .form-group - - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/project/web_ide/index', anchor: 'enable-live-preview') } - = f.gitlab_ui_checkbox_component :web_ide_clientside_preview_enabled, - s_('IDE|Live Preview'), - help_text: s_('Preview JavaScript projects in the Web IDE with CodeSandbox Live Preview. %{link_start}Learn more.%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - = f.submit _('Save changes'), pajamas_button: true - = render_if_exists 'admin/application_settings/maintenance_mode_settings_form' = render 'admin/application_settings/gitpod' = render 'admin/application_settings/kroki' diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index a92bad5e601..d6a0974d10f 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -43,7 +43,9 @@ %td= application.redirect_uri %td= application.trusted? ? _('Yes'): _('No') %td= application.confidential? ? _('Yes'): _('No') - %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link' + %td + = render Pajamas::ButtonComponent.new(href: edit_admin_application_path(application), variant: :link) do + = _('Edit') %td= render 'delete_form', application: application = paginate @applications, theme: 'gitlab' diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml index f4906028e39..99cb63709f5 100644 --- a/app/views/admin/background_migrations/_migration.html.haml +++ b/app/views/admin/background_migrations/_migration.html.haml @@ -12,14 +12,17 @@ = gl_badge_tag migration.status_name.to_s.humanize, { size: :sm, variant: batched_migration_status_badge_variant(migration) } %td{ role: 'cell', data: { label: _('Action') } } - if migration.active? - = button_to pause_admin_background_migration_path(migration, database: params[:database]), - class: 'gl-button btn btn-icon has-tooltip', title: _('Pause'), 'aria-label' => _('Pause') do - = sprite_icon('pause', css_class: 'gl-button-icon gl-icon') + = render Pajamas::ButtonComponent.new(icon: 'pause', + method: :post, + href: pause_admin_background_migration_path(migration, database: params[:database]), + button_options: { class: 'has-tooltip', title: _('Pause'), 'aria-label' => _('Pause') }) - elsif migration.paused? - = button_to resume_admin_background_migration_path(migration, database: params[:database]), - class: 'gl-button btn btn-icon has-tooltip', title: _('Resume'), 'aria-label' => _('Resume') do - = sprite_icon('play', css_class: 'gl-button-icon gl-icon') + = render Pajamas::ButtonComponent.new(icon: 'play', + method: :post, + href: resume_admin_background_migration_path(migration, database: params[:database]), + button_options: { class: 'has-tooltip', title: _('Resume'), 'aria-label' => _('Resume') }) - elsif migration.failed? - = button_to retry_admin_background_migration_path(migration, database: params[:database]), - class: 'gl-button btn btn-icon has-tooltip', title: _('Retry'), 'aria-label' => _('Retry') do - = sprite_icon('retry', css_class: 'gl-button-icon gl-icon') + = 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') }) diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml index bef435c07f7..212cc437d3d 100644 --- a/app/views/admin/broadcast_messages/edit.html.haml +++ b/app/views/admin/broadcast_messages/edit.html.haml @@ -1,4 +1,5 @@ -- breadcrumb_title _("Messages") +- add_to_breadcrumbs _("Messages"), admin_broadcast_messages_path +- breadcrumb_title @broadcast_message.id - page_title _("Broadcast Messages") #js-broadcast-message{ data: { diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 8cf1d8555ce..0208b8ad836 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -18,6 +18,6 @@ impersonation: true, token: @impersonation_token, scopes: @scopes, - help_path: help_page_path('api/index', anchor: 'impersonation-tokens') + help_path: help_page_path('api/rest/index', anchor: 'impersonation-tokens') #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_impersonation_tokens.to_json, information: _("To see all the user's personal access tokens you must impersonate them first.") } } diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index 667c90f0228..7b00019cc21 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -4,21 +4,25 @@ - breadcrumb_title _("Jobs") - page_title _("Jobs") -.top-area - .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } - = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope +- if Feature.enabled?(:admin_jobs_vue) + #admin-jobs-app{ data: { job_statuses: job_statuses.to_json, empty_state_svg_path: image_path('jobs-empty-state.svg'), url: cancel_all_admin_jobs_path } } - - if @all_builds.running_or_pending.any? - #js-stop-jobs-modal - .nav-controls - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'js-stop-jobs-button', data: { url: cancel_all_admin_jobs_path } }) do - = s_('AdminArea|Stop all jobs') +- else + .top-area + .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } + = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope -.row-content-block.second-block - #{(@scope || 'all').capitalize} jobs + - if @all_builds.running_or_pending.any? + #js-stop-jobs-modal + .nav-controls + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'js-stop-jobs-button', data: { url: cancel_all_admin_jobs_path } }) do + = s_('AdminArea|Stop all jobs') -%ul.content-list.builds-content-list.admin-builds-table - = render "projects/jobs/table", builds: @builds, admin: true + .row-content-block.second-block + #{(@scope || 'all').capitalize} jobs + + %ul.content-list.builds-content-list.admin-builds-table + = render "projects/jobs/table", builds: @builds, admin: true diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index d6f2898a383..8d6df064c3c 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,7 +1,9 @@ - page_title _("Labels") %div - = link_to new_admin_label_path, class: "float-right btn gl-button btn-confirm" do + = render Pajamas::ButtonComponent.new(variant: :confirm, + href: new_admin_label_path, + button_options: { class: 'float-right' }) do = _('New label') %h1.page-title.gl-font-size-h-display = _('Labels') diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 18cd3400c60..e942a513166 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -2,7 +2,7 @@ - add_page_specific_style 'page_bundles/search' - params[:visibility_level] ||= [] -.top-area +.top-area.gl-flex-direction-column-reverse .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) @@ -12,19 +12,7 @@ = gl_tab_link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) = gl_tab_link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) - - .nav-controls.gl-pl-2 - .search-holder - = render 'shared/projects/search_form', autofocus: true, admin_view: true - - current_namespace = _('Namespace') - - if params[:namespace_id].present? - - namespace = Namespace.find(params[:namespace_id]) - - current_namespace = "#{namespace.kind}: #{namespace.full_path}" - %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { show_any: 'true', field_name: 'namespace_id', placeholder: current_namespace, update_location: 'true' }, type: 'button' } - %span.gl-dropdown-button-text - = current_namespace - - = link_to new_project_path, class: 'gl-button btn btn-confirm' do - = _('New Project') + .nav-controls + = render 'shared/projects/search_form', autofocus: true, admin_view: true = render 'projects' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index bfa17daf1c2..464027e73f4 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -140,10 +140,7 @@ .col-sm-3.col-form-label = f.label :new_namespace_id, _("Namespace") .col-sm-9 - - placeholder = _('Search for Namespace') - %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { field_name: 'new_namespace_id', placeholder: placeholder }, type: 'button' } - %span.gl-dropdown-button-text - = placeholder + .js-namespace-select{ data: { field_name: 'new_namespace_id', toggle_text_placeholder: _('Search for Namespace') } } .form-group.row .offset-sm-3.col-sm-9 diff --git a/app/views/admin/runners/new.html.haml b/app/views/admin/runners/new.html.haml new file mode 100644 index 00000000000..dd93ecfcf8c --- /dev/null +++ b/app/views/admin/runners/new.html.haml @@ -0,0 +1,5 @@ +- add_to_breadcrumbs _('Runners'), admin_runners_path +- breadcrumb_title s_('Runners|New') +- page_title s_('Runners|Create an instance runner') + +#js-admin-new-runner{ data: { legacy_registration_token: Gitlab::CurrentSettings.runners_registration_token } } diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 73027441fe6..96dd16a96da 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -32,10 +32,9 @@ = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do = s_('AdminUsers|Blocked') = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked)) - - if ban_feature_available? - = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do - = s_('AdminUsers|Banned') - = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned)) + = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do + = s_('AdminUsers|Banned') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned)) = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { qa_selector: 'pending_approval_tab' } } do = s_('AdminUsers|Pending approval') = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval)) @@ -64,7 +63,7 @@ = button_tag s_('AdminUsers|Search users') if Rails.env.test? .dropdown.gl-sm-ml-3 = label_tag s_('AdminUsers|Sort by') - = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { right: true } + = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { placement: 'right' } #js-admin-users-app{ data: admin_users_data_attributes(@users) } = render Pajamas::SpinnerComponent.new(size: :lg, class: 'gl-my-7') diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml index 5eed969ed35..d6fe20e48bf 100644 --- a/app/views/authentication/_register.html.haml +++ b/app/views/authentication/_register.html.haml @@ -9,13 +9,18 @@ - if current_user.two_factor_otp_enabled? .row.gl-mb-3 .col-md-5 - %button#js-setup-token-2fa-device.gl-button.btn.btn-confirm= _("Set up new device") + = render Pajamas::ButtonComponent.new(variant: :confirm, + button_options: { id: 'js-setup-token-2fa-device' }) do + = _("Set up new device") .col-md-7 %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.") - else .row.gl-mb-3 .col-md-4 - %button#js-setup-token-2fa-device.gl-button.btn.btn-confirm.btn-block{ disabled: true }= _("Set up new device") + = render Pajamas::ButtonComponent.new(variant: :confirm, + disabled: true, + button_options: { id: 'js-setup-token-2fa-device' }) do + = _("Set up new device") .col-md-8 %p= _("You need to register a two-factor authentication app before you can set up a device.") @@ -24,7 +29,8 @@ %div %p %span <%= error_message %> (<%= error_name %>) - %a.btn.btn-default.gl-button#js-token-2fa-try-again= _("Try again?") + = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do + = _("Try again?") -# haml-lint:disable InlineJavaScript %script#js-register-token-2fa-registered{ type: "text/template" } @@ -37,4 +43,5 @@ = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name") .col-md-3 = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" - = submit_tag _("Register device"), class: "gl-button btn btn-confirm" + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _("Register device") diff --git a/app/views/ci/runner/_setup_runner_in_aws.html.haml b/app/views/ci/runner/_setup_runner_in_aws.html.haml deleted file mode 100644 index 09fa0176da6..00000000000 --- a/app/views/ci/runner/_setup_runner_in_aws.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -%h5= _('Use GitLab Runner in AWS') - -%p - = _('Use an AWS CloudFormation Template (CFT) to install and configure GitLab Runner in AWS.') - -%ol - %li - = _('Copy this registration token.') - %br - %code#registration_token{ data: { testid: 'registration_token' } }= registration_token - = clipboard_button(target: '#registration_token', title: _('Copy token')) - %li - = _('Choose the preferred Runner and populate the AWS CFT.') - = link_to _('Learn more.'), 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg', target: '_blank', rel: 'noopener noreferrer' - -#js-runner-aws-deployments diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index af98025d257..8aaa09b7862 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -17,6 +17,7 @@ is_group: is_group.to_s, group_id: @group&.id || '', group_path: @group&.full_path, + maskable_raw_regex: ci_variable_maskable_raw_regex, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s, aws_logo_svg_path: image_path('aws_logo.svg'), @@ -24,7 +25,6 @@ aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'), aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'), contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'prevent-cicd-variable-expansion'), - protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'), masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), environment_scope_link: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable') } } diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index ddc3b7d05e1..b49f1aa061a 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -18,12 +18,18 @@ %h4 = s_('ClusterIntegration|Cluster management project') - %p - = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', - placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) - .text-muted - = html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank', rel: 'noopener noreferrer' + .js-vue-project-select{ data: { label: _('Select a cluster management project'), + input_name: 'cluster[management_project_id]', + input_id: 'cluster[management_project_id]', + order_by: 'last_activity_at', + group_id: group_id, + user_id: user_id, + include_subgroups: true.to_s, + membership: true.to_s, + selected: @cluster.management_project_id } } + %p.text-muted.gl-mt-n5 + = html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank', rel: 'noopener noreferrer' = field.submit _('Save changes'), pajamas_button: true .sub-section.form-group diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index 572f2d6d9a2..34576b6e5af 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -3,11 +3,12 @@ - managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' .js-namespace-prefixed - = platform_field.text_field :namespace, - label: s_('ClusterIntegration|Project namespace prefix (optional, unique)'), label_class: 'label-bold', - help: '%{help_text} %{help_link}'.html_safe % { help_text: managed_namespace_help_text, help_link: managed_namespace_help_link } + .form-group + = platform_field.label :namespace, s_('ClusterIntegration|Project namespace prefix (optional, unique)'), class: 'label-bold' + = platform_field.text_field :namespace, class: 'form-control' + %small.form-text.text-muted= '%{help_text} %{help_link}'.html_safe % { help_text: managed_namespace_help_text, help_link: managed_namespace_help_link } .js-namespace.hidden - = platform_field.text_field :namespace, - label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold', - help: '%{help_text}'.html_safe % { help_text: non_managed_namespace_help_text }, - disabled: true + .form-group + = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' + = platform_field.text_field :namespace, class: 'form-control', disabled: true + %small.form-text.text-muted= '%{help_text}'.html_safe % { help_text: non_managed_namespace_help_text } diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml index 11277a83e3a..59706b6d8c4 100644 --- a/app/views/clusters/clusters/_provider_details_form.html.haml +++ b/app/views/clusters/clusters/_provider_details_form.html.haml @@ -1,52 +1,62 @@ -= bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'js-provider-details gl-show-field-errors' }, += gitlab_ui_form_for cluster, url: update_cluster_url_path, html: { class: 'js-provider-details gl-show-field-errors', role: 'form' }, as: :cluster do |field| - - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? - = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true, - title: s_('ClusterIntegration|Cluster name is required.'), - readonly: cluster.read_only_kubernetes_platform_fields?, - label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold', - input_group_class: 'gl-field-error-anchor', append: copy_name_btn + .form-group + - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold required' + .input-group.gl-field-error-anchor + = field.text_field :name, class: 'form-control js-select-on-focus cluster-name', required: true, + title: s_('ClusterIntegration|Cluster name is required.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + append: copy_name_btn = field.fields_for :platform_kubernetes, platform do |platform_field| - - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? - = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true, - title: s_('ClusterIntegration|API URL should be a valid http/https url.'), - readonly: cluster.read_only_kubernetes_platform_fields?, - label: s_('ClusterIntegration|API URL'), label_class: 'label-bold', - input_group_class: 'gl-field-error-anchor', append: copy_api_url - - - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? - = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '10', - readonly: cluster.read_only_kubernetes_platform_fields?, - placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), - label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold', - input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn - - = platform_field.password_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token', - readonly: cluster.read_only_kubernetes_platform_fields?, autocomplete: 'new-password', - label: s_('ClusterIntegration|Enter new Service Token'), label_class: 'label-bold', - input_group_class: 'gl-field-error-anchor' - - = platform_field.form_group :authorization_type do - = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'), - label_class: 'label-bold', inline: true }, 'rbac', 'abac' + .form-group + - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = platform_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold required' + .input-group.gl-field-error-anchor + = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + append: copy_api_url + + .form-group + - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), + class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? + = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' + .input-group.gl-field-error-anchor + = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', rows: '10', + readonly: cluster.read_only_kubernetes_platform_fields?, + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + append: copy_ca_cert_btn + + .form-group + = platform_field.label :token, s_('ClusterIntegration|Enter new Service Token'), class: 'label-bold required' + .input-group.gl-field-error-anchor + = platform_field.password_field :token, type: 'password', class: 'form-control js-select-on-focus js-cluster-token', + readonly: cluster.read_only_kubernetes_platform_fields?, autocomplete: 'new-password' + + .form-group + .form-check + = platform_field.check_box :authorization_type, { disabled: true, inline: true, class: 'form-check-input' }, 'rbac', 'abac' + = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') .form-group - = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), - class: 'js-gl-managed', - label_class: 'label-bold' } + .form-check + = field.check_box :managed, { class: 'js-gl-managed form-check-input' } + = field.label :managed, s_('ClusterIntegration|GitLab-managed cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' .form-group - = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } + .form-check + = field.check_box :namespace_per_environment, { class: 'form-check-input' } + = field.label :namespace_per_environment, s_('ClusterIntegration|Namespace per environment'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 557c95f8478..ed169b2bfd1 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -7,48 +7,63 @@ - rbac_help_text = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + ' ' - rbac_help_text << s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') -= bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' }, += gitlab_ui_form_for @user_cluster, html: { class: 'gl-show-field-errors', role: 'form' }, url: clusterable.create_user_clusters_path, as: :cluster do |field| - = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), - label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - = field.text_field :environment_scope, required: true, title: s_('ClusterIntegration|Environment scope is required.'), - label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold', - help: s_('ClusterIntegration|Choose which of your environments will use this cluster.') + = form_errors(@user_cluster) + + .form-group + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold required' + = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), class: 'form-control' + + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold required' + = field.text_field :environment_scope, required: true, title: s_('ClusterIntegration|Environment scope is required.'), class: 'form-control' + %small.form-text.text-muted + = s_('ClusterIntegration|Choose which of your environments will use this cluster.') = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - = platform_kubernetes_field.url_field :api_url, required: true, - title: s_('ClusterIntegration|API URL should be a valid http/https url.'), - label: s_('ClusterIntegration|API URL'), label_class: 'label-bold', - help: '%{help_text} %{help_link}'.html_safe % { help_text: api_url_help_text, help_link: more_info_link } - - = platform_kubernetes_field.text_area :ca_cert, - rows: '10', - placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), - label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold', - help: '%{help_text} %{help_link}'.html_safe % { help_text: ca_cert_help_text, help_link: more_info_link } - - = platform_kubernetes_field.text_field :token, required: true, - title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'), - autocomplete: 'off', label_class: 'label-bold', - help: '%{help_text} %{help_link}'.html_safe % { help_text: token_help_text, help_link: more_info_link } - - = platform_kubernetes_field.form_group :authorization_type, - { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do - = platform_kubernetes_field.check_box :authorization_type, - { data: { qa_selector: 'rbac_checkbox'}, label: s_('ClusterIntegration|RBAC-enabled cluster'), - label_class: 'label-bold', inline: true }, 'rbac', 'abac' + .form-group + = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold required' + = platform_kubernetes_field.url_field :api_url, required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), class: 'form-control' + %small.form-text.text-muted + = '%{help_text} %{help_link}'.html_safe % { help_text: api_url_help_text, help_link: more_info_link } + + .form-group + = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' + = platform_kubernetes_field.text_area :ca_cert, + rows: '10', + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + class: 'form-control' + %small.form-text.text-muted + = '%{help_text} %{help_link}'.html_safe % { help_text: ca_cert_help_text, help_link: more_info_link } + + .form-group + = platform_kubernetes_field.label :token, s_('ClusterIntegration|Service Token'), class: 'label-bold required' + = platform_kubernetes_field.text_field :token, required: true, title: s_('ClusterIntegration|Service token is required.'), autocomplete: 'off', class: 'form-control' + %small.form-text.text-muted + = '%{help_text} %{help_link}'.html_safe % { help_text: token_help_text, help_link: more_info_link } + + .form-group + .form-check + = platform_kubernetes_field.check_box :authorization_type, { data: { qa_selector: 'rbac_checkbox'}, inline: true, class: 'form-check-input' }, 'rbac', 'abac' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' + %small.form-text.text-muted + = '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } .form-group - = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), - class: 'js-gl-managed', - label_class: 'label-bold' } + .form-check + = field.check_box :managed, { class: 'js-gl-managed form-check-input' } + = field.label :managed, s_('ClusterIntegration|GitLab-managed cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' .form-group - = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' } + .form-check + = field.check_box :namespace_per_environment, { class: 'form-check-input' } + = field.label :namespace_per_environment, s_('ClusterIntegration|Namespace per environment'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml index ca9f69ab73a..f003b4f3339 100644 --- a/app/views/dashboard/_activity_head.html.haml +++ b/app/views/dashboard/_activity_head.html.haml @@ -3,6 +3,7 @@ .top-area = gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do - = gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? } + = gl_tab_link_to _("Your activity"), activity_dashboard_path, { item_active: params[:filter].nil? } + = gl_tab_link_to _("Your projects"), activity_dashboard_path(filter: 'projects') = gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred') = gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed') diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 10e653fd427..c58d4cff034 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -1,7 +1,7 @@ = content_for :flash_message do = render 'shared/project_limit' -.page-title-holder.d-flex.align-items-center +.page-title-holder.gl-display-flex.gl-align-items-center %h1.page-title.gl-font-size-h-display= _('Projects') - if current_user.can_create_project? @@ -10,7 +10,7 @@ = _("New project") .top-area - .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0 + .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-flex-basis-0.gl-min-w-0 .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) = render 'dashboard/projects_nav' diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index 5293f685d06..0933f6d6a94 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -13,7 +13,7 @@ - if current_user .page-title-controls - = render 'shared/new_project_item_select', path: 'issues/new', label: _("issue"), with_feature_enabled: 'issues', type: :issues + = render 'shared/new_project_item_vue_select' - if ::Feature.enabled?(:vue_issues_dashboard) .js-issues-dashboard{ data: dashboard_issues_list_data(current_user) } diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index c921375edd1..712f987a783 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -10,14 +10,16 @@ - if current_user .page-title-controls.ml-0.mb-3.ml-sm-auto.mb-sm-0 - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _("merge request"), with_feature_enabled: 'merge_requests', type: :merge_requests + = render 'shared/new_project_item_vue_select' .top-area - = render 'shared/issuable/nav', type: :merge_requests, display_count: !@no_filters_set + = 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 - if current_user && @no_filters_set = render 'shared/dashboard/no_filter_selected' +- elsif @search_timeout_occurred + = render 'shared/dashboard/search_timeout_occurred' - else = render 'shared/merge_requests' diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index bc8e3e6ab69..2556791da12 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -8,9 +8,7 @@ - if current_user .page-title-controls - = render 'shared/new_project_item_select', - path: '-/milestones/new', label: _('Milestone'), - include_groups: true, type: :milestones + = render 'shared/new_project_item_vue_select' - if @milestone_states.any? { |name, count| count > 0 } .top-area @@ -22,9 +20,7 @@ = render 'shared/empty_states/milestones_tab', active_tab: params[:state] do - if current_user .page-title-controls - = render 'shared/new_project_item_select', - path: '-/milestones/new', label: _('Milestone'), - include_groups: true, type: :milestones + = render 'shared/new_project_item_vue_select' - else .milestones %ul.content-list @@ -35,6 +31,4 @@ = render 'shared/empty_states/milestones' do - if current_user .page-title-controls - = render 'shared/new_project_item_select', - path: '-/milestones/new', label: _('Milestone'), - include_groups: true, type: :milestones + = render 'shared/new_project_item_vue_select' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index f97c0e2b9b6..ebd7f20c54a 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -25,7 +25,7 @@ = author_avatar(todo, size: 24) .todo-note - if todo_author_display?(todo) - .author-name.bold.gl-display-inline< + .author-name.bold.gl-display-inline{ data: { qa_selector: "todo_author_name_content" } }< - if todo.author = link_to_author(todo, self_added: todo.self_added?) - else diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index c0bd3ee3f0d..9e59f9d700f 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -23,10 +23,10 @@ .nav-controls - if @allowed_todos.any?(&:pending?) .gl-mr-3 - = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'gl-button btn btn-default btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do + = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-loading gl-align-items-center js-todos-mark-all'}, method: :delete, href: destroy_all_dashboard_todos_path(todos_filter_params)) do = gl_loading_icon(inline: true) = s_("Todos|Mark all as done") - = link_to bulk_restore_dashboard_todos_path, class: 'gl-button btn btn-default btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do + = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-loading gl-align-items-center js-todos-undo-all hidden'}, method: :patch, href: bulk_restore_dashboard_todos_path(todos_filter_params)) do = gl_loading_icon(inline: true) = s_("Todos|Undo mark all as done") @@ -37,7 +37,7 @@ .filter-item.gl-m-2 - if params[:group_id].present? = hidden_field_tag(:group_id, params[:group_id]) - = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static' } }) + = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static', qa_selector: 'group_dropdown' } }) .filter-item.gl-m-2 - if params[:project_id].present? = hidden_field_tag(:project_id, params[:project_id]) diff --git a/app/views/devise/shared/_language_switcher.html.haml b/app/views/devise/shared/_language_switcher.html.haml index 4c47e3efd0f..9dc9a940149 100644 --- a/app/views/devise/shared/_language_switcher.html.haml +++ b/app/views/devise/shared/_language_switcher.html.haml @@ -1,3 +1 @@ -- return unless ::Feature.enabled?(:preferred_language_switcher) - .js-language-switcher{ data: { locales: ordered_selectable_locales.to_json } } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index b9fe61229bc..f4f3965bdc1 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -66,7 +66,7 @@ = render_if_exists 'devise/shared/phone_verification', form: f %div - - if Feature.enabled?(:arkose_labs_signup_challenge) + - if arkose_labs_challenge_enabled? = render_if_exists 'devise/registrations/arkose_labs' - elsif show_recaptcha_sign_up? = recaptcha_tags nonce: content_security_policy_nonce diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 67a88f3d623..e49b3eb7781 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -4,4 +4,4 @@ - if current_user - unless has_label %span.gl-float-left= _("Visibility:") - = gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { right: true }) + = gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { placement: 'right' }) diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index bf0e8b627fd..f0fd9026b30 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -1,6 +1,6 @@ - return unless can_admin_group_member?(group) .js-invite-members-modal{ data: { is_project: 'false', - access_levels: GroupMember.access_level_roles.to_json, + access_levels: group.access_level_roles.to_json, reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s, help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 8763912438b..0c416d57b75 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -28,8 +28,10 @@ .settings-content = render 'groups/settings/permissions' += render_if_exists 'groups/merge_requests', expanded: expanded, group: @group = render_if_exists 'groups/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user = render_if_exists 'groups/insights', expanded: expanded += render_if_exists 'groups/analytics_dashboards', expanded: expanded %section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 92f6c896e7b..8d858ad72d2 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -4,16 +4,19 @@ - add_page_specific_style 'page_bundles/issuable_list' .top-area - = render 'shared/issuable/nav', type: :merge_requests + = render 'shared/issuable/nav', type: :merge_requests, display_count: !@search_timeout_occurred - if current_user .nav-controls - if @can_bulk_update = render_if_exists 'projects/merge_requests/bulk_update_button' - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _("merge request"), type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true + = render 'shared/new_project_item_vue_select' = render 'shared/issuable/search_bar', type: :merge_requests - if @can_bulk_update = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :merge_requests -= render 'shared/merge_requests' +- if @search_timeout_occurred + = render 'shared/dashboard/search_timeout_occurred' +- else + = render 'shared/merge_requests' diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index cae347630ee..aaa42aaea3a 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -38,8 +38,12 @@ = render 'project_badges', project: project .controls.gl-flex-shrink-0.gl-ml-5 - = link_to _('Members'), project_project_members_path(project), id: dom_id(project, :edit), class: "btn gl-button", data: { qa_selector: 'project_members_button' } - = link_to _('Edit'), edit_project_path(project), id: dom_id(project, :edit), class: "btn gl-button", data: { qa_selector: 'project_edit_button' } + = render Pajamas::ButtonComponent.new(href: project_project_members_path(project), + button_options: { data: { qa_selector: 'project_members_button' } }) do + = _('Members') + = render Pajamas::ButtonComponent.new(href: edit_project_path(project), + button_options: { data: { qa_selector: 'project_edit_button' } }) do + = _('Edit') = render 'delete_project_button', project: project, data: { qa_selector: 'project_delete_button' } - if @projects.blank? .nothing-here-block= _("This group has no projects yet") diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml index 9ea83397348..7e98f6035a6 100644 --- a/app/views/groups/runners/index.html.haml +++ b/app/views/groups/runners/index.html.haml @@ -1,3 +1,3 @@ - page_title s_('Runners|Runners') -#js-group-runners{ data: group_runners_data_attributes(@group).merge({ group_runners_limited_count: @group_runners_limited_count, registration_token: @group_runner_registration_token }) } +#js-group-runners{ data: group_runners_data_attributes(@group).merge({ registration_token: @group_runner_registration_token }) } diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 2fc314cc37f..43673d54478 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,8 +1,9 @@ +- add_page_specific_style 'page_bundles/ci_status' - add_page_specific_style 'page_bundles/runner_details' -- add_to_breadcrumbs _('Runners'), group_runners_path(@group) - title = "##{@runner.id} (#{@runner.short_sha})" - breadcrumb_title title - page_title title +- add_to_breadcrumbs _('Runners'), group_runners_path(@group) #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 3c76e8a864a..a4a83330fa9 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -1,12 +1,13 @@ - form_id = "transfer-group-form" -- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s } +- initial_data = { button_text: s_('GroupSettings|Transfer group'), group_full_path: @group.full_path, group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s } .sub-section{ data: { qa_selector: 'transfer_group_content' } } %h4.warning-title= s_('GroupSettings|Transfer group') %p= _('Transfer group to another parent group.') = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f| %ul - - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/repository/index.html#what-happens-when-a-repository-path-changes" target="_blank" rel="noopener noreferrer">'.html_safe + - learn_more_link = help_page_url('user/project/repository/index', anchor: 'what-happens-when-a-repository-path-changes') + - learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link } - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe } %li= warning_text.html_safe %li= s_('GroupSettings|You can only transfer the group to a group you manage.') diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index 2861e696e31..faed486b20f 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _('Package and registry settings') -- page_title _('Package and registry settings') +- breadcrumb_title _('Packages and registries settings') +- page_title _('Packages and registries settings') - @content_class = 'limit-container-width' unless fluid_layout %section#js-packages-and-registries-settings{ data: { group_path: @group.full_path, diff --git a/app/views/groups/usage_quotas/index.html.haml b/app/views/groups/usage_quotas/index.html.haml index a8c1071b876..253894e5fb8 100644 --- a/app/views/groups/usage_quotas/index.html.haml +++ b/app/views/groups/usage_quotas/index.html.haml @@ -1,7 +1,3 @@ - page_title s_("UsageQuota|Usage") -.gl-alert.gl-alert-no-icon.gl-alert-info.gl-mt-6 - %h2.gl-alert-title - Development - .gl-alert-content - Placeholder for usage quotas Vue app +#js-usage-quotas-view{ data: { namespace_name: @group.name } } diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index c968d84fc36..5a6e93c3573 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -7,4 +7,4 @@ - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco') -= render partial: 'shared/ide_root', locals: { data: ide_data, loading_text: _('Loading the GitLab IDE...') } += render partial: 'shared/ide_root', locals: { data: ide_data(project: @project, branch: @branch, path: @path, merge_request: @merge_request, fork_info: @fork_info), loading_text: _('Loading the GitLab IDE...') } diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 7d0a46f3630..5293013b813 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -10,8 +10,9 @@ = import_github_authorize_message - if github_import_configured? && !has_ci_cd_only_params? - = link_to status_import_github_path(namespace_id: params[:namespace_id]), class: 'gl-button btn btn-confirm' do - = sprite_icon('github', css_class: 'gl-mr-2') + = render Pajamas::ButtonComponent.new(variant: :confirm, + href: status_import_github_path(namespace_id: params[:namespace_id]), + icon: 'github') do = title %hr @@ -31,5 +32,9 @@ = render_if_exists 'import/github/ci_cd_only' .form-actions.gl-display-flex.gl-justify-content-end - = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default' - = submit_tag _('Authenticate'), class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'authenticate_button' } + = render Pajamas::ButtonComponent.new(href: new_project_path) do + = _('Cancel') + = render Pajamas::ButtonComponent.new(variant: :confirm, + type: :submit, + button_options: { class: 'gl-ml-3', data: { qa_selector: 'authenticate_button' } }) do + = _('Authenticate') diff --git a/app/views/layouts/_bizible.html.haml b/app/views/layouts/_bizible.html.haml index 8d173a7ab61..28ae34cd5e1 100644 --- a/app/views/layouts/_bizible.html.haml +++ b/app/views/layouts/_bizible.html.haml @@ -1,4 +1,4 @@ -- if bizible_enabled? +- if bizible_enabled?(@invite_email) <!-- Bizible --> = javascript_tag nonce: content_security_policy_nonce do :plain diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index ea2f452b9e2..dd441d0d155 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -37,7 +37,7 @@ = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] = stylesheet_link_tag "test_environment", media: "all" if Rails.env.test? - = stylesheet_link_tag_defer "fonts" if use_new_fonts? + = stylesheet_link_tag_defer "fonts" = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index 60ab6927fd2..9026bec84c3 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -13,7 +13,6 @@ = preload_link_tag(path_to_stylesheet("highlight/themes/#{user_color_scheme}"), crossorigin: css_crossorigin) - if Gitlab::Tracking.enabled? && Gitlab::Tracking.collector_hostname %link{ rel: 'preconnect', href: "https://#{Gitlab::Tracking.collector_hostname}", crossorigin: '' } - - if use_new_fonts? -# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug. -# See https://github.com/web-platform-tests/wpt/pull/36930 %link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 010ddd8da39..d2ed70d6b48 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -2,8 +2,12 @@ - @left_sidebar = true .layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class } - if show_super_sidebar? - - sidebar_data = super_sidebar_context(current_user).to_json + - sidebar_data = super_sidebar_context(current_user, group: @group, project: @project).to_json %aside.js-super-sidebar.nav-sidebar{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url } } + + - if display_whats_new? + #whats-new-app{ data: { version_digest: whats_new_version_digest } } + - elsif defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 0b5c4730b64..5db7f22e36b 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -15,6 +15,7 @@ gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new( namespace: namespace, project: @project, - user: current_user + user: current_user, + new_nav: show_super_sidebar? ).to_context.to_json.to_json} gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json}; diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index fa79219df4a..53e88d95893 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -8,7 +8,7 @@ %body{ class: body_classes, data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" - = render "layouts/visual_review" if ENV['REVIEW_APPS_ENABLED'] + = render "layouts/visual_review" if review_apps_enabled? = render 'peek/bar' = header_message diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml index 028c22fe9e5..89f238eb6b3 100644 --- a/app/views/layouts/dashboard.html.haml +++ b/app/views/layouts/dashboard.html.haml @@ -1,9 +1,7 @@ - page_title _("Dashboard") - header_title _("Dashboard"), root_path unless header_title -- if Feature.enabled?(:your_work_sidebar, current_user) - - @left_sidebar = true - - nav "your_work" -- else - - @hide_breadcrumbs = true + +- @left_sidebar = true +- nav "your_work" = render template: "layouts/application" diff --git a/app/views/layouts/explore.html.haml b/app/views/layouts/explore.html.haml index 389dee853ba..c495bab4547 100644 --- a/app/views/layouts/explore.html.haml +++ b/app/views/layouts/explore.html.haml @@ -1,6 +1,6 @@ - page_title _("Explore") -- if current_user && Feature.enabled?(:your_work_sidebar, current_user) +- if current_user - @left_sidebar = true - nav "your_work" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 8363d424c1b..af27026845e 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -3,7 +3,7 @@ %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', qa_selector: 'user_profile_link' } do + = 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", qa_selector: 'user_profile_link' } do = render 'layouts/header/current_user_dropdown_item' - else .gl-py-3.gl-px-4 @@ -23,28 +23,28 @@ = emoji_icon('rocket') - if current_user_menu?(:settings) %li - = link_to s_("CurrentUser|Edit profile"), profile_path, data: { qa_selector: 'edit_profile_link' } + = link_to s_("CurrentUser|Edit profile"), profile_path, data: { qa_selector: '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 + = 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 + = 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 + = 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") + = 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 + = 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" } - if Feature.enabled?(:super_sidebar_nav, current_user) %li.divider @@ -53,4 +53,4 @@ - if current_user_menu?(:sign_out) %li.divider %li - = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link' } + = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" } diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 558af352ae9..6d000c3e9ad 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -8,7 +8,7 @@ .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 .title %span.gl-sr-only GitLab - = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do + = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **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? @@ -36,7 +36,11 @@ - else = render 'layouts/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'} do + = 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 @@ -49,7 +53,7 @@ data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_issues_link', - track_property: 'navigation', + track_property: 'navigation_top', container: 'body' } do = sprite_icon('issues') - issues_count = assigned_issuables_count(:issues) @@ -62,9 +66,9 @@ data: { qa_selector: 'merge_requests_shortcut_button', toggle: "dropdown", placement: 'bottom', - track_label: 'main_navigation', - track_action: 'click_merge_link', - track_property: 'navigation', + 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 @@ -75,13 +79,17 @@ %li.dropdown-header = _('Merge requests') %li - = link_to assigned_mrs_dashboard_path, class: 'gl-display-flex! gl-align-items-center js-prefetch-document' do - = _('Assigned to you') + = 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' do - = _('Review requests for you') + = 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) @@ -90,15 +98,15 @@ data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_to_do_link', - track_property: 'navigation', + 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{ data: { track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation', track_experiment: 'cross_stage_fdm' } } - = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown" } do + %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', track_experiment: 'cross_stage_fdm' } do %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question-o') @@ -110,8 +118,8 @@ %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: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) } - = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do + %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { qa_selector: 'user_menu', testid: 'user-menu' }, 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: { qa_selector: 'user_avatar_content' } }) = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group = sprite_icon('chevron-down', css_class: 'caret-down') @@ -121,15 +129,12 @@ %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', qa_selector: 'stop_impersonation_link' } }) - if header_link?(:sign_in) - - if Gitlab.com? - %li.nav-item.gl-display-none.gl-sm-display-block + - if allow_signup? + %li.nav-item = render Pajamas::ButtonComponent.new(href: new_user_registration_path) do - = _('Sign up now') - %li.nav-item.gl-display-none.gl-sm-display-block - = link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes') - = render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none' - - else - = render 'layouts/header/sign_in_register_button' + = _('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: 'top-nav-responsive-toggle', qa_selector: 'mobile_navbar_button' } } %span.sr-only= _('Toggle navigation') diff --git a/app/views/layouts/header/_gitlab_version.html.haml b/app/views/layouts/header/_gitlab_version.html.haml index 581d4d498e1..22771ac09c9 100644 --- a/app/views/layouts/header/_gitlab_version.html.haml +++ b/app/views/layouts/header/_gitlab_version.html.haml @@ -7,7 +7,7 @@ testid: 'gitlab-version-container', track_action: 'click_link', track_label: 'version_help_dropdown', - track_property: "#{Gitlab.version_info.major}.#{Gitlab.version_info.minor}" + track_property: 'navigation_top' } } %span diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index bdd1ae291fd..f50df72afbc 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -5,22 +5,22 @@ = render_if_exists 'layouts/header/help_dropdown/cross_stage_fdm' = render 'layouts/header/whats_new_dropdown_item' %li - = link_to _("Help"), help_path + = link_to _("Help"), help_path, data: {track_action: 'click_link', track_label: 'help', track_property: 'navigation_top'} %li - = link_to _("Support"), support_url + = 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" } + %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" + = 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 + = 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/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml index f39fb53032d..372babea18e 100644 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ b/app/views/layouts/header/_new_dropdown.html.haml @@ -6,8 +6,14 @@ - 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" } } - = 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', qa_selector: 'new_menu_toggle' } do +%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', + qa_selector: 'new_menu_toggle', testid: 'new-dropdown' } do = sprite_icon('plus-square') = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right.dropdown-extended-height diff --git a/app/views/layouts/header/_sign_in_register_button.html.haml b/app/views/layouts/header/_sign_in_register_button.html.haml deleted file mode 100644 index cadb7cfe683..00000000000 --- a/app/views/layouts/header/_sign_in_register_button.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -- top_class = local_assigns.fetch(:class, nil) - -%li.nav-item{ class: top_class } - %div - - sign_in_text = allow_signup? ? _('Sign in / Register') : _('Sign in') - = render Pajamas::ButtonComponent.new(href: new_session_path(:user, redirect_to_referer: 'yes'), button_options: { class: 'btn-sign-in'}) do - = sign_in_text diff --git a/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml b/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml new file mode 100644 index 00000000000..cabdd4b09ec --- /dev/null +++ b/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml @@ -0,0 +1,3 @@ +- return unless Feature.enabled?(:deactivation_email_additional_text) && Gitlab::CurrentSettings.deactivation_email_additional_text.present? +%p + = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.deactivation_email_additional_text) diff --git a/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb b/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb new file mode 100644 index 00000000000..5862c8059f9 --- /dev/null +++ b/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb @@ -0,0 +1,3 @@ +<% return unless Feature.enabled?(:deactivation_email_additional_text) && Gitlab::CurrentSettings.deactivation_email_additional_text.present? %> + +<%= Gitlab::CurrentSettings.deactivation_email_additional_text %> diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index e1978009114..087eca3ba35 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -130,6 +130,18 @@ = link_to profile_preferences_path do %strong.fly-out-top-item-name = _('Preferences') + - if saved_replies_enabled? + = nav_link(controller: :saved_replies) do + = link_to profile_saved_replies_path do + .nav-icon-container + = sprite_icon('symlink') + %span.nav-item-name + = _('Saved Replies') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :saved_replies, html_options: { class: "fly-out-top-item" }) do + = link_to profile_saved_replies_path do + %strong.fly-out-top-item-name + = _('Saved Replies') = nav_link(controller: :active_sessions) do = link_to profile_active_sessions_path do .nav-icon-container diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml index bab7bc6b9da..7ac108e7f31 100644 --- a/app/views/layouts/service_desk.html.haml +++ b/app/views/layouts/service_desk.html.haml @@ -15,17 +15,20 @@ = stylesheet_link_tag 'notify' = yield :head %body + = html_header_message .content = yield .footer{ style: "margin-top: 10px;" } - %p - — - %br - = link_to "Unsubscribe", @unsubscribe_url - - -# EE-specific start - - if Gitlab::CurrentSettings.email_additional_text.present? - %br + %p + — %br - = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.email_additional_text) - -# EE-specific end + = link_to "Unsubscribe", @unsubscribe_url + + -# EE-specific start + - if Gitlab::CurrentSettings.email_additional_text.present? + %br + %br + = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.email_additional_text) + -# EE-specific end + + = html_footer_message diff --git a/app/views/layouts/service_desk.text.erb b/app/views/layouts/service_desk.text.erb new file mode 100644 index 00000000000..2b5b0b51483 --- /dev/null +++ b/app/views/layouts/service_desk.text.erb @@ -0,0 +1,7 @@ +<%= text_header_message %> + +<%= yield -%> + +<%= @unsubscribe_url %> +<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text' %><%# EE-specific end %> +<%= text_footer_message -%> diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index fd331d4b6c8..95a204a3319 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -2,7 +2,7 @@ - header_title _("Snippets"), snippets_path - snippets_upload_path = snippets_upload_path(@snippet, current_user) -- if current_user && Feature.enabled?(:your_work_sidebar, current_user) +- if current_user - @left_sidebar = true - nav "your_work" diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml index d6812821966..f634a749008 100644 --- a/app/views/notify/autodevops_disabled_email.html.haml +++ b/app/views/notify/autodevops_disabled_email.html.haml @@ -11,13 +11,13 @@ - link_style = "color: #1b69b6; text-decoration:none;" - pipeline_link = link_to("\##{@pipeline.iid}", pipeline_url(@pipeline), style: link_style).html_safe - project_link = link_to(@project.name, project_url(@project), style: link_style).html_safe - - supported_langs_link = link_to(s_('Notify|currently supported languages'), 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: link_style).html_safe + - supported_langs_link = link_to(s_('Notify|currently supported languages'), help_page_url('topics/autodevops/stages', anchor: 'currently-supported-languages'), style: link_style).html_safe - settings_link = link_to(s_('Notify|CI/CD project settings'), project_settings_ci_cd_url(@project), style: link_style).html_safe = s_('Notify|The Auto DevOps pipeline failed for pipeline %{pipeline_link} and has been disabled for %{project_link}. In order to use the Auto DevOps pipeline with your project, please review the %{supported_langs_link}, adjust your project accordingly, and turn on the Auto DevOps pipeline within your %{settings_link}.').html_safe % { pipeline_link: pipeline_link, project_link: project_link, supported_langs_link: supported_langs_link, settings_link: settings_link } %tr.pre-section %td{ style: 'text-align: center;border-bottom:1px solid #ededed' } - %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/', style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } + %a{ href: help_page_url('topics/autodevops/index'), style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } %button{ type: 'button', style: 'border-color: #dfdfdf; border-style: solid; border-width: 1px; border-radius: 4px; font-size: 14px; padding: 8px 16px; background-color:#fff; margin: 8px 0; cursor: pointer;' } = s_('Notify|Learn more about Auto DevOps') diff --git a/app/views/notify/service_desk_new_note_email.text.erb b/app/views/notify/service_desk_new_note_email.text.erb index 79144fc1bf4..bcac004f82c 100644 --- a/app/views/notify/service_desk_new_note_email.text.erb +++ b/app/views/notify/service_desk_new_note_email.text.erb @@ -3,4 +3,3 @@ <%= _("Author: %{author_name}") % { author_name: sanitize_name(@note.author_name) } %> <%= @note.note %> -<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text'%><%# EE-specific end %> diff --git a/app/views/notify/service_desk_thank_you_email.text.erb b/app/views/notify/service_desk_thank_you_email.text.erb index 8b52219c83b..9d9d5c7a1bf 100644 --- a/app/views/notify/service_desk_thank_you_email.text.erb +++ b/app/views/notify/service_desk_thank_you_email.text.erb @@ -1,6 +1,3 @@ <%= _("Thank you for your support request! We are tracking your request as ticket #%{issue_iid}, and will respond as soon as we can.") % { issue_iid: @issue.iid } %> <%= _("To unsubscribe from this issue, please paste the following link into your browser:") %> - -<%= @unsubscribe_url %> -<%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text' %><%# EE-specific end %> diff --git a/app/views/notify/two_factor_otp_attempt_failed_email.html.haml b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml index fec7083e524..83f028af500 100644 --- a/app/views/notify/two_factor_otp_attempt_failed_email.html.haml +++ b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml @@ -42,7 +42,7 @@ %tbody %tr{ style: 'width:100%;' } %td{ style: "#{default_style}text-align:center;" } - - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } + - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') } = _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.') - if password_authentication_enabled_for_web? diff --git a/app/views/notify/two_factor_otp_attempt_failed_email.text.haml b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml index 8f839cd83ee..8718ab034ff 100644 --- a/app/views/notify/two_factor_otp_attempt_failed_email.text.haml +++ b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml @@ -3,5 +3,5 @@ = _('We detected an attempt to sign in to your %{host} account using a wrong two-factor authentication code, from the following IP address: %{ip}, at %{time}') % { host: Gitlab.config.gitlab.host, ip: @ip, time: @time } = _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.') -= _('If you did not recently try to sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } += _('If you did not recently try to sign in, you should immediately change your password: %{password_link}.') % { password_link: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') } = _('Make sure you choose a strong, unique password.') diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml index 64bf4e7b4e8..f8a0ae1352c 100644 --- a/app/views/notify/unknown_sign_in_email.html.haml +++ b/app/views/notify/unknown_sign_in_email.html.haml @@ -42,7 +42,7 @@ %tbody %tr{ style: 'width:100%;' } %td{ style: "#{default_style}text-align:center;" } - - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } + - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') } = _('If you recently signed in and recognize the IP address, you may disregard this email.') - if password_authentication_enabled_for_web? @@ -52,5 +52,6 @@ - unless @user.two_factor_enabled? %p - - mfa_link_start = '<a href="https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html" target="_blank">'.html_safe + - mfa_url = help_page_url('user/profile/account/two_factor_authentication') + - mfa_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: mfa_url } = _('To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method.').html_safe % { mfa_link_start: mfa_link_start, mfa_link_end: '</a>'.html_safe } diff --git a/app/views/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml index 54c7a245ab9..f3e318f0d15 100644 --- a/app/views/notify/unknown_sign_in_email.text.haml +++ b/app/views/notify/unknown_sign_in_email.text.haml @@ -3,8 +3,8 @@ = _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip } = _('If you recently signed in and recognize the IP address, you may disregard this email.') -= _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/user_passwords.html#change-your-password' } += _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') } = _('Passwords should be unique and not used for any other sites or services.') - unless @user.two_factor_enabled? - = _('To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}.') % { mfa_link: 'https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html' } + = _('To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}.') % { mfa_link: help_page_url('user/profile/account/two_factor_authentication') } diff --git a/app/views/notify/user_deactivated_email.html.haml b/app/views/notify/user_deactivated_email.html.haml index a9262cab219..d8ba2639fe6 100644 --- a/app/views/notify/user_deactivated_email.html.haml +++ b/app/views/notify/user_deactivated_email.html.haml @@ -15,3 +15,5 @@ %p = _('Please contact your GitLab administrator if you think this is an error.') + += render 'layouts/mailer/user_deactivated_additional_text' diff --git a/app/views/notify/user_deactivated_email.text.erb b/app/views/notify/user_deactivated_email.text.erb index 9e7d00f4ad1..6faeb5ba0f4 100644 --- a/app/views/notify/user_deactivated_email.text.erb +++ b/app/views/notify/user_deactivated_email.text.erb @@ -8,3 +8,4 @@ <%= _('To reactivate your account, sign in to GitLab at %{gitlab_url}.') % { gitlab_url: root_url } %> <%= _('Please contact your GitLab administrator if you think this is an error.') %> +<%= render 'layouts/mailer/user_deactivated_additional_text' %> diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 219e7c4d2fe..825fb98782a 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -21,14 +21,23 @@ .key-list-item-dates %span.last-used-at.gl-mr-3 = s_('Profiles|Last used:') - = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') + -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved. + - if Feature.enabled?(:disable_ssh_key_used_tracking) + = _('Unavailable') + = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys') + - else + = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') %span.expires.gl-mr-3 = key.expired? ? s_('Profiles|Expired:') : s_('Profiles|Expires:') = key.expires_at ? key.expires_at.to_date : _('Never') %span.last-used-at.gl-mr-3 = s_('Profiles|Usage type:') = ssh_key_usage_types.invert[key.usage_type] - %span.key-created-at.gl-display-flex.gl-align-items-center - - if key.can_delete? - .gl-ml-3 - = render 'shared/ssh_keys/key_delete', icon: true, button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) + .gl-display-flex.gl-float-right + - if key.can_delete? + - if key.signing? && !is_admin && Feature.enabled?(:revoke_ssh_signatures) + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do + = _('Revoke') + .gl-pl-3 + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) }) do + = _('Remove') diff --git a/app/views/profiles/saved_replies/index.html.haml b/app/views/profiles/saved_replies/index.html.haml new file mode 100644 index 00000000000..2ae7a092249 --- /dev/null +++ b/app/views/profiles/saved_replies/index.html.haml @@ -0,0 +1,10 @@ +- page_title _('Saved Replies') + +#js-saved-replies-root.row.gl-mt-5{ data: { base_path: profile_saved_replies_path } } + .col-lg-4 + %h4.gl-mt-0 + = page_title + %p + = _('Saved replies can be used when creating comments inside issues, merge requests, and epics.') + .col-lg-8 + = gl_loading_icon(size: 'lg') diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index a140d780180..5ffffb80d97 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -53,7 +53,7 @@ = status_form.hidden_field :emoji, data: { js_name: 'emoji' } = status_form.hidden_field :message, data: { js_name: 'message' } = status_form.hidden_field :availability, data: { js_name: 'availability' } - = status_form.hidden_field :clear_status_after, data: { js_name: 'clearStatusAfter' } + = status_form.hidden_field :clear_status_after, value: @user.status&.clear_status_at&.to_s(:iso8601), data: { js_name: 'clearStatusAfter' } .col-lg-12 %hr .row.user-time-preferences.js-search-settings-section @@ -104,6 +104,15 @@ = f.label :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') + - external_accounts_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: external_accounts_help_url } + - external_accounts_docs_link = s_('Profiles|Your Discord user ID. Should be between %{min} and %{max} digits long. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}').html_safe % { min: '17', max: '20', external_accounts_link_start: external_accounts_link_start, external_accounts_link_end: '</a>'.html_safe } + = f.label :discord + = f.text_field :discord, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|User ID") + %small.form-text.text-gl-muted + = external_accounts_docs_link + + .form-group.gl-form-group = 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 diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 4c045574834..3add3af3c65 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -125,7 +125,12 @@ %span.gl-text-gray-500 = _("no name set") %td= registration[:created_at].to_date.to_s(:medium) - %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.'), confirm_btn_variant: "danger" }, aria: { label: _('Delete') } + %td + = render Pajamas::ButtonComponent.new(variant: :danger, + href: registration[:delete_path], + method: :delete, + button_options: { class: 'float-right', data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.'), confirm_btn_variant: "danger" }, aria: { label: _('Delete') }}) do + = _('Delete') - else .settings-message.text-center diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index e4eed63f45a..e2d1a50ae5e 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -4,7 +4,6 @@ - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } - add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0) -- source = visible_fork_source(@project) - 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") @@ -18,7 +17,8 @@ = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview - if project.forked? && Feature.enabled?(:fork_divergence_counts, @project.fork_source) - #js-fork-info{ data: { source_name: source ? source.full_name : '', source_path: source ? project_path(source) : '' } } + + #js-fork-info{ data: vue_fork_divergence_data(project, ref), project_id: @project.id } - if is_project_overview .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } diff --git a/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml b/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml index 94f8d3cc4a3..a9609434f15 100644 --- a/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml +++ b/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml @@ -1,13 +1,13 @@ - form = local_assigns.fetch(:form) = form.gitlab_ui_checkbox_component :only_allow_merge_if_pipeline_succeeds, - s_('ProjectSettings|Pipelines must succeed'), - help_text: s_("ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running.") + s_('MergeChecks|Pipelines must succeed'), + help_text: s_("MergeChecks|Merge requests can't be merged if the latest pipeline did not succeed or is still running.") .gl-pl-6 = form.gitlab_ui_checkbox_component :allow_merge_on_skipped_pipeline, - s_('ProjectSettings|Skipped pipelines are considered successful'), - help_text: s_('ProjectSettings|Introduces the risk of merging changes that do not pass the pipeline.'), + s_('MergeChecks|Skipped pipelines are considered successful'), + help_text: s_('MergeChecks|Introduces the risk of merging changes that do not pass the pipeline.'), checkbox_options: { class: 'gl-pl-6' } = form.gitlab_ui_checkbox_component :only_allow_merge_if_all_discussions_are_resolved, - s_('ProjectSettings|All threads must be resolved'), - checkbox_options: { data: { qa_selector: 'allow_merge_if_all_discussions_are_resolved_checkbox' } } + s_('MergeChecks|All threads must be resolved'), + checkbox_options: { data: { qa_selector: 'only_allow_merge_if_all_discussions_are_resolved_checkbox' } } diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index ec83782985b..53a1abdff33 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -6,11 +6,13 @@ .row{ id: project_name_id } = f.hidden_field :ci_cd_only, value: ci_cd_only - .form-group.project-name.col-sm-12 + .form-group.gl-form-group.project-name.col-sm-12 = f.label :name, class: 'label-bold' do %span= _("Project name") = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } - #project_name_error.gl-field-error.hidden + %small#js-project-name-description.form-text.text-gl-muted + = s_("ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.") + #js-project-name-error.gl-field-error.gl-mt-2.gl-display-none .form-group.project-path.col-sm-6.gl-pr-0 = f.label :namespace_id, class: 'label-bold' do %span= _('Project URL') diff --git a/app/views/projects/airflow/dags/index.html.haml b/app/views/projects/airflow/dags/index.html.haml new file mode 100644 index 00000000000..d631d084db1 --- /dev/null +++ b/app/views/projects/airflow/dags/index.html.haml @@ -0,0 +1,11 @@ +- breadcrumb_title s_('Airflow|Airflow DAGs') +- page_title s_('Airflow|Airflow DAGs') + +.page-title-holder + %h1.page-title.gl-font-size-h-display= s_('Airflow|Airflow DAGs') + +#js-show-airflow-dags{ data: { + dags: @dags.to_json, + pagination: @pagination.to_json + } +} diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index c91dfe6d28e..3359ea5f63b 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,6 +1,7 @@ - breadcrumb_title _('Artifacts') - page_title @path.presence, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') - add_page_specific_style 'page_bundles/tree' +- add_page_specific_style 'page_bundles/ci_status' = render "projects/jobs/header" diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml index 7fa47aed7c6..09970f7b12c 100644 --- a/app/views/projects/artifacts/index.html.haml +++ b/app/views/projects/artifacts/index.html.haml @@ -1,11 +1,3 @@ - page_title _('Artifacts') -%div{ class: container_class } - %h1.page-title.gl-font-size-h-display.gl-mb-0 - = s_('Artifacts|Artifacts') - .gl-mb-6 - %strong= s_('Artifacts|Total artifacts size') - = number_to_human_size(@total_size, precicion: 2) - #js-artifact-management{ data: { "project-path": @project.full_path, - "can-destroy-artifacts" => can?(current_user, :destroy_artifacts, @project).to_s, - "artifacts-management-feedback-image-path": image_path('illustrations/chat-bubble-sm.svg') } } +#js-artifact-management{ data: artifacts_app_data(@project) } diff --git a/app/views/projects/blame/_page.html.haml b/app/views/projects/blame/_page.html.haml new file mode 100644 index 00000000000..92fb99c30a6 --- /dev/null +++ b/app/views/projects/blame/_page.html.haml @@ -0,0 +1,34 @@ +- current_line = @blame.first_line + +.file-content.blame.code{ class: user_color_scheme } + - groups_length = @blame.groups.size - 1 + - @blame.groups.each_with_index do |blame_group, index| + - commit_data = @blame.commit_data(blame_group[:commit]) + - line_count = blame_group[:lines].count + + .tr{ class: ('last-row' if groups_length == index) } + .td.blame-commit.commit{ class: commit_data.age_map_class } + = commit_data.author_avatar + + .commit-row-title + %span.item-title.str-truncated-100 + = commit_data.commit_link + = commit_data.project_blame_link + + .light + = commit_data.commit_author_link + = _('committed') + #{commit_data.time_ago_tooltip} + + .td.line-numbers + - (current_line...(current_line + line_count)).each do |i| + %a.diff-line-num.file-line-num.no-link{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i } + = i + + .td.lines + %pre.code.highlight + %code + - blame_group[:lines].each do |line| + #{line} + + - current_line += line_count diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index dd041377b49..827aa86d61a 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,10 +1,11 @@ - page_title _("Blame"), @blob.path, @ref - add_page_specific_style 'page_bundles/tree' +- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page } -#blob-content-holder.tree-holder.js-per-page{ data: { testid: 'blob-content-holder', per_page: @blame_per_page } } +#blob-content-holder.tree-holder.js-per-page{ data: dataset } = render "projects/blob/breadcrumb", blob: @blob, blame: true - .file-holder.gl-overflow-hidden + .file-holder = render "projects/blob/header", blob: @blob, blame: true .file-blame-legend @@ -21,44 +22,9 @@ %span.legend-box.legend-box-9 %span.right-label Older - .table-responsive.file-content.blame.code{ class: "#{user_color_scheme} gl-rounded-0!", data: { qa_selector: 'blame_file_content' } } - %table - - current_line = @blame.first_line - - @blame.groups.each do |blame_group| - - commit_data = @blame.commit_data(blame_group[:commit]) - - line_count = blame_group[:lines].count - - %tr{ style: intrinsic_row_css(line_count) } - %td.blame-commit{ class: commit_data.age_map_class } - .commit - = commit_data.author_avatar - - .commit-row-title - %span.item-title.str-truncated-100 - = commit_data.commit_link - %span - = commit_data.project_blame_link - - - .light - = commit_data.commit_author_link - = _('committed') - #{commit_data.time_ago_tooltip} - - %td.line-numbers - - (current_line...(current_line + line_count)).each do |i| - %a.diff-line-num.gl-justify-content-end{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i, class: "gl-display-flex!" } - .file-line-num - = i - \ - - %td.lines.gl-w-full - %pre.code.highlight - %code - - blame_group[:lines].each do |line| - #{line} - - - current_line += line_count + .table-responsive.blame-table{ data: { qa_selector: 'blame_file_content' } } + .blame-table-wrapper + = render partial: 'page' - if @blame_pagination && @blame_pagination.total_pages > 1 .gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100 diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 475bc9e1c20..f43d19e2542 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -2,6 +2,11 @@ - page_title _('Branches') - add_to_breadcrumbs(_('Repository'), project_tree_path(@project)) +-# Possible values for variables passed down from the projects/branches_controller.rb +-# +-# @mode - overview|active|stale|all (default:overview) +-# @sort - name_asc|updated_asc|updated_desc + .top-area.gl-border-0 = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-b-0' }) do = gl_tab_link_to s_('Branches|Overview'), project_branches_path(@project), { item_active: @mode == 'overview', title: s_('Branches|Show overview of the branches') } @@ -10,7 +15,12 @@ = gl_tab_link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), { item_active: %w[overview active stale].exclude?(@mode), title: s_('Branches|Show all branches') } .nav-controls - #js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } } + #js-branches-sort-dropdown{ data: { + project_branches_filtered_path: project_branches_path(@project, state: 'all'), + sort_options: branches_sort_options_hash.to_json, + show_dropdown: @mode == 'overview' ? 'false' : 'true', + sorted_by: @sort } + } - if can? current_user, :push_code, @project .js-delete-merged-branches{ data: { diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index a755cb9f5b0..a8a911adb7d 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -1,54 +1,55 @@ - project = project || @project - dropdown_class = local_assigns.fetch(:dropdown_class, '') -.git-clone-holder.js-git-clone-holder - %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } - %span.gl-mr-2.js-clone-dropdown-label - = _('Clone') - = 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' } } - - if ssh_enabled? - %li{ class: 'gl-px-4!' } - %label.label-bold - = _('Clone with SSH') - .input-group.btn-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } - .input-group-append - = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") - = render_if_exists 'projects/buttons/geo' - - if http_enabled? - %li.pt-2{ 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, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } - .input-group-append - = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") - = render_if_exists 'projects/buttons/geo' - = render_if_exists 'projects/buttons/kerberos_clone_field' - %li.divider.mt-2 - %li.pt-2.gl-dropdown-item - %label.label-bold{ class: 'gl-px-4!' } - = _('Open in your IDE') +- if can?(current_user, :download_code, @project) + .git-clone-holder.js-git-clone-holder + %a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } + %span.gl-mr-2.js-clone-dropdown-label + = _('Clone') + = 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' } } - if ssh_enabled? - - escaped_ssh_url_to_repo = CGI.escape(project.ssh_url_to_repo) - %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_ssh_url_to_repo } - .gl-dropdown-item-text-wrapper - = _('Visual Studio Code (SSH)') + %li{ class: 'gl-px-4!' } + %label.label-bold + = _('Clone with SSH') + .input-group.btn-group + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } + .input-group-append + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = render_if_exists 'projects/buttons/geo' - if http_enabled? - - escaped_http_url_to_repo = CGI.escape(project.http_url_to_repo) - %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_http_url_to_repo } - .gl-dropdown-item-text-wrapper - = _('Visual Studio Code (HTTPS)') - - if ssh_enabled? - %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_ssh_url_to_repo } - .gl-dropdown-item-text-wrapper - = _('IntelliJ IDEA (SSH)') - - if http_enabled? - %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_http_url_to_repo } - .gl-dropdown-item-text-wrapper - = _('IntelliJ IDEA (HTTPS)') - - if show_xcode_link?(@project) - %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } - .gl-dropdown-item-text-wrapper - = _("Xcode") + %li.pt-2{ 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, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } + .input-group-append + = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = render_if_exists 'projects/buttons/geo' + = render_if_exists 'projects/buttons/kerberos_clone_field' + %li.divider.mt-2 + %li.pt-2.gl-dropdown-item + %label.label-bold{ class: 'gl-px-4!' } + = _('Open in your IDE') + - if ssh_enabled? + - escaped_ssh_url_to_repo = CGI.escape(project.ssh_url_to_repo) + %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_ssh_url_to_repo } + .gl-dropdown-item-text-wrapper + = _('Visual Studio Code (SSH)') + - if http_enabled? + - escaped_http_url_to_repo = CGI.escape(project.http_url_to_repo) + %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_http_url_to_repo } + .gl-dropdown-item-text-wrapper + = _('Visual Studio Code (HTTPS)') + - if ssh_enabled? + %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_ssh_url_to_repo } + .gl-dropdown-item-text-wrapper + = _('IntelliJ IDEA (SSH)') + - if http_enabled? + %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_http_url_to_repo } + .gl-dropdown-item-text-wrapper + = _('IntelliJ IDEA (HTTPS)') + - if show_xcode_link?(@project) + %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } + .gl-dropdown-item-text-wrapper + = _("Xcode") diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml index e1bf0940f59..87ccf01c011 100644 --- a/app/views/projects/commit/_ajax_signature.html.haml +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -1,2 +1,2 @@ - if commit.has_signature? - %button{ tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } + %a.js-loading-signature-badge{ tabindex: 0, role: 'button', data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } diff --git a/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml index 2568a69cc2c..f80f194bd4d 100644 --- a/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml +++ b/app/views/projects/commit/_multiple_signatures_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = _('Multiple signatures') - description = _('This commit was signed with multiple signatures.') -- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' } +- locals = { signature: signature, title: title, description: description, label: _('Unverified'), variant: 'muted' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml index ffc4b25dc21..345a1964259 100644 --- a/app/views/projects/commit/_other_user_signature_badge.html.haml +++ b/app/views/projects/commit/_other_user_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = _("Different user's signature") - description = _("This commit was signed with a different user's verified signature.") -- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' } +- locals = { signature: signature, title: title, description: description, label: _('Unverified'), variant: 'muted' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_revoked_key_signature_badge.html.haml b/app/views/projects/commit/_revoked_key_signature_badge.html.haml new file mode 100644 index 00000000000..cbcc39249ce --- /dev/null +++ b/app/views/projects/commit/_revoked_key_signature_badge.html.haml @@ -0,0 +1,5 @@ +- title = s_('CommitSignature|Unverified signature') +- description = s_('CommitSignature|This commit was signed with a key that was revoked.') +- locals = { signature: signature, title: title, description: description, label: s_('CommitSignature|Unverified'), variant: 'muted' } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml index 61fdf6fc87a..afce5c10065 100644 --- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = _('GPG key mismatch') - description = _('This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.') -- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: ['invalid'] } +- locals = { signature: signature, title: title, description: description, label: _('Unverified'), variant: 'muted' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 41ba581b9d9..88631f14e56 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -2,12 +2,10 @@ - title = local_assigns.fetch(:title) - description = local_assigns.fetch(:description, nil) - label = local_assigns.fetch(:label) -- css_class = local_assigns.fetch(:css_class) - -- css_classes = commit_signature_badge_classes(css_class) +- variant = local_assigns.fetch(:variant) - title = capture do - .gpg-popover-status + .signature-popover %div %strong = title @@ -31,5 +29,5 @@ = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') -%a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } - = label +%a.signature-badge.gl-display-flex{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } + = gl_badge_tag label, variant: variant diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml index 0eae8d5564d..62a27aea60e 100644 --- a/app/views/projects/commit/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = _('Unverified signature') - description = _('This commit was signed with an unverified signature.') -- locals = { signature: signature, title: title, description: description, label: _('Unverified'), css_class: 'invalid' } +- locals = { signature: signature, title: title, description: description, label: _('Unverified'), variant: 'muted' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml index 417d816c711..dd5d0d7d6eb 100644 --- a/app/views/projects/commit/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = _('Verified commit') - description = _('This commit was signed with a verified signature and the committer email was verified to belong to the same user.') -- locals = { signature: signature, title: title, description: description, label: _('Verified'), css_class: 'valid' } +- locals = { signature: signature, title: title, description: description, label: _('Verified'), variant: 'success' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml index 6204a6977c0..94b9abda69e 100644 --- a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } -- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Unverified'), variant: 'muted', icon: 'status_notfound_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/x509/_verified_signature_badge.html.haml b/app/views/projects/commit/x509/_verified_signature_badge.html.haml index 357ad467539..a10516d275a 100644 --- a/app/views/projects/commit/x509/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/x509/_verified_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } -- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Verified'), variant: 'success', icon: 'status_success_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 8f802792e6a..689862eae8a 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -9,7 +9,7 @@ .tree-holder .nav-block .tree-ref-container - .tree-ref-holder + .tree-ref-holder.gl-max-w-26 #js-project-commits-ref-switcher{ data: { "project-id" => @project.id, "ref" => @ref, "commits_path": project_commits_path(@project), "ref_type": @ref_type.to_s } } %ul.breadcrumb.repo-breadcrumb diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f6e3c15c08b..e87005434e4 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -36,6 +36,8 @@ = c.body do = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } += render_if_exists 'projects/settings/analytics_dashboards', expanded: expanded + %section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only @@ -72,6 +74,9 @@ = link_to _('Run housekeeping'), housekeeping_project_path(@project), method: :post, class: "btn gl-button btn-default" + .gl-display-inline-flex + #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } + = render 'export', project: @project = render_if_exists 'projects/settings/archive' diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml index dcd5fb2574e..7a275b51c74 100644 --- a/app/views/projects/environments/edit.html.haml +++ b/app/views/projects/environments/edit.html.haml @@ -3,4 +3,5 @@ #js-edit-environment{ data: { project_environments_path: project_environments_path(@project), update_environment_path: project_environment_path(@project, @environment), + protected_environment_settings_path: (project_settings_ci_cd_path(@project, anchor: 'js-protected-environments-settings') if @project.licensed_feature_available?(:protected_environments)), environment: environment_data(@environment)} } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 7cd4ab08680..1d4e907dd61 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -4,7 +4,7 @@ .file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } .nav-block .tree-ref-holder - = render 'shared/ref_switcher', destination: 'find_file', path: @path + #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } } %ul.breadcrumb.repo-breadcrumb %li.breadcrumb-item = link_to project_tree_path(@project, @ref) do diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 905c5779c7d..d28ee30b6f9 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -15,8 +15,7 @@ .dropdown.gl-display-inline.gl-md-ml-3.issue-sort-dropdown.gl-mt-3.gl-md-mt-0 .btn-group{ role: 'group' } - .btn-group{ role: 'group' } - = gl_redirect_listbox_tag [created_at, activity], @sort + = gl_redirect_listbox_tag [created_at, activity], @sort = forks_sort_direction_button(sort_value) - if current_user && can?(current_user, :fork_project, @project) diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index edf8f71c673..f026a6158cb 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -34,7 +34,7 @@ .col-md-6 .tree-ref-container .tree-ref-holder - = render 'shared/ref_switcher', destination: 'graphs_commits', path: @path + #js-project-graph-ref-switcher{ data: { project_id: @project.id, graph_path: project_graph_path(@project, @id), project_branch: current_ref } } %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index a27f076d5dd..754de2db8f3 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,14 +1,5 @@ - page_title _('Contributors') -- if Feature.enabled?(:use_ref_type_parameter, @project) - - graph_path = project_graph_path(@project, current_ref, ref_type: @ref_type, format: :json) - - commits_path = project_commits_path(@project, current_ref, ref_type: @ref_type) -- else - - graph_path = project_graph_path(@project, current_ref, format: :json) - - commits_path = project_commits_path(@project, current_ref) +- 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) -.sub-header-block.gl-bg-gray-10.gl-p-5 - .tree-ref-holder.gl-display-inline-block.gl-vertical-align-middle.gl-mr-3> - = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), commits_path, class: 'btn gl-button btn-default' - -.js-contributors-graph{ class: container_class, data: { project_graph_path: graph_path, project_branch: current_ref, default_branch: @project.default_branch } } +.js-contributors-graph{ class: container_class, data: { project_graph_path: graph_path, project_branch: current_ref, default_branch: @project.default_branch, project_id: @project.id, commits_path: commits_path } } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 40935ab6f70..c86f9c79912 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -11,4 +11,5 @@ target_type: 'issue', show_timeline_view_toggle: show_timeline_view_toggle?(@issue).to_s, current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, - can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}" } } + can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}", + report_abuse_path: add_category_abuse_reports_path } } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 1d3320e4f82..18975bc3db6 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -20,7 +20,7 @@ %span.issuable-authored.d-none.d-sm-inline-block · - if issue.service_desk_reply_to - #{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }} + #{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.present(current_user: current_user).service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }} - else #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), user: link_to_member(@project, issue.author, avatar: false) }} = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml deleted file mode 100644 index 0e950c26d34..00000000000 --- a/app/views/projects/learn_gitlab/index.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- breadcrumb_title _("Learn GitLab") -- page_title _("Learn GitLab") -- add_page_specific_style 'page_bundles/learn_gitlab' -- data = learn_gitlab_data(@project) - -= render 'projects/invite_members_modal', project: @project - -#js-learn-gitlab-app{ data: data } diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 0123c5efd3d..92b0a5a0b90 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -47,6 +47,6 @@ - if moved_mr_sidebar_enabled? %li.gl-dropdown-divider %hr.dropdown-divider - #js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, reported_from_url: merge_request_url(@merge_request) } } + .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, reported_from_url: merge_request_url(@merge_request) } } #js-report-abuse-drawer diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 47eb3e016d3..880bffc43ab 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -19,11 +19,10 @@ - if mr_action == 'diffs' - add_page_startup_api_call @endpoint_diff_batch_url -.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } +.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version, diffs_batch_cache_key: @diffs_batch_cache_key } } + = render "projects/merge_requests/mr_title" - if moved_mr_sidebar_enabled? #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } } - = render "projects/merge_requests/mr_title" - .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" .merge-request-tabs-holder{ class: "#{'js-tabs-affix' unless ENV['RAILS_ENV'] == 'test'} #{'gl-static' if moved_mr_sidebar_enabled?}" } @@ -81,7 +80,8 @@ target_type: 'merge_request', help_page_path: suggest_changes_help_path, current_user_data: @current_user_data, - is_locked: @merge_request.discussion_locked.to_s } } + is_locked: @merge_request.discussion_locked.to_s, + report_abuse_path: add_category_abuse_reports_path } } - if moved_mr_sidebar_enabled? = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 48334023cf0..0570d22529b 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -9,66 +9,16 @@ .card-new-merge-request %h2.gl-font-size-h2 = _('Source branch') - .clearfix - .merge-request-select.dropdown - = f.hidden_field :source_project_id - = dropdown_toggle @merge_request.source_project_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[source_project_id]", disabled: @merge_request.persisted?, default_text: _("Select source project") }, { toggle_class: "js-compare-dropdown js-source-project" } - .dropdown-menu.dropdown-menu-selectable.dropdown-source-project - = dropdown_title(_("Select source project")) - = dropdown_filter(_("Search projects")) - = dropdown_content do - = render 'projects/merge_requests/dropdowns/project', - projects: [@merge_request.source_project], - selected: f.object.source_project_id - .merge-request-select.dropdown - = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch.presence || _("Select source branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[source_branch]", 'refs-url': refs_project_path(@source_project), selected: f.object.source_branch, default_text: _("Select target branch"), qa_selector: "source_branch_dropdown" }, { toggle_class: "js-compare-dropdown js-source-branch monospace" } - .dropdown-menu.dropdown-menu-selectable.js-source-branch-dropdown.git-revision-dropdown - = dropdown_title(_("Select source branch")) - = dropdown_filter(_("Search branches")) - = dropdown_content - = dropdown_loading - .gl-bg-gray-50.gl-rounded-base.gl-mx-2.gl-my-4 - .compare-commit-empty.js-source-commit-empty.gl-display-flex.gl-align-items-center.gl-p-5{ style: 'display: none;' } - = sprite_icon('branch', size: 16, css_class: 'gl-mr-3') - = _('Select a branch to compare') - = gl_loading_icon(css_class: 'js-source-loading gl-py-3') - %ul.list-unstyled.mr_source_commit + #js-source-project-dropdown{ data: { current_project: { value: f.object.source_project_id.to_s, text: f.object.source_project.full_path, refsUrl: refs_project_path(f.object.source_project) }.to_json, current_branch: { value: f.object.source_branch.presence, text: f.object.source_branch.presence }.to_json } } .col-lg-6 .card-new-merge-request %h2.gl-font-size-h2 = _('Target branch') - .clearfix - .merge-request-select.dropdown - - if Feature.enabled?(:mr_compare_dropdowns, @project) - #js-target-project-dropdown{ data: { target_projects_path: project_new_merge_request_json_target_projects_path(@project), current_project: { value: f.object.target_project_id.to_s, text: f.object.target_project.full_path }.to_json } } - - else - - projects = target_projects(@project) - = f.hidden_field :target_project_id - = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted?, default_text: _("Select target project") }, { toggle_class: "js-compare-dropdown js-target-project" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-project - = dropdown_title(_("Select target project")) - = dropdown_filter(_("Search projects")) - = dropdown_content do - = render 'projects/merge_requests/dropdowns/project', - projects: projects, - selected: f.object.target_project_id - .merge-request-select.dropdown - = f.hidden_field :target_branch - = dropdown_toggle f.object.target_branch.presence || _("Select target branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch, default_text: _("Select target branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" } - .dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.git-revision-dropdown - = dropdown_title(_("Select target branch")) - = dropdown_filter(_("Search branches")) - = dropdown_content - = dropdown_loading - .gl-bg-gray-50.gl-rounded-base.gl-mx-2.gl-my-4 - .compare-commit-empty.js-target-commit-empty.gl-display-flex.gl-align-items-center.gl-p-5{ style: 'display: none;' } - = sprite_icon('branch', size: 16, css_class: 'gl-mr-3') - = _('Select a branch to compare') - = gl_loading_icon(css_class: 'js-target-loading gl-py-3') - %ul.list-unstyled.mr_target_commit + #js-target-project-dropdown{ data: { target_projects_path: project_new_merge_request_json_target_projects_path(@project), current_project: { value: f.object.target_project_id.to_s, text: f.object.target_project.full_path, refsUrl: refs_project_path(f.object.target_project) }.to_json, current_branch: { value: f.object.target_branch.presence, text: f.object.target_branch.presence }.to_json } } - if @merge_request.errors.any? = form_errors(@merge_request) - = f.submit _('Compare branches and continue'), data: { qa_selector: 'compare_branches_button' }, pajamas_button: true + .row + .col-12 + = f.submit _('Compare branches and continue'), data: { qa_selector: 'compare_branches_button' }, pajamas_button: true diff --git a/app/views/projects/mirrors/_branch_filter.html.haml b/app/views/projects/mirrors/_branch_filter.html.haml new file mode 100644 index 00000000000..b9db9898d49 --- /dev/null +++ b/app/views/projects/mirrors/_branch_filter.html.haml @@ -0,0 +1,6 @@ +.form-check.gl-mb-3 + = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input' + = label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label' + .form-text.text-muted + = _('If enabled, only protected branches will be mirrored.') + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index f4e57450aa1..4cfe463fa38 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -27,12 +27,7 @@ = render 'projects/mirrors/mirror_repos_form', f: f - .form-check.gl-mb-3 - = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input' - = label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label' - .form-text.text-muted - = _('If enabled, only protected branches will be mirrored.') - = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' + = render 'projects/mirrors/branch_filter' .panel-footer = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' } diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index fb8133e6de8..46833b5986b 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -26,7 +26,9 @@ - @project.remote_mirrors.each_with_index do |mirror, index| - next if mirror.new_record? %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row_container' } } - %td{ data: { qa_selector: 'mirror_repository_url_content' } }= mirror.safe_url || _('Invalid URL') + %td{ data: { qa_selector: 'mirror_repository_url_content' } } + = mirror.safe_url || _('Invalid URL') + = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror %td= _('Push') %td = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 339c5d82919..136f504084e 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -4,6 +4,7 @@ = rm_f.hidden_field :enabled, value: '1' = rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+" = rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden' + = render_if_exists partial: 'projects/mirrors/branch_name_regex', locals: { f: rm_f } = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden' = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f } diff --git a/app/views/projects/ml/candidates/show.html.haml b/app/views/projects/ml/candidates/show.html.haml index 7fa98f69edf..77262243efb 100644 --- a/app/views/projects/ml/candidates/show.html.haml +++ b/app/views/projects/ml/candidates/show.html.haml @@ -2,6 +2,5 @@ - add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project) - add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid) - breadcrumb_title "Candidate #{@candidate.iid}" -- data = candidate_as_data(@candidate) -#js-show-ml-candidate{ data: { candidate: data } } +#js-show-ml-candidate{ data: { view_model: show_candidate_view_model(@candidate) } } diff --git a/app/views/projects/ml/experiments/index.html.haml b/app/views/projects/ml/experiments/index.html.haml index a84cb15d940..dd064239e36 100644 --- a/app/views/projects/ml/experiments/index.html.haml +++ b/app/views/projects/ml/experiments/index.html.haml @@ -1,11 +1,7 @@ -- breadcrumb_title _('ML Experiments') -- page_title _('ML Experiments') +- breadcrumb_title s_('MlExperimentTracking|Model experiments') +- page_title s_('MlExperimentTracking|Model experiments') -.page-title-holder.d-flex.align-items-center - %h1.page-title.gl-font-size-h-display= _('Machine Learning Experiments') - -= render "incubation_banner" - -%div{ class: container_class } - .content-list.builds-content-list - = render "experiment_list", experiments: @experiments, project: @project +#js-project-ml-experiments-index{ data: { + experiments: experiments_as_data(@project, @experiments), + page_info: formatted_page_info(@page_info) +} } diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml index 143981eebe6..4433d1fafe9 100644 --- a/app/views/projects/ml/experiments/show.html.haml +++ b/app/views/projects/ml/experiments/show.html.haml @@ -4,6 +4,7 @@ - items = candidates_table_items(@candidates) - metrics = unique_logged_names(@candidates, &:latest_metrics) - params = unique_logged_names(@candidates, &:params) +- page_info = formatted_page_info(@page_info) .page-title-holder.d-flex.align-items-center %h1.page-title.gl-font-size-h-display= @experiment.name @@ -12,5 +13,5 @@ candidates: items, metrics: metrics, params: params, - pagination: @pagination.to_json + page_info: page_info } } diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml index e430dc2f372..097ffb0b7fe 100644 --- a/app/views/projects/network/_head.html.haml +++ b/app/views/projects/network/_head.html.haml @@ -1,6 +1,6 @@ .row-content-block.second-block.content-component-block.gl-px-0.gl-py-3 - .tree-ref-holder - = render partial: 'shared/ref_switcher', locals: {destination: 'graph'} + .gl-w-20 + #js-graph-ref-switcher{ data: { project_id: @project.id, ref: @ref, network_path: project_network_path(@project) } } .oneline = _("You can move around the graph by using the arrow keys.") diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 70bb97b7625..c8d4f02274b 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title _("Graph") - page_title _("Graph"), @ref -- network_path = Feature.enabled?(:use_ref_type_parameter) ? project_network_path(@project, @id, ref_type: @ref_type) : project_network_path(@project, @id) +- network_path = project_network_path(@project, @id, ref_type: @ref_type) = render "head" .gl-mt-5 .project-network.gl-border-1.gl-border-solid.gl-border-gray-300 diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 2351bd209a7..8c94a18e1b0 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -8,9 +8,8 @@ %li = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) - unless is_current_user - %li - = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do - = _('Report abuse to administrator') + .gl-ml-n2 + .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: note.author.id, reported_from_url: noteable_note_url(note) } } - if note_editable %li = link_to note_url(note), method: :delete, data: { confirm: _('Are you sure you want to delete this comment?'), confirm_btn_variant: 'danger', qa_selector: 'delete_comment_button' }, aria: { label: _('Delete comment') }, remote: true, class: 'js-note-delete' do diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml index f3a7037bdab..543a564568b 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_project_runners.html.haml @@ -1,17 +1,13 @@ %h4 - = _('Specific runners') + = s_('Runners|Project runners') .bs-callout.help-callout - if can?(current_user, :register_project_runners, @project) - = _('These runners are specific to this project.') - - if params[:ci_runner_templates] - %hr - = render partial: 'ci/runner/setup_runner_in_aws', - locals: { registration_token: @project.runners_token } + = s_('Runners|These runners are assigned to this project.') %hr = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @project.runners_token, - type: s_('Runners|specific'), + type: _('project'), reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path, project_path: @project.path_with_namespace, group_path: '' } @@ -22,13 +18,13 @@ %hr - if @project_runners.any? - %h4.underlined-title= _('Available specific runners') - %ul.bordered-list.activated-specific-runners + %h4.underlined-title= s_('Runners|Assigned project runners') + %ul.bordered-list{ data: { testid: 'assigned_project_runners' } } = render partial: 'projects/runners/runner', collection: @project_runners, as: :runner = paginate @project_runners, theme: "gitlab", param_name: "project_page", params: { expand_runners: true, anchor: 'js-runners-settings' } - if @assignable_runners.any? %h4.underlined-title= _('Other available runners') - %ul.bordered-list.available-specific-runners + %ul.bordered-list{ data: { testid: 'available_project_runners' } } = render partial: 'projects/runners/runner', collection: @assignable_runners, as: :runner = paginate @assignable_runners, theme: "gitlab", param_name: "specific_page", :params => { :anchor => 'js-runners-settings'} diff --git a/app/views/projects/runners/_settings.html.haml b/app/views/projects/runners/_settings.html.haml index a02bdac442b..f85b5fd9866 100644 --- a/app/views/projects/runners/_settings.html.haml +++ b/app/views/projects/runners/_settings.html.haml @@ -2,7 +2,7 @@ .row .col-sm-6 - = render 'projects/runners/specific_runners' + = render 'projects/runners/project_runners' .col-sm-6 = render 'projects/runners/shared_runners' = render 'projects/runners/group_runners' diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml index 5244587c16d..d27d268d65e 100644 --- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml +++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs _('Package and registry settings'), project_settings_packages_and_registries_path(@project) +- add_to_breadcrumbs _('Packages and registries settings'), project_settings_packages_and_registries_path(@project) - breadcrumb_title s_('ContainerRegistry|Clean up image tags') -- page_title s_('ContainerRegistry|Clean up image tags'), _('Package and registry settings') +- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages and registries settings') - @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data } 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 e0ac07f5f31..c81b38f44dd 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _('Package and registry settings') -- page_title _('Package and registry settings') +- breadcrumb_title _('Packages and registries settings') +- page_title _('Packages and registries settings') - @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings{ data: settings_data } diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 953bfcf71ab..de171a25e8d 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -15,7 +15,7 @@ -# reused in EE. = render "projects/settings/repository/protected_branches", protected_branch_entity: @project = render "shared/deploy_tokens/index", group_or_project: @project, description: deploy_token_description -= render @deploy_keys += render 'shared/deploy_keys/index' = render "projects/cleanup/show" = render_if_exists 'shared/promotions/promote_repository_features' diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml index 23578652862..2d5581fc1c5 100644 --- a/app/views/projects/starrers/index.html.haml +++ b/app/views/projects/starrers/index.html.haml @@ -14,7 +14,7 @@ %button.user-search-btn{ 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: { right: true } + = gl_redirect_listbox_tag starrers_sort_options, @sort, class: 'gl-ml-3', data: { placement: 'right' } - if @starrers.size > 0 .row.gl-mt-3 = render partial: 'starrer', collection: @starrers, as: :starrer diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index fd807350245..6cd3c584f2a 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,7 +1,7 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) -.tree-ref-container.gl-display-flex.mb-2.mb-md-0 - .tree-ref-holder +.tree-ref-container.gl-display-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0 + .tree-ref-holder.gl-max-w-26 #js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project) } } #js-repo-breadcrumb{ data: breadcrumb_data_attributes } diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml index c35895e000c..8235411d240 100644 --- a/app/views/protected_branches/shared/_branches_list.html.haml +++ b/app/views/protected_branches/shared/_branches_list.html.haml @@ -22,7 +22,7 @@ %th = s_("ProtectedBranch|Allowed to merge") %th - = s_("ProtectedBranch|Allowed to push") + = s_("ProtectedBranch|Allowed to push and merge") %th = s_("ProtectedBranch|Allowed to force push") %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' } 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 315daa5e029..109d92af8a7 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .col-sm-12 = yield :merge_access_levels .form-group.row - = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-sm-12' + = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push and merge:"), class: 'col-sm-12' .col-sm-12 = yield :push_access_levels .form-group.row diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml index b4fd7a24b41..c2a5dd8a9b0 100644 --- a/app/views/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_protected_branch.html.haml @@ -1,14 +1,15 @@ - can_admin_entity = protected_branch_can_admin_entity?(protected_branch_entity) - url = protected_branch_path_by_entity(protected_branch, protected_branch_entity) +- protected_branch_test_type = protected_branch.project_level? ? 'project-level' : 'group-level' -%tr.js-protected-branch-edit-form{ data: { url: url, testid: 'protected-branch' } } +%tr.js-protected-branch-edit-form{ data: { url: url, testid: 'protected-branch', test_type: protected_branch_test_type } } %td %span.ref-name= protected_branch.name - - if protected_branch_entity.is_a?(Project) && protected_branch_entity.root_ref?(protected_branch.name) - = gl_badge_tag s_('ProtectedBranch|default'), variant: :info + - if protected_branch.project_level? + - if protected_branch_entity.root_ref?(protected_branch.name) + = gl_badge_tag s_('ProtectedBranch|default'), variant: :info - - if protected_branch_entity.is_a?(Project) %div - if protected_branch.wildcard? - matching_branches = protected_branch.matching(repository.branch_names) @@ -18,8 +19,12 @@ = yield - = render_if_exists 'protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch, protected_branch_entity: protected_branch_entity + = render_if_exists 'protected_branches/ee/code_owner_approval_table', can_update: local_assigns[:can_update], protected_branch: protected_branch, protected_branch_entity: protected_branch_entity - if can_admin_entity - %td - = link_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-danger btn-sm" + %td.text-right{ data: { testid: 'protected-branch-action' } } + - if local_assigns[:is_inherited] + %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Inherited - This setting can be changed at the group level'), 'aria-hidden': 'true' } + = sprite_icon 'lock' + - else + = link_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, class: "btn gl-button btn-danger btn-sm" diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 9abb759389d..65501b27451 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,27 +1,35 @@ { - "name": "<%= Appearance.current&.title.presence || _('GitLab') %>", - "short_name": "<%= appearance_short_name %>", - "description": "<%= Appearance.current&.description.presence || _("The complete DevOps platform. One application with endless possibilities. Organizations rely on GitLab’s source code management, CI/CD, security, and more to deliver software rapidly.") %>", + "name": "<%= appearance_pwa_name %>", + "short_name": "<%= appearance_pwa_short_name %>", + "description": "<%= appearance_pwa_description %>", "start_url": "<%= explore_projects_path %>", "scope": "<%= root_path %>", "display": "browser", "orientation": "any", "background_color": "#fff", "theme_color": "<%= user_theme_primary_color %>", - "icons": [{ - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/logo-192.png') %>", - "sizes": "192x192", - "type": "image/png" - }, + "icons": [ + <% widths = Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS %> + <% widths.each do |width| -%> { - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/logo-512.png') %>", - "sizes": "512x512", + <% if source = appearance_pwa_icon_path_scaled(width) -%> + "src": "<%= source %>", + "sizes": "<%= width.to_s + "x" + width.to_s %>", "type": "image/png" - }, - { - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/maskable-logo.png') %>", + <% else -%> + "error": "<%= "#{width} is not an allowed PWA scale" %>" + <% end -%> + } + <% unless width == widths.last -%> + , + <% end -%> + <% end -%> + <% unless current_appearance&.pwa_icon.present? -%> + ,{ + "src": "<%= appearance_maskable_logo %>", "sizes": "512x512", "type": "image/png", "purpose": "maskable" - }] + } + <% end -%>] } diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml deleted file mode 100644 index 74a5d5fb425..00000000000 --- a/app/views/search/_category.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- users = capture_haml do - - if show_user_search_tab? - = search_filter_link 'users', _("Users") - -.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - = gl_tabs_nav({ class: 'scrolling-tabs nav-links', data: { testid: 'search-filter' } }) do - - if @project - - if project_search_tabs?(:blobs) - = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' } - - if project_search_tabs?(:issues) - = search_filter_link 'issues', _("Issues") - - if project_search_tabs?(:merge_requests) - = search_filter_link 'merge_requests', _("Merge requests") - - if project_search_tabs?(:wiki) - = search_filter_link 'wiki_blobs', _("Wiki") - - if project_search_tabs?(:commits) - = search_filter_link 'commits', _("Commits") - - if project_search_tabs?(:notes) - = search_filter_link 'notes', _("Comments") - - if project_search_tabs?(:milestones) - = search_filter_link 'milestones', _("Milestones") - = users - - - elsif @search_service_presenter.show_snippets? - = search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil } - - else - = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } - = render_if_exists 'search/category_code' if feature_flag_tab_enabled?(:global_search_code_tab) - = render_if_exists 'search/epics_filter_link' - = search_filter_link 'issues', _("Issues") if feature_flag_tab_enabled?(:global_search_issues_tab) - = search_filter_link 'merge_requests', _("Merge requests") if feature_flag_tab_enabled?(:global_search_merge_requests_tab) - = render_if_exists 'search/category_wiki' if feature_flag_tab_enabled?(:global_search_wiki_tab) - = render_if_exists 'search/category_elasticsearch' - = search_filter_link 'milestones', _("Milestones") - = users diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 5c952042663..fee943042f9 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,18 +1,9 @@ - search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4' = render_if_exists 'shared/promotions/promote_advanced_search' -- if Feature.enabled?(:search_page_vertical_nav, current_user) - .results.gl-md-display-flex.gl-mt-0 - #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } - .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - = render partial: 'search/results_status' unless @search_objects.to_a.empty? - = render partial: 'search/results_list' -- else - = render partial: 'search/results_status' unless @search_objects.to_a.empty? - .results.gl-md-display-flex.gl-mt-3 - - if %w[issues merge_requests].include?(@scope) - #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } - - .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - = render partial: 'search/results_list' +.results.gl-md-display-flex.gl-mt-0 + #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } } + .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden + = render partial: 'search/results_status' unless @search_objects.to_a.empty? + = render partial: 'search/results_list' diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index 195f0f3ad8a..7a57b5cc0fc 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -1,5 +1,7 @@ - if @timeout = render partial: "search/results/timeout" +- elsif @search_results.respond_to?(:failed?) && @search_results.failed? + = render partial: "search/results/error" - elsif @search_objects.to_a.empty? = render partial: "search/results/empty" - else diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index 3cd100db8b7..27405631360 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -1,6 +1,25 @@ - return unless @search_service_presenter.show_results_status? -- if Feature.enabled?(:search_page_vertical_nav, current_user) - = render partial: 'search/results_status_vert_nav' -- else - = render partial: 'search/results_status_horiz_nav' +.search-results-status + .gl-display-flex.gl-flex-direction-column + .gl-p-5.gl-display-flex + .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1.gl-white-space-nowrap.gl-max-w-full + - unless @search_service_presenter.without_count? + = search_entries_info(@search_objects, @scope, @search_term) + - unless @search_service_presenter.show_snippets? + - if @project + - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1 gl-text-truncate search-wrap-f-md-down') + - if @scope == 'blobs' + = _("in") + .mx-md-1 + #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif @group + - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + - if @search_service_presenter.show_sort_dropdown? + .gl-md-display-flex.gl-flex-direction-column + #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } + %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full diff --git a/app/views/search/_results_status_horiz_nav.html.haml b/app/views/search/_results_status_horiz_nav.html.haml deleted file mode 100644 index c0778b70c04..00000000000 --- a/app/views/search/_results_status_horiz_nav.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -.search-results-status - .row-content-block.gl-display-flex - .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 - - unless @search_service_presenter.without_count? - = search_entries_info(@search_objects, @scope, @search_term) - - unless @search_service_presenter.show_snippets? - - if @project - - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1') - - if @scope == 'blobs' - = _("in") - .mx-md-1 - #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif @group - - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - - if @search_service_presenter.show_sort_dropdown? - .gl-md-display-flex.gl-flex-direction-column - #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } - diff --git a/app/views/search/_results_status_vert_nav.html.haml b/app/views/search/_results_status_vert_nav.html.haml deleted file mode 100644 index 29cc0a20123..00000000000 --- a/app/views/search/_results_status_vert_nav.html.haml +++ /dev/null @@ -1,23 +0,0 @@ -.search-results-status - .gl-display-flex.gl-flex-direction-column - .gl-p-5.gl-display-flex - .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1 - - unless @search_service_presenter.without_count? - = search_entries_info(@search_objects, @scope, @search_term) - - unless @search_service_presenter.show_snippets? - - if @project - - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1') - - if @scope == 'blobs' - = _("in") - .mx-md-1 - #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif @group - - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - - if @search_service_presenter.show_sort_dropdown? - .gl-md-display-flex.gl-flex-direction-column - #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } - %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full diff --git a/app/views/search/results/_error.html.haml b/app/views/search/results/_error.html.haml new file mode 100644 index 00000000000..f0d9283c620 --- /dev/null +++ b/app/views/search/results/_error.html.haml @@ -0,0 +1,7 @@ +.gl-display-flex.gl-flex-direction-column.gl-align-items-center + %div + .svg-content.svg-150 + = image_tag 'illustrations/search-timeout-md.svg' + %div + %h4.gl-text-center.gl-font-weight-bold= s_('SearchError|A search query problem has occurred') + %p.gl-text-center= s_('SearchError|To resolve the problem, check the query syntax and try again.') diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index 67f16075649..c7c91e768df 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -3,11 +3,11 @@ - noteable_identifier = note.noteable.try(:iid) || note.noteable.try(:id) .search-result-row - %h5.note-search-caption.str-truncated - = sprite_icon('comment', css_class: 'gl-vertical-align-text-bottom') - = link_to_member(project, note.author, avatar: false) - - link_to_project = link_to(project.full_name, project) - = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project } + %h5.note-search-caption.gl-max-w-full + %span.gl-display-inline-block.gl-text-truncate.search-max-w-inherit.gl-vertical-align-bottom + = sprite_icon('comment', css_class: 'gl-vertical-align-text-bottom') + = link_to_member(project, note.author, avatar: false) + = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to(project.full_name, project) } · - if note.for_commit? diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 2eb6af94008..04103794e60 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -8,7 +8,7 @@ - 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) -- if @search_results +- if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?) - if @search_service_presenter.without_count? - page_description(_("%{scope} results for term '%{term}'") % { scope: @scope, term: @search_term }) - else @@ -22,6 +22,4 @@ .gl-mt-3 #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } } - if @search_term - - if Feature.disabled?(:search_page_vertical_nav, current_user) - = render 'search/category' = render 'search/results' diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml deleted file mode 100644 index c549c4e6e4d..00000000000 --- a/app/views/shared/_flash_user_callout.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: callouts_path } -- extra_flash_class = local_assigns.fetch(:extra_flash_class, nil) - -.flash-container.flash-container-page.user-callout{ data: callout_data } - -# We currently only support `alert`, `warning`, `notice`, `success` - %div{ class: "flash-#{flash_type}" } - %div{ class: "#{container_class unless fluid_layout} #{extra_flash_class unless @no_container} #{@content_class}" } - %span= message - %button.btn.gl-button.btn-default.close.js-close{ type: 'button', - 'aria-label' => _('Dismiss') } - = sprite_icon('close', css_class: 'dismiss-icon') diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml index 06889428e82..6d65f2e61bd 100644 --- a/app/views/shared/_help_dropdown_forum_link.html.haml +++ b/app/views/shared/_help_dropdown_forum_link.html.haml @@ -1,2 +1,2 @@ = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', - rel: 'noopener noreferrer', data: { 'track_action': 'click_forum', 'track_property': 'question_menu' } + rel: 'noopener noreferrer', data: { 'track_action': 'click_link', 'track_label': 'community_forum', 'track_property': 'navigation_top' } diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index 42a146a4f65..360a3f3eb89 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -5,5 +5,4 @@ = render partial: 'projects/issues/issue', collection: @issues = paginate @issues, theme: "gitlab" - else - - project_select_button = local_assigns.fetch(:project_select_button, false) - = render 'shared/empty_states/issues', project_select_button: project_select_button + = render 'shared/empty_states/issues' diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml deleted file mode 100644 index d080d8be8fe..00000000000 --- a/app/views/shared/_new_project_item_select.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- if any_projects?(@projects) - .dropdown.b-dropdown.gl-dropdown.btn-group.project-item-select-holder{ class: 'gl-display-inline-flex!' } - %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } } - = gl_loading_icon(inline: true, color: 'light') - = project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled] - %button.btn.dropdown-toggle.btn-confirm.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button{ 'aria-label': _('Toggle project select') } diff --git a/app/views/shared/_new_project_item_vue_select.html.haml b/app/views/shared/_new_project_item_vue_select.html.haml new file mode 100644 index 00000000000..9ea99df106e --- /dev/null +++ b/app/views/shared/_new_project_item_vue_select.html.haml @@ -0,0 +1,2 @@ +- if any_projects?(@projects) + .js-new-resource-dropdown{ data: { group_id: @group&.id, full_path: @group&.full_path, username: @current_user&.username } } diff --git a/app/views/shared/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml index d4c3e11d051..70d9db998fc 100644 --- a/app/views/shared/_user_dropdown_contributing_link.html.haml +++ b/app/views/shared/_user_dropdown_contributing_link.html.haml @@ -1,2 +1,2 @@ -= link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do += 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 index 18bfb5d7e3e..1a02f9958b0 100644 --- a/app/views/shared/_user_dropdown_instance_review.html.haml +++ b/app/views/shared/_user_dropdown_instance_review.html.haml @@ -2,5 +2,5 @@ %li.divider %li - = link_to admin_instance_review_path, target: '_blank', class: 'text-nowrap' do + = 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/dashboard/_search_timeout_occurred.html.haml b/app/views/shared/dashboard/_search_timeout_occurred.html.haml new file mode 100644 index 00000000000..4b6331ee986 --- /dev/null +++ b/app/views/shared/dashboard/_search_timeout_occurred.html.haml @@ -0,0 +1,8 @@ +.row.empty-state.text-center + .col-12 + .svg-130.gl-mt-3 + = image_tag 'illustrations/issue-dashboard_results-without-filter.svg' + .col-12 + .text-content + %h4 + = _("Too many results to display. Edit your search or add a filter.") diff --git a/app/views/shared/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml index b30ec2e7b3a..512daf7b96b 100644 --- a/app/views/shared/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml @@ -1,10 +1,10 @@ -- submit_btn_css ||= 'gl-button btn btn-danger btn-sm' +- submit_btn_css ||= 'btn btn-danger btn-md gl-button btn-danger-secondary' = form_tag path do %input{ :name => "_method", :type => "hidden", :value => "delete" } - if defined? small - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'remove', button_options: { data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'remove', button_options: { class: submit_btn_css, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do %span.sr-only = _('Destroy') - else - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { aria: { label: _('Destroy') }, class: submit_btn_css, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: submit_btn_css, aria: { label: _('Destroy') }, data: { confirm: _("Are you sure?"), confirm_btn_variant: "danger" } }) do = _('Destroy') diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index 562b1aee4ca..5b0cff2c1c0 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -51,4 +51,4 @@ - if @created = link_to _('Continue'), index_path, class: 'btn btn-confirm btn-md gl-button gl-mr-3' = link_to _('Edit'), edit_path, class: 'btn btn-default btn-md gl-button' - = render 'shared/doorkeeper/applications/delete_form', path: delete_path, submit_btn_css: 'btn btn-danger btn-md gl-button btn-danger-secondary' + = render 'shared/doorkeeper/applications/delete_form', path: delete_path diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index fb410274859..37f7fbc0de5 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -1,7 +1,5 @@ - button_path = local_assigns.fetch(:new_project_issue_button_path, false) -- project_select_button = local_assigns.fetch(:project_select_button, false) - show_import_button = local_assigns.fetch(:show_import_button, false) && can?(current_user, :import_issues, @project) -- has_button = button_path || project_select_button - closed_issues_count = issuables_count_for_state(:issues, :closed) - opened_issues_count = issuables_count_for_state(:issues, :opened) - is_opened_state = params[:state] == 'opened' @@ -39,11 +37,9 @@ = _("The Issue Tracker is the place to add things that need to be improved or solved in a project") %p = _("Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.") - - if has_button + - if button_path .text-center - - if project_select_button - = render 'shared/new_project_item_select', path: 'issues/new', label: _('issue'), type: :issues, with_feature_enabled: 'issues' - - elsif show_new_issue_link?(@project) + - if show_new_issue_link?(@project) = link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link' - if show_import_button diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index fe602db4393..8e4051fa335 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -1,6 +1,4 @@ - button_path = local_assigns.fetch(:button_path, false) -- project_select_button = local_assigns.fetch(:project_select_button, false) -- has_button = button_path || project_select_button - closed_merged_count = issuables_count_for_state(:merged, :closed) - opened_merged_count = issuables_count_for_state(:merged, :opened) - is_opened_state = params[:state] == 'opened' @@ -37,9 +35,6 @@ = _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others") %p = _("Interested parties can even contribute by pushing commits if they want to.") - - if has_button + - if button_path .text-center - - if project_select_button - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('merge request'), type: :merge_requests, with_feature_enabled: 'merge_requests' - - else - = link_to _('New merge request'), button_path, class: 'gl-button btn btn-confirm', title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" } + = link_to _('New merge request'), button_path, class: 'gl-button btn btn-confirm', title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" } diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 80edce8e7c4..0b39f42165f 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -2,4 +2,4 @@ - groups_sort_options = options_hash.map { |value, title| { value: value, text: title, href: filter_groups_path(sort: value) } } %div{ data: { testid: 'group_sort_by_dropdown' } } - = gl_redirect_listbox_tag groups_sort_options, project_list_sort_by, data: { right: true } + = gl_redirect_listbox_tag groups_sort_options, project_list_sort_by, data: { placement: 'right' } diff --git a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml index fec443738c3..db1754c1864 100644 --- a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml @@ -5,13 +5,13 @@ %ul.list-unstyled.indent-list %li 1. - = link_to 'https://docs.gitlab.com/ee/user/project/integrations/mattermost_slash_commands.html#enable-custom-slash-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do + = link_to help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'enable-custom-slash-commands-in-mattermost'), target: '_blank', rel: 'noopener noreferrer nofollow' do Enable custom slash commands = sprite_icon('external-link') on your Mattermost installation. %li 2. - = link_to 'https://docs.gitlab.com/ee/user/project/integrations/mattermost_slash_commands.html#create-a-slash-command', target: '_blank', rel: 'noopener noreferrer nofollow' do + = link_to help_page_url('user/project/integrations/mattermost_slash_commands', anchor: 'create-a-slash-command-in-mattermost'), target: '_blank', rel: 'noopener noreferrer nofollow' do Add a slash command = sprite_icon('external-link') in your Mattermost team with the options listed below. diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index da8477f4b2e..b125fe34464 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -20,9 +20,8 @@ .title = _('Assignee') .filter-item - - field_name = "update[assignee_ids][]" - = dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) + %input.js-assignee-ids-input{ type: "hidden", name: "update[assignee_ids][]" } + .js-assignee-dropdown{ data: { full_path: @project.full_path } } - if is_issue = render_if_exists 'shared/issuable/epic_dropdown', parent: @project.group .block diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 39a123f4775..f54354674e2 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -14,7 +14,7 @@ %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class} #{'right-sidebar-merge-requests' if moved_sidebar_enabled}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if moved_sidebar_enabled}" } .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if moved_sidebar_enabled}" } - %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", class: "#{'gl-display-block' if moved_sidebar_enabled}", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } + %button.btn.gl-button.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ type: "reset", class: "gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } = sidebar_gutter_toggle_icon - if signed_in && !moved_sidebar_enabled .js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } @@ -24,7 +24,7 @@ = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in - if issuable_sidebar[:supports_severity] - .js-sidebar-severity-root + .js-sidebar-severity-widget-root - if reviewers .block.reviewer{ data: { qa_selector: 'reviewers_block_container' } } @@ -97,21 +97,8 @@ .block .js-sidebar-copy-email-root - if issuable_sidebar.dig(:current_user, :can_move) - .block.js-sidebar-move-issue-block - .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } - = sprite_icon('long-arrow') - .dropdown.sidebar-move-issue-dropdown.hide-collapsed - = render Pajamas::ButtonComponent.new(block: true, button_options: { class: 'js-sidebar-dropdown-toggle js-move-issue', data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_action: "click_button", track_value: "" } }) do - = _('Move issue') - .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height - = dropdown_title(_('Move issue')) - = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') - = dropdown_content - = dropdown_loading - = dropdown_footer add_content_class: true do - %button.gl-button.btn.btn-confirm.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true } - = gl_loading_icon(inline: true, css_class: 'sidebar-move-issue-confirmation-loading-icon gl-mr-2') - = _('Move') + .block + .js-sidebar-move-issue-block{ data: { project_full_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } -# haml-lint:disable InlineJavaScript %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 8ca30d7ca97..c6f3e4d97a8 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -39,7 +39,7 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_max_select(dropdown_options[:data], nil) + - data['max-select'] = dropdown_max_select(dropdown_options[:data]) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index 4df393eeb67..b360fac0a55 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -35,7 +35,7 @@ - data[:suggested_reviewers_header] = dropdown_options[:data][:suggested_reviewers_header] - data[:all_members_header] = dropdown_options[:data][:all_members_header] - data[:show_suggested] = dropdown_options[:data][:show_suggested] - - data['max-select'] = dropdown_max_select(dropdown_options[:data], :limit_reviewer_and_assignee_size) + - data['max-select'] = dropdown_max_select(dropdown_options[:data]) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index ccc1a9fda6e..0a3fd4f8b9e 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -5,5 +5,5 @@ .gl-ml-3 .btn-group{ role: 'group' } - = gl_redirect_listbox_tag(items, selected, data: { right: true }) + = gl_redirect_listbox_tag(items, selected, data: { placement: 'right' }) = issuable_sort_direction_button(@sort) diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 0f6ef33d532..4d31baee25b 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -1,27 +1,18 @@ - issuable = local_assigns.fetch(:issuable) -- has_wip_commits = local_assigns.fetch(:has_wip_commits) - form = local_assigns.fetch(:form) - no_issuable_templates = issuable_templates(ref_project, issuable.to_ability_name).empty? -- toggle_wip_link_start = '<a href="" class="js-toggle-wip">' -- toggle_wip_link_end = '</a>' -- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe }).html_safe -- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe }).html_safe %div{ data: { testid: 'issue-title-input-field' } } = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, autocomplete: 'off', class: 'form-control pad', dir: 'auto', data: { qa_selector: 'issuable_form_title_field' } - if issuable.respond_to?(:draft?) - .form-text.text-muted - .js-wip-explanation{ style: "display: none;" } - = remove_wip_text - .js-no-wip-explanation - - if has_wip_commits - = _('It looks like you have some draft commits in this branch.') - %br - .invisible - .js-unwrap-on-load - = add_wip_text + .gl-pt-3 + = render Pajamas::CheckboxTagComponent.new(name: 'mark_as_draft', checkbox_options: { class: 'js-toggle-draft' }) do |c| + = c.label do + = s_('MergeRequests|Mark as draft') + = c.help_text do + = s_('MergeRequests|Drafts cannot be merged until marked ready.') - if no_issuable_templates && can?(current_user, :push_code, issuable.project) = render 'shared/issuable/form/default_templates' diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index bb582b159ba..f8bafaedd59 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -1,3 +1,3 @@ - label_sort_options = label_sort_options_hash.map { |value, text| { value: value, text: text, href: page_filter_path(sort: value) } } -= gl_redirect_listbox_tag label_sort_options, @sort, data: { right: true } += gl_redirect_listbox_tag label_sort_options, @sort, data: { placement: 'right' } diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index 0b38b9d7945..94086636d5a 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -14,4 +14,5 @@ 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 + method: :post, + data: { qa_selector: 'request_access_link' } diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml index 5452cd486da..eea36127745 100644 --- a/app/views/shared/nav/_sidebar_menu_item.html.haml +++ b/app/views/shared/nav/_sidebar_menu_item.html.haml @@ -1,8 +1,11 @@ = 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: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do - %span + %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/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index e34f412baa4..eb36de8167c 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -27,3 +27,5 @@ = html_escape(_("This %{issuable} is locked. Only %{strong_open}project members%{strong_close} can comment.")) % { issuable: issuable.class.to_s.titleize.downcase, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } -# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe + +#js-report-abuse-drawer diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 59f8bf0e875..3d0564a5e64 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,5 +1,5 @@ - @sort ||= sort_value_latest_activity -.dropdown.js-project-filter-dropdown-wrap.gl-display-inline +.dropdown.js-project-filter-dropdown-wrap.gl-display-inline{ class: 'gl-m-0!' } = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 813d8c5ed2b..09d63347ed6 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -37,7 +37,7 @@ - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil = render "shared/projects/project", project: project, skip_namespace: skip_namespace, avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar, - forks: project.forking_enabled?, show_last_commit_as_description: show_last_commit_as_description, + forks: able_to_see_forks_count?(project, user), show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: able_to_see_merge_requests?(project, user), issues: able_to_see_issues?(project, user), pipeline_status: pipeline_status, compact_mode: compact_mode = paginate_collection(projects, remote: remote) unless skip_pagination diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 40cd81ab3da..2adc7844a67 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -97,15 +97,15 @@ = link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do = sprite_icon('star-o', size: 14, css_class: 'gl-mr-2') = badge_count(project.star_count) - - if forks + - if show_count?(disabled: !forks, compact_mode: compact_mode) = link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do = sprite_icon('fork', size: 14, css_class: 'gl-mr-2') = badge_count(project.forks_count) - - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) + - if show_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2') = badge_count(project.open_merge_requests_count) - - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) + - if show_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'gl-mr-2') = badge_count(project.open_issues_count) diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 07a6d5bec78..47e0e165276 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,9 +1,11 @@ - placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name') +- admin_view ||= false +- top_padding = admin_view ? 'gl-lg-pt-3' : '' -= form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| += form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, - class: "project-filter-form-field form-control input-short js-projects-list-filter", + class: "project-filter-form-field form-control input-short js-projects-list-filter gl-m-0!", spellcheck: false, id: 'project-filter-form-field', autofocus: local_assigns[:autofocus] @@ -26,19 +28,28 @@ - if params[:language].present? = hidden_field_tag :language, params[:language] - - if Feature.enabled?(:project_language_search, current_user) - .dropdown.inline - = dropdown_toggle(search_language_placeholder, { toggle: 'dropdown', testid: 'project-language-dropdown' }) - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable + .dropdown{ class: 'gl-m-0!' } + = dropdown_toggle(search_language_placeholder, { toggle: 'dropdown', testid: 'project-language-dropdown' }) + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable + %li + = link_to _('Any'), filter_projects_path(language: nil, name: nil) + - programming_languages.each do |language| %li - = link_to _('Any'), filter_projects_path(language: nil) - - programming_languages.each do |language| - %li - = link_to filter_projects_path(language: language.id), class: language_state_class(language) do - = language.name + = link_to filter_projects_path(language: language.id, name: nil), class: language_state_class(language) do + = language.name = submit_tag nil, class: 'gl-display-none!' = render 'shared/projects/dropdown' = render_if_exists 'shared/projects/search_fields' + + - if admin_view + - if params[:namespace_id].present? + - namespace = Namespace.find(params[:namespace_id]) + - selected_text = "#{namespace.kind}: #{namespace.full_path}" if namespace + .gl-display-flex.gl-w-full.gl-md-w-auto{ class: 'gl-m-0!' } + .js-namespace-select{ data: { field_name: 'namespace_id', selected_id: namespace&.id, selected_text: selected_text, update_location: 'true' } } + + = link_to new_project_path, class: 'gl-button btn btn-confirm gl-display-inline gl-mb-0!' do + = _('New Project') diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml index a1599b3ec49..63ecdaf4149 100644 --- a/app/views/shared/runners/_runner_type_alert.html.haml +++ b/app/views/shared/runners/_runner_type_alert.html.haml @@ -12,5 +12,5 @@ title: s_('Runners|This runner is associated with specific projects.'), dismissible: false) do |c| = c.body do - = s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared or group runner.') - = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer' + = s_('Runners|You can set up a project runner to be used by multiple projects but you cannot make this a shared or group runner.') + = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'project-runners'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml index c6a18c804da..a8a93f3dd76 100644 --- a/app/views/shared/runners/_runner_type_badge.html.haml +++ b/app/views/shared/runners/_runner_type_badge.html.haml @@ -4,4 +4,4 @@ - elsif runner.group_type? = gl_badge_tag s_('Runners|group'), variant: :success - else - = gl_badge_tag s_('Runners|specific'), variant: :info + = gl_badge_tag s_('Runners|project'), variant: :info diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml index 4b89b2a0cbf..80cd23989a0 100644 --- a/app/views/shared/ssh_keys/_key_delete.html.haml +++ b/app/views/shared/ssh_keys/_key_delete.html.haml @@ -1,7 +1,5 @@ -- icon = local_assigns[:icon] - category = local_assigns[:category] || :primary .gl-p-2 - = render Pajamas::ButtonComponent.new(variant: :danger, category: category, icon: ('remove' if icon), button_options: { class: 'js-confirm-modal-button', data: button_data }) do - - unless icon - = _('Delete') + = render Pajamas::ButtonComponent.new(variant: :danger, category: category, button_options: { class: 'js-confirm-modal-button', data: button_data }) do + = _('Delete') diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index c19b518acd6..155a7b1827f 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -19,7 +19,9 @@ = gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm) = gl_badge_tag(sslBadgeText, size: :sm) - .col-md-4.col-lg-5.text-right-md.gl-mt-2 - %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3' - %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn gl-button btn-default btn-sm gl-mr-3' - = link_to _('Delete'), destroy_hook_path(hook), aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm_btn_variant: "danger", confirm: s_('Webhooks|Are you sure you want to delete this webhook?') }, method: :delete, class: 'btn gl-button btn-secondary btn-danger-secondary btn-sm' + .col-md-4.col-lg-5.gl-mt-2.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline.gl-gap-3 + = render 'shared/web_hooks/test_button', hook: hook, size: 'small' + = render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do + = _('Edit') + = render Pajamas::ButtonComponent.new(href: destroy_hook_path(hook), category: :secondary, variant: :danger, size: :small, method: :delete, button_options: { 'aria-label' => s_('Webhooks|Delete webhook'), data: { confirm_btn_variant: "danger", confirm: s_('Webhooks|Are you sure you want to delete this webhook?') } }) do + = _('Delete') diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml index 7a78a32fe87..dbd26e5bd07 100644 --- a/app/views/shared/web_hooks/_test_button.html.haml +++ b/app/views/shared/web_hooks/_test_button.html.haml @@ -1,13 +1,5 @@ -- button_class = local_assigns.fetch(:button_class, '') - hook = local_assigns.fetch(:hook) -- triggers = hook.class.triggers +- size = local_assigns.fetch(:size, 'medium') +- triggers = hook.class.triggers.each_value -.hook-test-button.dropdown.gl-dropdown.inline> - %button.btn.gl-button{ 'data-toggle' => 'dropdown', class: button_class } - = _('Test') - = sprite_icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - .gl-dropdown-inner - - triggers.each_value do |event| - %li.gl-dropdown-item - = link_to_test_hook(hook, event) +.js-webhook-test-dropdown{ data: { items: webhook_test_items(hook, triggers).to_json, size: size } } diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index cb6a67bd8d4..8b8c981da96 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -28,7 +28,8 @@ = render_wiki_content(@sidebar_page) - elsif @sidebar_wiki_entries %ul.wiki-pages - = render @sidebar_wiki_entries, context: 'sidebar' + - @sidebar_wiki_entries.each do |entry| + = render partial: entry.to_partial_path, object: entry, locals: { context: 'sidebar' } .block.w-100 - if @sidebar_limited = link_to wiki_path(@wiki, action: :pages), class: 'btn gl-button btn-block', data: { qa_selector: 'view_all_pages_button' } do diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index a492d1e5aa0..5c2233a4db2 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,4 +1,6 @@ -%li{ data: { qa_selector: 'wiki_directory_content' } } - = wiki_directory.title +%li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } } + = link_to wiki_page_path(@wiki, wiki_directory), data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do + = wiki_directory.title %ul - = render wiki_directory.entries, context: context + - wiki_directory.entries.each do |entry| + = render partial: entry.to_partial_path, object: entry, locals: { context: context } diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index e1252e91c10..f35649d031c 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -17,6 +17,7 @@ = wiki_sort_controls(@wiki, params[:direction]) %ul.wiki-pages-list.content-list - = render @wiki_entries, context: 'pages' + - @wiki_entries.each do |entry| + = render partial: entry.to_partial_path, object: entry, locals: { context: 'pages' } = paginate @wiki_pages, theme: 'gitlab' diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index af29de6b0c4..b9290972656 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -101,6 +101,10 @@ = 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') + - unless @user.discord.blank? + = 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') - unless @user.website_url.blank? = render 'middle_dot_divider', stacking: true do - if Feature.enabled?(:security_auto_fix) && @user.bot? @@ -125,94 +129,98 @@ = @user.bio - unless profile_tabs.empty? - .scrolling-tabs-container - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs - - if profile_tab?(:overview) - %li.js-overview-tab - = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do - = s_('UserProfile|Overview') - - if profile_tab?(:activity) - %li.js-activity-tab - = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do - = s_('UserProfile|Activity') - - unless Feature.enabled?(:security_auto_fix) && @user.bot? - - if profile_tab?(:groups) - %li.js-groups-tab - = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do - = s_('UserProfile|Groups') - - if profile_tab?(:contributed) - %li.js-contributed-tab - = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do - = s_('UserProfile|Contributed projects') - - if profile_tab?(:projects) - %li.js-projects-tab - = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do - = s_('UserProfile|Personal projects') - - if profile_tab?(:starred) - %li.js-starred-tab - = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do - = s_('UserProfile|Starred projects') - - if profile_tab?(:snippets) - %li.js-snippets-tab - = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do - = s_('UserProfile|Snippets') - - if profile_tab?(:followers) - %li.js-followers-tab - = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do - = s_('UserProfile|Followers') - - if profile_tab?(:following) - %li.js-following-tab - = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do - = s_('UserProfile|Following') + - if Feature.enabled?(:profile_tabs_vue, current_user) + #js-profile-tabs + - else + .scrolling-tabs-container + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs + - if profile_tab?(:overview) + %li.js-overview-tab + = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do + = s_('UserProfile|Overview') + - if profile_tab?(:activity) + %li.js-activity-tab + = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do + = s_('UserProfile|Activity') + - unless Feature.enabled?(:security_auto_fix) && @user.bot? + - if profile_tab?(:groups) + %li.js-groups-tab + = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do + = s_('UserProfile|Groups') + - if profile_tab?(:contributed) + %li.js-contributed-tab + = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do + = s_('UserProfile|Contributed projects') + - if profile_tab?(:projects) + %li.js-projects-tab + = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do + = s_('UserProfile|Personal projects') + - if profile_tab?(:starred) + %li.js-starred-tab + = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do + = s_('UserProfile|Starred projects') + - if profile_tab?(:snippets) + %li.js-snippets-tab + = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do + = s_('UserProfile|Snippets') + - if profile_tab?(:followers) + %li.js-followers-tab + = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do + = s_('UserProfile|Followers') + - if profile_tab?(:following) + %li.js-following-tab + = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do + = s_('UserProfile|Following') %div{ class: container_class } - .tab-content - - if profile_tab?(:overview) - #js-overview.tab-pane - = render "users/overview" - - - if profile_tab?(:activity) - #activity.tab-pane - .flash-container - - if can?(current_user, :read_cross_project) - %h4.prepend-top-20 - = s_('UserProfile|Most Recent Activity') - .content_list{ data: { href: user_activity_path } } - .loading - = gl_loading_icon(size: 'md') - - unless @user.bot? - - if profile_tab?(:groups) - #groups.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:contributed) - #contributed.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:projects) - #projects.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:starred) - #starred.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:snippets) - #snippets.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:followers) - #followers.tab-pane - -# This tab is always loaded via AJAX - - - if profile_tab?(:following) - #following.tab-pane - -# This tab is always loaded via AJAX - - .loading.hide - .gl-spinner.gl-spinner-md + - unless Feature.enabled?(:profile_tabs_vue, current_user) + .tab-content + - if profile_tab?(:overview) + #js-overview.tab-pane + = render "users/overview" + + - if profile_tab?(:activity) + #activity.tab-pane + .flash-container + - if can?(current_user, :read_cross_project) + %h4.prepend-top-20 + = s_('UserProfile|Most Recent Activity') + .content_list{ data: { href: user_activity_path } } + .loading + = gl_loading_icon(size: 'md') + - unless @user.bot? + - if profile_tab?(:groups) + #groups.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:contributed) + #contributed.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:projects) + #projects.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:starred) + #starred.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:snippets) + #snippets.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:followers) + #followers.tab-pane + -# This tab is always loaded via AJAX + + - if profile_tab?(:following) + #following.tab-pane + -# This tab is always loaded via AJAX + + .loading.hide + .gl-spinner.gl-spinner-md - if profile_tabs.empty? .svg-content diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 693afdea43a..c660243d336 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -255,6 +255,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:ci_runners_stale_machines_cleanup_cron + :worker_name: Ci::Runners::StaleMachinesCleanupCronWorker + :feature_category: :runner_fleet + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:ci_schedule_delete_objects_cron :worker_name: Ci::ScheduleDeleteObjectsCronWorker :feature_category: :continuous_integration @@ -653,7 +662,7 @@ :tags: [] - :name: cronjob:prune_old_events :worker_name: PruneOldEventsWorker - :feature_category: :users + :feature_category: :user_profile :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -806,7 +815,7 @@ :tags: [] - :name: cronjob:user_status_cleanup_batch :worker_name: UserStatusCleanup::BatchWorker - :feature_category: :users + :feature_category: :user_profile :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -815,7 +824,7 @@ :tags: [] - :name: cronjob:users_create_statistics :worker_name: Users::CreateStatisticsWorker - :feature_category: :users + :feature_category: :user_profile :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -833,7 +842,7 @@ :tags: [] - :name: cronjob:users_migrate_records_to_ghost_user_in_batches :worker_name: Users::MigrateRecordsToGhostUserInBatchesWorker - :feature_category: :users + :feature_category: :user_profile :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2192,7 +2201,7 @@ :tags: [] - :name: approve_blocked_pending_approval_users :worker_name: ApproveBlockedPendingApprovalUsersWorker - :feature_category: :users + :feature_category: :user_profile :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2298,6 +2307,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: ci_cancel_redundant_pipelines + :worker_name: Ci::CancelRedundantPipelinesWorker + :feature_category: :continuous_integration + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: ci_delete_objects :worker_name: Ci::DeleteObjectsWorker :feature_category: :continuous_integration @@ -2318,7 +2336,7 @@ :tags: [] - :name: ci_parse_secure_file_metadata :worker_name: Ci::ParseSecureFileMetadataWorker - :feature_category: :mobile_signing_deployment + :feature_category: :mobile_devops :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2917,7 +2935,7 @@ :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: false + :idempotent: true :tags: [] - :name: new_note :worker_name: NewNoteWorker @@ -3146,7 +3164,7 @@ :tags: [] - :name: projects_record_target_platforms :worker_name: Projects::RecordTargetPlatformsWorker - :feature_category: :experimentation_activation + :feature_category: :projects :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown diff --git a/app/workers/approve_blocked_pending_approval_users_worker.rb b/app/workers/approve_blocked_pending_approval_users_worker.rb index 661ec87c1dd..13ce2454cf5 100644 --- a/app/workers/approve_blocked_pending_approval_users_worker.rb +++ b/app/workers/approve_blocked_pending_approval_users_worker.rb @@ -9,7 +9,7 @@ class ApproveBlockedPendingApprovalUsersWorker idempotent! - feature_category :users + feature_category :user_profile def perform(current_user_id) current_user = User.find(current_user_id) diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index d5eca86744e..6bce13c5ff0 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -4,6 +4,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker PERFORM_DELAY = 5.seconds + DEFAULT_BATCH_SIZE = 5 data_consistency :always feature_category :importers @@ -16,10 +17,11 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker return if @bulk_import.finished? || @bulk_import.failed? return @bulk_import.fail_op! if all_entities_failed? return @bulk_import.finish! if all_entities_processed? && @bulk_import.started? + return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running @bulk_import.start! if @bulk_import.created? - created_entities.find_each do |entity| + created_entities.first(next_batch_size).each do |entity| BulkImports::CreatePipelineTrackersService.new(entity).execute! entity.start! @@ -58,4 +60,16 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker def re_enqueue BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id) end + + def started_entities + entities.with_status(:started) + end + + def max_batch_size_exceeded? + started_entities.count >= DEFAULT_BATCH_SIZE + end + + def next_batch_size + [DEFAULT_BATCH_SIZE - started_entities.count, 0].max + end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 62e85d38e61..8f03c74e13e 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -103,14 +103,8 @@ module BulkImports pipeline_tracker.file_extraction_pipeline? end - def job_timeout? - return false unless file_extraction_pipeline? - - time_since_entity_created > Pipeline::NDJSON_EXPORT_TIMEOUT - end - def empty_export_timeout? - export_empty? && time_since_entity_created > Pipeline::EMPTY_EXPORT_STATUS_TIMEOUT + export_empty? && time_since_tracker_created > Pipeline::EMPTY_EXPORT_STATUS_TIMEOUT end def export_failed? @@ -167,8 +161,8 @@ module BulkImports logger.error(structured_payload(payload)) end - def time_since_entity_created - Time.zone.now - entity.created_at + def time_since_tracker_created + Time.zone.now - (pipeline_tracker.created_at || entity.created_at) end def lease_timeout @@ -178,5 +172,11 @@ module BulkImports def lease_key "gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}" end + + def job_timeout? + return false unless file_extraction_pipeline? + + time_since_tracker_created > Pipeline::NDJSON_EXPORT_TIMEOUT + end end end diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 12856805243..fe23d10c2ac 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -9,14 +9,20 @@ module Ci include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :continuous_integration + deduplicate :until_executed, including_scheduled: true # rubocop: disable CodeReuse/ActiveRecord def perform # Archive stale live traces which still resides in redis or database # This could happen when Ci::ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL # More details in https://gitlab.com/gitlab-org/gitlab-foss/issues/36791 - Ci::Build.with_stale_live_trace.find_each(batch_size: 100) do |build| - Ci::ArchiveTraceService.new.execute(build, worker_name: self.class.name) + + if Feature.enabled?(:deduplicate_archive_traces_cron_worker) + Ci::ArchiveTraceService.new.batch_execute(worker_name: self.class.name) + else + Ci::Build.with_stale_live_trace.find_each(batch_size: 100) do |build| + Ci::ArchiveTraceService.new.execute(build, worker_name: self.class.name) + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/ci/cancel_redundant_pipelines_worker.rb b/app/workers/ci/cancel_redundant_pipelines_worker.rb new file mode 100644 index 00000000000..8ae8b2df952 --- /dev/null +++ b/app/workers/ci/cancel_redundant_pipelines_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ci + class CancelRedundantPipelinesWorker + include ApplicationWorker + + data_consistency :always + feature_category :continuous_integration + idempotent! + deduplicate :until_executed + urgency :high + + def perform(pipeline_id) + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + Ci::PipelineCreation::CancelRedundantPipelinesService + .new(pipeline) + .execute + end + end + end +end diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 734755f176a..52a4f075cf0 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -17,7 +17,7 @@ module Ci def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - create_deployments!(pipeline) if Feature.enabled?(:move_create_deployments_to_worker, pipeline.project) + create_deployments!(pipeline) Ci::PipelineCreation::StartPipelineService .new(pipeline) diff --git a/app/workers/ci/parse_secure_file_metadata_worker.rb b/app/workers/ci/parse_secure_file_metadata_worker.rb index 0d2495d3155..8703bb13776 100644 --- a/app/workers/ci/parse_secure_file_metadata_worker.rb +++ b/app/workers/ci/parse_secure_file_metadata_worker.rb @@ -4,7 +4,7 @@ module Ci class ParseSecureFileMetadataWorker include ::ApplicationWorker - feature_category :mobile_signing_deployment + feature_category :mobile_devops 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 69ab477c80a..722c513a4bb 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 @@ -30,7 +30,7 @@ module Ci end result = ::Ci::Runners::ReconcileExistingRunnerVersionsService.new.execute - result.payload.each { |key, value| log_extra_metadata_on_done(key, value) } + log_hash_metadata_on_done(result.payload) end end end diff --git a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb new file mode 100644 index 00000000000..9a11db33fb6 --- /dev/null +++ b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Ci + module Runners + class StaleMachinesCleanupCronWorker + include ApplicationWorker + + # This worker does not schedule other workers that require context. + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :runner_fleet + urgency :low + + idempotent! + + def perform + result = ::Ci::Runners::StaleMachinesCleanupService.new.execute + log_extra_metadata_on_done(:status, result.status) + log_hash_metadata_on_done(result.payload) + end + end + end +end diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb index 55b23bbab62..b8332838b13 100644 --- a/app/workers/ci/schedule_delete_objects_cron_worker.rb +++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb @@ -14,8 +14,8 @@ module Ci feature_category :continuous_integration idempotent! - def perform(*args) - Ci::DeleteObjectsWorker.perform_with_capacity(*args) + def perform(...) + Ci::DeleteObjectsWorker.perform_with_capacity(...) end end end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 222d045b0ba..e2e31b0a5bd 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -36,6 +36,11 @@ module ApplicationWorker @done_log_extra_metadata[key] = value end + def log_hash_metadata_on_done(hash) + @done_log_extra_metadata ||= {} + hash.each { |key, value| @done_log_extra_metadata[key] = value } + end + def logging_extras return {} unless @done_log_extra_metadata diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb index c5f8c9c8464..718031ec33e 100644 --- a/app/workers/concerns/git_garbage_collect_methods.rb +++ b/app/workers/concerns/git_garbage_collect_methods.rb @@ -57,7 +57,7 @@ module GitGarbageCollectMethods end def gc?(task) - task == :gc || task == :prune + %i[gc eager prune].include?(task) end def try_obtain_lease(key) @@ -87,7 +87,7 @@ module GitGarbageCollectMethods if task == :prune client.prune_unreachable_objects else - client.optimize_repository + client.optimize_repository(eager: task == :eager) end rescue GRPC::NotFound => e Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index 0a43a0fc4d2..64fa705329e 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -25,8 +25,8 @@ module Gitlab end end - def try_import(*args) - import(*args) + def try_import(...) + import(...) true rescue RateLimitError false diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb index bcedb4efcc0..af66d80b3e9 100644 --- a/app/workers/concerns/limited_capacity/worker.rb +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -61,8 +61,8 @@ module LimitedCapacity end end - def perform(*args) - perform_registered(*args) if job_tracker.register(jid, max_running_jobs) + def perform(...) + perform_registered(...) if job_tracker.register(jid, max_running_jobs) end def perform_work(*args) @@ -81,9 +81,9 @@ module LimitedCapacity job_tracker.clean_up end - def report_prometheus_metrics(*args) + def report_prometheus_metrics(...) report_running_jobs_metrics - set_metric(:remaining_work_gauge, remaining_work_count(*args)) + set_metric(:remaining_work_gauge, remaining_work_count(...)) set_metric(:max_running_jobs_gauge, max_running_jobs) end diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index f23e3fb20c2..1fe950b7570 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -3,13 +3,6 @@ module WaitableWorker extend ActiveSupport::Concern - class_methods do - # Schedules multiple jobs and waits for them to be completed. - def bulk_perform_and_wait(args_list) - bulk_perform_async(args_list) - end - end - def perform(*args) notify_key = args.pop if Gitlab::JobWaiter.key?(args.last) diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb index b59e4bd1f86..37b40c73ca6 100644 --- a/app/workers/database/batched_background_migration/execution_worker.rb +++ b/app/workers/database/batched_background_migration/execution_worker.rb @@ -11,7 +11,7 @@ module Database INTERVAL_VARIANCE = 5.seconds.freeze LEASE_TIMEOUT_MULTIPLIER = 3 - MAX_RUNNING_MIGRATIONS = 2 + MAX_RUNNING_MIGRATIONS = 4 included do data_consistency :always diff --git a/app/workers/gitlab/import/stuck_import_job.rb b/app/workers/gitlab/import/stuck_import_job.rb index efbea7d8133..7278106efb9 100644 --- a/app/workers/gitlab/import/stuck_import_job.rb +++ b/app/workers/gitlab/import/stuck_import_job.rb @@ -37,7 +37,7 @@ module Gitlab def mark_imports_without_jid_as_failed! enqueued_import_states_without_jid - .each(&method(:mark_as_failed)) + .each { |import_state| mark_as_failed(import_state) } .size end @@ -61,7 +61,7 @@ module Gitlab ) completed_import_states - .each(&method(:mark_as_failed)) + .each { |import_state| mark_as_failed(import_state) } .size end diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb index 7d45a6785ea..6b3e1c5321b 100644 --- a/app/workers/incident_management/close_incident_worker.rb +++ b/app/workers/incident_management/close_incident_worker.rb @@ -30,7 +30,7 @@ module IncidentManagement def close_incident(incident) ::Issues::CloseService - .new(project: incident.project, current_user: user) + .new(container: incident.project, current_user: user) .execute(incident, system_note: false) end diff --git a/app/workers/issues/close_worker.rb b/app/workers/issues/close_worker.rb index 0d540ee8c4f..343f50cd7cf 100644 --- a/app/workers/issues/close_worker.rb +++ b/app/workers/issues/close_worker.rb @@ -42,7 +42,7 @@ module Issues end commit = Commit.build_from_sidekiq_hash(project, params["commit_hash"]) - service = Issues::CloseService.new(project: project, current_user: author) + service = Issues::CloseService.new(container: project, current_user: author) service.execute(issue, commit: commit) end diff --git a/app/workers/merge_requests/close_issue_worker.rb b/app/workers/merge_requests/close_issue_worker.rb index 8c3ba1bc5ab..5ac48423d41 100644 --- a/app/workers/merge_requests/close_issue_worker.rb +++ b/app/workers/merge_requests/close_issue_worker.rb @@ -45,7 +45,7 @@ module MergeRequests end Issues::CloseService - .new(project: project, current_user: user) + .new(container: project, current_user: user) .execute(issue, commit: merge_request) end end diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb index da1eca067a9..f9dbd85cd44 100644 --- a/app/workers/merge_requests/delete_source_branch_worker.rb +++ b/app/workers/merge_requests/delete_source_branch_worker.rb @@ -18,15 +18,10 @@ class MergeRequests::DeleteSourceBranchWorker # Source branch changed while it's being removed return if merge_request.source_branch_sha != source_branch_sha - if Feature.enabled?(:add_delete_branch_worker, merge_request.source_project) - ::Projects::DeleteBranchWorker.new.perform(merge_request.source_project.id, user_id, - merge_request.source_branch) - else - ::Branches::DeleteService.new(merge_request.source_project, user).execute(merge_request.source_branch) - end - ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) .execute(merge_request) + + ::Projects::DeleteBranchWorker.new.perform(merge_request.source_project.id, user_id, merge_request.source_branch) rescue ActiveRecord::RecordNotFound end end diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index e14f0dc7dfe..07699a50e36 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -26,7 +26,7 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker issuable.create_cross_references!(user) Issues::AfterCreateService - .new(project: issuable.project, current_user: user) + .new(container: issuable.project, current_user: user) .execute(issuable) end end diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index d6e8d517b5a..a32a414c0ba 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -8,6 +8,9 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 include NewIssuable + idempotent! + deduplicate :until_executed + feature_category :code_review_workflow urgency :high worker_resource_boundary :cpu @@ -15,6 +18,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) + return if issuable.prepared? MergeRequests::AfterCreateService .new(project: issuable.target_project, current_user: user) diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index 3e681c3f111..3fe4231bb55 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -99,7 +99,7 @@ module ObjectStorage end def migrate(uploads) - uploads.map(&method(:process_upload)) + uploads.map { |upload| process_upload(upload) } end def process_upload(upload) diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb index 822fe5a1517..1eff3ea02dd 100644 --- a/app/workers/packages/debian/generate_distribution_worker.rb +++ b/app/workers/packages/debian/generate_distribution_worker.rb @@ -4,7 +4,6 @@ module Packages module Debian class GenerateDistributionWorker include ApplicationWorker - include ::Packages::FIPS data_consistency :always include Gitlab::Utils::StrongMemoize @@ -21,8 +20,6 @@ module Packages loggable_arguments 0 def perform(container_type, distribution_id) - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - @container_type = container_type @distribution_id = distribution_id diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb index d477a6f2e1f..0a716c61203 100644 --- a/app/workers/packages/debian/process_changes_worker.rb +++ b/app/workers/packages/debian/process_changes_worker.rb @@ -4,7 +4,6 @@ module Packages module Debian class ProcessChangesWorker include ApplicationWorker - include ::Packages::FIPS data_consistency :always include Gitlab::Utils::StrongMemoize @@ -16,8 +15,6 @@ module Packages feature_category :package_registry def perform(package_file_id, user_id) - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - @package_file_id = package_file_id @user_id = user_id @@ -25,8 +22,6 @@ module Packages ::Packages::Debian::ProcessChangesService.new(package_file, user).execute rescue StandardError => e - raise if e.instance_of?(DisabledError) - Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id) package_file.destroy! end diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb index 587c0b78c9c..e9d6ad57749 100644 --- a/app/workers/packages/debian/process_package_file_worker.rb +++ b/app/workers/packages/debian/process_package_file_worker.rb @@ -4,7 +4,6 @@ module Packages module Debian class ProcessPackageFileWorker include ApplicationWorker - include ::Packages::FIPS include Gitlab::Utils::StrongMemoize data_consistency :always @@ -15,25 +14,20 @@ module Packages queue_namespace :package_repositories feature_category :package_registry - def perform(package_file_id, user_id, distribution_name, component_name) - raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? - + def perform(package_file_id, distribution_name, component_name) @package_file_id = package_file_id - @user_id = user_id @distribution_name = distribution_name @component_name = component_name - return unless package_file && user && distribution_name && component_name + return unless package_file && distribution_name && component_name # return if file has already been processed return unless package_file.debian_file_metadatum&.unknown? - ::Packages::Debian::ProcessPackageFileService.new(package_file, user, distribution_name, component_name).execute + ::Packages::Debian::ProcessPackageFileService.new(package_file, distribution_name, component_name).execute rescue StandardError => e - raise if e.instance_of?(DisabledError) - - Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id, + Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, distribution_name: @distribution_name, component_name: @component_name) - package_file.destroy! + package_file.package.update_column(:status, :error) end private @@ -42,11 +36,6 @@ module Packages ::Packages::PackageFile.find_by_id(@package_file_id) end strong_memoize_attr :package_file - - def user - ::User.find_by_id(@user_id) - end - strong_memoize_attr :user end end end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index fb843bd421c..ca589acf26c 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -15,30 +15,18 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker worker_resource_boundary :cpu def perform - if Feature.enabled?(:ci_use_run_pipeline_schedule_worker) - in_lock(lock_key, **lock_params) do - Ci::PipelineSchedule - .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] }, - context_proc: ->(schedule) { { project: schedule.project, user: schedule.owner } } - ) - end - end - else - Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| - schedules.each do |schedule| - next unless schedule.project - - with_context(project: schedule.project, user: schedule.owner) do - Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule) - end + in_lock(lock_key, **lock_params) do + Ci::PipelineSchedule + .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 } } + ) end - end end end diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb index 886919ecace..5a6c88fca69 100644 --- a/app/workers/projects/post_creation_worker.rb +++ b/app/workers/projects/post_creation_worker.rb @@ -38,12 +38,11 @@ module Projects def create_incident_management_timeline_event_tags(project) tags = project.incident_management_timeline_event_tags.pluck_names - start_time_name = ::IncidentManagement::TimelineEventTag::START_TIME_TAG_NAME - end_time_name = ::IncidentManagement::TimelineEventTag::END_TIME_TAG_NAME + predefined_tags = ::IncidentManagement::TimelineEventTag::PREDEFINED_TAGS - project.incident_management_timeline_event_tags.new(name: start_time_name) unless tags.include?(start_time_name) - - project.incident_management_timeline_event_tags.new(name: end_time_name) unless tags.include?(end_time_name) + predefined_tags.each do |tag| + project.incident_management_timeline_event_tags.new(name: tag) unless tags.include?(tag) + end project.save! rescue StandardError => e diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb index 899721492e9..e69450692d9 100644 --- a/app/workers/projects/record_target_platforms_worker.rb +++ b/app/workers/projects/record_target_platforms_worker.rb @@ -9,7 +9,7 @@ module Projects APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze ANDROID_PLATFORM_LANGUAGES = %w(java kotlin).freeze - feature_category :experimentation_activation + feature_category :projects data_consistency :always deduplicate :until_executed urgency :low diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb index ec23bde5898..065d103fe07 100644 --- a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb +++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb @@ -19,6 +19,7 @@ module Projects refresh = Projects::RefreshBuildArtifactsSizeStatisticsService.new.execute return unless refresh + log_extra_metadata_on_done(:refresh_id, refresh.id) log_extra_metadata_on_done(:project_id, refresh.project_id) log_extra_metadata_on_done(:last_job_artifact_id, refresh.last_job_artifact_id) log_extra_metadata_on_done(:last_batch, refresh.destroyed?) diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb index 2d74c271ddc..c8dfb2ade0a 100644 --- a/app/workers/prune_old_events_worker.rb +++ b/app/workers/prune_old_events_worker.rb @@ -10,7 +10,7 @@ class PruneOldEventsWorker # rubocop:disable Scalability/IdempotentWorker include CronjobQueue # rubocop:enable Scalability/CronWorkerContext - feature_category :users + feature_category :user_profile DELETE_LIMIT = 10_000 diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index db82cf3af91..a7037863ef5 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -19,7 +19,9 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker return unless schedule && schedule.project && user - if Feature.enabled?(:ci_use_run_pipeline_schedule_worker) + options.symbolize_keys! + + if options[:scheduling] return if schedule.next_run_at > Time.current update_next_run_at_for(schedule) diff --git a/app/workers/tasks_to_be_done/create_worker.rb b/app/workers/tasks_to_be_done/create_worker.rb index 0953f190fd0..d3824ceb4ae 100644 --- a/app/workers/tasks_to_be_done/create_worker.rb +++ b/app/workers/tasks_to_be_done/create_worker.rb @@ -17,7 +17,7 @@ module TasksToBeDone member_task.tasks_to_be_done.each do |task| service_class(task) - .new(project: project, current_user: current_user, assignee_ids: assignee_ids) + .new(container: project, current_user: current_user, assignee_ids: assignee_ids) .execute end end diff --git a/app/workers/user_status_cleanup/batch_worker.rb b/app/workers/user_status_cleanup/batch_worker.rb index f3d73b2e6e9..95a8bf6fe90 100644 --- a/app/workers/user_status_cleanup/batch_worker.rb +++ b/app/workers/user_status_cleanup/batch_worker.rb @@ -11,7 +11,7 @@ module UserStatusCleanup include CronjobQueue # rubocop:enable Scalability/CronWorkerContext - feature_category :users + feature_category :user_profile idempotent! diff --git a/app/workers/users/create_statistics_worker.rb b/app/workers/users/create_statistics_worker.rb index 0c27d165ded..e258fb92907 100644 --- a/app/workers/users/create_statistics_worker.rb +++ b/app/workers/users/create_statistics_worker.rb @@ -11,7 +11,7 @@ module Users include CronjobQueue # rubocop:enable Scalability/CronWorkerContext - feature_category :users + feature_category :user_profile def perform UsersStatistics.create_current_stats! diff --git a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb index d9a80b6e899..cfb463241d3 100644 --- a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb +++ b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb @@ -7,7 +7,7 @@ module Users include CronjobQueue # rubocop: disable Scalability/CronWorkerContext sidekiq_options retry: false - feature_category :users + feature_category :user_profile data_consistency :always idempotent! |