diff options
Diffstat (limited to 'app')
1429 files changed, 20001 insertions, 19673 deletions
diff --git a/app/assets/images/callouts/rich_text_editor_illustration.svg b/app/assets/images/callouts/rich_text_editor_illustration.svg new file mode 100644 index 00000000000..b07d8871fe6 --- /dev/null +++ b/app/assets/images/callouts/rich_text_editor_illustration.svg @@ -0,0 +1,79 @@ +<svg width="280" height="130" viewBox="0 0 280 130" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_191_42179)"> +<circle cx="189.5" cy="-42.5" r="131.5" fill="url(#paint0_radial_191_42179)"/> +<circle cx="-41.5" cy="-97.5" r="198.5" fill="url(#paint1_radial_191_42179)"/> +<circle cx="309.5" cy="-7.5" r="121.5" fill="url(#paint2_radial_191_42179)"/> +<g filter="url(#filter0_b_191_42179)"> +<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white" fill-opacity="0.01"/> +</g> +</g> +<g transform="translate(64, 16)"> +<path d="M135.455 109.089H47.0349C30.7979 109.089 17.6364 95.8523 17.6364 79.5229V0H106.056C122.293 0 135.455 13.2364 135.455 29.5658V109.091V109.089Z" fill="white"/> +<path d="M37.0022 29H116C116 46 116 63 116 80C116 84.4183 112.549 88 108.293 88L37 88V29H37.0022Z" fill="white"/> +<path d="M116 16H37V29H116V16Z" fill="#AEA5D6"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M36 15H101V16H37V42H36V15Z" fill="#171321"/> +<path d="M53 22.5C53 23.8807 51.8807 25 50.5 25C49.1193 25 48 23.8807 48 22.5C48 21.1193 49.1193 20 50.5 20C51.8807 20 53 21.1193 53 22.5Z" fill="#A888F4"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M50.5 24C51.3284 24 52 23.3284 52 22.5C52 21.6716 51.3284 21 50.5 21C49.6716 21 49 21.6716 49 22.5C49 23.3284 49.6716 24 50.5 24ZM50.5 25C51.8807 25 53 23.8807 53 22.5C53 21.1193 51.8807 20 50.5 20C49.1193 20 48 21.1193 48 22.5C48 23.8807 49.1193 25 50.5 25Z" fill="#171321"/> +<path d="M60 22.5C60 23.8807 58.8807 25 57.5 25C56.1193 25 55 23.8807 55 22.5C55 21.1193 56.1193 20 57.5 20C58.8807 20 60 21.1193 60 22.5Z" fill="#FF9D73"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M57.5 24C58.3284 24 59 23.3284 59 22.5C59 21.6716 58.3284 21 57.5 21C56.6716 21 56 21.6716 56 22.5C56 23.3284 56.6716 24 57.5 24ZM57.5 25C58.8807 25 60 23.8807 60 22.5C60 21.1193 58.8807 20 57.5 20C56.1193 20 55 21.1193 55 22.5C55 23.8807 56.1193 25 57.5 25Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5 10.5C30.3325 10.5 38.152 14.4668 42.923 22.3723L43 22.5L42.923 22.6277C38.152 30.5332 30.3325 34.5 22.5 34.5C14.6675 34.5 6.84799 30.5332 2.07704 22.6277L2 22.5L2.07704 22.3723C6.84799 14.4668 14.6675 10.5 22.5 10.5Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M41.8274 22.5C37.2212 15.1579 29.8576 11.5 22.5 11.5C15.1424 11.5 7.77878 15.1579 3.1726 22.5C7.77878 29.8421 15.1424 33.5 22.5 33.5C29.8576 33.5 37.2212 29.8421 41.8274 22.5ZM2 22.5L2.07704 22.6277C6.84799 30.5332 14.6675 34.5 22.5 34.5C30.3325 34.5 38.152 30.5332 42.923 22.6277L43 22.5L42.923 22.3723C38.152 14.4668 30.3325 10.5 22.5 10.5C14.6675 10.5 6.84799 14.4668 2.07704 22.3723L2 22.5Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0 22.5C0 21.3954 0.895434 20.5 2 20.5C3.10457 20.5 4 21.3954 4 22.5C4 23.6046 3.10457 24.5 2 24.5C0.895434 24.5 0 23.6046 0 22.5Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2 21.5C1.44772 21.5 1 21.9477 1 22.5C1 23.0523 1.44772 23.5 2 23.5C2.55229 23.5 3 23.0523 3 22.5C3 21.9477 2.55229 21.5 2 21.5ZM2 20.5C0.895434 20.5 0 21.3954 0 22.5C0 23.6046 0.895434 24.5 2 24.5C3.10457 24.5 4 23.6046 4 22.5C4 21.3954 3.10457 20.5 2 20.5Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M41 22.5C41 21.3954 41.8954 20.5 43 20.5C44.1046 20.5 45 21.3954 45 22.5C45 23.6046 44.1046 24.5 43 24.5C41.8954 24.5 41 23.6046 41 22.5Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M43 21.5C42.4477 21.5 42 21.9477 42 22.5C42 23.0523 42.4477 23.5 43 23.5C43.5523 23.5 44 23.0523 44 22.5C44 21.9477 43.5523 21.5 43 21.5ZM43 20.5C41.8954 20.5 41 21.3954 41 22.5C41 23.6046 41.8954 24.5 43 24.5C44.1046 24.5 45 23.6046 45 22.5C45 21.3954 44.1046 20.5 43 20.5Z" fill="#171321"/> +<path d="M22.5 30C26.6421 30 30 26.6421 30 22.5C30 18.3579 26.6421 15 22.5 15C18.3579 15 15 18.3579 15 22.5C15 26.6421 18.3579 30 22.5 30Z" fill="#10B1B1"/> +<path d="M27.0838 22.3192C27.0838 23.5746 25.3096 22.4248 23.8629 20.9715C22.4317 19.5337 21.3192 17.7969 22.5614 17.7969C25.0589 17.7969 27.0838 19.8217 27.0838 22.3192Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M37 34V65H36V34H37Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M37 93V70.0117H36V94H57V93H37Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M64 93H93V94H64V93Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M116 65V38H117V65H116Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M144 104H122V103H144V104Z" fill="#AEA5D6"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M138 97H129V96H138V97Z" fill="#AEA5D6"/> +<path d="M104 34H47V46H104V34Z" fill="#E7E4F2"/> +<path d="M74 51H48V83H74V51Z" fill="#FF9D73"/> +<path d="M60.5 70C61.8807 70 63 68.8807 63 67.5C63 66.1193 61.8807 65 60.5 65C59.1193 65 58 66.1193 58 67.5C58 68.8807 59.1193 70 60.5 70Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 69C61.3284 69 62 68.3284 62 67.5C62 66.6716 61.3284 66 60.5 66C59.6716 66 59 66.6716 59 67.5C59 68.3284 59.6716 69 60.5 69ZM63 67.5C63 68.8807 61.8807 70 60.5 70C59.1193 70 58 68.8807 58 67.5C58 66.1193 59.1193 65 60.5 65C61.8807 65 63 66.1193 63 67.5Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M74 50V84H73V50H74Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M47 84V70H48V84H47Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M47 65V50H48V65H47Z" fill="#171321"/> +<path d="M104 51H78V71H104V51Z" fill="#E7E4F2"/> +<path d="M104 76H78V83H104V76Z" fill="#E7E4F2"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M23 34V58.5C23 63.1944 26.8056 67 31.5 67H59V68H31.5C26.2533 68 22 63.7467 22 58.5V34H23Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M46 51H75V52H46V51Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M75 83H46V82H75V83Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M14 67H32V68H14V67Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2 67H10V68H2V67Z" fill="#AEA5D6"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5 69.5V95.5C58.5 100.194 62.3056 104 67 104H94L93.5 105H67C61.7533 105 57.5 100.747 57.5 95.5V69.5H58.5Z" fill="#171321"/> +<rect x="130.598" y="54.4473" width="19" height="46" transform="rotate(45 130.598 54.4473)" fill="white"/> +<path d="M111.506 100.41L98.0714 86.9746L93.4752 105.006L111.506 100.41Z" fill="#FF9D73"/> +<path d="M140.498 44.5479L144.033 48.0834C146.666 50.7156 147.982 52.0318 148.701 53.443C150.154 56.2951 150.154 59.6706 148.701 62.5228C147.982 63.934 146.666 65.2501 144.033 67.8824L144.033 67.8824L130.598 54.4473L140.498 44.5479Z" fill="#5829CB"/> +<path d="M130.598 54.4473L131.305 55.1544L98.7785 87.6813L98.0714 86.9742L130.598 54.4473Z" fill="#171321"/> +<path d="M143.326 67.1758L144.033 67.8829L111.506 100.41L110.799 99.7027L143.326 67.1758Z" fill="#171321"/> +<path d="M136.962 60.8115L137.669 61.5186L105.142 94.0455L104.435 93.3384L136.962 60.8115Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M95.4861 97.1172L93.4752 105.006L101.364 102.995L95.4861 97.1172Z" fill="#171321"/> +</g> +<defs> +<filter id="filter0_b_191_42179" x="-50" y="-50" width="380" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImageFix" stdDeviation="25"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_191_42179"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_191_42179" result="shape"/> +</filter> +<radialGradient id="paint0_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(189.5 -42.5) rotate(89.5818) scale(125.986)"> +<stop stop-color="#7759C2"/> +<stop offset="1" stop-color="#7759C2" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint1_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-41.5 -97.5) rotate(89.5818) scale(190.176)"> +<stop stop-color="#D64028"/> +<stop offset="1" stop-color="#D64028" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint2_radial_191_42179" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(309.5 -7.5) rotate(89.5818) scale(116.405)"> +<stop stop-color="#EF76F1"/> +<stop offset="1" stop-color="#EF76F1" stop-opacity="0"/> +</radialGradient> +<clipPath id="clip0_191_42179"> +<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/app/assets/images/service_desk_callout.svg b/app/assets/images/service_desk_callout.svg new file mode 100644 index 00000000000..2886388279e --- /dev/null +++ b/app/assets/images/service_desk_callout.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><rect width="7" height="1" x="59" y="38" fill="#E1DBF2" rx=".5"/><path fill="#6B4FBB" d="M60.5 42a3.5 3.5 0 0 0 0-7v7z"/><rect width="7" height="1" x="12" y="38" fill="#E1DBF2" transform="matrix(-1 0 0 1 31 0)" rx=".5"/><path fill="#6B4FBB" d="M17.5 42a3.5 3.5 0 0 1 0-7v7z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M39 58c10.493 0 19-8.507 19-19s-8.507-19-19-19-19 8.507-19 19 8.507 19 19 19zm0 4c-12.703 0-23-10.297-23-23s10.297-23 23-23 23 10.297 23 23-10.297 23-23 23z"/><path fill="#6B4FBB" d="M35 56a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M26.5 40c0 4.143 3.355 7.5 7.494 7.5h10.012A7.497 7.497 0 0 0 51.5 40c0-4.143-3.355-7.5-7.494-7.5H33.994A7.497 7.497 0 0 0 26.5 40zm-3 0c0-5.799 4.698-10.5 10.494-10.5h10.012C49.802 29.5 54.5 34.2 54.5 40c0 5.799-4.698 10.5-10.494 10.5H33.994C28.198 50.5 23.5 45.8 23.5 40z"/><path fill="#6B4FBB" fill-rule="nonzero" d="M35.255 42.406a1 1 0 1 1 1.872-.703 2.001 2.001 0 0 0 3.76-.038 1 1 0 1 1 1.886.665 4 4 0 0 1-7.518.076zM31.5 40a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm15 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/><path fill="#6B4FBB" d="M38 22h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2zm0 3h2a1 1 0 0 1 0 2h-2a1 1 0 0 1 0-2z" style="mix-blend-mode:multiply"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 57a237c3e84..d15c8e6e703 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -114,8 +114,7 @@ export default { <template> <dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.EVENT_SUCCESS]="onSuccess"> - <div> - <hr /> + <div class="gl-pt-6"> <h5>{{ header }}</h5> <p v-if="information" data-testid="information-section"> diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue index 3954e541fe0..23803e82476 100644 --- a/app/assets/javascripts/access_tokens/components/token.vue +++ b/app/assets/javascripts/access_tokens/components/token.vue @@ -30,26 +30,19 @@ export default { </script> <template> - <div class="row"> - <div class="col-lg-12"> - <hr /> - </div> - <div class="col-lg-4"> - <h4 class="gl-mt-0"><slot name="title"></slot></h4> - <slot name="description"></slot> - </div> - <div class="col-lg-8"> - <input-copy-toggle-visibility - :label="inputLabel" - :label-for="inputId" - :form-input-group-props="formInputGroupProps" - :value="token" - :copy-button-title="copyButtonTitle" - > - <template #description> - <slot name="input-description"></slot> - </template> - </input-copy-toggle-visibility> - </div> + <div> + <h4 class="gl-my-0"><slot name="title"></slot></h4> + <slot name="description"></slot> + <input-copy-toggle-visibility + :label="inputLabel" + :label-for="inputId" + :form-input-group-props="formInputGroupProps" + :value="token" + :copy-button-title="copyButtonTitle" + > + <template #description> + <slot name="input-description"></slot> + </template> + </input-copy-toggle-visibility> </div> </template> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue index 1f72f5e19e2..88119ed8a84 100644 --- a/app/assets/javascripts/access_tokens/components/tokens_app.vue +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -79,7 +79,7 @@ export default { </script> <template> - <div class="js-search-settings-section"> + <div class="settings-section gl-pt-0! js-search-settings-section"> <token v-for="(tokenData, tokenType) in enabledTokenTypes" :key="tokenType" @@ -89,10 +89,18 @@ export default { :copy-button-title="$options.i18n[tokenType].copyButtonTitle" :data-testid="$options.htmlAttributes[tokenType].containerTestId" > - <template #title>{{ $options.i18n[tokenType].label }}</template> + <template #title> + <div class="settings-sticky-header"> + <div class="settings-sticky-header-inner"> + {{ $options.i18n[tokenType].label }} + </div> + </div> + </template> <template #description> - <p>{{ $options.i18n[tokenType].description }}</p> - <p>{{ $options.i18n.canNotAccessOtherData }}</p> + <p class="gl-text-secondary"> + {{ $options.i18n[tokenType].description }} + {{ $options.i18n.canNotAccessOtherData }} + </p> </template> <template #input-description> <gl-sprintf :message="$options.i18n[tokenType].inputDescription"> diff --git a/app/assets/javascripts/actioncable_connection_monitor.js b/app/assets/javascripts/actioncable_connection_monitor.js deleted file mode 100644 index fc4e436c7fb..00000000000 --- a/app/assets/javascripts/actioncable_connection_monitor.js +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable no-restricted-globals */ - -import { logger } from '@rails/actioncable'; - -// This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js -// so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this. - -// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting -// revival reconnections if things go astray. Internal class, not intended for direct user manipulation. - -const now = () => new Date().getTime(); - -const secondsSince = (time) => (now() - time) / 1000; -class ConnectionMonitor { - constructor(connection) { - this.visibilityDidChange = this.visibilityDidChange.bind(this); - this.connection = connection; - this.reconnectAttempts = 0; - } - - start() { - if (!this.isRunning()) { - this.startedAt = now(); - delete this.stoppedAt; - this.startPolling(); - addEventListener('visibilitychange', this.visibilityDidChange); - logger.log( - `ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`, - ); - } - } - - stop() { - if (this.isRunning()) { - this.stoppedAt = now(); - this.stopPolling(); - removeEventListener('visibilitychange', this.visibilityDidChange); - logger.log('ConnectionMonitor stopped'); - } - } - - isRunning() { - return this.startedAt && !this.stoppedAt; - } - - recordPing() { - this.pingedAt = now(); - } - - recordConnect() { - this.reconnectAttempts = 0; - this.recordPing(); - delete this.disconnectedAt; - logger.log('ConnectionMonitor recorded connect'); - } - - recordDisconnect() { - this.disconnectedAt = now(); - logger.log('ConnectionMonitor recorded disconnect'); - } - - // Private - - startPolling() { - this.stopPolling(); - this.poll(); - } - - stopPolling() { - clearTimeout(this.pollTimeout); - } - - poll() { - this.pollTimeout = setTimeout(() => { - this.reconnectIfStale(); - this.poll(); - }, this.getPollInterval()); - } - - getPollInterval() { - const { staleThreshold, reconnectionBackoffRate } = this.constructor; - const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10); - const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate; - const jitter = jitterMax * Math.random(); - return staleThreshold * 1000 * backoff * (1 + jitter); - } - - reconnectIfStale() { - if (this.connectionIsStale()) { - logger.log( - `ConnectionMonitor detected stale connection. reconnectAttempts = ${ - this.reconnectAttempts - }, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${ - this.constructor.staleThreshold - } s`, - ); - this.reconnectAttempts += 1; - if (this.disconnectedRecently()) { - logger.log( - `ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince( - this.disconnectedAt, - )} s`, - ); - } else { - logger.log('ConnectionMonitor reopening'); - this.connection.reopen(); - } - } - } - - get refreshedAt() { - return this.pingedAt ? this.pingedAt : this.startedAt; - } - - connectionIsStale() { - return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; - } - - disconnectedRecently() { - return ( - this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold - ); - } - - visibilityDidChange() { - if (document.visibilityState === 'visible') { - setTimeout(() => { - if (this.connectionIsStale() || !this.connection.isOpen()) { - logger.log( - `ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`, - ); - this.connection.reopen(); - } - }, 200); - } - } -} - -ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) -ConnectionMonitor.reconnectionBackoffRate = 0.15; - -export default ConnectionMonitor; diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js index aeb61e61a3d..5658ffc1a38 100644 --- a/app/assets/javascripts/actioncable_consumer.js +++ b/app/assets/javascripts/actioncable_consumer.js @@ -1,10 +1,3 @@ import { createConsumer } from '@rails/actioncable'; -import ConnectionMonitor from './actioncable_connection_monitor'; -const consumer = createConsumer(); - -if (consumer.connection) { - consumer.connection.monitor = new ConnectionMonitor(consumer.connection); -} - -export default consumer; +export default createConsumer(); diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue new file mode 100644 index 00000000000..f05f96d6302 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_category.vue @@ -0,0 +1,33 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { ABUSE_CATEGORIES } from '../constants'; + +export default { + name: 'AbuseCategory', + components: { + GlLabel, + }, + props: { + category: { + type: String, + required: true, + }, + }, + computed: { + categoryObject() { + return ABUSE_CATEGORIES[this.category]; + }, + }, +}; +</script> + +<template> + <gl-label + v-if="categoryObject" + size="sm" + :background-color="categoryObject.backgroundColor" + :title="categoryObject.title" + :target="null" + :class="`gl-text-${categoryObject.color}`" + /> +</template> diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue index b8a4640de59..b229dd9e993 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue @@ -5,12 +5,14 @@ import { queryToObject } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { SORT_UPDATED_AT } from '../constants'; +import AbuseCategory from './abuse_category.vue'; export default { name: 'AbuseReportRow', components: { GlLink, ListItem, + AbuseCategory, }, props: { report: { @@ -44,13 +46,24 @@ export default { <template> <list-item data-testid="abuse-report-row"> <template #left-primary> - <gl-link :href="report.reportPath" class="gl-font-weight-normal gl-mb-2" data-testid="title"> + <gl-link + :href="report.reportPath" + class="gl-font-weight-normal gl-pt-4 gl-text-gray-900" + data-testid="abuse-report-title" + > {{ title }} </gl-link> </template> + <template #left-secondary> + <abuse-category + :category="report.category" + class="gl-mt-2 gl-mb-3" + data-testid="abuse-report-category" + /> + </template> <template #right-secondary> - <div data-testid="abuse-report-date">{{ displayDate }}</div> + <div class="gl-mt-7" data-testid="abuse-report-date">{{ displayDate }}</div> </template> </list-item> </template> diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index 9458aea299e..acb79293dfb 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -5,7 +5,7 @@ import { OPERATORS_IS, TOKEN_TITLE_STATUS, } from '~/vue_shared/components/filtered_search_bar/constants'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; const STATUS_OPTIONS = [ { value: 'closed', title: __('Closed') }, @@ -78,3 +78,46 @@ export const FILTERED_SEARCH_TOKENS = [ FILTERED_SEARCH_TOKEN_REPORTER, FILTERED_SEARCH_TOKEN_STATUS, ]; + +export const ABUSE_CATEGORIES = { + spam: { + backgroundColor: '#f5d9a8', + color: 'orange-700', + title: s__('AbuseReport|Spam'), + }, + offensive: { + backgroundColor: '#e1d8f9', + color: 'purple-700', + title: s__('AbuseReport|Offensive or Abusive'), + }, + phishing: { + backgroundColor: '#7c7ccc', + color: 'indigo-800', + title: s__('AbuseReport|Phishing'), + }, + crypto: { + backgroundColor: '#fdd4cd', + color: 'red-700', + title: s__('AbuseReport|Crypto Mining'), + }, + credentials: { + backgroundColor: '#cbe2f9', + color: 'blue-700', + title: s__('AbuseReport|Personal information or credentials'), + }, + copyright: { + backgroundColor: '#c3e6cd', + color: 'green-700', + title: s__('AbuseReport|Copyright or trademark violation'), + }, + malware: { + backgroundColor: '#fdd4cd', + color: 'red-700', + title: s__('AbuseReport|Malware'), + }, + other: { + backgroundColor: '#dcdcde', + color: 'gray-700', + title: s__('AbuseReport|Other'), + }, +}; diff --git a/app/assets/javascripts/admin/applications/components/delete_application.vue b/app/assets/javascripts/admin/applications/components/delete_application.vue index 77694296b0a..287a5537cf4 100644 --- a/app/assets/javascripts/admin/applications/components/delete_application.vue +++ b/app/assets/javascripts/admin/applications/components/delete_application.vue @@ -26,7 +26,7 @@ export default { methods: { buttonEvent(e) { e.preventDefault(); - this.show(e.target.dataset); + this.show(e.currentTarget.dataset); }, show(dataset) { const { name, path } = dataset; diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue index 427e6c14327..42a959e1b89 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -17,7 +17,15 @@ import { createAlert, VARIANT_DANGER } from '~/alert'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { THEMES, TYPES, TYPE_BANNER } from '../constants'; +import { + THEMES, + TYPES, + TYPE_BANNER, + TARGET_OPTIONS, + TARGET_ALL, + TARGET_ALL_MATCHING_PATH, + TARGET_ROLES, +} from '../constants'; import DatetimePicker from './datetime_picker.vue'; const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } }; @@ -59,10 +67,8 @@ export default { theme: s__('BroadcastMessages|Theme'), dismissable: s__('BroadcastMessages|Dismissable'), dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'), + target: s__('BroadcastMessages|Target broadcast message'), targetRoles: s__('BroadcastMessages|Target roles'), - targetRolesDescription: s__( - 'BroadcastMessages|The broadcast message displays only to users in projects and groups who have these roles.', - ), targetPath: s__('BroadcastMessages|Target Path'), targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'), startsAt: s__('BroadcastMessages|Starts at'), @@ -79,6 +85,7 @@ export default { }, messageThemes: THEMES, messageTypes: TYPES, + targetOptions: TARGET_OPTIONS, props: { broadcastMessage: { type: Object, @@ -92,6 +99,7 @@ export default { type: this.broadcastMessage.broadcastType, theme: this.broadcastMessage.theme, dismissable: this.broadcastMessage.dismissable || false, + targetSelected: '', targetPath: this.broadcastMessage.targetPath, targetAccessLevels: this.broadcastMessage.targetAccessLevels, targetAccessLevelOptions: this.targetAccessLevelOptions.map(([text, value]) => ({ @@ -122,6 +130,14 @@ export default { ? this.messagesPath : `${this.messagesPath}/${this.broadcastMessage.id}`; }, + showTargetRoles() { + return this.targetSelected === TARGET_ROLES; + }, + showTargetPath() { + return ( + this.targetSelected === TARGET_ROLES || this.targetSelected === TARGET_ALL_MATCHING_PATH + ); + }, formPayload() { return JSON.stringify({ message: this.message, @@ -143,6 +159,17 @@ export default { }, immediate: true, }, + targetSelected(newTarget) { + if (newTarget === TARGET_ALL) { + this.targetPath = ''; + this.targetAccessLevels = []; + } else if (newTarget === TARGET_ALL_MATCHING_PATH) { + this.targetAccessLevels = []; + } + }, + }, + created() { + this.targetSelected = this.initialTarget(); }, methods: { async onSubmit() { @@ -179,6 +206,15 @@ export default { this.renderedMessage = ''; } }, + + initialTarget() { + if (this.targetAccessLevels.length > 0) { + return TARGET_ROLES; + } else if (this.targetPath !== '') { + return TARGET_ALL_MATCHING_PATH; + } + return TARGET_ALL; + }, }, safeHtmlConfig: { ADD_TAGS: ['use'], @@ -245,14 +281,29 @@ export default { </gl-form-group> </template> - <gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes"> + <gl-form-group :label="$options.i18n.target" label-for="target-select"> + <gl-form-select + id="target-select" + v-model="targetSelected" + :options="$options.targetOptions" + data-testid="target-select" + /> + </gl-form-group> + + <gl-form-group + v-show="showTargetRoles" + :label="$options.i18n.targetRoles" + data-testid="target-roles-checkboxes" + > <gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" /> - <gl-form-text> - {{ $options.i18n.targetRolesDescription }} - </gl-form-text> </gl-form-group> - <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> + <gl-form-group + v-show="showTargetPath" + :label="$options.i18n.targetPath" + label-for="target-path-input" + data-testid="target-path-input" + > <gl-form-input id="target-path-input" v-model="targetPath" /> <gl-form-text> {{ $options.i18n.targetPathDescription }} diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js index ed137181a48..76e1cf91c2f 100644 --- a/app/assets/javascripts/admin/broadcast_messages/constants.js +++ b/app/assets/javascripts/admin/broadcast_messages/constants.js @@ -21,6 +21,27 @@ export const THEMES = [ { value: 'light', text: s__('BroadcastMessages|Light') }, ]; +export const TARGET_ALL = 'target_all'; +export const TARGET_ALL_MATCHING_PATH = 'target_all_matching_path'; +export const TARGET_ROLES = 'target_roles'; + +export const TARGET_OPTIONS = [ + { + value: TARGET_ALL, + text: s__('BroadcastMessages|Show to all users on all pages'), + }, + { + value: TARGET_ALL_MATCHING_PATH, + text: s__('BroadcastMessages|Show to all users on specific matching pages'), + }, + { + value: TARGET_ROLES, + text: s__( + 'BroadcastMessages|Show only to users who have specific roles on groups/project pages', + ), + }, +]; + export const NEW_BROADCAST_MESSAGE = { message: '', broadcastType: TYPES[0].value, diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index d7bdceb4798..36dcde619cf 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -12,7 +12,7 @@ const messageHtml = ` <li>${s__("AdminUsers|The user can't log in.")}</li> <li>${s__("AdminUsers|The user can't access git repositories.")}</li> <li>${s__( - 'AdminUsers|Issues and merge requests authored by this user are hidden from other users.', + 'AdminUsers|Projects, issues, merge requests, and comments of this user are hidden from other users.', )}</li> </ul> <p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index 2d2c598f953..65737be1e67 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -109,7 +109,7 @@ export default { :empty-text="s__('AdminUsers|No users found')" show-empty stacked="md" - :tbody-tr-attr="{ 'data-qa-selector': 'user_row_content' }" + :tbody-tr-attr="{ 'data-testid': 'user-row-content' }" > <template #cell(name)="{ item: user }"> <user-avatar :user="user" :admin-user-path="paths.adminUser" /> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 428291f2313..033f48827f1 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -16,7 +16,7 @@ import { import * as Sentry from '@sentry/browser'; import { isEqual, isEmpty, omit } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { PROMO_URL, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import { integrationTypes, integrationSteps, @@ -38,6 +38,7 @@ export default { placeholders: { prometheus: targetPrometheusUrlPlaceholder, }, + incidentManagementDocsLink: `${DOCS_URL_IN_EE_DIR}/operations/incident_management/integrations.html#configuration`, JSON_VALIDATE_DELAY, typeSet, integrationSteps, @@ -121,14 +122,12 @@ export default { name: '', token: '', url: '', - apiUrl: '', }, activeTabIndex: this.tabIndex, currentIntegration: null, parsedPayload: [], validationState: { name: true, - apiUrl: true, }, pricingLink: `${PROMO_URL}/pricing`, }; @@ -187,20 +186,14 @@ export default { ); }, isFormDirty() { - const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } = + const { type, active, name, payloadAlertFields = [], payloadAttributeMappings = [] } = this.currentIntegration || {}; - const { - name: formName, - apiUrl: formApiUrl, - active: formActive, - type: formType, - } = this.integrationForm; + const { name: formName, active: formActive, type: formType } = this.integrationForm; const isDirty = type !== formType || active !== formActive || name !== formName || - apiUrl !== formApiUrl || !isEqual(this.parsedPayload, payloadAlertFields) || !isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings)); @@ -210,25 +203,19 @@ export default { return this.isFormValid && this.isFormDirty; }, dataForSave() { - const { name, apiUrl, active } = this.integrationForm; + const { name, active } = this.integrationForm; const customMappingVariables = { payloadAttributeMappings: this.mapping, payloadExample: this.samplePayload.json || '{}', }; - const variables = this.isHttp - ? { name, active, ...customMappingVariables } - : { apiUrl, active }; + const variables = this.isHttp ? { name, active, ...customMappingVariables } : { active }; return { type: this.integrationForm.type, variables }; }, testAlertModal() { return this.isFormDirty ? testAlertModalId : null; }, - prometheusUrlInvalidFeedback() { - const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl; - return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError; - }, }, watch: { tabIndex(val) { @@ -246,13 +233,12 @@ export default { type, active, url, - apiUrl, token, payloadExample, payloadAlertFields, payloadAttributeMappings, } = val; - this.integrationForm = { type, name, active, url, apiUrl, token }; + this.integrationForm = { type, name, active, url, token }; if (this.showMappingBuilder) { this.resetPayloadAndMappingConfirmed = false; @@ -270,14 +256,6 @@ export default { validateName() { this.validationState.name = Boolean(this.integrationForm.name?.length); }, - validateApiUrl() { - try { - const parsedUrl = new URL(this.integrationForm.apiUrl); - this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol); - } catch (e) { - this.validationState.apiUrl = false; - } - }, isValidNonEmptyJSON(JSONString) { if (JSONString) { let parsed; @@ -297,14 +275,12 @@ export default { }, triggerValidation() { if (this.isHttp) { - this.validationState.apiUrl = true; this.validateName(); if (!this.validationState.name) { this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } else if (this.isPrometheus) { this.validationState.name = true; - this.validateApiUrl(); } }, sendTestAlert() { @@ -331,7 +307,6 @@ export default { this.integrationForm.type = integrationTypes.none.value; this.integrationForm.name = ''; this.integrationForm.active = false; - this.integrationForm.apiUrl = ''; this.samplePayload = { json: null, error: null, @@ -489,28 +464,6 @@ export default { class="gl-mt-4 gl-font-weight-normal" /> </gl-form-group> - - <gl-form-group - v-if="isPrometheus" - class="gl-my-4" - :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label" - label-for="api-url" - :invalid-feedback="prometheusUrlInvalidFeedback" - :state="validationState.apiUrl" - > - <gl-form-input - id="api-url" - v-model="integrationForm.apiUrl" - type="text" - :placeholder="$options.placeholders.prometheus" - data-qa-selector="prometheus_url_field" - @input="validateApiUrl" - /> - <span class="gl-text-gray-400"> - {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} - </span> - </gl-form-group> - <template v-if="showMappingBuilder"> <gl-form-group data-testid="sample-payload-section" @@ -617,7 +570,7 @@ export default { > <alert-settings-form-help-block :message="viewCredentialsHelpMsg" - link="https://docs.gitlab.com/ee/operations/incident_management/integrations.html#configuration" + :link="$options.incidentManagementDocsLink" /> <gl-form-group id="integration-webhook"> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 6d914fe8361..218b09cb1b6 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -65,12 +65,6 @@ export const i18n = { proceedWithoutSave: s__('AlertSettings|Send without saving'), cancel: __('Cancel'), }, - prometheusFormUrl: { - label: s__('AlertSettings|Prometheus API base URL'), - help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'), - blankUrlError: __('URL cannot be blank'), - invalidUrlError: __('URL is invalid'), - }, restKeyInfo: { label: s__( 'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.', diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql index 6d9307959df..2d8430dbede 100644 --- a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql @@ -5,5 +5,4 @@ fragment IntegrationItem on AlertManagementIntegration { name url token - apiUrl } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql index bb22795ddd5..c2acd928c5c 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql @@ -1,9 +1,7 @@ #import "../fragments/integration_item.fragment.graphql" -mutation createPrometheusIntegration($projectPath: ID!, $apiUrl: String!, $active: Boolean!) { - prometheusIntegrationCreate( - input: { projectPath: $projectPath, apiUrl: $apiUrl, active: $active } - ) { +mutation createPrometheusIntegration($projectPath: ID!, $active: Boolean!) { + prometheusIntegrationCreate(input: { projectPath: $projectPath, active: $active }) { errors integration { ...IntegrationItem diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 33d6eb139f7..92649477922 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -78,6 +78,7 @@ export default { title: TOKEN_TITLE_AUTHOR, type: TOKEN_TYPE_AUTHOR, token: UserToken, + dataType: 'user', initialUsers: this.authorsData, unique: true, operators: OPERATORS_IS, @@ -88,6 +89,7 @@ export default { title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, token: UserToken, + dataType: 'user', initialUsers: this.assigneesData, unique: false, operators: OPERATORS_IS, diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index 25699c17b10..7ec7eac24ec 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -39,8 +39,8 @@ export const DORA_METRICS = { }; const VSA_FLOW_METRICS_GROUP = { - key: 'key_metrics', - title: s__('ValueStreamAnalytics|Key metrics'), + key: 'lifecycle_metrics', + title: s__('ValueStreamAnalytics|Lifecycle metrics'), keys: Object.values(FLOW_METRICS), }; diff --git a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue index dfe94aeb884..06b83c87985 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -49,11 +49,13 @@ export default { return data.users?.nodes || []; }, result({ data }) { - const { - users: { pageInfo }, - } = data; - this.pageInfo = pageInfo; - this.fetchNextPage(); + if (data) { + const { + users: { pageInfo }, + } = data; + this.pageInfo = pageInfo; + this.fetchNextPage(); + } }, error(error) { this.handleError(error); diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index 1b216e6f721..f9edebb9141 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -18,10 +18,10 @@ const axiosGet = (url, query, options, callback) => { ...options, }, }) - .then(({ data }) => { + .then(({ data, headers }) => { callback(data); - return data; + return { data, headers }; }); }; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 17ad1a0b31d..c056b42b5b6 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -11,6 +11,7 @@ const USER_POST_STATUS_PATH = '/api/:version/user/status'; const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; const USER_FOLLOWERS_PATH = '/api/:version/users/:id/followers'; +const USER_FOLLOWING_PATH = '/api/:version/users/:id/following'; const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count'; export function getUsers(query, options) { @@ -82,6 +83,16 @@ export function getUserFollowers(userId, params) { }); } +export function getUserFollowing(userId, params) { + const url = buildApiUrl(USER_FOLLOWING_PATH).replace(':id', encodeURIComponent(userId)); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...params, + }, + }); +} + export function associationsCount(userId) { const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId)); return axios.get(url); diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index e6c3a0cba58..96889f0059c 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,10 +1,14 @@ <script> import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; import { createAlert } from '~/alert'; -import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import Autosave from '~/autosave'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants'; +import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; export default { components: { @@ -14,9 +18,10 @@ export default { GlForm, GlFormGroup, GlFormCheckbox, - MarkdownField, + MarkdownEditor, ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), }, + mixins: [glFeatureFlagsMixin()], data() { return { isSubmitting: false, @@ -27,11 +32,24 @@ export default { approve: false, approval_password: '', }, + formFieldProps: { + id: 'review-note-body', + name: 'review[note]', + placeholder: __('Write a comment or drag your files here…'), + 'aria-label': __('Comment'), + 'data-testid': 'comment-textarea', + }, }; }, computed: { ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']), ...mapState('batchComments', ['shouldAnimateReviewButton']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, + autosaveKey() { + return `submit_review_dropdown/${this.getNoteableData.id}`; + }, }, watch: { 'noteData.approve': function noteDataApproveWatch() { @@ -41,10 +59,6 @@ export default { }, }, mounted() { - this.autosave = new Autosave( - this.$refs.textarea, - `submit_review_dropdown/${this.getNoteableData.id}`, - ); this.noteData.noteable_type = this.noteableType; this.noteData.noteable_id = this.getNoteableData.id; @@ -67,10 +81,12 @@ export default { async submitReview() { this.isSubmitting = true; + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'MergeRequest_review'); + try { await this.publishReview(this.noteData); - this.autosave.reset(); + markdownEditorEventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, this.descriptionAutosaveKey); if (window.mrTabs && (this.noteData.note || this.noteData.approve)) { if (this.noteData.note) { @@ -117,37 +133,26 @@ export default { {{ __('Summary comment (optional)') }} </template> <div class="common-note-form gfm-form"> - <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden"> - <markdown-field - :is-submitting="isSubmitting" - :add-spacing-classes="false" - :textarea-value="noteData.note" - :markdown-preview-path="getNoteableData.preview_note_path" - :markdown-docs-path="getNotesData.markdownDocsPath" - :quick-actions-docs-path="getNotesData.quickActionsDocsPath" - :restricted-tool-bar-items="$options.restrictedToolbarItems" - :force-autosize="false" - class="js-no-autosize" - > - <template #textarea> - <textarea - id="review-note-body" - ref="textarea" - v-model="noteData.note" - dir="auto" - :disabled="isSubmitting" - name="review[note]" - class="note-textarea js-gfm-input markdown-area" - data-supports-quick-actions="true" - data-testid="comment-textarea" - :aria-label="__('Comment')" - :placeholder="__('Write a comment or drag your files here…')" - @keydown.meta.enter="submitReview" - @keydown.ctrl.enter="submitReview" - ></textarea> - </template> - </markdown-field> - </div> + <markdown-editor + ref="markdownEditor" + v-model="noteData.note" + :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" + class="js-no-autosize" + :is-submitting="isSubmitting" + :render-markdown-path="getNoteableData.preview_note_path" + :markdown-docs-path="getNotesData.markdownDocsPath" + :form-field-props="formFieldProps" + enable-autocomplete + :autocomplete-data-sources="autocompleteDataSources" + :disabled="isSubmitting" + :restricted-tool-bar-items="$options.restrictedToolbarItems" + :force-autosize="false" + :autosave-key="autosaveKey" + supports-quick-actions + @input="$emit('input', $event)" + @keydown.meta.enter="submitReview" + @keydown.ctrl.enter="submitReview" + /> </div> </gl-form-group> <template v-if="getNoteableData.current_user.can_approve"> diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 45e7256a734..070ce38c8aa 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -3,6 +3,7 @@ import { createAlert } from '~/alert'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants'; +import { updateNoteErrorMessage } from '~/notes/utils'; import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; import service from '../../../services/drafts_service'; import * as types from './mutation_types'; @@ -18,10 +19,8 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => commit(types.ADD_NEW_DRAFT, res); return res; }) - .catch(() => { - createAlert({ - message: __('An error occurred adding a draft to the thread.'), - }); + .catch((e) => { + throw e.response; }); export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) => @@ -37,10 +36,8 @@ export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) => return res; }) - .catch(() => { - createAlert({ - message: __('An error occurred adding a new draft.'), - }); + .catch((e) => { + throw e.response; }); export const deleteDraft = ({ commit, getters }, draft) => @@ -113,7 +110,7 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet export const updateDraft = ( { commit, getters }, - { note, noteText, resolveDiscussion, position, callback }, + { note, noteText, resolveDiscussion, position, flashContainer, callback, errorCallback }, ) => { const params = { draftId: note.id, @@ -129,11 +126,14 @@ export const updateDraft = ( .then((res) => res.data) .then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) .then(callback) - .catch(() => + .catch((e) => { createAlert({ - message: __('An error occurred while updating the comment'), - }), - ); + message: updateNoteErrorMessage(e), + parent: flashContainer, + }); + + errorCallback(); + }); }; export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 29204020058..8849e9f7a11 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,4 +1,10 @@ -import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji'; +import { + initEmojiMap, + getEmojiInfo, + emojiFallbackImageSrc, + emojiImageTag, + findCustomEmoji, +} from '../emoji'; import isEmojiUnicodeSupported from '../emoji/support'; class GlEmoji extends HTMLElement { @@ -33,6 +39,7 @@ class GlEmoji extends HTMLElement { this.childNodes && Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3); + const customEmoji = findCustomEmoji(name); const hasImageFallback = fallbackSrc?.length > 0; const hasCssSpriteFallback = fallbackSpriteClass?.length > 0; @@ -51,7 +58,7 @@ class GlEmoji extends HTMLElement { this.classList.add(fallbackSpriteClass); } else if (hasImageFallback) { this.innerHTML = ''; - this.appendChild(emojiImageTag(name, fallbackSrc)); + this.appendChild(emojiImageTag(name, customEmoji?.src || fallbackSrc)); } else { const src = emojiFallbackImageSrc(name); this.innerHTML = ''; diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 39a7a76e91f..333858f717c 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,13 +3,12 @@ import highlightCurrentUser from './highlight_current_user'; import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; -import renderMetrics from './render_metrics'; import renderObservability from './render_observability'; import { renderJSONTable } from './render_json_table'; function initPopovers(elements) { if (!elements.length) return; - import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') + import(/* webpackChunkName: 'IssuablePopoverBundle' */ 'ee_else_ce/issuable/popover') .then(({ default: initIssuablePopovers }) => { initIssuablePopovers(elements); }) @@ -30,7 +29,6 @@ export function renderGFM(element) { tableEls, userEls, popoverEls, - metricsEls, observabilityEls, ] = [ '.js-syntax-highlight', @@ -39,8 +37,7 @@ export function renderGFM(element) { '.js-render-mermaid', '[lang="json"][data-lang-params="table"]', '.gfm-project_member', - '.gfm-issue, .gfm-work_item, .gfm-merge_request', - '.js-render-metrics', + '.gfm-issue, .gfm-work_item, .gfm-merge_request, .gfm-epic', '.js-render-observability', ].map((selector) => Array.from(element.querySelectorAll(selector))); @@ -50,9 +47,6 @@ export function renderGFM(element) { renderSandboxedMermaid(mermaidEls); renderJSONTable(tableEls.map((e) => e.parentNode)); highlightCurrentUser(userEls); - if (!window.gon?.features?.removeMonitorMetrics) { - renderMetrics(metricsEls); - } renderObservability(observabilityEls); initPopovers(popoverEls); } diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js deleted file mode 100644 index e7a2a6ce47c..00000000000 --- a/app/assets/javascripts/behaviors/markdown/render_metrics.js +++ /dev/null @@ -1,47 +0,0 @@ -import Vue from 'vue'; -import { createStore } from '~/monitoring/stores/embed_group/'; - -// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-foss/issues/64369. -export default function renderMetrics(elements) { - if (!elements.length) { - return Promise.resolve(); - } - - const wrapperList = []; - - elements.forEach((element) => { - let wrapper; - const { previousElementSibling } = element; - const isFirstElementInGroup = !previousElementSibling?.urls; - - if (isFirstElementInGroup) { - wrapper = document.createElement('div'); - wrapper.urls = [element.dataset.dashboardUrl]; - element.parentNode.insertBefore(wrapper, element); - wrapperList.push(wrapper); - } else { - wrapper = previousElementSibling; - wrapper.urls.push(element.dataset.dashboardUrl); - } - - // Clean up processed element - element.parentNode.removeChild(element); - }); - - return import( - /* webpackChunkName: 'gfm_metrics' */ '~/monitoring/components/embeds/embed_group.vue' - ).then(({ default: EmbedGroup }) => { - const EmbedGroupComponent = Vue.extend(EmbedGroup); - - wrapperList.forEach((wrapper) => { - // eslint-disable-next-line no-new - new EmbedGroupComponent({ - el: wrapper, - store: createStore(), - propsData: { - urls: wrapper.urls, - }, - }); - }); - }); -} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index bcd92d09033..ce77ede9fe4 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -139,7 +139,7 @@ $(document).on('markdown-preview:show', (e, $form) => { // toggle content $form.find('.md-write-holder').hide(); $form.find('.md-preview-holder').show(); - $form.find('.md-header-toolbar, .js-zen-enter').addClass('gl-display-none!'); + $form.find('.haml-markdown-button, .js-zen-enter').addClass('gl-display-none!'); markdownPreview.showPreview($form); }); @@ -162,7 +162,7 @@ $(document).on('markdown-preview:hide', (e, $form) => { $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); - $form.find('.md-header-toolbar, .js-zen-enter').removeClass('gl-display-none!'); + $form.find('.haml-markdown-button, .js-zen-enter').removeClass('gl-display-none!'); markdownPreview.hideReferencedCommands($form); }); diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index a8932f8c73b..1ec204b4034 100644 --- a/app/assets/javascripts/blob/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js @@ -1,6 +1,5 @@ /* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return */ -import $ from 'jquery'; import { scrollToElement } from '~/lib/utils/common_utils'; // LineHighlighter @@ -52,11 +51,12 @@ const LineHighlighter = function (options = {}) { }; LineHighlighter.prototype.bindEvents = function () { - const $fileHolder = $(this.options.fileHolderSelector); - - $fileHolder.on('click', 'a[data-line-number]', this.clickHandler); - $fileHolder.on('highlight:line', this.highlightHash); - window.addEventListener('hashchange', (e) => this.highlightHash(e.target.location.hash)); + const fileHolder = document.querySelector(this.options.fileHolderSelector); + if (fileHolder) { + fileHolder.addEventListener('click', this.clickHandler); + fileHolder.addEventListener('highlight:line', this.highlightHash); + window.addEventListener('hashchange', (e) => this.highlightHash(e.target.location.hash)); + } }; LineHighlighter.prototype.highlightHash = function (newHash) { @@ -82,29 +82,35 @@ LineHighlighter.prototype.highlightHash = function (newHash) { }; LineHighlighter.prototype.clickHandler = function (event) { - let range; - event.preventDefault(); - this.clearHighlight(); - const lineNumber = $(event.target).closest('a').data('lineNumber'); - const current = this.hashToRange(this._hash); - if (!(current[0] && event.shiftKey)) { - // If there's no current selection, or there is but Shift wasn't held, - // treat this like a single-line selection. - this.setHash(lineNumber); - return this.highlightLine(lineNumber); - } else if (event.shiftKey) { - if (lineNumber < current[0]) { - range = [lineNumber, current[0]]; - } else { - range = [current[0], lineNumber]; + const isLine = event.target.matches('a[data-line-number]'); + if (isLine) { + let range; + event.preventDefault(); + this.clearHighlight(); + const lineNumber = parseInt(event.target.dataset.lineNumber, 10); + const current = this.hashToRange(this._hash); + if (!(current[0] && event.shiftKey)) { + // If there's no current selection, or there is but Shift wasn't held, + // treat this like a single-line selection. + this.setHash(lineNumber); + return this.highlightLine(lineNumber); + } else if (event.shiftKey) { + if (lineNumber < current[0]) { + range = [lineNumber, current[0]]; + } else { + range = [current[0], lineNumber]; + } + this.setHash(range[0], range[1]); + return this.highlightRange(range); } - this.setHash(range[0], range[1]); - return this.highlightRange(range); } }; LineHighlighter.prototype.clearHighlight = function () { - return $(`.${this.highlightLineClass}`).removeClass(this.highlightLineClass); + const highlightedLines = document.getElementsByClassName(this.highlightLineClass); + Array.from(highlightedLines).forEach(function (line) { + line.classList.remove(this.highlightLineClass); + }, this); }; // Convert a URL hash String into line numbers @@ -133,7 +139,10 @@ LineHighlighter.prototype.hashToRange = function (hash) { // // lineNumber - Line number to highlight LineHighlighter.prototype.highlightLine = function (lineNumber) { - return $(`#LC${lineNumber}`).addClass(this.highlightLineClass); + const lineElement = document.getElementById(`LC${lineNumber}`); + if (lineElement) { + lineElement.classList.add(this.highlightLineClass); + } }; // Highlight all lines within a range @@ -144,7 +153,7 @@ LineHighlighter.prototype.highlightRange = function (range) { const results = []; const ref = range[0] <= range[1] ? range : range.reverse(); - for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) { + for (let lineNumber = ref[0]; lineNumber <= ref[1]; lineNumber += 1) { results.push(this.highlightLine(lineNumber)); } diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 0b9243c07c5..ca8299ddf80 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -5,9 +5,11 @@ import { s__ } from '~/locale'; import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import eventHub from '~/boards/eventhub'; import { listsQuery } from 'ee_else_ce/boards/constants'; import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import errorQuery from '../graphql/client/error.query.graphql'; export default { i18n: { @@ -38,6 +40,7 @@ export default { addColumnFormVisible: false, isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), apolloError: null, + error: null, }; }, apollo: { @@ -75,6 +78,10 @@ export default { this.apolloError = this.$options.i18n.fetchError; }, }, + error: { + query: errorQuery, + update: (data) => data.boardsAppError, + }, }, computed: { @@ -106,11 +113,16 @@ export default { }, created() { window.addEventListener('popstate', refreshCurrentPage); + eventHub.$on('updateBoard', this.refetchLists); }, destroyed() { window.removeEventListener('popstate', refreshCurrentPage); + eventHub.$off('updateBoard', this.refetchLists); }, methods: { + refetchLists() { + this.$apollo.queries.boardListsApollo.refetch(); + }, setActiveId(id) { this.activeListId = id; }, @@ -145,7 +157,7 @@ export default { :is-swimlanes-on="isSwimlanesOn" :filter-params="filterParams" :board-lists-apollo="boardListsApollo" - :apollo-error="apolloError" + :apollo-error="apolloError || error" :list-query-variables="listQueryVariables" @setActiveList="setActiveId" @setAddColumnFormVisibility="addColumnFormVisible = $event" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index befd04c29ae..6036f0c359c 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -43,7 +43,14 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [boardCardInner], - inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard', 'issuableType', 'isGroupBoard'], + inject: [ + 'rootPath', + 'scopedLabelsAvailable', + 'isEpicBoard', + 'issuableType', + 'isGroupBoard', + 'isApolloBoard', + ], props: { item: { type: Object, @@ -78,6 +85,9 @@ export default { }, computed: { ...mapState(['isShowingLabels', 'allowSubEpics']), + isLoading() { + return this.item.isLoading || this.item.iid === '-1'; + }, cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness @@ -201,7 +211,9 @@ export default { updateHistory({ url: `${filterPath}${filter}`, }); - this.performSearch(); + if (!this.isApolloBoard) { + this.performSearch(); + } eventHub.$emit('updateTokens'); } }, @@ -243,7 +255,7 @@ export default { <a :href="item.path || item.webUrl || ''" :title="item.title" - :class="{ 'gl-text-gray-400!': item.isLoading }" + :class="{ 'gl-text-gray-400!': isLoading }" class="js-no-trigger gl-text-body gl-hover-text-gray-900" @mousemove.stop >{{ item.title }}</a @@ -272,9 +284,9 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" > - <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" /> <span - v-if="item.referencePath" + v-if="item.referencePath && !isLoading" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index a51e4ddc8f8..14c781f588f 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -4,7 +4,6 @@ import { sortBy } from 'lodash'; import produce from 'immer'; import Draggable from 'vuedraggable'; import { mapState, mapActions } from 'vuex'; -import eventHub from '~/boards/eventhub'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { defaultSortableOptions } from '~/sortable/constants'; import { @@ -107,24 +106,15 @@ export default { return this.canDragColumns ? options : {}; }, errorToDisplay() { - return this.isApolloBoard ? this.apolloError : this.error; + return this.apolloError || this.error || null; }, }, - created() { - eventHub.$on('updateBoard', this.refetchLists); - }, - beforeDestroy() { - eventHub.$off('updateBoard', this.refetchLists); - }, methods: { ...mapActions(['moveList', 'unsetError']), afterFormEnters() { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - refetchLists() { - this.$apollo.queries.boardListsApollo.refetch(); - }, highlightList(listId) { this.highlightedLists.push(listId); diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 604e71f5993..9ea801dc9a2 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -226,10 +226,12 @@ export default { } this.cancel(); - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); + if (!this.isApolloBoard) { + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); + } } catch { this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index af309ba9912..b4249c63b4d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -22,6 +22,7 @@ import { removeItemFromList, updateEpicsCount, updateIssueCountAndWeight, + setError, } from '../graphql/cache_updates'; import { shouldCloneCard, moveItemVariables } from '../boards_util'; import eventHub from '../eventhub'; @@ -33,7 +34,7 @@ export default { name: 'BoardList', i18n: { loading: __('Loading'), - loadingMoreboardItems: __('Loading more'), + loadingMoreBoardItems: __('Loading more'), showingAllIssues: __('Showing all issues'), showingAllEpics: __('Showing all epics'), }, @@ -83,6 +84,7 @@ export default { isLoadingMore: false, toListId: null, toList: {}, + addItemToListInProgress: false, }; }, apollo: { @@ -213,7 +215,8 @@ export default { return !this.disabled; }, treeRootWrapper() { - return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress + return this.canMoveIssue && + (!this.listsFlags[this.list.id]?.addItemToListInProgress || this.addItemToListInProgress) ? Draggable : 'ul'; }, @@ -468,14 +471,14 @@ export default { this.updateCountAndWeight({ fromListId, toListId, issuable, cache }); }, - updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) { + updateCountAndWeight({ fromListId, toListId, issuable, isAddingItem, cache }) { if (!this.isEpicBoard) { updateIssueCountAndWeight({ fromListId, toListId, filterParams: this.filterParams, issuable, - shouldClone: isAddingIssue || this.shouldCloneCard, + shouldClone: isAddingItem || this.shouldCloneCard, cache, }); } else { @@ -486,7 +489,7 @@ export default { fromListId, filterParams, issuable, - shouldClone: this.shouldCloneCard, + shouldClone: isAddingItem || this.shouldCloneCard, cache, }); } @@ -538,6 +541,59 @@ export default { }, }); }, + async addListItem(input) { + this.toggleForm(); + this.addItemToListInProgress = true; + try { + await this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].createMutation, + variables: { + input: this.isEpicBoard ? input : { ...input, moveAfterId: this.boardListItems[0]?.id }, + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + update: (cache, { data: { createIssuable } }) => { + const { issuable } = createIssuable; + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + issuable, + newIndex: 0, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + this.updateCountAndWeight({ + fromListId: null, + toListId: this.list.id, + issuable, + isAddingItem: true, + cache, + }); + }, + optimisticResponse: { + createIssuable: { + errors: [], + issuable: { + ...listIssuablesQueries[this.issuableType].optimisticResponse, + title: input.title, + }, + }, + }, + }); + } catch (error) { + setError({ + message: sprintf( + __('An error occurred while creating the %{issuableType}. Please try again.'), + { + issuableType: this.isEpicBoard ? 'epic' : 'issue', + }, + ), + error, + }); + } finally { + this.addItemToListInProgress = false; + } + }, }, }; </script> @@ -556,8 +612,18 @@ export default { > <gl-loading-icon size="sm" /> </div> - <board-new-issue v-if="issueCreateFormVisible" :list="list" /> - <board-new-epic v-if="epicCreateFormVisible" :list="list" /> + <board-new-issue + v-if="issueCreateFormVisible" + :list="list" + :board-id="boardId" + @addNewIssue="addListItem" + /> + <board-new-epic + v-if="epicCreateFormVisible" + :list="list" + :board-id="boardId" + @addNewEpic="addListItem" + /> <component :is="treeRootWrapper" v-show="!loading" @@ -610,7 +676,7 @@ export default { <gl-loading-icon v-if="loadingMore" size="sm" - :label="$options.i18n.loadingMoreboardItems" + :label="$options.i18n.loadingMoreBoardItems" /> <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 61a9b22bfc5..8db86d0e894 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,12 +1,12 @@ <script> import { GlButton, + GlButtonGroup, GlLabel, GlTooltip, GlIcon, GlSprintf, GlTooltipDirective, - GlDisclosureDropdown, } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; @@ -35,15 +35,14 @@ import ItemCount from './item_count.vue'; export default { i18n: { newIssue: s__('Boards|Create new issue'), - listActions: s__('Boards|List actions'), newEpic: s__('Boards|Create new epic'), listSettings: s__('Boards|Edit list settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), }, components: { - GlDisclosureDropdown, GlButton, + GlButtonGroup, GlLabel, GlTooltip, GlIcon, @@ -194,50 +193,6 @@ export default { canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; }, - actionListItems() { - const items = []; - - if (this.isNewIssueShown) { - const newIssueText = this.$options.i18n.newIssue; - items.push({ - text: newIssueText, - action: this.showNewIssueForm, - extraAttrs: { - 'data-testid': 'newIssueBtn', - title: newIssueText, - 'aria-label': newIssueText, - }, - }); - } - - if (this.isNewEpicShown) { - const newEpicText = this.$options.i18n.newEpic; - items.push({ - text: newEpicText, - action: this.showNewEpicForm, - extraAttrs: { - 'data-testid': 'newEpicBtn', - title: newEpicText, - 'aria-label': newEpicText, - }, - }); - } - - if (this.isSettingsShown) { - const listSettingsText = this.$options.i18n.listSettings; - items.push({ - text: listSettingsText, - action: this.openSidebarSettings, - extraAttrs: { - 'data-testid': 'settingsBtn', - title: listSettingsText, - 'aria-label': listSettingsText, - }, - }); - } - - return items; - }, }, apollo: { boardList: { @@ -525,23 +480,42 @@ export default { <!-- EE end --> </span> </div> - <gl-disclosure-dropdown - v-if="showListHeaderActions" - v-gl-tooltip.hover.top="{ - title: $options.i18n.listActions, - boundary: 'viewport', - }" - data-testid="header-list-actions" - class="gl-py-2 gl-ml-3" - :aria-label="$options.i18n.listActions" - :title="$options.i18n.listActions" - category="tertiary" - icon="ellipsis_v" - :text-sr-only="true" - :items="actionListItems" - no-caret - placement="right" - /> + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2"> + <gl-button + v-if="isNewIssueShown" + ref="newIssueBtn" + v-gl-tooltip.hover + :aria-label="$options.i18n.newIssue" + :title="$options.i18n.newIssue" + size="small" + icon="plus" + data-testid="new-issue-btn" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isNewEpicShown" + v-gl-tooltip.hover + :aria-label="$options.i18n.newEpic" + :title="$options.i18n.newEpic" + size="small" + icon="plus" + data-testid="new-epic-btn" + @click="showNewEpicForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="$options.i18n.listSettings" + size="small" + :title="$options.i18n.listSettings" + icon="settings" + data-testid="settings-btn" + @click="openSidebarSettings" + /> + </gl-button-group> </h3> </header> </template> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 8b9fafca306..b68444fb011 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,30 +1,73 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import { mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; +import { setError } from '../graphql/cache_updates'; import BoardNewItem from './board_new_item.vue'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', + i18n: { + errorFetchingBoard: s__('Boards|An error occurred while fetching board. Please try again.'), + }, components: { BoardNewItem, ProjectSelect, }, mixins: [BoardNewIssueMixin], - inject: ['groupId', 'fullPath', 'isGroupBoard'], + inject: ['boardType', 'groupId', 'fullPath', 'isGroupBoard', 'isEpicBoard', 'isApolloBoard'], props: { list: { type: Object, required: true, }, + boardId: { + type: String, + required: true, + }, + }, + data() { + return { + selectedProject: {}, + board: {}, + }; + }, + apollo: { + board: { + query() { + return getBoardQuery(this.boardType, this.isEpicBoard); + }, + variables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + }; + }, + skip() { + return !this.isApolloBoard; + }, + update(data) { + const { board } = data.workspace; + return { + ...board, + labels: board.labels?.nodes, + }; + }, + error(error) { + setError({ + error, + message: this.$options.i18n.errorFetchingBoard, + }); + }, + }, }, computed: { - ...mapState(['selectedProject']), ...mapGetters(['getBoardItemsByList']), formEventPrefix() { return toggleFormEventPrefix.issue; @@ -42,8 +85,20 @@ export default { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; + if (this.isApolloBoard) { + return this.addNewIssueToList({ + issueInput: { + title, + labelIds: labels?.map((l) => l.id), + assigneeIds: assignees?.map((a) => a?.id), + milestoneId: milestone?.id, + projectPath: this.projectPath, + }, + }); + } + + const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; return this.addListNewIssue({ list: this.list, issueInput: { @@ -58,6 +113,22 @@ export default { this.cancel(); }); }, + addNewIssueToList({ issueInput }) { + const { labels, assignee, milestone, weight } = this.board; + const config = { + labels, + assigneeId: assignee?.id || null, + milestoneId: milestone?.id || null, + weight, + }; + const input = formatIssueInput(issueInput, config); + + if (!this.isGroupBoard) { + input.projectPath = this.fullPath; + } + + this.$emit('addNewIssue', input); + }, cancel() { eventHub.$emit(`${this.formEventPrefix}${this.list.id}`); }, @@ -74,6 +145,6 @@ export default { @form-submit="submit" @form-cancel="cancel" > - <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" v-model="selectedProject" :list="list" /> </board-new-item> </template> 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 3c056f296e1..f60f00be368 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -75,6 +75,7 @@ export default { type: TOKEN_TYPE_ASSIGNEE, operators: OPERATORS_IS_NOT, token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: this.preloadedUsers(), @@ -86,6 +87,7 @@ export default { operators: OPERATORS_IS_NOT, symbol: '@', token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: this.preloadedUsers(), diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 960c8e472b8..7bbc444701a 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,11 +1,8 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { debounce } from 'lodash'; import { s__ } from '~/locale'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { ListType } from '../constants'; +import groupProjectsQuery from '../graphql/group_projects.query.graphql'; +import { setError } from '../graphql/cache_updates'; export default { name: 'ProjectSelect', @@ -14,6 +11,9 @@ export default { dropdownText: s__(`BoardNewIssue|Select a project`), searchPlaceholder: s__(`BoardNewIssue|Search projects`), emptySearchResult: s__(`BoardNewIssue|No matching results`), + errorFetchingProjects: s__( + 'Boards|An error occurred while fetching group projects. Please try again.', + ), }, defaultFetchOptions: { with_issues_enabled: true, @@ -24,70 +24,107 @@ export default { components: { GlCollapsibleListbox, }, - inject: ['groupId'], + inject: ['groupId', 'fullPath'], + model: { + prop: 'selectedProject', + event: 'selectProject', + }, props: { list: { type: Object, required: true, }, + selectedProject: { + type: Object, + required: true, + }, }, data() { return { initialLoading: true, selectedProjectId: '', - selectedProject: {}, searchTerm: '', + projects: {}, + isLoadingMore: false, }; }, + apollo: { + projects: { + query: groupProjectsQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.searchTerm, + }; + }, + update(data) { + return data.group.projects; + }, + error(error) { + setError({ + error, + message: this.$options.i18n.errorFetchingProjects, + }); + }, + result() { + this.initialLoading = false; + }, + }, + }, computed: { - ...mapState(['groupProjectsFlags']), - ...mapGetters(['activeGroupProjects']), - projects() { - return this.activeGroupProjects.map((project) => ({ - value: project.id, - text: project.nameWithNamespace, - })); + isLoading() { + return this.$apollo.queries.projects.loading && !this.isLoadingMore; + }, + activeGroupProjects() { + return ( + this.projects?.nodes?.map((project) => ({ + value: project.id, + text: project.nameWithNamespace, + })) || [] + ); }, selectedProjectName() { return this.selectedProject.name || this.$options.i18n.dropdownText; }, - fetchOptions() { - const additionalAttrs = {}; - if (this.list.type && this.list.type !== ListType.backlog) { - additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; - } - - return { - ...this.$options.defaultFetchOptions, - ...additionalAttrs, - }; - }, isFetchResultEmpty() { return this.activeGroupProjects.length === 0; }, hasNextPage() { - return this.groupProjectsFlags.pageInfo?.hasNextPage; + return this.projects.pageInfo?.hasNextPage; }, }, watch: { - searchTerm: debounce(function debouncedSearch() { - this.fetchGroupProjects({ search: this.searchTerm }); - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - }, - mounted() { - this.fetchGroupProjects({}); - this.initialLoading = false; + endCursor() { + return this.projects.pageInfo?.endCursor; + }, }, methods: { - ...mapActions(['fetchGroupProjects', 'setSelectedProject']), selectProject(projectId) { this.selectedProjectId = projectId; - this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId); - this.setSelectedProject(this.selectedProject); + this.$emit( + 'selectProject', + this.projects.nodes.find((project) => project.id === projectId), + ); }, - loadMoreProjects() { + async loadMoreProjects() { if (!this.hasNextPage) return; - this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); + this.isLoadingMore = true; + try { + await this.$apollo.queries.projects.fetchMore({ + variables: { + fullPath: this.fullPath, + search: this.searchTerm, + after: this.endCursor, + }, + }); + } catch (error) { + setError({ + error, + message: this.$options.i18n.errorFetchingProjects, + }); + } finally { + this.isLoadingMore = false; + } }, onSearch(query) { this.searchTerm = query; @@ -107,14 +144,14 @@ export default { searchable infinite-scroll data-testid="project-select-dropdown" - :items="projects" + :items="activeGroupProjects" :toggle-text="selectedProjectName" :header-text="$options.i18n.headerTitle" :loading="initialLoading" - :searching="groupProjectsFlags.isLoading" + :searching="isLoading" :search-placeholder="$options.i18n.searchPlaceholder" :no-results-text="$options.i18n.emptySearchResult" - :infinite-scroll-loading="groupProjectsFlags.isLoadingMore" + :infinite-scroll-loading="isLoadingMore" @select="selectProject" @search="onSearch" @bottom-reached="loadMoreProjects" diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index d4d1bc7804e..cb607e5220e 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,6 +1,7 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import { s__, __ } from '~/locale'; +import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import createBoardListMutation from './graphql/board_list_create.mutation.graphql'; @@ -11,6 +12,7 @@ import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql'; +import issueCreateMutation from './graphql/issue_create.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'; @@ -126,6 +128,30 @@ export const listIssuablesQueries = { [TYPE_ISSUE]: { query: listIssuesQuery, moveMutation: issueMoveListMutation, + createMutation: issueCreateMutation, + optimisticResponse: { + assignees: { nodes: [], __typename: 'UserCoreConnection' }, + confidential: false, + dueDate: null, + emailsDisabled: false, + hidden: false, + humanTimeEstimate: null, + humanTotalTimeSpent: null, + id: 'gid://gitlab/Issue/-1', + iid: '-1', + labels: { nodes: [], __typename: 'LabelConnection' }, + milestone: null, + referencePath: '', + relativePosition: null, + severity: 'UNKNOWN', + timeEstimate: 0, + title: '', + totalTimeSpent: 0, + type: 'ISSUE', + webUrl: '', + weight: null, + __typename: TYPENAME_ISSUE, + }, }, }; diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js index 084809e4e60..e54701a63c0 100644 --- a/app/assets/javascripts/boards/graphql/cache_updates.js +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -1,11 +1,26 @@ +import * as Sentry from '@sentry/browser'; import produce from 'immer'; +import { defaultClient } from '~/graphql_shared/issuable_client'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import { listsDeferredQuery } from 'ee_else_ce/boards/constants'; -export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) { +import setErrorMutation from './client/set_error.mutation.graphql'; + +export function removeItemFromList({ + query, + variables, + boardType, + id, + issuableType, + listId = undefined, + cache, +}) { cache.updateQuery({ query, variables }, (sourceData) => produce(sourceData, (draftData) => { - const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + const list = listId + ? draftData[boardType]?.board.lists.nodes.find((l) => l.id === listId) + : draftData[boardType].board.lists.nodes[0]; + const { nodes: items } = list[`${issuableType}s`]; items.splice( items.findIndex((item) => item.id === id), 1, @@ -21,11 +36,15 @@ export function addItemToList({ issuable, newIndex, issuableType, + listId = undefined, cache, }) { cache.updateQuery({ query, variables }, (sourceData) => produce(sourceData, (draftData) => { - const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`]; + const list = listId + ? draftData[boardType]?.board.lists.nodes.find((l) => l.id === listId) + : draftData[boardType].board.lists.nodes[0]; + const { nodes: items } = list[`${issuableType}s`]; items.splice(newIndex, 0, issuable); }), ); @@ -116,3 +135,16 @@ export function updateEpicsCount({ }), ); } + +export function setError({ message, error, captureError = true }) { + defaultClient.mutate({ + mutation: setErrorMutation, + variables: { + error: message, + }, + }); + + if (captureError) { + Sentry.captureException(error); + } +} diff --git a/app/assets/javascripts/boards/graphql/client/error.query.graphql b/app/assets/javascripts/boards/graphql/client/error.query.graphql new file mode 100644 index 00000000000..56f2588f3b9 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/error.query.graphql @@ -0,0 +1,3 @@ +query boardsAppError { + boardsAppError @client +} diff --git a/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql new file mode 100644 index 00000000000..56fc592d21b --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/set_error.mutation.graphql @@ -0,0 +1,3 @@ +mutation setError($error: String!) { + setError(error: $error) @client +} diff --git a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql index 643d5dcfe4c..55cb34c0930 100644 --- a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql @@ -1,8 +1,8 @@ #import "ee_else_ce/boards/graphql/issue.fragment.graphql" mutation CreateIssue($input: CreateIssueInput!) { - createIssue(input: $input) { - issue { + createIssuable: createIssue(input: $input) { + issuable: issue { ...Issue } errors diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index d96d92948be..e044283534a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -743,11 +743,11 @@ export default { }, }) .then(({ data }) => { - if (data.createIssue.errors.length) { + if (data.createIssuable.errors.length) { throw new Error(); } - const rawIssue = data.createIssue?.issue; + const rawIssue = data.createIssuable?.issuable; const formattedIssue = formatIssue(rawIssue); dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); dispatch('addListItem', { list, item: formattedIssue, position: 0 }); diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue index 117c15be907..50fe610d335 100644 --- a/app/assets/javascripts/branches/components/delete_merged_branches.vue +++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue @@ -69,7 +69,7 @@ export default { this.openModal(); }, extraAttrs: { - 'data-qa-selector': 'delete_merged_branches_button', + 'data-testid': 'delete-merged-branches-button', class: 'gl-text-red-500!', }, }, @@ -102,12 +102,11 @@ export default { category="tertiary" no-caret placement="right" - data-qa-selector="delete_merged_branches_dropdown_button" class="gl-display-none gl-md-display-block!" :items="dropdownItems" /> <gl-button - data-qa-selector="delete_merged_branches_button" + data-testid="delete-merged-branches-button" category="secondary" variant="danger" class="gl-display-block gl-md-display-none!" @@ -153,7 +152,6 @@ export default { </gl-sprintf> <gl-form-input v-model="enteredText" - data-qa-selector="delete_merged_branches_input" type="text" size="sm" class="gl-mt-2" @@ -178,7 +176,6 @@ export default { ref="deleteMergedBrancesButton" :disabled="isDeleteButtonDisabled" variant="danger" - data-qa-selector="delete_merged_branches_confirmation_button" data-testid="delete-merged-branches-confirmation-button" @click="submitForm" >{{ $options.i18n.deleteButtonText }}</gl-button diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index 09b02068388..a25f871ac92 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -24,6 +24,10 @@ export default { type: Array, required: true, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, selectedEnvironmentScope: { type: String, required: false, @@ -32,6 +36,7 @@ export default { }, data() { return { + isDropdownShown: false, selectedEnvironment: '', searchTerm: '', }; @@ -46,17 +51,20 @@ export default { return environment.toLowerCase().includes(lowerCasedSearchTerm); }); }, - isEnvScopeLimited() { - return this.glFeatures?.ciLimitEnvironmentScope; + isDropdownLoading() { + return this.areEnvironmentsLoading && this.hasEnvScopeQuery && !this.isDropdownShown; + }, + isDropdownSearching() { + return this.areEnvironmentsLoading && this.hasEnvScopeQuery && this.isDropdownShown; }, searchedEnvironments() { - // If FF is enabled, search query will be fired so this component will already - // receive filtered environments during the refetch. - // If FF is disabled, search the existing list of environments in the frontend - let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments; + // If hasEnvScopeQuery (applies only to projects for now), search query will be fired so this + // component will already receive filtered environments during the refetch. + // Otherwise (applies to groups), search the existing list of environments in the frontend + let filtered = this.hasEnvScopeQuery ? this.environments : this.filteredEnvironments; // If there is no search term, make sure to include * - if (this.isEnvScopeLimited && !this.searchTerm) { + if (this.hasEnvScopeQuery && !this.searchTerm) { filtered = uniq([...filtered, '*']); } @@ -65,15 +73,12 @@ export default { text: environment, })); }, - shouldShowSearchLoading() { - return this.areEnvironmentsLoading && this.isEnvScopeLimited; - }, shouldRenderCreateButton() { return this.searchTerm && !this.environments.includes(this.searchTerm); }, shouldRenderDivider() { return ( - (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading + (this.hasEnvScopeQuery || this.shouldRenderCreateButton) && !this.areEnvironmentsLoading ); }, environmentScopeLabel() { @@ -84,7 +89,7 @@ export default { debouncedSearch: debounce(function debouncedSearch(searchTerm) { const newSearchTerm = searchTerm.trim(); this.searchTerm = newSearchTerm; - if (this.isEnvScopeLimited) { + if (this.hasEnvScopeQuery) { this.$emit('search-environment-scope', newSearchTerm); } }, 500), @@ -96,6 +101,9 @@ export default { this.$emit('create-environment-scope', this.searchTerm); this.selectEnvironment(this.searchTerm); }, + toggleDropdownShown(isShown) { + this.isDropdownShown = isShown; + }, }, ENVIRONMENT_QUERY_LIMIT, i18n: { @@ -111,14 +119,17 @@ export default { block searchable :items="searchedEnvironments" - :searching="shouldShowSearchLoading" + :loading="isDropdownLoading" + :searching="isDropdownSearching" :toggle-text="environmentScopeLabel" @search="debouncedSearch" @select="selectEnvironment" + @shown="toggleDropdownShown(true)" + @hidden="toggleDropdownShown(false)" > <template #footer> <gl-dropdown-divider v-if="shouldRenderDivider" /> - <div v-if="isEnvScopeLimited" data-testid="max-envs-notice"> + <div v-if="hasEnvScopeQuery" data-testid="max-envs-notice"> <gl-dropdown-item class="gl-list-style-none" disabled> <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm"> <template #limit> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 9c79adffdae..2045b127a82 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 @@ -3,6 +3,7 @@ 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, UPDATE_MUTATION_ACTION } from '../constants'; +import getGroupEnvironments from '../graphql/queries/group_environments.query.graphql'; 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'; @@ -22,6 +23,15 @@ export default { graphqlId() { return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, + queriesAvailable() { + if (this.glFeatures.ciGroupEnvScopeGraphql) { + return this.$options.queryData; + } + + return { + ciVariables: this.$options.queryData.ciVariables, + }; + }, }, mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -33,6 +43,10 @@ export default { lookup: (data) => data?.group?.ciVariables, query: getGroupVariables, }, + environments: { + lookup: (data) => data?.group?.environmentScopes, + query: getGroupEnvironments, + }, }, }; </script> @@ -45,6 +59,6 @@ export default { entity="group" :full-path="groupPath" :mutation-data="$options.mutationData" - :query-data="$options.queryData" + :query-data="queriesAvailable" /> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 41514d2d2f1..3af48635f3f 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 @@ -93,6 +93,10 @@ export default { required: false, default: false, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, mode: { type: String, required: true, @@ -147,7 +151,7 @@ export default { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, environmentsList() { - if (this.glFeatures?.ciLimitEnvironmentScope) { + if (this.hasEnvScopeQuery) { return this.environments; } @@ -385,6 +389,7 @@ export default { <ci-environments-dropdown v-if="areScopedVariablesAvailable" :are-environments-loading="areEnvironmentsLoading" + :has-env-scope-query="hasEnvScopeQuery" :selected-environment-scope="variable.environmentScope" :environments="environmentsList" @select-environment="setEnvironmentScope" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index 26e20c690bc..b8a95f9081a 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -33,6 +33,10 @@ export default { required: false, default: false, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, isLoading: { type: Boolean, required: false, @@ -107,6 +111,7 @@ export default { :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" + :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :variables="variables" :mode="mode" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index ee2c0a771cf..9786f25ed87 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -159,12 +159,13 @@ export default { return this.queryData?.environments?.query || {}; }, skip() { - return !this.queryData?.environments?.query; + return !this.hasEnvScopeQuery; }, variables() { return { + first: ENVIRONMENT_QUERY_LIMIT, fullPath: this.fullPath, - ...this.environmentQueryVariables, + search: '', }; }, update(data) { @@ -179,23 +180,12 @@ export default { areEnvironmentsLoading() { return this.$apollo.queries.environments.loading; }, - environmentQueryVariables() { - if (this.glFeatures?.ciLimitEnvironmentScope) { - return { - first: ENVIRONMENT_QUERY_LIMIT, - search: '', - }; - } - - return {}; + hasEnvScopeQuery() { + return Boolean(this.queryData?.environments?.query); }, isLoading() { - // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when - // environment query is loading and FF is enabled - // https://gitlab.com/gitlab-org/gitlab/-/issues/396990 return ( (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) || - this.areEnvironmentsLoading || this.isLoadingMoreItems ); }, @@ -248,9 +238,7 @@ export default { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, async searchEnvironmentScope(searchTerm) { - if (this.glFeatures?.ciLimitEnvironmentScope) { - this.$apollo.queries.environments.refetch({ search: searchTerm }); - } + this.$apollo.queries.environments.refetch({ search: searchTerm }); }, async variableMutation(mutationAction, variable) { try { @@ -296,6 +284,7 @@ export default { :are-scoped-variables-available="areScopedVariablesAvailable" :entity="entity" :environments="environments" + :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :is-loading="isLoading" :max-variable-limit="maxVariableLimit" diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql new file mode 100644 index 00000000000..5768d370474 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql @@ -0,0 +1,10 @@ +query getGroupEnvironments($fullPath: ID!, $first: Int, $search: String) { + group(fullPath: $fullPath) { + id + environmentScopes(first: $first, search: $search) { + nodes { + name + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 0b57433e894..8d670cb5389 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -2,6 +2,7 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; import { pipelineEditorTrackingOptions } from '../../../constants'; export default { @@ -34,7 +35,7 @@ export default { this.track(actions.helpDrawerLinks.runners, { label }); }, }, - RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', + RUNNER_HELP_URL: `${DOCS_URL}/runner/register/index.html`, }; </script> <template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue index 794763e0cd8..76db9613dc1 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue @@ -26,7 +26,7 @@ export default { return [ { key: 'artifacts.paths', - title: i18n.ARTIFACTS_AND_CACHE, + title: i18n.ARTIFACTS_PATHS, paths: this.job.artifacts.paths, generateInputDataTestId: (index) => `artifacts-paths-input-${index}`, generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`, diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue index d0f206e767f..460f508ee74 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue @@ -54,6 +54,13 @@ export default { `${this.startInNumber} ${this.startInUnit}${plural}`, ); }, + updateWhen(when) { + this.$emit('update-job', 'rules[0].when', when); + + if (when === JOB_RULES_WHEN.delayed.value) { + this.updateStartIn(); + } + }, }, }; </script> @@ -73,7 +80,7 @@ export default { :options="$options.whenOptions" data-testid="rules-when-select" :value="job.rules[0].when" - @input="$emit('update-job', 'rules[0].when', $event)" + @input="updateWhen" /> </gl-form-group> <gl-form-group diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index 6695c6179cf..0700d9e5439 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -16,6 +16,7 @@ import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; +import { ALL_SCOPE } from '../constants'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; @@ -58,6 +59,9 @@ export default { pipelinesPath: { default: '', }, + newSchedulePath: { + default: '', + }, }, apollo: { schedules: { @@ -65,7 +69,9 @@ export default { variables() { return { projectPath: this.fullPath, - status: this.scope, + // we need to ensure we send null to the API when + // the scope is 'ALL' + status: this.scope === ALL_SCOPE ? null : this.scope, }; }, update(data) { @@ -111,7 +117,7 @@ export default { { text: s__('PipelineSchedules|All'), count: limitedCounterWithDelimiter(this.count), - scope: null, + scope: ALL_SCOPE, showBadge: true, attrs: { 'data-testid': 'pipeline-schedules-all-tab' }, }, @@ -134,7 +140,7 @@ export default { // this watcher ensures that the count on the all tab // is not updated when switching to other tabs schedulesCount(newCount) { - if (!this.scope) { + if (!this.scope || this.scope === ALL_SCOPE) { this.count = newCount; } }, @@ -253,10 +259,10 @@ export default { </gl-alert> <gl-tabs - v-if="isLoading || count > 0" + v-if="isLoading || schedulesCount > 0" sync-active-tab-with-query-params query-param-name="scope" - nav-class="gl-flex-grow-1 gl-align-items-center" + nav-class="gl-flex-grow-1 gl-align-items-center gl-mt-2" > <gl-tab v-for="tab in tabs" @@ -289,13 +295,18 @@ export default { </gl-tab> <template #tabs-end> - <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> + <gl-button + :href="newSchedulePath" + variant="confirm" + class="gl-ml-auto" + data-testid="new-schedule-button" + > {{ $options.i18n.newSchedule }} </gl-button> </template> </gl-tabs> - <pipeline-schedule-empty-state v-else-if="!isLoading && count === 0" /> + <pipeline-schedule-empty-state v-else-if="!isLoading && schedulesCount === 0" /> <take-ownership-modal :visible="showTakeOwnershipModal" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue index 39ac55bb9c5..fbdb60f61f1 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -1,5 +1,5 @@ <script> -import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg?raw'; +import SCHEDULE_MD_SVG_URL from '@gitlab/svgs/dist/illustrations/schedule-md.svg?url'; import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; @@ -20,15 +20,18 @@ export default { ], createNew: s__('PipelineSchedules|Create a new pipeline schedule'), }, + SCHEDULE_MD_SVG_URL, components: { GlEmptyState, GlLink, GlSprintf, }, - computed: { - scheduleSvgPath() { - return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`; + inject: { + newSchedulePath: { + default: '', }, + }, + computed: { schedulesHelpPath() { return helpPagePath('ci/pipelines/schedules'); }, @@ -37,9 +40,9 @@ export default { </script> <template> <gl-empty-state - :svg-path="scheduleSvgPath" + :svg-path="$options.SCHEDULE_MD_SVG_URL" :primary-button-text="$options.i18n.createNew" - primary-button-link="#" + :primary-button-link="newSchedulePath" > <template #title> <h3> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 367b1812a27..d84a9a4a4b5 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -8,18 +8,22 @@ import { GlFormGroup, GlFormInput, GlFormTextarea, - GlLink, - GlSprintf, + GlLoadingIcon, } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import Vue from 'vue'; import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import { visitUrl, queryToObject } from '~/lib/utils/url_utility'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import RefSelector from '~/ref/components/ref_selector.vue'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql'; +import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; +const scheduleId = queryToObject(window.location.search).id; + export default { components: { GlButton, @@ -30,21 +34,12 @@ export default { GlFormGroup, GlFormInput, GlFormTextarea, - GlLink, - GlSprintf, + GlLoadingIcon, RefSelector, TimezoneDropdown, IntervalPatternInput, }, - inject: [ - 'fullPath', - 'projectId', - 'defaultBranch', - 'cron', - 'cronTimezone', - 'dailyLimit', - 'settingsLink', - ], + inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'], props: { timezoneData: { type: Array, @@ -55,34 +50,79 @@ export default { required: false, default: '', }, + editing: { + type: Boolean, + required: true, + }, + }, + apollo: { + schedule: { + query: getPipelineSchedulesQuery, + variables() { + return { + projectPath: this.fullPath, + ids: scheduleId, + }; + }, + update(data) { + return data.project?.pipelineSchedules?.nodes[0] || {}; + }, + result({ data }) { + if (data) { + const { + project: { + pipelineSchedules: { nodes }, + }, + } = data; + + const schedule = nodes[0]; + const variables = schedule.variables?.nodes || []; + + this.description = schedule.description; + this.cron = schedule.cron; + this.cronTimezone = schedule.cronTimezone; + this.scheduleRef = schedule.ref; + this.variables = variables.map((variable) => { + return { + id: variable.id, + variableType: variable.variableType, + key: variable.key, + value: variable.value, + destroy: false, + }; + }); + this.addEmptyVariable(); + this.activated = schedule.active; + } + }, + skip() { + return !this.editing; + }, + error() { + createAlert({ message: this.$options.i18n.scheduleFetchError }); + }, + }, }, data() { return { - refValue: { - shortName: this.refParam, - // this is needed until we add support for ref type in url query strings - // ensure default branch is called with full ref on load - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, - }, + cron: '', description: '', scheduleRef: this.defaultBranch, activated: true, - timezone: this.cronTimezone, - formCiVariables: {}, - // TODO: Add the GraphQL query to help populate the predefined variables - // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131 - predefinedValueOptions: {}, + cronTimezone: '', + variables: [], + schedule: {}, }; }, i18n: { activated: __('Activated'), - cronTimezone: s__('PipelineSchedules|Cron timezone'), + cronTimezoneText: s__('PipelineSchedules|Cron timezone'), description: s__('PipelineSchedules|Description'), shortDescriptionPipeline: s__( 'PipelineSchedules|Provide a short description for this pipeline', ), - savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + editScheduleBtnText: s__('PipelineSchedules|Edit pipeline schedule'), + createScheduleBtnText: s__('PipelineSchedules|Create pipeline schedule'), cancel: __('Cancel'), targetBranchTag: __('Select target branch or tag'), intervalPattern: s__('PipelineSchedules|Interval Pattern'), @@ -91,6 +131,15 @@ export default { ), removeVariableLabel: s__('CiVariables|Remove variable'), variables: s__('Pipeline|Variables'), + scheduleCreateError: s__( + 'PipelineSchedules|An error occurred while creating the pipeline schedule.', + ), + scheduleUpdateError: s__( + 'PipelineSchedules|An error occurred while updating the pipeline schedule.', + ), + scheduleFetchError: s__( + 'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.', + ), }, typeOptions: { [VARIABLE_TYPE]: __('Variable'), @@ -103,15 +152,6 @@ export default { dropdownHeader: this.$options.i18n.targetBranchTag, }; }, - refFullName() { - return this.refValue.fullName; - }, - variables() { - return this.formCiVariables[this.refFullName]?.variables ?? []; - }, - descriptions() { - return this.formCiVariables[this.refFullName]?.descriptions ?? {}; - }, typeOptionsListbox() { return [ { @@ -127,52 +167,136 @@ export default { getEnabledRefTypes() { return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; }, + preparedVariablesUpdate() { + return this.variables.filter((variable) => variable.key !== ''); + }, + preparedVariablesCreate() { + return this.preparedVariablesUpdate.map((variable) => { + return { + key: variable.key, + value: variable.value, + variableType: variable.variableType, + }; + }); + }, + loading() { + return this.$apollo.queries.schedule.loading; + }, + buttonText() { + return this.editing + ? this.$options.i18n.editScheduleBtnText + : this.$options.i18n.createScheduleBtnText; + }, }, created() { - Vue.set(this.formCiVariables, this.refFullName, { - variables: [], - descriptions: {}, - }); - - this.addEmptyVariable(this.refFullName); + this.addEmptyVariable(); }, methods: { - addEmptyVariable(refValue) { - const { variables } = this.formCiVariables[refValue]; + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; - const lastVar = variables[variables.length - 1]; if (lastVar?.key === '' && lastVar?.value === '') { return; } - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - variable_type: VARIABLE_TYPE, + this.variables.push({ + variableType: VARIABLE_TYPE, key: '', value: '', + destroy: false, }); }, setVariableAttribute(key, attribute, value) { - const { variables } = this.formCiVariables[this.refFullName]; - const variable = variables.find((v) => v.key === key); + const variable = this.variables.find((v) => v.key === key); variable[attribute] = value; }, - shouldShowValuesDropdown(key) { - return this.predefinedValueOptions[key]?.length > 1; - }, removeVariable(index) { - this.variables.splice(index, 1); + this.variables[index].destroy = true; }, canRemove(index) { return index < this.variables.length - 1; }, + async createPipelineSchedule() { + try { + const { + data: { + pipelineScheduleCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: createPipelineScheduleMutation, + variables: { + input: { + description: this.description, + cron: this.cron, + cronTimezone: this.cronTimezone, + ref: this.scheduleRef, + variables: this.preparedVariablesCreate, + active: this.activated, + projectPath: this.fullPath, + }, + }, + }); + + if (errors.length > 0) { + createAlert({ message: errors[0] }); + } else { + visitUrl(this.schedulesPath); + } + } catch { + createAlert({ message: this.$options.i18n.scheduleCreateError }); + } + }, + async updatePipelineSchedule() { + try { + const { + data: { + pipelineScheduleUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updatePipelineScheduleMutation, + variables: { + input: { + id: this.schedule.id, + description: this.description, + cron: this.cron, + cronTimezone: this.cronTimezone, + ref: this.scheduleRef, + variables: this.preparedVariablesUpdate, + active: this.activated, + }, + }, + }); + + if (errors.length > 0) { + createAlert({ message: errors[0] }); + } else { + visitUrl(this.schedulesPath); + } + } catch { + createAlert({ message: this.$options.i18n.scheduleUpdateError }); + } + }, + scheduleHandler() { + if (this.editing) { + this.updatePipelineSchedule(); + } else { + this.createPipelineSchedule(); + } + }, + setCronValue(cron) { + this.cron = cron; + }, + setTimezone(timezone) { + this.cronTimezone = timezone.identifier || ''; + }, }, }; </script> <template> - <div class="col-lg-8"> - <gl-form> + <div class="col-lg-8 gl-pl-0"> + <gl-loading-icon v-if="loading && editing" size="lg" /> + <gl-form v-else> <!--Description--> <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> <gl-form-input @@ -181,6 +305,7 @@ export default { type="text" :placeholder="$options.i18n.shortDescriptionPipeline" data-testid="schedule-description" + required /> </gl-form-group> <!--Interval Pattern--> @@ -190,21 +315,24 @@ export default { :initial-cron-interval="cron" :daily-limit="dailyLimit" :send-native-errors="false" + @cronValue="setCronValue" /> </gl-form-group> <!--Timezone--> - <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <gl-form-group :label="$options.i18n.cronTimezoneText" label-for="schedule-timezone"> <timezone-dropdown id="schedule-timezone" - :value="timezone" + :value="cronTimezone" :timezone-data="timezoneData" name="schedule-timezone" + @input="setTimezone" /> </gl-form-group> <!--Branch/Tag Selector--> <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag"> <ref-selector id="schedule-target-branch-tag" + v-model="scheduleRef" :enabled-ref-types="getEnabledRefTypes" :project-id="projectId" :value="scheduleRef" @@ -217,23 +345,23 @@ export default { <gl-form-group :label="$options.i18n.variables"> <div v-for="(variable, index) in variables" - :key="variable.uniqueId" - class="gl-mb-3 gl-pb-2" - data-testid="ci-variable-row" + :key="`var-${index}`" data-qa-selector="ci_variable_row_container" > <div - class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + v-if="!variable.destroy" + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" > <gl-dropdown - :text="$options.typeOptions[variable.variable_type]" + :text="$options.typeOptions[variable.variableType]" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-type" > <gl-dropdown-item v-for="type in Object.keys($options.typeOptions)" :key="type" - @click="setVariableAttribute(variable.key, 'variable_type', type)" + @click="setVariableAttribute(variable.key, 'variableType', type)" > {{ $options.typeOptions[type] }} </gl-dropdown-item> @@ -244,26 +372,10 @@ export default { :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" data-qa-selector="ci_variable_key_field" - @change="addEmptyVariable(refFullName)" + @change="addEmptyVariable()" /> - <gl-dropdown - v-if="shouldShowValuesDropdown(variable.key)" - :text="variable.value" - :class="$options.formElementClasses" - class="gl-flex-grow-1 gl-mr-0!" - data-testid="pipeline-form-ci-variable-value-dropdown" - > - <gl-dropdown-item - v-for="value in predefinedValueOptions[variable.key]" - :key="value" - data-testid="pipeline-form-ci-variable-value-dropdown-items" - @click="setVariableAttribute(variable.key, 'value', value)" - > - {{ value }} - </gl-dropdown-item> - </gl-dropdown> + <gl-form-textarea - v-else v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3 gl-h-7!" @@ -292,30 +404,19 @@ export default { /> </template> </div> - <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> - {{ descriptions[variable.key] }} - </div> </div> - - <template #description - ><gl-sprintf :message="$options.i18n.variablesDescription"> - <template #link="{ content }"> - <gl-link :href="settingsLink">{{ content }}</gl-link> - </template> - </gl-sprintf></template - > </gl-form-group> <!--Activated--> - <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{ - $options.i18n.activated - }}</gl-form-checkbox> + <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3"> + {{ $options.i18n.activated }} + </gl-form-checkbox> - <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{ - $options.i18n.savePipelineSchedule - }}</gl-button> - <gl-button type="reset" data-testid="schedule-cancel-button">{{ - $options.i18n.cancel - }}</gl-button> + <gl-button variant="confirm" data-testid="schedule-submit-button" @click="scheduleHandler"> + {{ buttonText }} + </gl-button> + <gl-button :href="schedulesPath" data-testid="schedule-cancel-button"> + {{ $options.i18n.cancel }} + </gl-button> </gl-form> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index 5bd58bfd95d..a56da06f5da 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export const i18n = { playTooltip: s__('PipelineSchedules|Run pipeline schedule'), @@ -44,6 +45,11 @@ export default { canRemove() { return this.schedule.userPermissions.adminPipelineSchedule; }, + editPathWithIdParam() { + const id = getIdFromGraphQLId(this.schedule.id); + + return `${this.schedule.editPath}?id=${id}`; + }, }, }; </script> @@ -67,7 +73,14 @@ export default { data-testid="take-ownership-pipeline-schedule-btn" @click="$emit('showTakeOwnershipModal', schedule.id)" /> - <gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" /> + <gl-button + v-if="canUpdate" + v-gl-tooltip + :href="editPathWithIdParam" + :title="$options.i18n.editTooltip" + icon="pencil" + data-testid="edit-pipeline-schedule-btn" + /> <gl-button v-if="canRemove" v-gl-tooltip diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js index b4ab1143f60..16dab33ce29 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/constants.js +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -1,2 +1,3 @@ -export const VARIABLE_TYPE = 'env_var'; -export const FILE_TYPE = 'file'; +export const VARIABLE_TYPE = 'ENV_VAR'; +export const FILE_TYPE = 'FILE'; +export const ALL_SCOPE = 'ALL'; diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..0bea1bb8360 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation createPipelineSchedule($input: PipelineScheduleCreateInput!) { + pipelineScheduleCreate(input: $input) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..a6a937af74a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation updatePipelineSchedule($input: PipelineScheduleUpdateInput!) { + pipelineScheduleUpdate(input: $input) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql index 6167c7dc577..29a26be0344 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -1,16 +1,24 @@ -query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) { +query getPipelineSchedulesQuery( + $projectPath: ID! + $status: PipelineScheduleStatus + $ids: [ID!] = null +) { currentUser { id username } project(fullPath: $projectPath) { id - pipelineSchedules(status: $status) { + pipelineSchedules(status: $status, ids: $ids) { count nodes { id description + cron + cronTimezone + ref forTag + editPath refPath refForDisplay lastPipeline { @@ -34,6 +42,14 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat name webPath } + variables { + nodes { + id + variableType + key + value + } + } userPermissions { playPipelineSchedule updatePipelineSchedule diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js index 8bca4f85e9f..71db9400909 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -18,7 +18,7 @@ export default () => { return false; } - const { fullPath, pipelinesPath } = containerEl.dataset; + const { fullPath, pipelinesPath, newSchedulePath, schedulesPath } = containerEl.dataset; return new Vue({ el: containerEl, @@ -27,6 +27,8 @@ export default () => { provide: { fullPath, pipelinesPath, + newSchedulePath, + schedulesPath, }, render(createElement) { return createElement(PipelineSchedules); diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index 445161f99cb..6bf121d39b6 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -9,7 +9,7 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -export default (selector) => { +export default (selector, editing = false) => { const containerEl = document.querySelector(selector); if (!containerEl) { @@ -18,13 +18,12 @@ export default (selector) => { const { fullPath, - cron, dailyLimit, timezoneData, - cronTimezone, projectId, defaultBranch, settingsLink, + schedulesPath, } = containerEl.dataset; return new Vue({ @@ -36,15 +35,15 @@ export default (selector) => { projectId, defaultBranch, dailyLimit: dailyLimit ?? '', - cronTimezone: cronTimezone ?? '', - cron: cron ?? '', settingsLink, + schedulesPath, }, render(createElement) { return createElement(PipelineSchedulesForm, { props: { timezoneData: JSON.parse(timezoneData), refParam: defaultBranch, + editing, }, }); }, diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue deleted file mode 100644 index b21a486e259..00000000000 --- a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import ReportItem from '~/ci/reports/components/report_item.vue'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; - -export default { - components: { - ReportItem, - SmartVirtualList, - }, - props: { - component: { - type: String, - required: false, - default: '', - }, - nestedLevel: { - type: Number, - required: false, - default: 0, - validator: (value) => [0, 1, 2].includes(value), - }, - resolvedIssues: { - type: Array, - required: false, - default: () => [], - }, - unresolvedIssues: { - type: Array, - required: false, - default: () => [], - }, - resolvedHeading: { - type: String, - required: false, - default: s__('ciReport|Fixed'), - }, - unresolvedHeading: { - type: String, - required: false, - default: s__('ciReport|New'), - }, - }, - groups: ['unresolved', 'resolved'], - typicalReportItemHeight: 32, - maxShownReportItems: 20, - computed: { - groups() { - return this.$options.groups - .map((group) => ({ - name: group, - issues: this[`${group}Issues`], - heading: this[`${group}Heading`], - })) - .filter(({ issues }) => issues.length > 0); - }, - listLength() { - // every group has a header which is rendered as a list item - const groupsCount = this.groups.length; - const issuesCount = this.groups.reduce( - (totalIssues, { issues }) => totalIssues + issues.length, - 0, - ); - - return groupsCount + issuesCount; - }, - listClasses() { - return { - 'gl-pl-9': this.nestedLevel === 1, - 'gl-pl-11-5': this.nestedLevel === 2, - }; - }, - }, -}; -</script> - -<template> - <smart-virtual-list - :length="listLength" - :remain="$options.maxShownReportItems" - :size="$options.typicalReportItemHeight" - :class="listClasses" - class="report-block-container" - wtag="ul" - wclass="report-block-list" - > - <template v-for="(group, groupIndex) in groups"> - <h2 - :key="group.name" - :data-testid="`${group.name}Heading`" - :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']" - class="h5 mb-1" - > - {{ group.heading }} - </h2> - <report-item - v-for="(issue, issueIndex) in group.issues" - :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`" - :issue="issue" - :show-report-section-status-icon="false" - :component="component" - status="none" - /> - </template> - </smart-virtual-list> -</template> diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue deleted file mode 100644 index ee55368c829..00000000000 --- a/app/assets/javascripts/ci/reports/components/summary_row.vue +++ /dev/null @@ -1,93 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import { ICON_WARNING } from '../constants'; - -/** - * Renders the summary row for each report - * - * Used both in MR widget and Pipeline's view for: - * - Unit tests reports - * - Security reports - */ - -export default { - name: 'ReportSummaryRow', - components: { - CiIcon, - HelpPopover, - GlLoadingIcon, - }, - props: { - nestedSummary: { - type: Boolean, - required: false, - default: false, - }, - summary: { - type: String, - required: false, - default: '', - }, - statusIcon: { - type: String, - required: true, - }, - popoverOptions: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - iconStatus() { - return { - group: this.statusIcon, - icon: `status_${this.statusIcon}`, - }; - }, - rowClasses() { - if (!this.nestedSummary) { - return ['gl-px-5']; - } - return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; - }, - statusIconSize() { - if (!this.nestedSummary) { - return 24; - } - return 16; - }, - }, -}; -</script> -<template> - <div - class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center" - :class="rowClasses" - > - <div class="gl-mr-3"> - <gl-loading-icon - v-if="statusIcon === 'loading'" - css-class="report-block-list-loading-icon" - size="lg" - /> - <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" /> - </div> - <div class="report-block-list-issue-description"> - <div class="report-block-list-issue-description-text" data-testid="summary-row-description"> - <slot name="summary">{{ summary }}</slot - ><span v-if="popoverOptions" class="text-nowrap" - > <help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> - </span> - </div> - </div> - <div - v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" - class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row" - > - <slot></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js index 1137236d355..3968f8db752 100644 --- a/app/assets/javascripts/ci/reports/constants.js +++ b/app/assets/javascripts/ci/reports/constants.js @@ -7,8 +7,6 @@ export const STATUS_SUCCESS = 'success'; export const STATUS_NEUTRAL = 'neutral'; export const STATUS_NOT_FOUND = 'not_found'; -export const ICON_WARNING = 'warning'; - export const status = { LOADING, ERROR, @@ -22,3 +20,6 @@ export const status = { export const SLOT_SUCCESS = 'success'; export const SLOT_LOADING = 'loading'; export const SLOT_ERROR = 'error'; + +export const CODE_QUALITY_SCALE_KEY = 'codeQuality'; +export const SAST_SCALE_KEY = 'sast'; diff --git a/app/assets/javascripts/ci/reports/sast/constants.js b/app/assets/javascripts/ci/reports/sast/constants.js new file mode 100644 index 00000000000..3800065917b --- /dev/null +++ b/app/assets/javascripts/ci/reports/sast/constants.js @@ -0,0 +1,44 @@ +export const SEVERITY_CLASSES = { + info: 'gl-text-blue-400', + low: 'gl-text-orange-300', + medium: 'gl-text-orange-400', + high: 'gl-text-red-600', + critical: 'gl-text-red-800', + unknown: 'gl-text-gray-400', +}; + +export const SEVERITY_ICONS = { + info: 'severity-info', + low: 'severity-low', + medium: 'severity-medium', + high: 'severity-high', + critical: 'severity-critical', + unknown: 'severity-unknown', +}; + +export const SEVERITIES = { + info: { + class: SEVERITY_CLASSES.info, + name: SEVERITY_ICONS.info, + }, + low: { + class: SEVERITY_CLASSES.low, + name: SEVERITY_ICONS.low, + }, + medium: { + class: SEVERITY_CLASSES.medium, + name: SEVERITY_ICONS.medium, + }, + high: { + class: SEVERITY_CLASSES.high, + name: SEVERITY_ICONS.high, + }, + critical: { + class: SEVERITY_CLASSES.critical, + name: SEVERITY_ICONS.critical, + }, + unknown: { + class: SEVERITY_CLASSES.unknown, + name: SEVERITY_ICONS.unknown, + }, +}; diff --git a/app/assets/javascripts/ci/reports/utils.js b/app/assets/javascripts/ci/reports/utils.js new file mode 100644 index 00000000000..bb6eddf2cce --- /dev/null +++ b/app/assets/javascripts/ci/reports/utils.js @@ -0,0 +1,20 @@ +import { SEVERITIES as SEVERITIES_CODE_QUALITY } from '~/ci/reports/codequality_report/constants'; +import { SEVERITIES as SEVERITIES_SAST } from '~/ci/reports/sast/constants'; +import { SAST_SCALE_KEY } from './constants'; + +function mapSeverity(findings) { + const severityInfo = + findings.scale === SAST_SCALE_KEY ? SEVERITIES_SAST : SEVERITIES_CODE_QUALITY; + return { + ...findings, + class: severityInfo[findings.severity].class, + name: severityInfo[findings.severity].name, + }; +} + +export function getSeverity(findings) { + if (Array.isArray(findings)) { + return findings.map((finding) => mapSeverity(finding)); + } + return mapSeverity(findings); +} 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 d385d32fd9d..c2ec8462a0e 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 @@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } 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 RunnerHeaderActions from '../components/runner_header_actions.vue'; import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; import { I18N_FETCH_ERROR } from '../constants'; @@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'AdminRunnerShowApp', components: { - RunnerDeleteButton, - RunnerEditButton, - RunnerPauseButton, RunnerHeader, + RunnerHeaderActions, RunnerDetailsTabs, }, props: { @@ -80,9 +76,11 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> - <runner-pause-button v-if="canUpdate" :runner="runner" /> - <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + <runner-header-actions + :runner="runner" + :edit-path="runner.editAdminUrl" + @deleted="onDeleted" + /> </template> </runner-header> <runner-details-tabs v-if="runner" :runner="runner" /> 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 4d88feebe53..2168685e703 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 @@ -126,10 +126,6 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_admin feature flag - return this.glFeatures.createRunnerWorkflowForAdmin; - }, }, watch: { search: { @@ -193,14 +189,14 @@ export default { /> <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm"> + <gl-button :href="newRunnerPath" variant="confirm"> {{ s__('Runners|New instance runner') }} </gl-button> <registration-dropdown class="gl-ml-3" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" - right + placement="right" /> </div> </div> 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 9f4ce14f704..cc31afea88c 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 @@ -1,6 +1,6 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, formatNumber } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; @@ -49,6 +49,12 @@ export default { managersCount() { return this.runner.managers?.count || 0; }, + firstIpAddress() { + return this.runner.managers?.nodes?.[0]?.ipAddress || null; + }, + additionalIpAddressCount() { + return this.managersCount - 1; + }, jobCount() { return formatJobCount(this.runner.jobCount); }, @@ -63,6 +69,9 @@ export default { return null; }, }, + methods: { + formatNumber, + }, i18n: { I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, @@ -120,8 +129,11 @@ export default { </gl-sprintf> </runner-summary-field> - <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> - {{ runner.ipAddress }} + <runner-summary-field v-if="firstIpAddress" icon="disk" :tooltip="__('IP Address')"> + {{ firstIpAddress }} + <template v-if="additionalIpAddressCount" + >(+{{ formatNumber(additionalIpAddressCount) }})</template + > </runner-summary-field> <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue index 2fdf8456615..0154cd2a3ec 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDropdownForm, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlIcon, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; @@ -20,12 +26,15 @@ export default { showInstallationInstructions: s__( 'Runners|Show runner installation and registration instructions', ), + supportForRegistrationTokensDeprecated: s__( + 'Runners|Support for registration tokens is deprecated', + ), }, components: { - GlDropdown, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlDropdownForm, - GlDropdownItem, - GlDropdownDivider, GlIcon, RegistrationToken, RunnerInstructionsModal, @@ -51,14 +60,6 @@ export default { }; }, computed: { - isDeprecated() { - // Show a compact version when used as secondary option - // create_runner_workflow_for_admin or create_runner_workflow_for_namespace - return ( - this.glFeatures?.createRunnerWorkflowForAdmin || - this.glFeatures?.createRunnerWorkflowForNamespace - ); - }, actionText() { switch (this.type) { case INSTANCE_TYPE: @@ -71,30 +72,6 @@ export default { return I18N_REGISTER_RUNNER; } }, - dropdownText() { - if (this.isDeprecated) { - return ''; - } - return this.actionText; - }, - dropdownToggleClass() { - if (this.isDeprecated) { - return ['gl-px-3!']; - } - return []; - }, - dropdownCategory() { - if (this.isDeprecated) { - return 'tertiary'; - } - return 'primary'; - }, - dropdownVariant() { - if (this.isDeprecated) { - return 'default'; - } - return 'confirm'; - }, }, methods: { onShowInstructionsClick() { @@ -103,46 +80,51 @@ export default { onTokenReset(token) { this.currentRegistrationToken = token; - this.$refs.runnerRegistrationDropdown.hide(true); + this.$refs.runnerRegistrationDropdown.close(); + }, + onCopy() { + this.$refs.runnerRegistrationDropdown.close(); }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="runnerRegistrationDropdown" - menu-class="gl-w-auto!" - :text="dropdownText" - :toggle-class="dropdownToggleClass" - :variant="dropdownVariant" - :category="dropdownCategory" + :toggle-text="actionText" + toggle-class="gl-px-3!" + variant="default" + category="tertiary" v-bind="$attrs" + icon="ellipsis_v" + text-sr-only + no-caret > - <template v-if="isDeprecated" #button-content> - <span class="gl-sr-only">{{ actionText }}</span> - <gl-icon name="ellipsis_v" /> - </template> <gl-dropdown-form class="gl-p-4!"> - <registration-token input-id="token-value" :value="currentRegistrationToken"> - <template v-if="isDeprecated" #label-description> + <registration-token input-id="token-value" :value="currentRegistrationToken" @copy="onCopy"> + <template #label-description> <gl-icon name="warning" class="gl-text-orange-500" /> <span class="gl-text-secondary"> - {{ s__('Runners|Support for registration tokens is deprecated') }} + {{ $options.i18n.supportForRegistrationTokensDeprecated }} </span> </template> </registration-token> </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> - {{ $options.i18n.showInstallationInstructions }} - <runner-instructions-modal - ref="runnerInstructionsModal" - :registration-token="currentRegistrationToken" - data-testid="runner-instructions-modal" - /> - </gl-dropdown-item> - <gl-dropdown-divider /> - <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> - </gl-dropdown> + <gl-disclosure-dropdown-group bordered> + <gl-disclosure-dropdown-item @action="onShowInstructionsClick"> + <template #list-item> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + ref="runnerInstructionsModal" + :registration-token="currentRegistrationToken" + data-testid="runner-instructions-modal" + /> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-group bordered> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue index b196bccf66f..339c92a427f 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -31,6 +31,7 @@ export default { onCopy() { // value already in the clipboard, simply notify the user this.$toast?.show(s__('Runners|Registration token copied!')); + this.$emit('copy'); }, }, I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), 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 6ce88fc54de..47ca3ed6227 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,5 +1,5 @@ <script> -import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -19,7 +19,7 @@ export default { name: 'RunnerRegistrationTokenReset', i18n, components: { - GlDropdownItem, + GlDisclosureDropdownItem, GlLoadingIcon, GlModal, }, @@ -124,18 +124,20 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalId"> - {{ __('Reset registration token') }} - <gl-modal - size="sm" - :modal-id="$options.modalId" - :action-primary="actionPrimary" - :action-secondary="actionSecondary" - :title="$options.i18n.modalTitle" - @primary="handleModalPrimary" - > - <p>{{ $options.i18n.modalCopy }}</p> - </gl-modal> - <gl-loading-icon v-if="loading" inline /> - </gl-dropdown-item> + <gl-disclosure-dropdown-item v-gl-modal="$options.modalId"> + <template #list-item> + {{ __('Reset registration token') }} + <gl-modal + size="sm" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> + <gl-loading-icon v-if="loading" inline /> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_action.vue b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue new file mode 100644 index 00000000000..db8133c1ccb --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue @@ -0,0 +1,126 @@ +<script> +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +import { createAlert } from '~/alert'; +import { sprintf, s__ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DELETED_TOAST } from '../constants'; +import RunnerDeleteModal from './runner_delete_modal.vue'; + +/** + * Component that wraps a delete GraphQL mutation for the + * runner, given its id. + * + * You can use the slot to define a presentation for the + * delete action, like a button or dropdown item. + * + * Usage: + * + * ```vue + * <runner-delete-action + * #default="{ loading, onClick }" + * :runner="runner" + * @done="onDeleted" + * > + * <button :disabled="loading" @click="onClick"> Delete! </button> + * </runner-pause-action> + * ``` + * + */ +export default { + name: 'RunnerDeleteAction', + components: { + RunnerDeleteModal, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return runner?.id && runner?.shortSha; + }, + }, + }, + emits: ['done'], + data() { + return { + loading: false, + }; + }, + computed: { + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerManagersCount() { + return this.runner.managers?.count || 0; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; + }, + }, + methods: { + onClick() { + this.$refs.modal.show(); + }, + async onDelete() { + // "loading" stays "true" until this row is removed, + // should only change back if the operation fails. + this.loading = true; + try { + await this.$apollo.mutate({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + update: (cache, { data }) => { + const { errors } = data.runnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + return; + } + + this.$emit('done', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + + // Remove deleted runner from the cache + const cacheId = cache.identify(this.runner); + cache.evict({ id: cacheId }); + cache.gc(); + }, + }); + } catch (e) { + this.onError(e); + } + }, + onError(error) { + this.loading = false; + const { message } = error; + const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { + runnerName: this.runnerName, + }); + + createAlert({ title, message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> +<template> + <div> + <slot :loading="loading" :on-click="onClick"></slot> + <runner-delete-modal + ref="modal" + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + :managers-count="runnerManagersCount" + @primary="onDelete" + /> + </div> +</template> 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 3560521e8d7..d228a022032 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -1,30 +1,21 @@ <script> -import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; -import { createAlert } from '~/alert'; -import { sprintf, s__ } from '~/locale'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; -import RunnerDeleteModal from './runner_delete_modal.vue'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_DELETE_RUNNER } from '../constants'; +import RunnerDeleteAction from './runner_delete_action.vue'; export default { name: 'RunnerDeleteButton', components: { GlButton, - RunnerDeleteModal, + RunnerDeleteAction, }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, props: { runner: { type: Object, required: true, - validator: (runner) => { - return runner?.id && runner?.shortSha; - }, }, compact: { type: Boolean, @@ -39,17 +30,11 @@ export default { }; }, computed: { - runnerId() { - return getIdFromGraphQLId(this.runner.id); - }, - runnerName() { - return `#${this.runnerId} (${this.runner.shortSha})`; - }, - runnerManagersCount() { - return this.runner.managers?.count || 0; - }, - runnerDeleteModalId() { - return `delete-runner-modal-${this.runnerId}`; + buttonContent() { + if (this.compact) { + return null; + } + return I18N_DELETE_RUNNER; }, icon() { if (this.compact) { @@ -57,12 +42,6 @@ export default { } return ''; }, - buttonContent() { - if (this.compact) { - return null; - } - return I18N_DELETE_RUNNER; - }, buttonClass() { // Ensure a square button is shown when compact: true. // Without this class we will have distorted/rectangular button. @@ -78,83 +57,36 @@ export default { return null; }, tooltip() { - // Only show basic "delete" tooltip when compact. - // Also prevent a "sticky" tooltip: If this button is - // loading, mouseout listeners don't run leaving the tooltip stuck - if (this.compact && !this.deleting) { + if (this.compact) { return I18N_DELETE_RUNNER; } return ''; }, }, methods: { - async onDelete() { - // Deleting stays "true" until this row is removed, - // should only change back if the operation fails. - this.deleting = true; - try { - await this.$apollo.mutate({ - mutation: runnerDeleteMutation, - variables: { - input: { - id: this.runner.id, - }, - }, - update: (cache, { data }) => { - const { errors } = data.runnerDelete; - - if (errors?.length) { - this.onError(new Error(errors.join(' '))); - return; - } - - this.$emit('deleted', { - message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), - }); - - // Remove deleted runner from the cache - const cacheId = cache.identify(this.runner); - cache.evict({ id: cacheId }); - cache.gc(); - }, - }); - } catch (e) { - this.onError(e); - } - }, - onError(error) { - this.deleting = false; - const { message } = error; - const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { - runnerName: this.runnerName, - }); - - createAlert({ title, message }); - captureException({ error, component: this.$options.name }); + onDone(event) { + this.$emit('deleted', event); }, }, }; </script> <template> - <div v-gl-tooltip="tooltip" class="btn-group"> - <gl-button - v-gl-modal="runnerDeleteModalId" - :aria-label="ariaLabel" - :icon="icon" - :class="buttonClass" - :loading="deleting" - variant="danger" - category="secondary" - v-bind="$attrs" - > - {{ buttonContent }} - </gl-button> - <runner-delete-modal - :modal-id="runnerDeleteModalId" - :runner-name="runnerName" - :managers-count="runnerManagersCount" - @primary="onDelete" - /> - </div> + <runner-delete-action class="btn-group" :runner="runner" @done="onDone"> + <template #default="{ loading, onClick }"> + <gl-button + v-gl-tooltip="loading ? '' : tooltip" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="loading" + variant="danger" + category="secondary" + v-bind="$attrs" + @click="onClick" + > + {{ buttonContent }} + </gl-button> + </template> + </runner-delete-action> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..0a81974a6d0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue @@ -0,0 +1,38 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { I18N_DELETE } from '../constants'; +import RunnerDeleteAction from './runner_delete_action.vue'; + +export default { + name: 'RunnerDeleteDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + RunnerDeleteAction, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + emits: ['deleted'], + methods: { + onDone(event) { + this.$emit('deleted', event); + }, + }, + I18N_DELETE, +}; +</script> + +<template> + <runner-delete-action :runner="runner" @done="onDone"> + <template #default="{ onClick }"> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <span class="gl-text-red-500">{{ $options.I18N_DELETE }}</span> + </template> + </gl-disclosure-dropdown-item> + </template> + </runner-delete-action> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue index 93f79fd67ea..124ac0b4e73 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue @@ -52,6 +52,9 @@ export default { }, }, methods: { + show() { + this.$refs.modal.show(); + }, onPrimary() { this.$refs.modal.hide(); }, diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue index 9e8055a8432..496985ff7ac 100644 --- a/app/assets/javascripts/ci/runner/components/runner_detail.vue +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -40,12 +40,12 @@ export default { <template> <div class="gl-display-contents"> - <dt class="gl-mb-5 gl-mr-6 gl-max-w-26"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26" data-testid="label-slot"> <template v-if="label || $scopedSlots.label"> <slot name="label">{{ label }}</slot> </template> </dt> - <dd class="gl-mb-5"> + <dd class="gl-mb-5" data-testid="value-slot"> <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue index 33e0acaf5c0..b4efd72b082 100644 --- a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue @@ -9,15 +9,23 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + props: { + href: { + type: String, + required: false, + default: null, + }, + }, I18N_EDIT, }; </script> <template> <gl-button + v-if="href" v-gl-tooltip="$options.I18N_EDIT" - v-bind="$attrs" :aria-label="$options.I18N_EDIT" + :href="href" icon="pencil" v-on="$listeners" /> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..d0dcc04c3dc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue @@ -0,0 +1,29 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; + +import { I18N_EDIT } from '../constants'; + +export default { + name: 'RunnerEditDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + }, + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + item() { + return { text: I18N_EDIT, href: this.href }; + }, + }, + I18N_EDIT, +}; +</script> + +<template> + <gl-disclosure-dropdown-item v-if="href" :item="item" /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue index f46e894bf2e..55a33ef2074 100644 --- a/app/assets/javascripts/ci/runner/components/runner_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -32,31 +32,29 @@ export default { }; </script> <template> - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-py-5" - > - <div> + <div class="gl-py-5"> + <div class="gl-display-flex gl-justify-content-space-between"> <h1 class="gl-font-size-h-display gl-my-0">{{ name }}</h1> - <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> - <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> - <runner-type-badge :type="runner.runnerType" /> - <span v-if="runner.createdAt"> - <gl-sprintf :message="__('%{locked} created %{timeago}')"> - <template #locked> - <gl-icon - v-if="runner.locked" - v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - name="lock" - :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - /> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </span> - </div> + <slot name="actions"></slot> + </div> + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> + <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> + <runner-type-badge :type="runner.runnerType" /> + <span v-if="runner.createdAt"> + <gl-sprintf :message="__('%{locked} created %{timeago}')"> + <template #locked> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </span> </div> - <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header_actions.vue b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue new file mode 100644 index 00000000000..bc6f184bd4d --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue @@ -0,0 +1,80 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; + +import RunnerDeleteButton from './runner_delete_button.vue'; +import RunnerEditButton from './runner_edit_button.vue'; +import RunnerPauseButton from './runner_pause_button.vue'; + +import RunnerEditDisclosureDropdownItem from './runner_edit_disclosure_dropdown_item.vue'; +import RunnerPauseDisclosureDropdownItem from './runner_pause_disclosure_dropdown_item.vue'; +import RunnerDeleteDisclosureDropdownItem from './runner_delete_disclosure_dropdown_item.vue'; + +export default { + name: 'RunnerHeaderActions', + components: { + GlDisclosureDropdown, + + RunnerDeleteButton, + RunnerEditButton, + RunnerPauseButton, + + RunnerEditDisclosureDropdownItem, + RunnerPauseDisclosureDropdownItem, + RunnerDeleteDisclosureDropdownItem, + }, + props: { + runner: { + type: Object, + required: true, + }, + editPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + }, + methods: { + onDeleted(event) { + this.$emit('deleted', event); + }, + }, +}; +</script> + +<template> + <div v-if="canUpdate || canDelete"> + <!-- sm and up screens --> + <div class="gl-display-none gl-sm-display-flex gl-gap-3"> + <runner-edit-button v-if="canUpdate" :href="editPath" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> + <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + </div> + + <!-- xs screens --> + <div class="gl-sm-display-none"> + <gl-disclosure-dropdown + icon="ellipsis_v" + :toggle-text="s__('Runner|Runner actions')" + text-sr-only + category="tertiary" + no-caret + > + <runner-edit-disclosure-dropdown-item v-if="canUpdate" :href="editPath" /> + <runner-pause-disclosure-dropdown-item v-if="canUpdate" :runner="runner" /> + <runner-delete-disclosure-dropdown-item + v-if="canDelete" + :runner="runner" + @deleted="onDeleted" + /> + </gl-disclosure-dropdown> + </div> + </div> +</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 d2836962a97..a4a489074c3 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 @@ -11,7 +11,6 @@ import { I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS, I18N_CONTACT_ADMIN_TO_REGISTER, - I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, I18N_NO_RESULTS, I18N_EDIT_YOUR_SEARCH, } from '~/ci/runner/constants'; @@ -44,15 +43,6 @@ export default { default: null, }, }, - computed: { - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_admin or create_runner_workflow_for_namespace - return ( - this.glFeatures?.createRunnerWorkflowForAdmin || - this.glFeatures?.createRunnerWorkflowForNamespace - ); - }, - }, modalId: 'runners-empty-state-instructions-modal', svgHeight: 145, EMPTY_STATE_SVG_URL, @@ -63,7 +53,6 @@ export default { I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS, I18N_CONTACT_ADMIN_TO_REGISTER, - I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, I18N_NO_RESULTS, I18N_EDIT_YOUR_SEARCH, }; @@ -85,39 +74,22 @@ export default { > <template #description> {{ $options.I18N_RUNNERS_ARE_AGENTS }} - <template v-if="shouldShowCreateRunnerWorkflow"> - <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> - <template #link="{ content }"> - <gl-link :href="newRunnerPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - <template v-if="registrationToken"> - <br /> - <gl-link v-gl-modal="$options.modalId">{{ - $options.I18N_STILL_USING_REGISTRATION_TOKENS - }}</gl-link> - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> - </template> - <template v-if="!newRunnerPath && !registrationToken"> - {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} - </template> - </template> - <gl-sprintf - v-else-if="registrationToken" - :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS" - > + <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> <template #link="{ content }"> - <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> + <gl-link :href="newRunnerPath">{{ content }}</gl-link> </template> </gl-sprintf> - <template v-else> + <template v-if="registrationToken"> + <br /> + <gl-link v-gl-modal="$options.modalId">{{ + $options.I18N_STILL_USING_REGISTRATION_TOKENS + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> + </template> + <template v-if="!newRunnerPath && !registrationToken"> {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} </template> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_action.vue b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue new file mode 100644 index 00000000000..184d6a83381 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue @@ -0,0 +1,89 @@ +<script> +import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; +import { createAlert } from '~/alert'; +import { captureException } from '~/ci/runner/sentry_utils'; + +/** + * Renderless component that wraps a GraphQL pause mutation for the + * runner, given its id and current "paused" value. + * + * You can use the slot to define a presentation for the delete action, + * like a button or dropdown item. + + * Usage: + * + * ```vue + * <runner-pause-action + * #default="{ loading, onClick }" + * :runner="runner" + * @done="onToggled" + * > + * <button :disabled="loading" @click="onClick">{{ runner.paused ? 'Go!' : 'Stop!' }}</button> + * </runner-pause-action> + * ``` + * + */ +export default { + name: 'RunnerPauseAction', + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['done'], + data() { + return { + loading: false, + }; + }, + methods: { + async onClick() { + this.loading = true; + try { + const input = { + id: this.runner.id, + paused: !this.runner.paused, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerTogglePausedMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.$emit('done'); + } catch (e) { + this.onError(e); + } finally { + this.loading = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, + render() { + return this.$scopedSlots.default({ + onClick: this.onClick, + loading: this.loading, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue index d16c8f98bad..15bb54027c7 100644 --- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue @@ -1,14 +1,14 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; -import { createAlert } from '~/alert'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; + +import { I18N_RESUME, I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME_TOOLTIP } from '../constants'; +import RunnerPauseAction from './runner_pause_action.vue'; export default { name: 'RunnerPauseButton', components: { GlButton, + RunnerPauseAction, }, directives: { GlTooltip: GlTooltipDirective, @@ -25,96 +25,47 @@ export default { }, }, emits: ['toggledPaused'], - data() { - return { - updating: false, - }; - }, computed: { isPaused() { return this.runner.paused; }, + tooltip() { + return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP; + }, icon() { return this.isPaused ? 'play' : 'pause'; }, label() { return this.isPaused ? I18N_RESUME : I18N_PAUSE; }, - buttonContent() { - if (this.compact) { - return null; - } - return this.label; - }, ariaLabel() { if (this.compact) { return this.label; } return null; }, - tooltip() { - // Prevent a "sticky" tooltip: If this button is disabled, - // mouseout listeners don't run leaving the tooltip stuck - if (!this.updating) { - return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP; - } - return ''; - }, - }, - methods: { - async onToggle() { - this.updating = true; - try { - const input = { - id: this.runner.id, - paused: !this.isPaused, - }; - - const { - data: { - runnerUpdate: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerTogglePausedMutation, - variables: { - input, - }, - }); - - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - this.$emit('toggledPaused'); - } catch (e) { - this.onError(e); - } finally { - this.updating = false; + buttonContent() { + if (this.compact) { + return null; } - }, - onError(error) { - const { message } = error; - - createAlert({ message }); - captureException({ error, component: this.$options.name }); + return this.label; }, }, }; </script> <template> - <gl-button - v-gl-tooltip="tooltip" - v-bind="$attrs" - :aria-label="ariaLabel" - :icon="icon" - :loading="updating" - @click="onToggle" - v-on="$listeners" - > - <!-- - Use <template v-if> to ensure a square button is shown when compact: true. - Sending empty content will still show a distorted/rectangular button. - --> - <template v-if="buttonContent">{{ buttonContent }}</template> - </gl-button> + <runner-pause-action :runner="runner" @done="$emit('toggledPaused')"> + <template #default="{ loading, onClick }"> + <gl-button + v-gl-tooltip="loading ? '' : tooltip" + :icon="icon" + :aria-label="ariaLabel" + :loading="loading" + @click="onClick" + > + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> + </template> + </runner-pause-action> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..3dd5e227a4a --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue @@ -0,0 +1,34 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; + +import { I18N_RESUME, I18N_PAUSE } from '../constants'; +import RunnerPauseAction from './runner_pause_action.vue'; + +export default { + name: 'RunnerPauseDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + RunnerPauseAction, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + emits: ['toggledPaused'], + computed: { + item() { + return { text: this.runner.paused ? I18N_RESUME : I18N_PAUSE }; + }, + }, +}; +</script> + +<template> + <runner-pause-action :runner="runner" @done="$emit('toggledPaused')"> + <template #default="{ onClick }"> + <gl-disclosure-dropdown-item :item="item" @action="onClick" /> + </template> + </runner-pause-action> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 40841696ead..203f97876de 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -1,4 +1,5 @@ import { __, s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; export const RUNNER_TYPENAME = 'CiRunner'; // __typename @@ -90,6 +91,7 @@ export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs'); export const I18N_RESUME = __('Resume'); export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); +export const I18N_DELETE = s__('Runners|Delete'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); @@ -117,9 +119,6 @@ export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using reg export const I18N_CONTACT_ADMIN_TO_REGISTER = s__( 'Runners|To register new runners, contact your administrator.', ); -export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__( - 'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', -); // No runners found export const I18N_NO_RESULTS = s__('Runners|No results found'); @@ -271,12 +270,10 @@ 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 INSTALL_HELP_URL = 'https://docs.gitlab.com/runner/install'; -export const EXECUTORS_HELP_URL = 'https://docs.gitlab.com/runner/executors/'; -export const SERVICE_COMMANDS_HELP_URL = - 'https://docs.gitlab.com/runner/commands/#service-related-commands'; -export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md'; -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'; -export const RUNNER_MANAGERS_HELP_URL = - 'https://docs.gitlab.com/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities'; +export const INSTALL_HELP_URL = `${DOCS_URL}/runner/install`; +export const EXECUTORS_HELP_URL = `${DOCS_URL}/runner/executors/`; +export const SERVICE_COMMANDS_HELP_URL = `${DOCS_URL}/runner/commands/#service-related-commands`; +export const CHANGELOG_URL = `https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md`; +export const DOCKER_HELP_URL = `${DOCS_URL}/runner/install/docker.html`; +export const KUBERNETES_HELP_URL = `${DOCS_URL}/runner/install/kubernetes.html`; +export const RUNNER_MANAGERS_HELP_URL = `${DOCS_URL}/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities`; diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index c0b888e758b..7ad9605d0a4 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -6,7 +6,6 @@ fragment ListItemShared on CiRunner { runnerType shortSha version - ipAddress paused locked jobCount @@ -22,8 +21,11 @@ fragment ListItemShared on CiRunner { updateRunner deleteRunner } - managers { + managers(first: 1) { count + nodes { + ipAddress + } } groups(first: 1) { nodes { 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 e885cf45c5a..4b570db772f 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 @@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } 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 RunnerHeaderActions from '../components/runner_header_actions.vue'; import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; import { I18N_FETCH_ERROR } from '../constants'; @@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'GroupRunnerShowApp', components: { - RunnerDeleteButton, - RunnerEditButton, - RunnerPauseButton, RunnerHeader, + RunnerHeaderActions, RunnerDetailsTabs, }, props: { @@ -85,9 +81,11 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" /> - <runner-pause-button v-if="canUpdate" :runner="runner" /> - <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + <runner-header-actions + :runner="runner" + :edit-path="editGroupRunnerPath" + @deleted="onDeleted" + /> </template> </runner-header> 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 74523bc335f..71584c40a38 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 @@ -155,10 +155,6 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_namespace feature flag - return this.glFeatures.createRunnerWorkflowForNamespace; - }, }, watch: { search: { @@ -231,11 +227,7 @@ export default { /> <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button - v-if="shouldShowCreateRunnerWorkflow && newRunnerPath" - :href="newRunnerPath" - variant="confirm" - > + <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> {{ s__('Runners|New group runner') }} </gl-button> <registration-dropdown @@ -243,7 +235,7 @@ export default { class="gl-ml-3" :registration-token="registrationToken" :type="$options.GROUP_TYPE" - right + placement="right" /> </div> </div> diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js index 102b240042f..3cb4376f41a 100644 --- a/app/assets/javascripts/clusters/forms/show/index.js +++ b/app/assets/javascripts/clusters/forms/show/index.js @@ -1,11 +1,8 @@ import Vue from 'vue'; -import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import IntegrationForm from '../components/integration_form.vue'; import { createStore } from '../stores'; export default () => { - dirtySubmitFactory(document.querySelectorAll('.js-cluster-integrations-form')); - const entryPoint = document.querySelector('#js-cluster-details-form'); if (!entryPoint) { diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue index 2675d46dd16..7b97a5af373 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue @@ -1,5 +1,11 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective, GlTooltip } from '@gitlab/ui'; +import { + GlButton, + GlButtonGroup, + GlModalDirective, + GlTooltip, + GlDisclosureDropdown, +} from '@gitlab/ui'; import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '../constants'; @@ -8,8 +14,8 @@ export default { INSTALL_AGENT_MODAL_ID, components: { GlButton, - GlDropdown, - GlDropdownItem, + GlButtonGroup, + GlDisclosureDropdown, GlTooltip, }, directives: { @@ -45,13 +51,17 @@ export default { actionItems() { const createCluster = { href: this.newClusterDocsPath, - title: this.$options.i18n.createCluster, - testid: 'create-cluster-link', + text: this.$options.i18n.createCluster, + extraAttrs: { + 'data-testid': 'create-cluster-link', + }, }; const connectCluster = { href: this.addClusterPath, - title: this.$options.i18n.connectClusterCertificate, - testid: 'connect-cluster-link', + text: this.$options.i18n.connectClusterCertificate, + extraAttrs: { + 'data-testid': 'connect-cluster-link', + }, }; const actions = []; @@ -61,7 +71,6 @@ export default { if (this.displayClusterAgents && this.certificateBasedClustersEnabled) { actions.push(connectCluster); } - return actions; }, }, @@ -81,39 +90,35 @@ export default { :title="$options.i18n.actionsDisabledHint" /> - <gl-button - v-if="!actionItems.length" - data-qa-selector="clusters_actions_button" - category="primary" - variant="confirm" - :disabled="!canAddCluster" - :href="defaultActionUrl" - > - {{ defaultActionText }} - </gl-button> - - <gl-dropdown - v-else + <!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.--> + <!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263--> + <gl-button-group ref="actions" - v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" data-qa-selector="clusters_actions_button" - category="primary" - variant="confirm" - :text="defaultActionText" - :disabled="!canAddCluster" - :split-href="defaultActionUrl" - split - right + class="gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0" > - <gl-dropdown-item - v-for="action in actionItems" - :key="action.title" - :href="action.href" - :data-testid="action.testid" - @click.stop + <gl-button + v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID" + :href="defaultActionUrl" + :disabled="!canAddCluster" + data-testid="clusters-default-action-button" + category="primary" + variant="confirm" > - {{ action.title }} - </gl-dropdown-item> - </gl-dropdown> + {{ defaultActionText }} + </gl-button> + <gl-disclosure-dropdown + v-if="actionItems.length" + class="split" + toggle-class="gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-pl-1!" + category="primary" + variant="confirm" + placement="right" + :toggle-text="defaultActionText" + :items="actionItems" + :disabled="!canAddCluster" + text-sr-only + /> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/comment_templates/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue index 9e0d2cc73ec..de3229acc78 100644 --- a/app/assets/javascripts/comment_templates/components/app.vue +++ b/app/assets/javascripts/comment_templates/components/app.vue @@ -3,21 +3,21 @@ export default {}; </script> <template> - <div class="row gl-mt-5"> - <div class="col-lg-4"> - <h4 class="gl-mt-0"> - {{ __('Comment templates') }} - </h4> - <p> - {{ - __( - 'Comment templates can be used when creating comments inside issues, merge requests, and epics.', - ) - }} - </p> - </div> - <div class="col-lg-8"> - <keep-alive><router-view /></keep-alive> + <div class="settings-section gl-mt-3"> + <div class="settings-sticky-header"> + <div class="settings-sticky-header-inner"> + <h4 class="gl-my-0"> + {{ __('Comment templates') }} + </h4> + </div> </div> + <p class="gl-text-secondary"> + {{ + __( + 'Comment templates can be used when creating comments inside issues, merge requests, and epics.', + ) + }} + </p> + <keep-alive><router-view /></keep-alive> </div> </template> diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index 47efccc3d0c..6bdf1b313cb 100644 --- a/app/assets/javascripts/comment_templates/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -112,7 +112,7 @@ export default { <template> <gl-form - class="new-note common-note-form gl-mb-6" + class="new-note common-note-form" data-testid="comment-template-form" @submit.prevent="onSubmit" > diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue index 52bebfd050c..46d6b49297d 100644 --- a/app/assets/javascripts/comment_templates/components/list.vue +++ b/app/assets/javascripts/comment_templates/components/list.vue @@ -44,14 +44,18 @@ export default { </script> <template> - <div class="gl-border-t gl-pt-4"> + <div class="settings-section"> <gl-loading-icon v-if="loading" size="lg" /> <template v-else> - <h5 class="gl-font-lg" data-testid="title"> - <gl-sprintf :message="__('My comment templates (%{count})')"> - <template #count>{{ count }}</template> - </gl-sprintf> - </h5> + <div class="settings-sticky-header"> + <div class="settings-sticky-header-inner"> + <h4 class="gl-my-0" data-testid="title"> + <gl-sprintf :message="__('My comment templates (%{count})')"> + <template #count>{{ count }}</template> + </gl-sprintf> + </h4> + </div> + </div> <ul class="gl-list-style-none gl-p-0 gl-m-0"> <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> </ul> diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue index d763700db42..70ba449113b 100644 --- a/app/assets/javascripts/comment_templates/components/list_item.vue +++ b/app/assets/javascripts/comment_templates/components/list_item.vue @@ -94,7 +94,7 @@ export default { </gl-tooltip> </div> </div> - <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div> + <div class="gl-mt-3 gl-font-monospace gl-white-space-pre-wrap">{{ template.content }}</div> <gl-modal ref="delete-modal" :title="__('Delete comment template')" diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue index 72a94dafc58..daa4ba689a7 100644 --- a/app/assets/javascripts/comment_templates/pages/index.vue +++ b/app/assets/javascripts/comment_templates/pages/index.vue @@ -52,10 +52,12 @@ export default { <template> <div> - <h5 class="gl-mt-0 gl-font-lg"> - {{ __('Add new comment template') }} - </h5> - <create-form @saved="refetchSavedReplies" /> + <div class="settings-section"> + <h5 class="gl-mt-0 gl-font-lg"> + {{ __('Add new comment template') }} + </h5> + <create-form @saved="refetchSavedReplies" /> + </div> <list :loading="$apollo.queries.savedReplies.loading" :saved-replies="savedReplies" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue index ce5b566ba20..948c58287fb 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -44,7 +44,7 @@ export default { this.menuVisible = false; }, strategy: 'fixed', - maxWidth: 'auto', + maxWidth: '400px', }, }), ); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index 6bb6bdc4e65..6ce6e731551 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -69,7 +69,6 @@ export default { mediaSrc: undefined, mediaCanonicalSrc: undefined, mediaAlt: undefined, - mediaTitle: undefined, isEditing: false, isUpdating: false, @@ -130,16 +129,13 @@ export default { const position = this.tiptapEditor.state.selection.from; - this.tiptapEditor - .chain() - .focus() - .updateAttributes(this.mediaType, { - src: this.mediaSrc, - alt: this.mediaAlt, - canonicalSrc: this.mediaCanonicalSrc, - title: this.mediaTitle, - }) - .run(); + const attrs = { + src: this.mediaSrc, + alt: this.mediaAlt, + canonicalSrc: this.mediaCanonicalSrc, + }; + + this.tiptapEditor.chain().focus().updateAttributes(this.mediaType, attrs).run(); this.tiptapEditor.commands.setNodeSelection(position); @@ -155,13 +151,11 @@ export default { this.isUpdating = true; - const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes( - this.mediaType, - ); + const { src, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes(this.mediaType); - this.mediaTitle = title; this.mediaAlt = alt; this.mediaCanonicalSrc = canonicalSrc || src; + this.uploading = uploading; this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); @@ -177,7 +171,6 @@ export default { }, resetMediaInfo() { - this.mediaTitle = null; this.mediaAlt = null; this.mediaCanonicalSrc = null; this.uploading = false; @@ -248,7 +241,6 @@ export default { data-qa-selector="file_upload_field" @change="onFileSelect" /> - <gl-link v-if="!showProgressIndicator" v-gl-tooltip @@ -261,17 +253,6 @@ export default { {{ mediaCanonicalSrc }} </gl-link> <gl-button - v-gl-tooltip - variant="default" - category="tertiary" - size="medium" - data-testid="copy-media-src" - :aria-label="copySourceLabel" - :title="copySourceLabel" - icon="copy-to-clipboard" - @click="copyMediaSrc" - /> - <gl-button v-if="!showProgressIndicator" v-gl-tooltip variant="default" @@ -290,8 +271,8 @@ export default { category="tertiary" size="medium" data-testid="edit-diagram" - :aria-label="replaceLabel" - title="Edit diagram" + :aria-label="editLabel" + :title="editLabel" icon="diagram" @click="editDiagram" /> @@ -307,28 +288,14 @@ export default { icon="retry" @click="replaceMedia" /> - <gl-button - v-gl-tooltip - variant="default" - category="tertiary" - size="medium" - data-testid="delete-media" - :aria-label="deleteLabel" - :title="deleteLabel" - icon="remove" - @click="deleteMedia" - /> </gl-button-group> <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia"> <gl-form-group :label="__('URL')" label-for="media-src"> <gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" /> </gl-form-group> - <gl-form-group :label="__('Description (alt text)')" label-for="media-alt"> + <gl-form-group :label="__('Alt text')" label-for="media-alt"> <gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" /> </gl-form-group> - <gl-form-group :label="__('Title')" label-for="media-title"> - <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" /> - </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> <gl-button class="gl-mr-3" diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 92f3c3fb8fa..1036b6552d1 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,8 +1,9 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; +import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; import { createContentEditor } from '../services/create_content_editor'; import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; @@ -17,8 +18,7 @@ import LoadingIndicator from './loading_indicator.vue'; export default { components: { - GlSprintf, - GlLink, + GlButton, LoadingIndicator, ContentEditorAlert, ContentEditorProvider, @@ -29,12 +29,20 @@ export default { MediaBubbleMenu, EditorStateObserver, ReferenceBubbleMenu, + EditorModeSwitcher, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { renderMarkdown: { type: Function, required: true, }, + markdownDocsPath: { + type: String, + required: true, + }, uploadsPath: { type: String, required: true, @@ -65,16 +73,21 @@ export default { default: false, validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), }, - quickActionsDocsPath: { - type: String, + supportsQuickActions: { + type: Boolean, required: false, - default: '', + default: false, }, drawioEnabled: { type: Boolean, required: false, default: false, }, + codeSuggestionsConfig: { + type: Object, + required: false, + default: () => ({}), + }, editable: { type: Boolean, required: false, @@ -129,6 +142,7 @@ export default { editable, enableAutocomplete, autocompleteDataSources, + codeSuggestionsConfig, } = this; // This is a non-reactive attribute intentionally since this is a complex object. @@ -140,6 +154,7 @@ export default { drawioEnabled, enableAutocomplete, autocompleteDataSources, + codeSuggestionsConfig, tiptapOptions: { autofocus, editable, @@ -204,17 +219,15 @@ export default { markdown: this.latestMarkdown, }); }, - }, - i18n: { - quickActionsText: s__( - 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.', - ), + handleEditorModeChanged() { + this.$emit('enableMarkdownEditor'); + }, }, }; </script> <template> <content-editor-provider :content-editor="contentEditor"> - <div> + <div class="md-area gl-overflow-hidden"> <editor-state-observer @docUpdate="notifyChange" @focus="focus" @@ -225,11 +238,11 @@ export default { <div data-testid="content-editor" data-qa-selector="content_editor_container" - class="md-area gl-border-none! gl-shadow-none!" :class="{ 'is-focused': focused }" > <formatting-toolbar ref="toolbar" + :supports-quick-actions="supportsQuickActions" :hide-attachment-button="disableAttachments" @enableMarkdownEditor="$emit('enableMarkdownEditor')" /> @@ -237,7 +250,7 @@ export default { {{ placeholder }} </div> <tiptap-editor-content - class="md gl-px-5" + class="md" data-testid="content_editor_editablebox" :editor="contentEditor.tiptapEditor" /> @@ -249,21 +262,19 @@ export default { <reference-bubble-menu /> </div> <div - v-if="quickActionsDocsPath" - class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" + class="gl-display-flex gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-2 gl-border-t gl-border-gray-100 gl-text-secondary" > - <div class="gl-w-full gl-line-height-32 gl-font-sm"> - <gl-sprintf :message="$options.i18n.quickActionsText"> - <template #keyboard="{ content }"> - <kbd>{{ content }}</kbd> - </template> - <template #quickActionsDocsLink="{ content }"> - <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </div> + <editor-mode-switcher size="small" value="richText" @switch="handleEditorModeChanged" /> + <gl-button + v-gl-tooltip + icon="markdown-mark" + :href="markdownDocsPath" + target="_blank" + category="tertiary" + size="small" + title="Markdown is supported" + class="gl-px-3!" + /> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index c53007b68cf..dc27278d255 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -1,5 +1,7 @@ <script> -import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; +import { __, sprintf } from '~/locale'; +import { getModifierKey } from '~/constants'; import trackUIControl from '../services/track_ui_control'; import ToolbarButton from './toolbar_button.vue'; import ToolbarAttachmentButton from './toolbar_attachment_button.vue'; @@ -14,122 +16,179 @@ export default { ToolbarTableButton, ToolbarAttachmentButton, ToolbarMoreDropdown, - EditorModeSwitcher, + CommentTemplatesDropdown, + }, + inject: { + newCommentTemplatePath: { default: null }, + tiptapEditor: { default: null }, + contentEditor: { default: null }, }, props: { + supportsQuickActions: { + type: Boolean, + required: false, + default: false, + }, hideAttachmentButton: { type: Boolean, default: false, required: false, }, }, + data() { + const modifierKey = getModifierKey(); + const shiftKey = modifierKey === '⌘' ? '⇧' : 'Shift+'; + + return { + i18n: { + bold: sprintf(__('Bold (%{modifierKey}B)'), { modifierKey }), + italic: sprintf(__('Italic (%{modifierKey}I)'), { modifierKey }), + strike: sprintf(__('Strikethrough (%{modifierKey}%{shiftKey}X)'), { + modifierKey, + shiftKey, + }), + quote: __('Insert a quote'), + code: __('Code'), + link: sprintf(__('Insert link (%{modifierKey}K)'), { modifierKey }), + bulletList: __('Add a bullet list'), + numberedList: __('Add a numbered list'), + taskList: __('Add a checklist'), + }, + }; + }, + computed: { + codeSuggestionsEnabled() { + return this.contentEditor.codeSuggestionsConfig?.canSuggest; + }, + }, methods: { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ property: contentType, value }); }, - handleEditorModeChanged() { - this.$emit('enableMarkdownEditor'); + insertSavedReply(savedReply) { + this.tiptapEditor.chain().focus().pasteContent(savedReply).run(); }, }, }; </script> <template> - <div class="gl-mx-2 gl-mt-2"> - <div - class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-bg-gray-50 gl-px-2 gl-rounded-base gl-justify-content-space-between" - data-testid="formatting-toolbar" - > - <div class="gl-py-2 gl-display-flex gl-flex-wrap"> - <toolbar-text-style-dropdown - data-testid="text-styles" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bold" - content-type="bold" - icon-name="bold" - editor-command="toggleBold" - :label="__('Bold text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="italic" - content-type="italic" - icon-name="italic" - editor-command="toggleItalic" - :label="__('Italic text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="strike" - content-type="strike" - icon-name="strikethrough" - editor-command="toggleStrike" - :label="__('Strikethrough')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - editor-command="toggleBlockquote" - :label="__('Insert a quote')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code" - content-type="code" - icon-name="code" - editor-command="toggleCode" - :label="__('Code')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="link" - content-type="link" - icon-name="link" - editor-command="editLink" - :label="__('Insert link')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bullet-list" - content-type="bulletList" - icon-name="list-bulleted" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleBulletList" - :label="__('Add a bullet list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="ordered-list" - content-type="orderedList" - icon-name="list-numbered" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleOrderedList" - :label="__('Add a numbered list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="task-list" - content-type="taskList" - icon-name="list-task" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleTaskList" - :label="__('Add a checklist')" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> - <toolbar-attachment-button - v-if="!hideAttachmentButton" - data-testid="attachment" - @execute="trackToolbarControlExecution" - /> - <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> - </div> - <div class="content-editor-switcher gl-display-flex gl-align-items-center gl-ml-auto"> - <editor-mode-switcher size="small" value="richText" @input="handleEditorModeChanged" /> - </div> + <div + class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-border-b gl-border-gray-100 gl-px-3 gl-rounded-top-base gl-justify-content-space-between" + data-testid="formatting-toolbar" + > + <div class="gl-py-3 gl-display-flex gl-flex-wrap"> + <toolbar-text-style-dropdown + data-testid="text-styles" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + v-if="codeSuggestionsEnabled" + data-testid="code-suggestion" + content-type="codeSuggestion" + icon-name="doc-code" + editor-command="insertCodeSuggestion" + :label="__('Insert suggestion')" + :show-active-state="false" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="i18n.bold" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="i18n.italic" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="strike" + content-type="strike" + icon-name="strikethrough" + editor-command="toggleStrike" + :label="i18n.strike" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="i18n.quote" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="i18n.code" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="editLink" + :label="i18n.link" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleBulletList" + :label="i18n.bulletList" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleOrderedList" + :label="i18n.numberedList" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="task-list" + content-type="taskList" + icon-name="list-task" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleTaskList" + :label="i18n.taskList" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <toolbar-attachment-button + v-if="!hideAttachmentButton" + data-testid="attachment" + @execute="trackToolbarControlExecution" + /> + <!-- TODO Add icon and trigger functionality from here --> + <toolbar-button + v-if="supportsQuickActions" + data-testid="quick-actions" + content-type="quickAction" + icon-name="quick-actions" + class="gl-display-none gl-sm-display-inline" + editor-command="insertQuickAction" + :label="__('Add a quick action')" + @execute="trackToolbarControlExecution" + /> + <comment-templates-dropdown + v-if="newCommentTemplatePath" + :new-comment-template-path="newCommentTemplatePath" + @select="insertSavedReply" + /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> </div> </div> </template> diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 4074e50a706..6535d9eaa5d 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,9 +1,8 @@ <script> -import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlDropdownItem, GlAvatarLabeled, GlLoadingIcon, }, @@ -43,7 +42,7 @@ export default { data() { return { - selectedIndex: 0, + selectedIndex: -1, }; }, @@ -95,7 +94,7 @@ export default { watch: { items() { - this.selectedIndex = 0; + this.selectedIndex = -1; }, selectedIndex() { this.scrollIntoView(); @@ -193,7 +192,7 @@ export default { }, scrollIntoView() { - this.$refs.dropdownItems[this.selectedIndex].$el.scrollIntoView({ block: 'nearest' }); + this.$refs.dropdownItems[this.selectedIndex]?.scrollIntoView({ block: 'nearest' }); }, selectItem(index) { @@ -215,72 +214,83 @@ export default { </script> <template> - <div> - <ul - v-if="!loading" - :class="{ show: items.length > 0 }" - class="gl-dropdown dropdown-menu gl-relative gl-m-0!" - data-testid="content-editor-suggestions-dropdown" + <div class="gl-new-dropdown content-editor-suggestions-dropdown"> + <div + v-if="!loading && items.length > 0" + class="gl-new-dropdown-panel gl-display-block! gl-absolute" > - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <gl-dropdown-item - v-for="(item, index) in items" - ref="dropdownItems" - :key="index" - :class="{ 'gl-bg-gray-50': index === selectedIndex }" - @click="selectItem(index)" - > - <gl-avatar-labeled - v-if="isUser" - :label="item.username" - :sub-label="avatarSubLabel(item)" - :src="item.avatar_url" - :entity-name="item.username" - :shape="item.type === 'Group' ? 'rect' : 'circle'" - :size="32" - /> - <span v-if="isIssue || isMergeRequest"> - <small>{{ item.iid }}</small> - {{ item.title }} - </span> - <span v-if="isVulnerability || isSnippet"> - <small>{{ item.id }}</small> - {{ item.title }} - </span> - <span v-if="isEpic"> - <small>{{ item.reference }}</small> - {{ item.title }} - </span> - <span v-if="isMilestone"> - {{ item.title }} - </span> - <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> - <span - data-testid="label-color-box" - class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" - :style="{ backgroundColor: item.color }" - ></span> - {{ item.title }} - </span> - <span v-if="isCommand"> - /{{ item.name }} <small> {{ item.params[0] }} </small><br /> - <em> - <small> {{ item.description }} </small> - </em> - </span> - <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> - <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> - <div class="gl-flex-grow-1"> - {{ item.name }}<br /> - <small>{{ item.d }}</small> + <div class="gl-new-dropdown-inner"> + <ul class="gl-new-dropdown-contents" data-testid="content-editor-suggestions-dropdown"> + <li + v-for="(item, index) in items" + :key="index" + role="presentation" + class="gl-new-dropdown-item" + :class="{ focused: index === selectedIndex }" + > + <div + ref="dropdownItems" + type="button" + role="menuitem" + class="gl-new-dropdown-item-content" + @click="selectItem(index)" + > + <div class="gl-new-dropdown-item-text-wrapper"> + <gl-avatar-labeled + v-if="isUser" + :label="item.username" + :sub-label="avatarSubLabel(item)" + :src="item.avatar_url" + :entity-name="item.username" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + :size="32" + /> + <span v-if="isIssue || isMergeRequest"> + <small>{{ item.iid }}</small> + {{ item.title }} + </span> + <span v-if="isVulnerability || isSnippet"> + <small>{{ item.id }}</small> + {{ item.title }} + </span> + <span v-if="isEpic"> + <small>{{ item.reference }}</small> + {{ item.title }} + </span> + <span v-if="isMilestone"> + {{ item.title }} + </span> + <span v-if="isLabel" class="gl-display-flex"> + <span + data-testid="label-color-box" + class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" + :style="{ backgroundColor: item.color }" + ></span> + {{ item.title }} + </span> + <div v-if="isCommand"> + <div class="gl-mb-1"> + <span class="gl-font-weight-bold">/{{ item.name }}</span> + <em class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</em> + </div> + <small class="gl-text-gray-500"> {{ item.description }} </small> + </div> + <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> + <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-flex-grow-1"> + {{ item.name }}<br /> + <small>{{ item.d }}</small> + </div> + </div> + </div> </div> - </div> - </gl-dropdown-item> + </li> + </ul> </div> - </ul> - <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!"> - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <div class="gl-px-5"> + </div> + <div v-if="loading" class="gl-new-dropdown-panel gl-display-block! gl-absolute"> + <div class="gl-new-dropdown-inner"> + <div class="gl-px-4 gl-py-3"> <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }} </div> </div> diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue index 1e13c17bc38..4cf150dd948 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -47,6 +47,7 @@ export default { category="tertiary" icon="paperclip" size="small" + class="gl-mr-3" lazy @click="openFileUpload" /> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index a62f66d8557..60bfaab25a5 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -49,6 +49,11 @@ export default { required: false, default: 'small', }, + showActiveState: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -78,7 +83,7 @@ export default { :variant="variant" :category="category" :size="size" - :class="{ 'gl-bg-gray-100!': isActive }" + :class="{ 'gl-bg-gray-100!': showActiveState && isActive }" :aria-label="label" :title="label" :icon="iconName" 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 99ba8c51948..b7f419d5840 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -15,10 +15,6 @@ export default { toggleId: uniqueId('dropdown-toggle-btn-'), items: [ { - text: __('Comment'), - action: () => this.insert('comment'), - }, - { text: __('Code block'), action: () => this.insert('codeBlock'), }, diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index eb7985f628a..ab1546b9016 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -1,5 +1,6 @@ <script> -import { GlDisclosureDropdown, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlButton, GlTooltip } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; import { __, sprintf } from '~/locale'; import { clamp } from '../services/utils'; @@ -14,13 +15,12 @@ export default { components: { GlButton, GlDisclosureDropdown, - }, - directives: { GlTooltip, }, inject: ['tiptapEditor'], data() { return { + toggleId: uniqueId('dropdown-toggle-btn-'), maxRows: MIN_ROWS, maxCols: MIN_COLS, rows: 1, @@ -82,43 +82,47 @@ export default { }; </script> <template> - <gl-disclosure-dropdown - ref="dropdown" - v-gl-tooltip - size="small" - category="tertiary" - icon="table" - :aria-label="__('Insert table')" - :toggle-text="__('Insert table')" - positioning-strategy="fixed" - class="content-editor-table-dropdown" - text-sr-only - :fluid-width="true" - @shown="setFocus(1, 1)" - > - <div - class="gl-p-3 gl-pt-2" - role="grid" - :aria-colcount="$options.MAX_COLS" - :aria-rowcount="$options.MAX_ROWS" + <div class="gl-display-inline-flex gl-vertical-align-middle"> + <gl-disclosure-dropdown + ref="dropdown" + :toggle-id="toggleId" + size="small" + category="tertiary" + icon="table" + no-caret + :aria-label="__('Insert table')" + :toggle-text="__('Insert table')" + positioning-strategy="fixed" + class="content-editor-table-dropdown gl-mr-3" + text-sr-only + :fluid-width="true" + @shown="setFocus(1, 1)" > - <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row"> - <div v-for="c of list(maxCols)" :key="c" role="gridcell"> - <gl-button - :ref="`table-${r}-${c}`" - :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" - :aria-label="getButtonLabel(r, c)" - class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" - @mouseover="setRowsAndCols(r, c)" - @focus="setRowsAndCols(r, c)" - @click="insertTable()" - @keydown="onKeydown($event.key)" - /> + <div + class="gl-p-3 gl-pt-2" + role="grid" + :aria-colcount="$options.MAX_COLS" + :aria-rowcount="$options.MAX_ROWS" + > + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row"> + <div v-for="c of list(maxCols)" :key="c" role="gridcell"> + <gl-button + :ref="`table-${r}-${c}`" + :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" + @mouseover="setRowsAndCols(r, c)" + @focus="setRowsAndCols(r, c)" + @click="insertTable()" + @keydown="onKeydown($event.key)" + /> + </div> </div> </div> - </div> - <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2"> - {{ getButtonLabel(rows, cols) }} - </div> - </gl-disclosure-dropdown> + <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2"> + {{ getButtonLabel(rows, cols) }} + </div> + </gl-disclosure-dropdown> + <gl-tooltip :target="toggleId" placement="top">{{ __('Insert table') }}</gl-tooltip> + </div> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index 4a3dfe3656c..efd0926d7ed 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -1,20 +1,33 @@ <script> import { debounce } from 'lodash'; +import { GlButton, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; -import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue'; import codeBlockLanguageLoader from '../../services/code_block_language_loader'; import EditorStateObserver from '../editor_state_observer.vue'; +import { memoizedGet } from '../../services/utils'; +import { + lineOffsetToLangParams, + langParamsToLineOffset, + toAbsoluteLineOffset, + getLines, + appendNewlines, +} from '../../services/code_suggestion_utils'; export default { name: 'CodeBlock', components: { + GlButton, + GlSprintf, NodeViewWrapper, NodeViewContent, EditorStateObserver, SandboxedMermaid, }, + directives: { + GlTooltip, + }, inject: ['contentEditor'], props: { editor: { @@ -39,13 +52,54 @@ export default { return { diagramUrl: '', diagramSource: '', + + allLines: [], + deletedLines: [], + addedLines: [], }; }, + computed: { + isCodeSuggestion() { + return ( + this.node.attrs.isCodeSuggestion && + this.contentEditor.codeSuggestionsConfig?.canSuggest && + this.contentEditor.codeSuggestionsConfig?.diffFile + ); + }, + classList() { + return this.isCodeSuggestion + ? 'gl-p-0! suggestion-added-input' + : `gl-p-3 code highlight ${this.$options.userColorScheme}`; + }, + lineOffset() { + return langParamsToLineOffset(this.node.attrs.langParams); + }, + absoluteLineOffset() { + if (!this.contentEditor.codeSuggestionsConfig) return [0, 0]; + + const { new_line: n } = this.contentEditor.codeSuggestionsConfig.line; + return toAbsoluteLineOffset(this.lineOffset, n); + }, + disableDecrementLineStart() { + return this.absoluteLineOffset[0] <= 1; + }, + disableIncrementLineStart() { + return this.lineOffset[0] >= 0; + }, + disableDecrementLineEnd() { + return this.lineOffset[1] <= 0; + }, + disableIncrementLineEnd() { + return this.absoluteLineOffset[1] >= this.allLines.length - 1; + }, + }, async mounted() { - this.updateDiagramPreview = debounce( - this.updateDiagramPreview, - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, - ); + if (this.isCodeSuggestion) { + await this.updateAllLines(); + this.updateCodeSuggestion(); + } + + this.updateCodeBlock = debounce(this.updateCodeBlock, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language); await codeBlockLanguageLoader.loadLanguage(lang.syntax); @@ -53,7 +107,26 @@ export default { this.updateAttributes({ language: this.node.attrs.language }); }, methods: { - async updateDiagramPreview() { + async updateAllLines() { + const { diffFile } = this.contentEditor.codeSuggestionsConfig; + this.allLines = (await memoizedGet(diffFile.view_path.replace('/blob/', '/raw/'))).split( + '\n', + ); + }, + updateCodeSuggestion() { + this.deletedLines = appendNewlines(getLines(this.absoluteLineOffset, this.allLines)); + this.addedLines = appendNewlines( + this.$refs.nodeViewContent?.$el.textContent.split('\n') || [], + ); + }, + updateNodeView() { + if (this.isCodeSuggestion) { + this.updateCodeSuggestion(); + } else { + this.updateCodeBlock(); + } + }, + async updateCodeBlock() { if (!this.node.attrs.showPreview) { this.diagramSource = ''; return; @@ -70,22 +143,34 @@ export default { ); } }, - }, - i18n: { - frontmatter: __('frontmatter'), + updateLineOffset(deltaStart = 0, deltaEnd = 0) { + const { lineOffset } = this; + + this.editor + .chain() + .updateAttributes('codeSuggestion', { + langParams: lineOffsetToLangParams([ + lineOffset[0] + deltaStart, + lineOffset[1] + deltaEnd, + ]), + }) + .run(); + }, }, userColorScheme: gon.user_color_scheme, }; </script> <template> - <editor-state-observer @transaction="updateDiagramPreview"> + <editor-state-observer :debounce="0" @transaction="updateNodeView"> <node-view-wrapper - :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`" + :class="classList" + class="content-editor-code-block gl-relative" as="pre" dir="auto" > <div v-if="node.attrs.showPreview" + contenteditable="false" class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > <sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" /> @@ -93,12 +178,108 @@ export default { </div> <span v-if="node.attrs.isFrontmatter" + contenteditable="false" data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" + >{{ __('frontmatter') }}:{{ node.attrs.language }}</span + > + <div + v-if="isCodeSuggestion" contenteditable="false" - >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span + class="gl-relative gl-z-index-0" + data-testid="code-suggestion-box" > - <node-view-content ref="nodeViewContent" as="code" /> + <div + class="md-suggestion-header gl-flex-wrap gl-z-index-1 gl-w-full gl-border-none! gl-font-regular gl-px-4 gl-py-3 gl-border-b-1! gl-border-b-solid! gl-mr-n10!" + > + <div class="gl-font-weight-bold gl-pr-3"> + {{ __('Suggested change') }} + </div> + + <div + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-pl-3 gl-gap-2 gl-white-space-nowrap" + > + <gl-sprintf :message="__('From line %{line1} to %{line2}')"> + <template #line1> + <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-mx-1"> + <gl-button + size="small" + icon="dash" + variant="confirm" + category="tertiary" + data-testid="decrement-line-start" + :aria-label="__('Decrement suggestion line start')" + :disabled="disableDecrementLineStart" + @click="updateLineOffset(-1, 0)" + /> + <div + class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace" + > + <strong>{{ absoluteLineOffset[0] }}</strong> + </div> + <gl-button + size="small" + icon="plus" + variant="confirm" + category="tertiary" + data-testid="increment-line-start" + :aria-label="__('Increment suggestion line start')" + :disabled="disableIncrementLineStart" + @click="updateLineOffset(1, 0)" + /> + </div> + </template> + <template #line2> + <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-ml-1"> + <gl-button + size="small" + icon="dash" + variant="confirm" + category="tertiary" + data-testid="decrement-line-end" + :aria-label="__('Decrement suggestion line end')" + :disabled="disableDecrementLineEnd" + @click="updateLineOffset(0, -1)" + /> + <div + class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace" + > + <strong>{{ absoluteLineOffset[1] }}</strong> + </div> + <gl-button + size="small" + icon="plus" + variant="confirm" + category="tertiary" + data-testid="increment-line-end" + :aria-label="__('Increment suggestion line end')" + :disabled="disableIncrementLineEnd" + @click="updateLineOffset(0, 1)" + /> + </div> + </template> + </gl-sprintf> + </div> + </div> + + <div class="suggestion-deleted" data-testid="suggestion-deleted"> + <code + v-for="(line, i) in deletedLines" + :key="i" + :data-line-number="absoluteLineOffset[0] + i" + >{{ line }}</code + > + </div> + <div class="suggestion-added gl-absolute" data-testid="suggestion-added"> + <code + v-for="(line, i) in addedLines" + :key="i" + :data-line-number="absoluteLineOffset[0] + i" + >{{ line }}</code + > + </div> + </div> + <node-view-content ref="nodeViewContent" as="code" class="gl-relative gl-z-index-1" /> </node-view-wrapper> </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue new file mode 100644 index 00000000000..0b80802d993 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -0,0 +1,103 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; + +export default { + name: 'ImageWrapper', + components: { + NodeViewWrapper, + }, + props: { + getPos: { + type: Function, + required: true, + }, + editor: { + type: Object, + required: true, + }, + node: { + type: Object, + required: true, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + dragData: {}, + }; + }, + mounted() { + document.addEventListener('mousemove', this.onDrag); + document.addEventListener('mouseup', this.onDragEnd); + }, + destroyed() { + document.removeEventListener('mousemove', this.onDrag); + document.removeEventListener('mouseup', this.onDragEnd); + }, + methods: { + onDragStart(handle, event) { + this.dragData = { + handle, + startX: event.screenX, + startY: event.screenY, + width: this.$refs.image.width, + height: this.$refs.image.height, + }; + }, + onDrag(event) { + const { handle, startX, width, height } = this.dragData; + if (!handle) return; + + const deltaX = event.screenX - startX; + const newWidth = handle.includes('w') ? width - deltaX : width + deltaX; + const newHeight = (height / width) * newWidth; + + this.$refs.image.setAttribute('width', newWidth); + this.$refs.image.setAttribute('height', newHeight); + }, + onDragEnd() { + const { handle } = this.dragData; + if (!handle) return; + + this.dragData = {}; + + this.editor + .chain() + .focus() + .updateAttributes(this.node.type, { + width: this.$refs.image.width, + height: this.$refs.image.height, + }) + .setNodeSelection(this.getPos()) + .run(); + }, + }, + resizeHandles: ['ne', 'nw', 'se', 'sw'], +}; +</script> +<template> + <node-view-wrapper as="span" class="gl-relative gl-display-inline-block"> + <span + v-for="handle in $options.resizeHandles" + v-show="selected" + :key="handle" + class="image-resize" + :class="`image-resize-${handle}`" + :data-testid="`image-resize-${handle}`" + @mousedown="onDragStart(handle, $event)" + ></span> + <img + ref="image" + :src="node.attrs.src" + :alt="node.attrs.alt" + :title="node.attrs.title" + :width="node.attrs.width || 'auto'" + :height="node.attrs.height || 'auto'" + :class="{ 'ProseMirror-selectednode': selected }" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue index 2b4b9891c77..4ec477232d4 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -3,11 +3,12 @@ import { NodeViewWrapper } from '@tiptap/vue-2'; import { GlLink } from '@gitlab/ui'; export default { - name: 'DetailsWrapper', + name: 'ReferenceWrapper', components: { NodeViewWrapper, GlLink, }, + inject: ['contentEditor'], props: { node: { type: Object, @@ -19,6 +20,11 @@ export default { default: false, }, }, + data() { + return { + href: '#', + }; + }, computed: { text() { return this.node.attrs.text; @@ -33,6 +39,11 @@ export default { return gon.current_username === this.text.substring(1); }, }, + async mounted() { + const text = this.node.attrs.originalText || this.node.attrs.text; + const { href } = await this.contentEditor.resolveReference(text); + this.href = href || ''; + }, }; </script> <template> @@ -40,7 +51,7 @@ export default { <span v-if="isCommand">{{ text }}</span> <gl-link v-else - href="#" + :href="href" tabindex="-1" class="gfm gl-cursor-text" :class="{ diff --git a/app/assets/javascripts/content_editor/extensions/code_suggestion.js b/app/assets/javascripts/content_editor/extensions/code_suggestion.js new file mode 100644 index 00000000000..c70a96769fb --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/code_suggestion.js @@ -0,0 +1,81 @@ +import { lowlight } from 'lowlight/lib/core'; +import { textblockTypeInputRule } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { memoizedGet } from '../services/utils'; +import CodeBlockHighlight from './code_block_highlight'; + +const backtickInputRegex = /^```suggestion[\s\n]$/; + +export default CodeBlockHighlight.extend({ + name: 'codeSuggestion', + + isolating: true, + + addOptions() { + return { + lowlight, + config: {}, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + language: { + default: 'suggestion', + }, + isCodeSuggestion: { + default: true, + }, + }; + }, + + addCommands() { + const ext = this; + + return { + insertCodeSuggestion: (attributes) => async ({ editor }) => { + // do not insert a new suggestion if already inside a suggestion + if (editor.isActive('codeSuggestion')) return false; + + const rawPath = ext.options.config.diffFile.view_path.replace('/blob/', '/raw/'); + const allLines = (await memoizedGet(rawPath)).split('\n'); + const { line } = ext.options.config; + let { lines } = ext.options.config; + + if (!lines.length) lines = [line]; + + const content = lines.map((l) => allLines[l.new_line - 1]).join('\n'); + const lineNumbers = `-${lines.length - 1}+0`; + + editor.commands.insertContent({ + type: 'codeSuggestion', + attrs: { langParams: lineNumbers, ...attributes }, + // empty strings are not allowed in text nodes + content: [{ type: 'text', text: content || ' ' }], + }); + + return true; + }, + }; + }, + + parseHTML() { + return [ + { + priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: 'pre[lang="suggestion"]', + }, + ]; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: () => ({ language: 'suggestion', langParams: '-0+0' }), + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/comment.js b/app/assets/javascripts/content_editor/extensions/comment.js deleted file mode 100644 index 8e247e552a3..00000000000 --- a/app/assets/javascripts/content_editor/extensions/comment.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Node, textblockTypeInputRule } from '@tiptap/core'; - -export const commentInputRegex = /^<!--[\s\n]$/; - -export default Node.create({ - name: 'comment', - content: 'text*', - marks: '', - group: 'block', - code: true, - isolating: true, - defining: true, - - parseHTML() { - return [ - { - tag: 'comment', - preserveWhitespace: 'full', - getContent(element, schema) { - const node = schema.node('paragraph', {}, [ - schema.text( - element.textContent.replace(/&#x([0-9A-F]{2,4});/gi, (_, code) => - String.fromCharCode(parseInt(code, 16)), - ) || ' ', - ), - ]); - return node.content; - }, - }, - ]; - }, - - renderHTML() { - return [ - 'pre', - { class: 'gl-p-0 gl-border-0 gl-bg-transparent gl-text-gray-300' }, - ['span', { class: 'content-editor-comment' }, 0], - ]; - }, - - addInputRules() { - return [ - textblockTypeInputRule({ - find: commentInputRegex, - type: this.type, - }), - ]; - }, -}); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index db13438de5e..f484ce98e90 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -2,11 +2,13 @@ import OrderedMap from 'orderedmap'; import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants'; import CodeBlockHighlight from './code_block_highlight'; +import CodeSuggestion from './code_suggestion'; import Diagram from './diagram'; import Frontmatter from './frontmatter'; @@ -14,7 +16,12 @@ const TEXT_FORMAT = 'text/plain'; const GFM_FORMAT = 'text/x-gfm'; const HTML_FORMAT = 'text/html'; const VS_CODE_FORMAT = 'vscode-editor-data'; -const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; +const CODE_BLOCK_NODE_TYPES = [ + CodeBlockHighlight.name, + CodeSuggestion.name, + Diagram.name, + Frontmatter.name, +]; function parseHTML(schema, html) { const parser = new DOMParser(); @@ -24,8 +31,23 @@ function parseHTML(schema, html) { return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; } +const findLoader = (editor, loaderId) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) { + position = pos; + return false; + } + + return true; + }); + + return position; +}; + export default Extension.create({ - name: 'pasteMarkdown', + name: 'copyPaste', priority: EXTENSION_PRIORITY_HIGHEST, addOptions() { return { @@ -35,7 +57,7 @@ export default Extension.create({ }, addCommands() { return { - pasteContent: (content = '', processMarkdown = true) => async () => { + pasteContent: (content = '', processMarkdown = true) => () => { const { editor, options } = this; const { renderMarkdown, eventHub } = options; const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); @@ -43,23 +65,37 @@ export default Extension.create({ const pasteSchemaSpec = { ...editor.schema.spec }; pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span'); pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre'); - const schema = new Schema(pasteSchemaSpec); + const pasteSchema = new Schema(pasteSchemaSpec); const promise = processMarkdown - ? deserializer.deserialize({ schema, markdown: content }) - : Promise.resolve(parseHTML(schema, content)); - - promise - .then(({ document }) => { + ? deserializer.deserialize({ schema: pasteSchema, markdown: content }) + : Promise.resolve(parseHTML(pasteSchema, content)); + const loaderId = uniqueId('loading'); + + Promise.resolve() + .then(() => { + editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } }); + return promise; + }) + .then(async ({ document }) => { if (!document) return; - const { firstChild } = document.content; + const pos = findLoader(editor, loaderId); + if (!pos) return; + + const { firstChild, childCount } = document.content; const toPaste = - document.content.childCount === 1 && firstChild.type.name === 'paragraph' + childCount === 1 && firstChild.type.name === 'paragraph' ? firstChild.content : document.content; - editor.commands.insertContent(toPaste.toJSON()); + editor + .chain() + .deleteRange({ from: pos, to: pos + 1 }) + .insertContentAt(pos, toPaste.toJSON(), { + updateSelection: false, + }) + .run(); }) .catch(() => { eventHub.$emit(ALERT_EVENT, { @@ -94,7 +130,7 @@ export default Extension.create({ return [ new Plugin({ - key: new PluginKey('pasteMarkdown'), + key: new PluginKey('copyPaste'), props: { handleDOMEvents: { copy: handleCutAndCopy, diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js index fb81c6b79b6..6d7ff92e64b 100644 --- a/app/assets/javascripts/content_editor/extensions/hard_break.js +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -2,8 +2,6 @@ import { HardBreak } from '@tiptap/extension-hard-break'; export default HardBreak.extend({ addKeyboardShortcuts() { - return { - 'Shift-Enter': () => this.editor.commands.setHardBreak(), - }; + return {}; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 58c16297886..d245b86543f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,5 +1,7 @@ import { Image } from '@tiptap/extension-image'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { PARSE_HTML_PRIORITY_HIGH } from '../constants'; +import ImageWrapper from '../components/wrappers/image.vue'; const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); @@ -97,4 +99,7 @@ export default Image.extend({ }, ]; }, + addNodeView() { + return VueNodeViewRenderer(ImageWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js new file mode 100644 index 00000000000..0115fb10d5d --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/loading.js @@ -0,0 +1,23 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'loading', + inline: true, + group: 'inline', + + addAttributes() { + return { + id: { + default: null, + }, + }; + }, + + renderHTML() { + return [ + 'span', + { class: 'gl-display-inline-flex gl-align-items-center' }, + ['span', { class: 'gl-dots-loader gl-mx-2' }, ['span']], + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js index c63b64fd784..bddd8b38b06 100644 --- a/app/assets/javascripts/content_editor/extensions/paragraph.js +++ b/app/assets/javascripts/content_editor/extensions/paragraph.js @@ -9,4 +9,14 @@ export default Paragraph.extend({ }, }; }, + + addKeyboardShortcuts() { + return { + 'Shift-Enter': async () => { + // can only delegate one shortcut to another async + await Promise.resolve(); + this.editor.commands.enter(); + }, + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index ef69b9bbda6..fd248709b5a 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -63,6 +63,12 @@ export default Node.create({ }; }, + addCommands() { + return { + insertQuickAction: () => ({ commands }) => commands.insertContent('<p>/</p>'), + }; + }, + addInputRules() { const { editor } = this; const { assetResolver } = this.options; diff --git a/app/assets/javascripts/content_editor/services/code_suggestion_utils.js b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js new file mode 100644 index 00000000000..836729790ae --- /dev/null +++ b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js @@ -0,0 +1,32 @@ +export function langParamsToLineOffset(langParams) { + if (!langParams) return [0, 0]; + const match = langParams.match(/([-+]\d+)([-+]\d+)/); + return match ? [parseInt(match[1], 10), parseInt(match[2], 10)] : [0, 0]; +} + +export function lineOffsetToLangParams(lineOffset) { + let langParams = ''; + langParams += lineOffset[0] <= 0 ? `-${-lineOffset[0]}` : `+${lineOffset[0]}`; + langParams += lineOffset[1] < 0 ? lineOffset[1] : `+${lineOffset[1]}`; + return langParams; +} + +export function toAbsoluteLineOffset(lineOffset, lineNumber) { + return [lineOffset[0] + lineNumber, lineOffset[1] + lineNumber]; +} + +export function getLines(absoluteLineOffset, allLines) { + return allLines.slice(absoluteLineOffset[0] - 1, absoluteLineOffset[1]); +} + +// \u200b is a zero width space character (Alternatively ​, ​ or ​). +// Due to the nature of HTML, if you have a blank line in the deleted/inserted code, it would +// render with 0 height. (Each line is in its <code> element.) This means that blank lines +// would be skipped when rendering the diff. +// We append this character to the end of each line to make sure that the line is not empty +// and the line numbers are rendered correctly. +const ZERO_WIDTH_SPACE = '\u200b'; + +export function appendNewlines(lines) { + return lines.map((l, i, arr) => `${l}${ZERO_WIDTH_SPACE}${i === arr.length - 1 ? '' : '\n'}`); +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index ec0f2f028d9..bc1ee696323 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,6 +1,14 @@ /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) { + constructor({ + tiptapEditor, + serializer, + deserializer, + assetResolver, + eventHub, + drawioEnabled, + codeSuggestionsConfig, + }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; @@ -8,9 +16,13 @@ export class ContentEditor { this._assetResolver = assetResolver; this._pristineDoc = null; + this.codeSuggestionsConfig = codeSuggestionsConfig; this.drawioEnabled = drawioEnabled; } + /** + * @type {import('@tiptap/core').Editor} + */ get tiptapEditor() { return this._tiptapEditor; } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index ee1f706ec7e..51e41ceefaf 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -9,8 +9,9 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import CodeSuggestion from '../extensions/code_suggestion'; import ColorChip from '../extensions/color_chip'; -import Comment from '../extensions/comment'; +import CopyPaste from '../extensions/copy_paste'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; @@ -40,10 +41,10 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; -import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; @@ -73,11 +74,6 @@ import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ extensions: [...extensions], - editorProps: { - attributes: { - class: 'gl-shadow-none!', - }, - }, ...options, }); @@ -90,6 +86,7 @@ export const createContentEditor = ({ drawioEnabled = false, enableAutocomplete, autocompleteDataSources = {}, + codeSuggestionsConfig = {}, } = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); @@ -112,8 +109,8 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - Comment, CodeBlockHighlight, + CodeSuggestion.configure({ config: codeSuggestionsConfig }), DescriptionItem, DescriptionList, Details, @@ -142,10 +139,11 @@ export const createContentEditor = ({ ExternalKeydownHandler.configure({ eventHub }), Link, ListItem, + Loading, MathInline, OrderedList, Paragraph, - PasteMarkdown.configure({ eventHub, renderMarkdown, serializer }), + CopyPaste.configure({ eventHub, renderMarkdown, serializer }), Reference.configure({ assetResolver }), ReferenceLabel, ReferenceDefinition, @@ -181,5 +179,6 @@ export const createContentEditor = ({ deserializer, assetResolver, drawioEnabled, + codeSuggestionsConfig, }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 4dbafd1632d..972b4acf523 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -8,12 +8,12 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import CodeSuggestion from '../extensions/code_suggestion'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import DrawioDiagram from '../extensions/drawio_diagram'; -import Comment from '../extensions/comment'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; @@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; @@ -52,7 +53,6 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { - renderComment, renderCodeBlock, renderHardBreak, renderTable, @@ -134,8 +134,8 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Comment.name]: renderComment, [Diagram.name]: preserveUnchanged(renderCodeBlock), + [CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), [DrawioDiagram.name]: preserveUnchanged({ render: renderImage, inline: true, @@ -195,6 +195,7 @@ const defaultSerializerConfig = { inline: true, }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [Loading.name]: () => {}, [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index b2cbc9c3fed..17e650644b3 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -365,13 +365,6 @@ export function renderPlayable(state, node) { renderImage(state, node); } -export function renderComment(state, node) { - state.write('<!--'); - state.write(node.textContent); - state.write('-->'); - state.closeBlock(node); -} - export function renderCodeBlock(state, node) { state.write( `\`\`\`${ diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index 1c128b4aa19..391d3b1a665 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -1,3 +1,6 @@ +import axios from 'axios'; +import { memoize } from 'lodash'; + export const hasSelection = (tiptapEditor) => { const { from, to } = tiptapEditor.state.selection; @@ -5,3 +8,8 @@ export const hasSelection = (tiptapEditor) => { }; export const clamp = (n, min, max) => Math.max(Math.min(n, max), min); + +export const memoizedGet = memoize(async (path) => { + const { data } = await axios(path, { responseType: 'blob' }); + return data.text(); +}); diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue index a7787ae84bc..9f166e594b8 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue @@ -1,8 +1,5 @@ <script> -import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import TargetLink from '../target_link.vue'; -import ResourceParentLink from '../resource_parent_link.vue'; import ContributionEventBase from './contribution_event_base.vue'; export default { @@ -12,7 +9,7 @@ export default { 'ContributionEvent|Approved merge request %{targetLink} in %{resourceParentLink}.', ), }, - components: { ContributionEventBase, GlSprintf, TargetLink, ResourceParentLink }, + components: { ContributionEventBase }, props: { /** * Expected format @@ -52,14 +49,10 @@ export default { </script> <template> - <contribution-event-base :event="event" icon-name="approval-solid" icon-class="gl-text-green-500"> - <gl-sprintf :message="$options.i18n.message"> - <template #targetLink> - <target-link :event="event" /> - </template> - <template #resourceParentLink> - <resource-parent-link :event="event" /> - </template> - </gl-sprintf> - </contribution-event-base> + <contribution-event-base + :event="event" + :message="$options.i18n.message" + icon-name="approval-solid" + icon-class="gl-text-green-500" + /> </template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue index 93ac94a6f4f..e3d3360cd0c 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue @@ -1,9 +1,19 @@ <script> -import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlAvatarLink, GlIcon, GlSprintf } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TargetLink from '../target_link.vue'; +import ResourceParentLink from '../resource_parent_link.vue'; export default { - components: { GlAvatarLabeled, GlAvatarLink, GlIcon, TimeAgoTooltip }, + components: { + GlAvatarLabeled, + GlAvatarLink, + GlIcon, + GlSprintf, + TimeAgoTooltip, + TargetLink, + ResourceParentLink, + }, props: { event: { type: Object, @@ -13,6 +23,11 @@ export default { type: String, required: true, }, + message: { + type: String, + required: false, + default: '', + }, iconClass: { type: String, required: false, @@ -44,7 +59,15 @@ export default { <div class="gl-pl-8 gl-mt-2" data-testid="event-body"> <div class="gl-text-secondary"> <gl-icon :class="iconClass" :name="iconName" /> - <slot></slot> + <gl-sprintf v-if="message" :message="message"> + <template #targetLink> + <target-link :event="event" /> + </template> + <template #resourceParentLink> + <resource-parent-link :event="event" /> + </template> + </gl-sprintf> + <slot v-else></slot> </div> <div v-if="$scopedSlots['additional-info']" class="gl-mt-2"> <slot name="additional-info"></slot> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue new file mode 100644 index 00000000000..8daccd66aeb --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_expired.vue @@ -0,0 +1,46 @@ +<script> +import { s__ } from '~/locale'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventExpired', + i18n: { + message: s__( + 'ContributionEvent|Removed due to membership expiration from %{resourceParentLink}.', + ), + }, + components: { ContributionEventBase }, + props: { + /** + * Expected format + * { + * created_at: string; + * action: "expired" + * author: { + * id: number; + * username: string; + * name: string; + * state: string; + * avatar_url: string; + * web_url: string; + * }; + * resource_parent: { + * type: "project"; + * full_name: string; + * full_path: string; + * web_url: string; + * avatar_url: string; + * }; + * }; + */ + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="expire" /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue new file mode 100644 index 00000000000..1b60582e7e1 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_joined.vue @@ -0,0 +1,44 @@ +<script> +import { s__ } from '~/locale'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventJoined', + i18n: { + message: s__('ContributionEvent|Joined project %{resourceParentLink}.'), + }, + components: { ContributionEventBase }, + props: { + /** + * Expected format + * { + * created_at: string; + * action: "joined" + * author: { + * id: number; + * username: string; + * name: string; + * state: string; + * avatar_url: string; + * web_url: string; + * }; + * resource_parent: { + * type: "project"; + * full_name: string; + * full_path: string; + * web_url: string; + * avatar_url: string; + * }; + * }; + */ + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="users" /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue new file mode 100644 index 00000000000..701126b4a74 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_left.vue @@ -0,0 +1,44 @@ +<script> +import { s__ } from '~/locale'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventLeft', + i18n: { + message: s__('ContributionEvent|Left project %{resourceParentLink}.'), + }, + components: { ContributionEventBase }, + props: { + /** + * Expected format + * { + * created_at: string; + * action: "left" + * author: { + * id: number; + * username: string; + * name: string; + * state: string; + * avatar_url: string; + * web_url: string; + * }; + * resource_parent: { + * type: "project"; + * full_name: string; + * full_path: string; + * web_url: string; + * avatar_url: string; + * }; + * }; + */ + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :message="$options.i18n.message" icon-name="leave" /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue new file mode 100644 index 00000000000..d2566160b18 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_merged.vue @@ -0,0 +1,29 @@ +<script> +import { s__ } from '~/locale'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventMerged', + i18n: { + message: s__( + 'ContributionEvent|Accepted merge request %{targetLink} in %{resourceParentLink}.', + ), + }, + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base + :event="event" + :message="$options.i18n.message" + icon-name="git-merge" + icon-class="gl-text-blue-600" + /> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue new file mode 100644 index 00000000000..ba9bc25e310 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_private.vue @@ -0,0 +1,24 @@ +<script> +import { s__ } from '~/locale'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventPrivate', + i18n: { + message: s__('ContributionEvent|Made a private contribution.'), + }, + components: { ContributionEventBase }, + props: { + event: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" icon-name="eye-slash">{{ + $options.i18n.message + }}</contribution-event-base> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue new file mode 100644 index 00000000000..557f2912f17 --- /dev/null +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_pushed.vue @@ -0,0 +1,110 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { PUSH_EVENT_REF_TYPE_BRANCH, PUSH_EVENT_REF_TYPE_TAG } from '../../constants'; +import ResourceParentLink from '../resource_parent_link.vue'; +import ContributionEventBase from './contribution_event_base.vue'; + +export default { + name: 'ContributionEventPushed', + i18n: { + new: { + [PUSH_EVENT_REF_TYPE_BRANCH]: s__( + 'ContributionEvent|Pushed a new branch %{refLink} in %{resourceParentLink}.', + ), + [PUSH_EVENT_REF_TYPE_TAG]: s__( + 'ContributionEvent|Pushed a new tag %{refLink} in %{resourceParentLink}.', + ), + }, + removed: { + [PUSH_EVENT_REF_TYPE_BRANCH]: s__( + 'ContributionEvent|Deleted branch %{refLink} in %{resourceParentLink}.', + ), + [PUSH_EVENT_REF_TYPE_TAG]: s__( + 'ContributionEvent|Deleted tag %{refLink} in %{resourceParentLink}.', + ), + }, + pushed: { + [PUSH_EVENT_REF_TYPE_BRANCH]: s__( + 'ContributionEvent|Pushed to branch %{refLink} in %{resourceParentLink}.', + ), + [PUSH_EVENT_REF_TYPE_TAG]: s__( + 'ContributionEvent|Pushed to tag %{refLink} in %{resourceParentLink}.', + ), + }, + multipleCommits: s__( + 'ContributionEvent|…and %{count} more commits. %{linkStart}Compare%{linkEnd}.', + ), + }, + components: { ContributionEventBase, GlSprintf, GlLink, ResourceParentLink }, + props: { + event: { + type: Object, + required: true, + }, + }, + computed: { + ref() { + return this.event.ref; + }, + commit() { + return this.event.commit; + }, + message() { + if (this.ref.is_new) { + return this.$options.i18n.new[this.ref.type]; + } else if (this.ref.is_removed) { + return this.$options.i18n.removed[this.ref.type]; + } + + return this.$options.i18n.pushed[this.ref.type]; + }, + iconName() { + if (this.ref.is_removed) { + return 'remove'; + } + + return 'commit'; + }, + hasMultipleCommits() { + return this.commit.count > 1; + }, + }, +}; +</script> + +<template> + <contribution-event-base :event="event" :icon-name="iconName"> + <gl-sprintf :message="message"> + <template #refLink> + <gl-link v-if="ref.path" :href="ref.path" class="gl-font-monospace">{{ ref.name }}</gl-link> + <span v-else class="gl-font-monospace">{{ ref.name }}</span> + </template> + <template #resourceParentLink> + <resource-parent-link :event="event" /> + </template> + </gl-sprintf> + <template v-if="!ref.is_removed" #additional-info> + <div> + <gl-link :href="commit.path" class="gl-font-monospace">{{ commit.truncated_sha }}</gl-link> + <template v-if="commit.title"> + · + <span>{{ commit.title }}</span> + </template> + </div> + <div v-if="hasMultipleCommits" class="gl-mt-2"> + <gl-sprintf :message="$options.i18n.multipleCommits"> + <template #count>{{ commit.count - 1 }}</template> + <template #link="{ content }"> + <gl-link :href="commit.compare_path" + >{{ content }} + <span class="gl-font-monospace" + >{{ commit.from_truncated_sha }}…{{ commit.to_truncated_sha }}</span + ></gl-link + > + </template> + </gl-sprintf> + </div> + </template> + </contribution-event-base> +</template> diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue index 41ec4f5692e..62c803b9217 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_events.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue @@ -1,7 +1,21 @@ <script> import EmptyComponent from '~/vue_shared/components/empty_component'; -import { EVENT_TYPE_APPROVED } from '../constants'; +import { + EVENT_TYPE_APPROVED, + EVENT_TYPE_EXPIRED, + EVENT_TYPE_JOINED, + EVENT_TYPE_LEFT, + EVENT_TYPE_PUSHED, + EVENT_TYPE_PRIVATE, + EVENT_TYPE_MERGED, +} from '../constants'; import ContributionEventApproved from './contribution_event/contribution_event_approved.vue'; +import ContributionEventExpired from './contribution_event/contribution_event_expired.vue'; +import ContributionEventJoined from './contribution_event/contribution_event_joined.vue'; +import ContributionEventLeft from './contribution_event/contribution_event_left.vue'; +import ContributionEventPushed from './contribution_event/contribution_event_pushed.vue'; +import ContributionEventPrivate from './contribution_event/contribution_event_private.vue'; +import ContributionEventMerged from './contribution_event/contribution_event_merged.vue'; export default { props: { @@ -99,6 +113,24 @@ export default { case EVENT_TYPE_APPROVED: return ContributionEventApproved; + case EVENT_TYPE_EXPIRED: + return ContributionEventExpired; + + case EVENT_TYPE_JOINED: + return ContributionEventJoined; + + case EVENT_TYPE_LEFT: + return ContributionEventLeft; + + case EVENT_TYPE_PUSHED: + return ContributionEventPushed; + + case EVENT_TYPE_PRIVATE: + return ContributionEventPrivate; + + case EVENT_TYPE_MERGED: + return ContributionEventMerged; + default: return EmptyComponent; } diff --git a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue index 5add9d788bb..dd7b20ac794 100644 --- a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue +++ b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue @@ -18,5 +18,7 @@ export default { </script> <template> - <gl-link :href="resourceParent.web_url">{{ resourceParent.full_name }}</gl-link> + <gl-link v-if="resourceParent" :href="resourceParent.web_url">{{ + resourceParent.full_name + }}</gl-link> </template> diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue index a661121b2fb..6559d6c7272 100644 --- a/app/assets/javascripts/contribution_events/components/target_link.vue +++ b/app/assets/javascripts/contribution_events/components/target_link.vue @@ -27,5 +27,5 @@ export default { </script> <template> - <gl-link v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link> + <gl-link v-if="target" v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link> </template> diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js index 05f968e7bc4..d4444e3bede 100644 --- a/app/assets/javascripts/contribution_events/constants.js +++ b/app/assets/javascripts/contribution_events/constants.js @@ -12,3 +12,7 @@ export const EVENT_TYPE_DESTROYED = 'destroyed'; export const EVENT_TYPE_EXPIRED = 'expired'; export const EVENT_TYPE_APPROVED = 'approved'; export const EVENT_TYPE_PRIVATE = 'private'; + +// From app/models/push_event_payload.rb#L22 +export const PUSH_EVENT_REF_TYPE_BRANCH = 'branch'; +export const PUSH_EVENT_REF_TYPE_TAG = 'tag'; diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue index ccd22085470..78d0a9da79a 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue @@ -76,7 +76,7 @@ export default { @formValidation="formValidation" /> <div class="form-actions"> - <gl-button variant="success" category="primary" :disabled="!formIsValid" @click="submit"> + <gl-button variant="confirm" category="primary" :disabled="!formIsValid" @click="submit"> {{ saveButtonText }} </gl-button> <gl-button class="float-right" :href="editIntegrationPath">{{ __('Cancel') }}</gl-button> diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index c49ab1ac43c..7ec3ec3f84d 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -225,7 +225,7 @@ export default { </div> </div> <h5>{{ $options.translations.addTokenHeader }}</h5> - <p class="profile-settings-content"> + <p> <gl-sprintf :message="$options.translations.addTokenDescription" :placeholders="placeholders.link" diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 08177cd0eac..6dbf12054cf 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -515,9 +515,8 @@ export default class Notes { } if (discussionContainer.length === 0) { if (noteEntity.diff_discussion_html) { - const discussionElement = document.createElement('table'); + let discussionElement = document.createElement('table'); let internalNote; - let discussionDOM; if (!noteEntity.on_image) { /* @@ -536,16 +535,15 @@ export default class Notes { 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; + discussionElement = internalNote.querySelector('table').querySelector('.notes_holder'); } 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', internalNote.firstElementChild); } - discussionElement.insertAdjacentElement('afterbegin', discussionDOM); renderGFM(discussionElement); const $discussion = $(discussionElement).unwrap(); @@ -1464,7 +1462,11 @@ export default class Notes { $note.addClass('fade-in-full'); renderGFM(Notes.getNodeToRender($note)); - $notesList.append($note); + if ($notesList.find('.discussion-reply-holder').length) { + $notesList.children('.timeline-entry').last().after($note); + } else { + $notesList.append($note); + } return $note; } diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue index 890d7f80f8d..413442074f0 100644 --- a/app/assets/javascripts/design_management/components/design_description/description_form.vue +++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormGroup, GlAlert, GlTooltipDirective } from '@gitlab/ui'; - import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -9,7 +8,7 @@ import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; - +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import updateDesignDescriptionMutation from '../../graphql/mutations/update_design_description.mutation.graphql'; import { UPDATE_DESCRIPTION_ERROR } from '../../utils/error_messages'; @@ -110,6 +109,11 @@ export default { async updateDesignDescription() { this.isSubmitting = true; + if (this.$refs.markdownEditor) { + // eslint-disable-next-line @gitlab/require-i18n-strings + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'Design'); + } + try { const designDescriptionInput = { description: this.descriptionText, id: this.design.id }; @@ -165,6 +169,7 @@ export default { </gl-alert> </div> <markdown-editor + ref="markdownEditor" :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 5affd448419..45f33967476 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -292,7 +292,9 @@ export default { <design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" /> <ul class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" + :class="{ 'gl-bg-blue-50': isDiscussionActive }" data-qa-selector="design_discussion_content" + data-testid="design-discussion-content" > <design-note :note="firstNote" @@ -300,7 +302,7 @@ export default { :is-resolving="isResolving" :is-discussion="true" :noteable-id="noteableId" - :class="{ 'gl-bg-blue-50': isDiscussionActive }" + :design-variables="designVariables" @delete-note="showDeleteNoteConfirmationModal($event)" > <template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion> @@ -343,7 +345,7 @@ export default { :is-resolving="isResolving" :noteable-id="noteableId" :is-discussion="false" - :class="{ 'gl-bg-blue-50': isDiscussionActive }" + :design-variables="designVariables" @delete-note="showDeleteNoteConfirmationModal($event)" /> <li diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 0eac2cad68d..1f2c9f19a95 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -7,14 +7,21 @@ import { GlLink, GlTooltipDirective, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { produce } from 'immer'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import EmojiPicker from '~/emoji/components/picker.vue'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; +import designNoteAwardEmojiToggleMutation from '../../graphql/mutations/design_note_award_emoji_toggle.mutation.graphql'; import { hasErrors } from '../../utils/cache_update'; import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils'; +import DesignNoteAwardsList from './design_note_awards_list.vue'; import DesignReplyForm from './design_reply_form.vue'; export default { @@ -24,7 +31,9 @@ export default { deleteCommentText: __('Delete comment'), }, components: { + DesignNoteAwardsList, DesignReplyForm, + EmojiPicker, GlAvatar, GlAvatarLink, GlButton, @@ -37,6 +46,7 @@ export default { GlTooltip: GlTooltipDirective, SafeHtml, }, + inject: ['issueIid', 'projectPath'], props: { note: { type: Object, @@ -56,6 +66,10 @@ export default { type: String, required: true, }, + designVariables: { + type: Object, + required: true, + }, }, data() { return { @@ -64,6 +78,26 @@ export default { }; }, computed: { + currentUserId() { + return window.gon.current_user_id; + }, + currentUserFullName() { + return window.gon.current_user_fullname; + }, + canAwardEmoji() { + return this.note.userPermissions.awardEmoji; + }, + awards() { + return this.note.awardEmoji.nodes.map((award) => { + return { + ...award, + user: { + ...award.user, + id: getIdFromGraphQLId(award.user.id), + }, + }; + }); + }, author() { return this.note.author; }, @@ -124,6 +158,93 @@ export default { this.$emit('error', data.errors[0]); } }, + isEmojiPresentForCurrentUser(name) { + return ( + this.awards.findIndex( + (emoji) => emoji.name === name && emoji.user.id === this.currentUserId, + ) > -1 + ); + }, + /** + * Prepare award emoji nodes based on emoji name + * and whether the user has toggled the emoji off or on + */ + getAwardEmojiNodes(name, toggledOn) { + // If the emoji toggled on, add the emoji + if (toggledOn) { + // If emoji is already present in award list, no action is needed + if (this.isEmojiPresentForCurrentUser(name)) { + return this.note.awardEmoji.nodes; + } + + // else make a copy of unmutable list and return the list after adding the new emoji + const awardEmojiNodes = [...this.note.awardEmoji.nodes]; + awardEmojiNodes.push({ + name, + __typename: 'AwardEmoji', + user: { + id: convertToGraphQLId(TYPENAME_USER, this.currentUserId), + name: this.currentUserFullName, + __typename: 'UserCore', + }, + }); + + return awardEmojiNodes; + } + + // else just filter the emoji + return this.note.awardEmoji.nodes.filter( + (emoji) => + !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId), + ); + }, + handleAwardEmoji(name) { + this.$apollo + .mutate({ + mutation: designNoteAwardEmojiToggleMutation, + variables: { + name, + awardableId: this.note.id, + }, + optimisticResponse: { + awardEmojiToggle: { + errors: [], + toggledOn: !this.isEmojiPresentForCurrentUser(name), + }, + }, + update: ( + cache, + { + data: { + awardEmojiToggle: { toggledOn }, + }, + }, + ) => { + const query = { + query: getDesignQuery, + variables: this.designVariables, + }; + + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { + awardEmoji, + } = draftState.project.issue.designCollection.designs.nodes[0].discussions.nodes + .find((d) => d.id === this.note.discussion.id) + .notes.nodes.find((n) => n.id === this.note.id); + + awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn); + }); + + cache.writeQuery({ ...query, data: newData }); + }, + }) + .catch((error) => { + Sentry.captureException(error); + this.$emit('error', error); + }); + }, }, updateNoteMutation, }; @@ -131,7 +252,12 @@ export default { <template> <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> - <gl-avatar-link :href="author.webUrl" class="gl-float-left gl-mr-3"> + <gl-avatar-link + :href="author.webUrl" + :data-user-id="authorId" + :data-username="author.username" + class="gl-float-left gl-mr-3 link-inherit-color js-user-link" + > <gl-avatar :size="32" :src="author.avatarUrl" :entity-name="author.username" /> </gl-avatar-link> @@ -140,7 +266,7 @@ export default { <gl-link v-once :href="author.webUrl" - class="js-user-link" + class="js-user-link link-inherit-color" data-testid="user-link" :data-user-id="authorId" :data-username="author.username" @@ -152,15 +278,23 @@ export default { <span class="note-headline-light note-headline-meta"> <span class="system-note-message"> <slot></slot> </span> <gl-link - class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm" + class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm link-inherit-color" :href="`#note_${noteAnchorId}`" > <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> </gl-link> </span> </div> - <div class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2"> + <div class="gl-display-flex gl-align-items-flex-start gl-mt-n2 gl-mr-n2"> <slot name="resolve-discussion"></slot> + <emoji-picker + v-if="canAwardEmoji" + toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" + boundary="viewport" + :right="false" + data-testid="note-emoji-button" + @click="handleAwardEmoji" + /> <gl-button v-if="isEditingAndHasPermissions" v-gl-tooltip @@ -175,7 +309,6 @@ export default { <gl-disclosure-dropdown v-if="isEditingAndHasPermissions" v-gl-tooltip.hover - toggle-class="btn-sm" icon="ellipsis_v" category="tertiary" data-qa-selector="design_discussion_actions_ellipsis_dropdown" @@ -198,8 +331,14 @@ export default { ></div> <slot name="resolved-status"></slot> </template> + <design-note-awards-list + v-if="awards.length" + :awards="awards" + :can-award-emoji="note.userPermissions.awardEmoji" + @award="handleAwardEmoji" + /> <design-reply-form - v-else + v-if="isEditing" :markdown-preview-path="markdownPreviewPath" :design-note-mutation="$options.updateNoteMutation" :mutation-variables="mutationVariables" diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue b/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue new file mode 100644 index 00000000000..f5456f47410 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/design_note_awards_list.vue @@ -0,0 +1,34 @@ +<script> +import AwardsList from '~/vue_shared/components/awards_list.vue'; + +export default { + components: { + AwardsList, + }, + props: { + awards: { + type: Array, + required: true, + }, + canAwardEmoji: { + type: Boolean, + required: true, + }, + }, + computed: { + currentUserId() { + return window.gon.current_user_id; + }, + }, +}; +</script> + +<template> + <awards-list + :awards="awards" + :can-award-emoji="canAwardEmoji" + :current-user-id="currentUserId" + class="gl-px-2 gl-mt-5" + @award="$emit('award', $event)" + /> +</template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 7474f8f3298..764c78ff581 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -233,7 +233,7 @@ export default { </template> </markdown-field> <slot name="resolve-checkbox"></slot> - <div class="note-form-actions gl-display-flex"> + <div class="note-form-actions gl-display-flex gl-mt-4!"> <gl-button ref="submitButton" :disabled="!hasValue" diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql index 28224671326..9af4733d5dc 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql @@ -11,6 +11,15 @@ fragment DesignNote on Note { bodyHtml createdAt resolved + awardEmoji { + nodes { + name + user { + id + name + } + } + } position { diffRefs { ...DesignDiffRefs diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql index e599ab19c2d..acc52e5de59 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql @@ -1,4 +1,5 @@ fragment DesignNotePermissions on NotePermissions { adminNote repositionNote + awardEmoji } diff --git a/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql new file mode 100644 index 00000000000..3e274d0b65f --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/design_note_award_emoji_toggle.mutation.graphql @@ -0,0 +1,6 @@ +mutation designNoteNoteToggleAwardEmoji($awardableId: AwardableID!, $name: String!) { + awardEmojiToggle(input: { awardableId: $awardableId, name: $name }) { + errors + toggledOn + } +} diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index e7308aad785..af7c5a25d94 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -146,12 +146,6 @@ export default { } return 'col-12'; }, - designContentWrapperClass() { - if (this.hasDesigns) { - return 'gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5'; - } - return null; - }, }, mounted() { if (this.$route.path === '/designs') { @@ -359,6 +353,7 @@ export default { <div data-testid="designs-root" class="gl-mt-4" + :class="{ 'gl-new-card': showToolbar }" @mouseenter="toggleOnPasteListener" @mouseleave="toggleOffPasteListener" > @@ -371,11 +366,7 @@ export default { > {{ uploadError }} </gl-alert> - <header - v-if="showToolbar" - class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-top-base" - data-testid="design-toolbar-wrapper" - > + <header v-if="showToolbar" class="gl-new-card-header" data-testid="design-toolbar-wrapper"> <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap gl-gap-3" > @@ -427,8 +418,13 @@ export default { </div> </div> </header> - <div :class="designContentWrapperClass"> - <gl-loading-icon v-if="isLoading" size="lg" /> + <div + :class="{ + 'gl-mx-5': showToolbar, + 'gl-new-card-body gl-mx-3!': hasDesigns, + }" + > + <gl-loading-icon v-if="isLoading" size="sm" class="gl-py-4" /> <gl-alert v-else-if="error" variant="danger" :dismissible="false"> {{ $options.i18n.designLoadingError }} </gl-alert> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index c0a9643e59e..5149dcc5d17 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -58,7 +58,6 @@ import HiddenFilesWarning from './hidden_files_warning.vue'; import NoChanges from './no_changes.vue'; import TreeList from './tree_list.vue'; import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; -import PreRenderer from './pre_renderer.vue'; export default { name: 'DiffsApp', @@ -66,7 +65,6 @@ export default { FindingsDrawer, DynamicScroller, DynamicScrollerItem, - PreRenderer, VirtualScrollerScrollSync, CompareVersions, DiffFile, @@ -95,6 +93,11 @@ export default { required: false, default: '', }, + endpointSast: { + type: String, + required: false, + default: '', + }, endpointCodequality: { type: String, required: false, @@ -277,6 +280,10 @@ export default { this.setCodequalityEndpoint(this.endpointCodequality); } + if (this.endpointSast) { + this.setSastEndpoint(this.endpointSast); + } + if (this.shouldShow) { this.fetchData(); } @@ -358,11 +365,13 @@ export default { 'moveToNeighboringCommit', 'setBaseConfig', 'setCodequalityEndpoint', + 'setSastEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', 'fetchFileByFile', 'fetchCoverageFiles', 'fetchCodequality', + 'fetchSast', 'rereadNoteHash', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', @@ -460,6 +469,10 @@ export default { this.fetchCodequality(); } + if (this.endpointSast) { + this.fetchSast(); + } + if (!this.isNotesFetched) { notesEventHub.$emit('fetchNotesData'); } @@ -665,22 +678,6 @@ export default { </dynamic-scroller-item> </template> <template #before> - <pre-renderer :max-length="diffFilesLength"> - <template #default="{ item, index, active }"> - <dynamic-scroller-item :item="item" :active="active"> - <diff-file - :file="item" - :reviewed="fileReviews[item.id]" - :is-first-file="index === 0" - :is-last-file="index === diffFilesLength - 1" - :help-page-path="helpPagePath" - :can-current-user-fork="canCurrentUserFork" - :view-diffs-file-by-file="viewDiffsFileByFile" - pre-render - /> - </dynamic-scroller-item> - </template> - </pre-renderer> <virtual-scroller-scroll-sync v-model="virtualScrollCurrentIndex" /> </template> </dynamic-scroller> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index d050f2fb9ae..3746ab9427f 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -2,7 +2,7 @@ import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -133,7 +133,7 @@ export default { /> </div> <div - class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" > <div class="commit-content" data-qa-selector="commit_content"> <a diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index f3f05e3d9d9..4ed54ecdf66 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -1,18 +1,23 @@ <script> import { GlButton } from '@gitlab/ui'; -import { NEW_CODE_QUALITY_FINDINGS } from '../i18n'; -import DiffCodeQualityItem from './diff_code_quality_item.vue'; +import { NEW_CODE_QUALITY_FINDINGS, NEW_SAST_FINDINGS } from '../i18n'; +import DiffInlineFindings from './diff_inline_findings.vue'; export default { i18n: { - newFindings: NEW_CODE_QUALITY_FINDINGS, + newCodeQualityFindings: NEW_CODE_QUALITY_FINDINGS, + newSastFindings: NEW_SAST_FINDINGS, }, - components: { GlButton, DiffCodeQualityItem }, + components: { GlButton, DiffInlineFindings }, props: { codeQuality: { type: Array, required: true, }, + sast: { + type: Array, + required: true, + }, }, }; </script> @@ -22,19 +27,18 @@ export default { data-testid="diff-codequality" class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4" > - <h4 - data-testid="diff-codequality-findings-heading" - class="gl-mt-0 gl-mb-0 gl-font-base gl-font-regular" - > - {{ $options.i18n.newFindings }} - </h4> - <ul class="gl-list-style-none gl-mb-0 gl-p-0"> - <diff-code-quality-item - v-for="finding in codeQuality" - :key="finding.description" - :finding="finding" - /> - </ul> + <diff-inline-findings + v-if="codeQuality.length" + :title="$options.i18n.newCodeQualityFindings" + :findings="codeQuality" + /> + + <diff-inline-findings + v-if="sast.length" + :title="$options.i18n.newSastFindings" + :findings="sast" + /> + <gl-button data-testid="diff-codequality-close" category="tertiary" diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue index eede110f46c..727b2a0c099 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { getSeverity } from '~/ci/reports/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -12,14 +12,21 @@ export default { type: Object, required: true, }, + link: { + type: Boolean, + required: false, + default: true, + }, }, - methods: { - severityClass(severity) { - return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; + computed: { + enhancedFinding() { + return getSeverity(this.finding); }, - severityIcon(severity) { - return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + listText() { + return `${this.finding.severity} - ${this.finding.description}`; }, + }, + methods: { toggleDrawer() { this.setDrawer(this.finding); }, @@ -33,8 +40,8 @@ export default { <span class="gl-mr-3"> <gl-icon :size="12" - :name="severityIcon(finding.severity)" - :class="severityClass(finding.severity)" + :name="enhancedFinding.name" + :class="enhancedFinding.class" class="codequality-severity-icon" /> </span> @@ -43,12 +50,13 @@ export default { data-testid="description-button-section" class="gl-display-flex" > - <gl-link category="primary" variant="link" @click="toggleDrawer"> - {{ finding.severity }} - {{ finding.description }}</gl-link + <gl-link v-if="link" category="primary" variant="link" @click="toggleDrawer"> + {{ listText }}</gl-link > + <span v-else>{{ listText }}</span> </span> <span v-else data-testid="description-plain-text" class="gl-display-flex"> - {{ finding.severity }} - {{ finding.description }} + {{ listText }} </span> </li> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 4d02fd80ba8..1c93cb4d021 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,7 +1,9 @@ <script> import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; +import { sprintf } from '~/locale'; +import { createAlert } from '~/alert'; +import { mapParallel, mapParallelNoSast } from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { diffViewerModes } from '~/ide/constants'; @@ -12,7 +14,9 @@ import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_d import NoteForm from '~/notes/components/note_form.vue'; import eventHub from '~/notes/event_hub'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; +import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n'; import { getDiffMode } from '../store/utils'; import DiffDiscussions from './diff_discussions.vue'; import DiffView from './diff_view.vue'; @@ -32,7 +36,7 @@ export default { UserAvatarLink, DiffFileDrafts, }, - mixins: [diffLineNoteFormMixin, draftCommentsMixin], + mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()], props: { diffFile: { type: Object, @@ -51,6 +55,7 @@ export default { 'getCommentFormForDiffFile', 'diffLines', 'fileLineCodequality', + 'fileLineSast', ]), ...mapGetters(['getNoteableData', 'noteableType', 'getUserData']), diffMode() { @@ -87,8 +92,11 @@ export default { return this.getUserData; }, mappedLines() { - // TODO: Do this data generation when we receive a response to save a computed property being created - return this.diffLines(this.diffFile).map(mapParallel(this)) || []; + if (this.glFeatures.sastReportsInInlineDiff) { + return this.diffLines(this.diffFile).map(mapParallel(this)) || []; + } + + return this.diffLines(this.diffFile).map(mapParallelNoSast(this)) || []; }, imageDiscussions() { return this.diffFile.discussions.filter( @@ -103,7 +111,7 @@ export default { }, methods: { ...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']), - handleSaveNote(note) { + handleSaveNote(note, parentElement, errorCallback) { this.saveDiffDiscussion({ note, formData: { @@ -116,6 +124,18 @@ export default { width: this.diffFileCommentForm.width, height: this.diffFileCommentForm.height, }, + }).catch((e) => { + const reason = e.response?.data?.errors; + const errorMessage = reason + ? sprintf(SAVING_THE_COMMENT_FAILED, { reason }) + : SOMETHING_WENT_WRONG; + + createAlert({ + message: errorMessage, + parent: parentElement, + }); + + errorCallback(); }); }, }, @@ -143,7 +163,7 @@ export default { {{ __('Contains only whitespace changes.') }} <gl-button category="tertiary" - variant="info" + variant="confirm" size="small" class="gl-ml-3" data-testid="diff-load-file-button" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 8e1c6cecbd1..4e1ccfc530e 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -25,7 +25,7 @@ import { FILE_DIFF_POSITION_TYPE, } from '../constants'; import eventHub from '../event_hub'; -import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n'; +import { DIFF_FILE, SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED, CONFLICT_TEXT } from '../i18n'; import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; import DiffDiscussions from './diff_discussions.vue'; import DiffFileHeader from './diff_file_header.vue'; @@ -88,11 +88,6 @@ export default { required: false, default: true, }, - preRender: { - type: Boolean, - required: false, - default: false, - }, }, idState() { return { @@ -104,7 +99,7 @@ export default { }, i18n: { ...DIFF_FILE, - genericError: GENERIC_ERROR, + genericError: SOMETHING_WENT_WRONG, }, computed: { ...mapState('diffs', [ @@ -122,7 +117,7 @@ export default { return getShortShaFromFile(this.file); }, showLoadingIcon() { - return this.idState.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); + return this.idState.isLoadingCollapsedDiff; }, hasDiff() { return hasDiff(this.file); @@ -177,9 +172,6 @@ export default { showLocalFileReviews() { return Boolean(gon.current_user_id); }, - codequalityDiffForFile() { - return this.codequalityDiff?.files?.[this.file.file_path] || []; - }, isCollapsed() { if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) { return this.viewDiffsFileByFile ? false : this.file.viewer?.automaticallyCollapsed; @@ -194,9 +186,8 @@ export default { }, showFileDiscussions() { return ( - this.glFeatures.commentOnFiles && !this.file.viewer?.manuallyCollapsed && - (this.fileDiscussions.length || this.file.drafts.length || this.file.hasCommentForm) + (this.fileDiscussions.length || this.file.drafts?.length || this.file.hasCommentForm) ); }, diffFileHash() { @@ -206,8 +197,6 @@ export default { watch: { 'file.id': { handler: function fileIdHandler() { - if (this.preRender) return; - this.manageViewedEffects(); }, }, @@ -220,7 +209,6 @@ export default { newHash && oldHash && !this.hasDiff && - !this.preRender && !this.idState.hasLoadedCollapsedDiff ) { this.requestDiff(); @@ -229,14 +217,10 @@ export default { }, }, created() { - if (this.preRender) return; - notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff); eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener); }, mounted() { - if (this.preRender) return; - if (this.hasDiff) { this.postRender(); } @@ -244,15 +228,12 @@ export default { this.manageViewedEffects(); }, beforeDestroy() { - if (this.preRender) return; - eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); }, methods: { ...mapActions('diffs', [ 'loadCollapsedDiff', 'assignDiscussionsToDiff', - 'setRenderIt', 'setFileCollapsedByUser', 'saveDiffDiscussion', 'toggleFileCommentForm', @@ -316,10 +297,6 @@ export default { .then(() => { idState.isLoadingCollapsedDiff = false; idState.hasLoadedCollapsedDiff = true; - - if (this.file.file_hash === file.file_hash) { - this.setRenderIt(this.file); - } }) .then(() => { if (this.file.file_hash !== file.file_hash) return; @@ -345,7 +322,7 @@ export default { hideForkMessage() { this.idState.forkMessageVisible = false; }, - handleSaveNote(note) { + handleSaveNote(note, parentElement, errorCallback) { this.saveDiffDiscussion({ note, formData: { @@ -354,8 +331,23 @@ export default { diffFile: this.file, positionType: FILE_DIFF_POSITION_TYPE, }, + }).catch((e) => { + const reason = e.response?.data?.errors; + const errorMessage = reason + ? sprintf(SAVING_THE_COMMENT_FAILED, { reason }) + : SOMETHING_WENT_WRONG; + + createAlert({ + message: errorMessage, + parent: parentElement, + }); + + errorCallback(); }); }, + handleSaveDraftNote(note, _, parentElement, errorCallback) { + this.addToReview(note, this.$options.FILE_DIFF_POSITION_TYPE, parentElement, errorCallback); + }, }, CONFLICT_TEXT, FILE_DIFF_POSITION_TYPE, @@ -364,15 +356,14 @@ export default { <template> <div - :id="!preRender && active && file.file_hash" + :id="file.file_hash" :class="{ - 'is-active': currentDiffFileId === file.file_hash, 'comments-disabled': Boolean(file.brokenSymlink), 'has-body': showBody, 'is-virtual-scrolling': isVirtualScrollingEnabled, }" :data-path="file.new_path" - class="diff-file file-holder gl-border-none" + class="diff-file file-holder gl-border-none gl-mb-0! gl-pb-5" > <diff-file-header :can-current-user-fork="canCurrentUserFork" @@ -383,7 +374,6 @@ export default { :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" :show-local-file-reviews="showLocalFileReviews" - :codequality-diff="codequalityDiffForFile" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" @toggleFile="handleToggle({ viaUserInteraction: true })" @@ -412,7 +402,7 @@ export default { </div> <template v-else> <div - :id="!preRender && active && `diff-content-${file.file_hash}`" + :id="`diff-content-${file.file_hash}`" :class="hasBodyClasses.contentByHash" data-testid="content-area" > @@ -463,7 +453,7 @@ export default { </template> </gl-sprintf> </gl-alert> - <div v-if="showFileDiscussions" class="gl-border-b" data-testid="file-discussions"> + <div v-if="showFileDiscussions" data-testid="file-discussions"> <div class="diff-file-discussions-wrapper"> <diff-discussions v-if="fileDiscussions.length" @@ -485,9 +475,7 @@ export default { class="gl-py-3 gl-px-5" data-testid="file-note-form" @handleFormUpdate="handleSaveNote" - @handleFormUpdateAddToReview=" - (note) => addToReview(note, $options.FILE_DIFF_POSITION_TYPE) - " + @handleFormUpdateAddToReview="handleSaveDraftNote" @cancelForm="toggleFileCommentForm(file.file_path)" /> </div> @@ -538,20 +526,3 @@ export default { </template> </div> </template> - -<style> -@keyframes shadow-fade { - from { - box-shadow: 0 0 4px #919191; - } - - to { - box-shadow: 0 0 0 #dfdfdf; - } -} - -.diff-file.is-active { - box-shadow: 0 0 0 #dfdfdf; - animation: shadow-fade 1.2s 0.1s 1; -} -</style> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 494a20045f7..e336161f952 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -101,11 +101,6 @@ export default { required: false, default: false, }, - codequalityDiff: { - type: Array, - required: false, - default: () => [], - }, }, idState() { return { @@ -212,7 +207,7 @@ export default { return this.expanded ? __('Hide file contents') : __('Show file contents'); }, showCommentButton() { - return this.getNoteableData.current_user.can_create_note && this.glFeatures.commentOnFiles; + return this.getNoteableData.current_user.can_create_note; }, }, watch: { @@ -428,6 +423,7 @@ export default { toggle-class="btn-icon js-diff-more-actions" class="gl-pt-0!" data-qa-selector="dropdown_button" + lazy @show="setMoreActionsShown(true)" @hidden="setMoreActionsShown(false)" > diff --git a/app/assets/javascripts/diffs/components/diff_inline_findings.vue b/app/assets/javascripts/diffs/components/diff_inline_findings.vue new file mode 100644 index 00000000000..1e9a1825d3e --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_inline_findings.vue @@ -0,0 +1,32 @@ +<script> +import DiffCodeQualityItem from './diff_code_quality_item.vue'; + +export default { + components: { DiffCodeQualityItem }, + props: { + title: { + type: String, + required: true, + }, + findings: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <h4 data-testid="diff-inline-findings-heading" class="gl-my-0 gl-font-base gl-font-regular"> + {{ title }} + </h4> + <ul class="gl-list-style-none gl-mb-0 gl-p-0"> + <diff-code-quality-item + v-for="finding in findings" + :key="finding.description" + :finding="finding" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue index 448272549d3..40e53438bc8 100644 --- a/app/assets/javascripts/diffs/components/diff_line.vue +++ b/app/assets/javascripts/diffs/components/diff_line.vue @@ -15,13 +15,22 @@ export default { parsedCodeQuality() { return (this.line.left ?? this.line.right)?.codequality; }, + parsedSast() { + return (this.line.left ?? this.line.right)?.sast; + }, codeQualityLineNumber() { - return this.parsedCodeQuality[0].line; + return this.parsedCodeQuality[0]?.line; + }, + sastLineNumber() { + return this.parsedSast[0]?.line; }, }, methods: { hideCodeQualityFindings() { - this.$emit('hideCodeQualityFindings', this.codeQualityLineNumber); + this.$emit( + 'hideCodeQualityFindings', + this.codeQualityLineNumber ? this.codeQualityLineNumber : this.sastLineNumber, + ); }, }, }; @@ -30,6 +39,7 @@ export default { <template> <diff-code-quality :code-quality="parsedCodeQuality" + :sast="parsedSast" @hideCodeQualityFindings="hideCodeQualityFindings" /> </template> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 9ddf5b51c9a..9a3256beff4 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; +import { createAlert } from '~/alert'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; @@ -15,6 +16,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, OLD_LINE_TYPE, } from '../constants'; +import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '../i18n'; export default { components: { @@ -207,10 +209,22 @@ export default { fileHash: this.diffFileHash, }); }), - handleSaveNote(note) { - return this.saveDiffDiscussion({ note, formData: this.formData }).then(() => - this.handleCancelCommentForm(), - ); + handleSaveNote(note, parentElement, errorCallback) { + return this.saveDiffDiscussion({ note, formData: this.formData }) + .then(() => this.handleCancelCommentForm()) + .catch((e) => { + const reason = e.response?.data?.errors; + const errorMessage = reason + ? sprintf(SAVING_THE_COMMENT_FAILED, { reason }) + : SOMETHING_WENT_WRONG; + + createAlert({ + message: errorMessage, + parent: parentElement, + }); + + errorCallback(); + }); }, updateStartLine(line) { this.commentLineStart = line; diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 1f5c9b4f2f5..3c9770864fa 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -141,6 +141,18 @@ export default { }, (props) => [props.inline, props.line.right?.codequality?.length].join(':'), ), + showSecurityLeft: memoize( + (props) => { + return props.inline && props.line.left?.sast?.length > 0; + }, + (props) => [props.inline, props.line.left?.sast?.length].join(':'), + ), + showSecurityRight: memoize( + (props) => { + return !props.inline && props.line.right?.sast?.length > 0; + }, + (props) => [props.inline, props.line.right?.sast?.length].join(':'), + ), classNameMapCellLeft: memoize( (props) => { return utils.classNameMapCell({ @@ -180,10 +192,13 @@ export default { ].join(':'), ), shouldRenderCommentButton: memoize( - (props) => { - return isLoggedIn() && !props.line.isContextLineLeft && !props.line.isMetaLineLeft; + (props, side) => { + return ( + isLoggedIn() && !props.line[`isContextLine${side}`] && !props.line[`isMetaLine${side}`] + ); }, - (props) => [props.line.isContextLineLeft, props.line.isMetaLineLeft].join(':'), + (props, side) => + [props.line[`isContextLine${side}`], props.line[`isMetaLine${side}`]].join(':'), ), interopLeftAttributes(props) { if (props.inline) { @@ -237,7 +252,7 @@ export default { <span v-if=" !props.line.left.isConflictMarker && - $options.shouldRenderCommentButton(props) && + $options.shouldRenderCommentButton(props, 'Left') && !props.line.hasDiscussionsLeft " class="add-diff-note tooltip-wrapper has-tooltip" @@ -322,12 +337,17 @@ export default { > <component :is="$options.CodeQualityGutterIcon" - v-if="$options.showCodequalityLeft(props)" + v-if="$options.showCodequalityLeft(props) || $options.showSecurityLeft(props)" :code-quality-expanded="props.codeQualityExpanded" :codequality="props.line.left.codequality" + :sast="props.line.left.sast" :file-path="props.filePath" @showCodeQualityFindings=" - listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line) + listeners.toggleCodeQualityFindings( + props.line.left.codequality[0] + ? props.line.left.codequality[0].line + : props.line.left.sast[0].line, + ) " /> </div> @@ -384,7 +404,10 @@ export default { <div :class="$options.classNameMapCellRight(props)" class="diff-td diff-line-num new_line"> <template v-if="props.line.right.type !== $options.CONFLICT_MARKER_THEIR"> <span - v-if="$options.shouldRenderCommentButton(props) && !props.line.hasDiscussionsRight" + v-if=" + $options.shouldRenderCommentButton(props, 'Right') && + !props.line.hasDiscussionsRight + " class="add-diff-note tooltip-wrapper has-tooltip" :title="props.line.right.addCommentTooltip" > @@ -455,12 +478,17 @@ export default { > <component :is="$options.CodeQualityGutterIcon" - v-if="$options.showCodequalityRight(props)" + v-if="$options.showCodequalityRight(props) || $options.showSecurityRight(props)" :codequality="props.line.right.codequality" + :sast="props.line.right.sast" :file-path="props.filePath" data-testid="codeQualityIcon" @showCodeQualityFindings=" - listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line) + listeners.toggleCodeQualityFindings( + props.line.right.codequality[0] + ? props.line.right.codequality[0].line + : props.line.right.sast[0].line, + ) " /> </div> diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index a489c96b0c9..28834dab3b3 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -189,3 +189,7 @@ export const mapParallel = (content) => (line) => { commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder', }; }; + +export const mapParallelNoSast = (content) => { + return mapParallel(content); +}; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 7c87ea1cbf2..6bacc6839d8 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -57,6 +57,7 @@ export default { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), ...mapState('diffs', [ 'codequalityDiff', + 'sastDiff', 'highlightedRow', 'coverageLoaded', 'selectedCommentPosition', @@ -75,7 +76,10 @@ export default { ); }, hasCodequalityChanges() { - return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0; + return ( + this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 || + this.sastDiff?.added?.length > 0 + ); }, }, created() { @@ -183,7 +187,10 @@ export default { ); }, getCodeQualityLine(line) { - return (line.left ?? line.right)?.codequality?.[0]?.line; + return ( + (line.left ?? line.right)?.codequality?.[0]?.line || + (line.left ?? line.right)?.sast?.[0]?.line + ); }, lineDrafts(line, side) { return (line[side]?.lineDrafts || []).filter((entry) => entry.isDraft); diff --git a/app/assets/javascripts/diffs/components/pre_renderer.vue b/app/assets/javascripts/diffs/components/pre_renderer.vue deleted file mode 100644 index e4320c40d2c..00000000000 --- a/app/assets/javascripts/diffs/components/pre_renderer.vue +++ /dev/null @@ -1,83 +0,0 @@ -<script> -export default { - inject: ['vscrollParent'], - props: { - maxLength: { - type: Number, - required: true, - }, - }, - data() { - return { - nextIndex: -1, - nextItem: null, - startedRender: false, - width: 0, - }; - }, - mounted() { - this.width = this.$el.parentNode.offsetWidth; - - this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => { - await this.$nextTick(); - - const nextItem = this.findNextToRender(); - - if (nextItem) { - this.startedRender = true; - requestIdleCallback(() => { - this.nextItem = nextItem; - - if (this.nextIndex === this.maxLength - 1) { - this.$nextTick(() => { - if (this.vscrollParent.itemsWithSize[this.maxLength - 1].size !== 0) { - this.clearRendering(); - } - }); - } - }); - } else if (this.startedRender) { - this.clearRendering(); - } - }); - }, - beforeDestroy() { - this.$_itemsWithSizeWatcher(); - }, - methods: { - clearRendering() { - this.nextItem = null; - - if (this.maxLength === this.vscrollParent.itemsWithSize.length) { - this.$_itemsWithSizeWatcher(); - } - }, - findNextToRender() { - return this.vscrollParent.itemsWithSize.find(({ size }, index) => { - const isNext = size === 0; - - if (isNext) { - this.nextIndex = index; - } - - return isNext; - }); - }, - }, -}; -</script> - -<template> - <div v-if="nextItem" :style="{ width: `${width}px` }" class="gl-absolute diff-file-offscreen"> - <slot - v-bind="{ item: nextItem.item, index: nextIndex, active: true, itemWithSize: nextItem }" - ></slot> - </div> -</template> - -<style scoped> -.diff-file-offscreen { - top: -200%; - left: -200%; -} -</style> diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 2d9ac76b3e4..a705f29ff65 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -2,23 +2,22 @@ import { GlButtonGroup, GlButton, - GlDropdown, + GlDisclosureDropdown, GlFormCheckbox, - GlTooltipDirective, + GlTooltip, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { SETTINGS_DROPDOWN } from '../i18n'; export default { i18n: SETTINGS_DROPDOWN, - directives: { - GlTooltip: GlTooltipDirective, - }, + toggleId: 'js-show-diff-settings', components: { GlButtonGroup, GlButton, - GlDropdown, + GlDisclosureDropdown, GlFormCheckbox, + GlTooltip, }, computed: { ...mapGetters('diffs', ['isInlineView', 'isParallelView']), @@ -43,74 +42,87 @@ export default { </script> <template> - <gl-dropdown - v-gl-tooltip - icon="settings" - :title="$options.i18n.preferences" - :text="$options.i18n.preferences" - :text-sr-only="true" - :aria-label="$options.i18n.preferences" - :header-text="$options.i18n.preferences" - toggle-class="js-show-diff-settings" - right - > - <div class="gl-px-3"> - <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span> - <gl-button-group class="gl-display-flex"> - <gl-button - :class="{ selected: !renderTreeList }" - class="gl-w-half js-list-view" - @click="setRenderTreeList({ renderTreeList: false })" - > - {{ __('List view') }} - </gl-button> - <gl-button - :class="{ selected: renderTreeList }" - class="gl-w-half js-tree-view" - @click="setRenderTreeList({ renderTreeList: true })" - > - {{ __('Tree view') }} - </gl-button> - </gl-button-group> - </div> - <div class="gl-mt-3 gl-px-3"> - <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('Compare changes') }}</span> - <gl-button-group class="gl-display-flex js-diff-view-buttons"> - <gl-button - id="inline-diff-btn" - :class="{ selected: isInlineView }" - class="gl-w-half js-inline-diff-button" - data-view-type="inline" - @click="setInlineDiffViewType" - > - {{ __('Inline') }} - </gl-button> - <gl-button - id="parallel-diff-btn" - :class="{ selected: isParallelView }" - class="gl-w-half js-parallel-diff-button" - data-view-type="parallel" - @click="setParallelDiffViewType" - > - {{ __('Side-by-side') }} - </gl-button> - </gl-button-group> - </div> - <gl-form-checkbox - data-testid="show-whitespace" - class="gl-mt-3 gl-ml-3" - :checked="showWhitespace" - @input="toggleWhitespace" - > - {{ $options.i18n.whitespace }} - </gl-form-checkbox> - <gl-form-checkbox - data-testid="file-by-file" - class="gl-ml-3 gl-mb-0" - :checked="viewDiffsFileByFile" - @input="toggleFileByFile" + <div> + <gl-disclosure-dropdown + :toggle-class="$options.toggleId" + :toggle-id="$options.toggleId" + icon="settings" + :text="$options.i18n.preferences" + text-sr-only + :aria-label="$options.i18n.preferences" + placement="right" + :auto-close="false" > - {{ $options.i18n.fileByFile }} - </gl-form-checkbox> - </gl-dropdown> + <slot name="header"> + <span + class="gl-font-weight-bold gl-display-block gl-mb-3 gl-pb-2 gl-text-center gl-border-b" + >{{ $options.i18n.preferences }}</span + > + </slot> + <div class="gl-px-3"> + <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span> + <gl-button-group class="gl-display-flex"> + <gl-button + :class="{ selected: !renderTreeList }" + class="gl-w-half js-list-view" + @click="setRenderTreeList({ renderTreeList: false })" + > + {{ __('List view') }} + </gl-button> + <gl-button + :class="{ selected: renderTreeList }" + class="gl-w-half js-tree-view" + @click="setRenderTreeList({ renderTreeList: true })" + > + {{ __('Tree view') }} + </gl-button> + </gl-button-group> + </div> + <div class="gl-mt-3 gl-px-3"> + <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ + __('Compare changes') + }}</span> + <gl-button-group class="gl-display-flex js-diff-view-buttons"> + <gl-button + id="inline-diff-btn" + :class="{ selected: isInlineView }" + class="gl-w-half js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </gl-button> + <gl-button + id="parallel-diff-btn" + :class="{ selected: isParallelView }" + class="gl-w-half js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </gl-button> + </gl-button-group> + </div> + <gl-form-checkbox + data-testid="show-whitespace" + class="gl-mt-3 gl-ml-3" + :checked="showWhitespace" + @input="toggleWhitespace" + > + {{ $options.i18n.whitespace }} + </gl-form-checkbox> + <gl-form-checkbox + data-testid="file-by-file" + class="gl-ml-3 gl-mb-0" + :checked="viewDiffsFileByFile" + @input="toggleFileByFile" + > + {{ $options.i18n.fileByFile }} + </gl-form-checkbox> + </gl-disclosure-dropdown> + + <gl-tooltip :target="$options.toggleId" triggers="hover">{{ + $options.i18n.preferences + }}</gl-tooltip> + </div> </template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index b9bfceee6b4..6f17d70b952 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -186,10 +186,10 @@ export default { v-show="search" :aria-label="__('Clear search')" type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" + class="gl-absolute gl-top-3 bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" @click="clearSearch" > - <gl-icon name="close" class="gl-absolute gl-top-3 gl-right-1 tree-list-icon" /> + <gl-icon name="close" class="gl-top-3 gl-right-1 tree-list-icon" /> </button> </div> </div> diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js index d44dffecc38..fc36153a870 100644 --- a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js +++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js @@ -18,17 +18,18 @@ export default { if (index < 0) return; - if (this.vscrollParent.itemsWithSize[index].size) { - this.scrollToIndex(index); - } else { + this.scrollToIndex(index); + + if (!this.vscrollParent.itemsWithSize[index].size) { this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => { await this.$nextTick(); if (this.vscrollParent.itemsWithSize[index].size) { this.$_itemsWithSizeWatcher(); - this.scrollToIndex(index); await this.$nextTick(); + + this.scrollToIndex(index); } }); } @@ -40,10 +41,12 @@ export default { if (this.$_itemsWithSizeWatcher) this.$_itemsWithSizeWatcher(); }, methods: { - scrollToIndex(index) { + async scrollToIndex(index) { this.vscrollParent.scrollToItem(index); this.$emit('update', -1); + await this.$nextTick(); + setTimeout(() => { handleLocationHash(); }); diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index e233a0cef0a..15e3893d22a 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -1,6 +1,5 @@ import { __, s__ } from '~/locale'; -export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); export const LOAD_SINGLE_DIFF_FAILED = s__( "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.", ); @@ -58,3 +57,18 @@ export const CONFLICT_TEXT = { export const HIDE_COMMENTS = __('Hide comments'); export const NEW_CODE_QUALITY_FINDINGS = __('New code quality findings'); +export const NEW_SAST_FINDINGS = __('New Security findings'); + +export const BUILDING_YOUR_MR = __( + 'Building your merge request… This page will update when the build is complete.', +); +export const SOMETHING_WENT_WRONG = __('Something went wrong on our end. Please try again!'); +export const SAVING_THE_COMMENT_FAILED = s__( + 'MergeRequests|Your comment could not be submitted because %{reason}.', +); +export const ERROR_LOADING_FULL_DIFF = s__( + 'MergeRequest|Error loading full diff. Please try again.', +); +export const ERROR_DISMISSING_SUGESTION_POPOVER = s__( + 'MergeRequest|Error dismissing suggestion popover. Please try again.', +); diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 29cf90dcbe2..b9cf26827f2 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -32,6 +32,7 @@ export default function initDiffsApp(store = notesStore) { return { endpointCoverage: dataset.endpointCoverage || '', endpointCodequality: dataset.endpointCodequality || '', + endpointSast: dataset.endpointSast || '', helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, @@ -79,6 +80,7 @@ export default function initDiffsApp(store = notesStore) { props: { endpointCoverage: this.endpointCoverage, endpointCodequality: this.endpointCodequality, + endpointSast: this.endpointSast, currentUser: this.currentUser, helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 029be6ebad9..2a557017953 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -6,13 +6,11 @@ import { scrollToElement, } from '~/lib/utils/common_utils'; import { createAlert, VARIANT_WARNING } from '~/alert'; -import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; -import { __, s__ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import { generateTreeList } from '~/diffs/utils/tree_worker_utils'; import { sortTree } from '~/ide/stores/utils'; @@ -52,9 +50,15 @@ import { EVT_MR_PREPARED, FILE_DIFF_POSITION_TYPE, } from '../constants'; -import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n'; +import { + DISCUSSION_SINGLE_DIFF_FAILED, + LOAD_SINGLE_DIFF_FAILED, + BUILDING_YOUR_MR, + SOMETHING_WENT_WRONG, + ERROR_LOADING_FULL_DIFF, + ERROR_DISMISSING_SUGESTION_POPOVER, +} from '../i18n'; import eventHub from '../event_hub'; -import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; import { getDerivedMergeRequestInformation } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; @@ -84,6 +88,7 @@ export const setBaseConfig = ({ commit }, options) => { defaultSuggestionCommitMessage, viewDiffsFileByFile, mrReviews, + diffViewType, } = options; commit(types.SET_BASE_CONFIG, { endpoint, @@ -98,6 +103,7 @@ export const setBaseConfig = ({ commit }, options) => { defaultSuggestionCommitMessage, viewDiffsFileByFile, mrReviews, + diffViewType, }); Array.from(new Set(Object.values(mrReviews).flat())).forEach((id) => { @@ -171,7 +177,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { }; const hash = window.location.hash.replace('#', '').split('diff-content-').pop(); let totalLoaded = 0; - let scrolledVirtualScroller = false; + let scrolledVirtualScroller = hash === ''; commit(types.SET_BATCH_LOADING_STATE, 'loading'); commit(types.SET_RETRIEVING_BATCHES, true); @@ -243,8 +249,6 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { return nextPage; }) .then((nextPage) => { - dispatch('startRenderDiffsQueue'); - if (nextPage) { return getBatch(nextPage); } @@ -290,9 +294,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { .catch((error) => { if (error.response.status === HTTP_STATUS_NOT_FOUND) { const alert = createAlert({ - message: __( - 'Building your merge request… This page will update when the build is complete.', - ), + message: BUILDING_YOUR_MR, variant: VARIANT_WARNING, }); @@ -318,7 +320,7 @@ export const fetchCoverageFiles = ({ commit, state }) => { }, errorCallback: () => createAlert({ - message: __('Something went wrong on our end. Please try again!'), + message: SOMETHING_WENT_WRONG, }), }); @@ -379,10 +381,6 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi const file = state.diffFiles.find((f) => f.file_hash === discussion.diff_file.file_hash); if (file) { - if (!file.renderIt) { - commit(types.RENDER_FILE, file); - } - if (file.viewer.automaticallyCollapsed) { notesEventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); @@ -400,46 +398,6 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi } }; -export const startRenderDiffsQueue = ({ state, commit }) => { - const diffFilesToRender = state.diffFiles.filter( - (file) => - !file.renderIt && - file.viewer && - (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text), - ); - let currentDiffFileIndex = 0; - - const checkItem = () => { - const nextFile = diffFilesToRender[currentDiffFileIndex]; - - if (nextFile) { - let retryCount = 0; - currentDiffFileIndex += 1; - commit(types.RENDER_FILE, nextFile); - - const requestIdle = () => - requestIdleCallback((idleDeadline) => { - // Wait for at least 5ms before trying to render - // or for 5 tries and then force render the file - if (idleDeadline.timeRemaining() >= 5 || retryCount > 4) { - checkItem(); - } else { - requestIdle(); - retryCount += 1; - } - }); - - requestIdle(); - } - }; - - if (diffFilesToRender.length) { - checkItem(); - } -}; - -export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file); - export const setInlineDiffViewType = ({ commit }) => { commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); @@ -619,12 +577,7 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData } if (formData.positionType === FILE_DIFF_POSITION_TYPE) { dispatch('toggleFileCommentForm', formData.diffFile.file_path); } - }) - .catch(() => - createAlert({ - message: s__('MergeRequests|Saving the comment failed'), - }), - ); + }); }; export const toggleTreeOpen = ({ commit }, path) => { @@ -757,7 +710,7 @@ export const cacheTreeListWidth = (_, size) => { export const receiveFullDiffError = ({ commit }, filePath) => { commit(types.RECEIVE_FULL_DIFF_ERROR, filePath); createAlert({ - message: s__('MergeRequest|Error loading full diff. Please try again.'), + message: ERROR_LOADING_FULL_DIFF, }); }; @@ -845,7 +798,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) = } }; -export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }) { +export function switchToFullDiffFromRenamedFile({ commit }, { diffFile }) { return axios .get(diffFile.context_lines_path, { params: { @@ -872,8 +825,6 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }, }); commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); - - dispatch('startRenderDiffsQueue'); }); } @@ -895,7 +846,7 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => }) .catch(() => { createAlert({ - message: s__('MergeRequest|Error dismissing suggestion popover. Please try again.'), + message: ERROR_DISMISSING_SUGESTION_POPOVER, }); }); diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index a8a831fb269..d82959daa9d 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -153,6 +153,11 @@ export const fileLineCodequality = () => () => { return null; }; +// This function is overwritten for the inline SAST feature in EE +export const fileLineSast = () => () => { + return null; +}; + /** * Returns index of a currently selected diff in diffFiles * @returns {number} diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 593c28f20ec..d5e1a05f4a5 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,11 +1,4 @@ -import { getCookie } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; - -const getViewTypeFromQueryString = () => getParameterValues('view')[0]; - -const viewTypeFromCookie = getCookie(DIFF_VIEW_COOKIE_NAME); -const defaultViewType = INLINE_DIFF_VIEW_TYPE; +import { INLINE_DIFF_VIEW_TYPE } from '../../constants'; export default () => ({ isLoading: true, @@ -25,7 +18,7 @@ export default () => ({ coverageLoaded: false, mergeRequestDiffs: [], mergeRequestDiff: null, - diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, + diffViewType: INLINE_DIFF_VIEW_TYPE, tree: [], treeEntries: {}, showTreeList: true, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 2786e971f4b..4855ca87e91 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -23,12 +23,6 @@ function updateDiffFilesInState(state, files) { return Object.assign(state, { diffFiles: files }); } -function renderFile(file) { - Object.assign(file, { - renderIt: true, - }); -} - export default { [types.SET_BASE_CONFIG](state, options) { const { @@ -44,6 +38,7 @@ export default { defaultSuggestionCommitMessage, viewDiffsFileByFile, mrReviews, + diffViewType, } = options; Object.assign(state, { endpoint, @@ -58,6 +53,7 @@ export default { defaultSuggestionCommitMessage, viewDiffsFileByFile, mrReviews, + diffViewType, }); }, @@ -100,10 +96,6 @@ export default { Object.assign(state, { coverageFiles, coverageLoaded: true }); }, - [types.RENDER_FILE](state, file) { - renderFile(file); - }, - [types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) { Object.assign(state, { mergeRequestDiffs, @@ -353,10 +345,6 @@ export default { file.viewer.manuallyCollapsed = null; } } - - if (file && !collapsed) { - renderFile(file); - } }, [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { const file = state.diffFiles.find((f) => f.file_path === filePath); diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 68536d36ac0..97dfd351e67 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -307,7 +307,6 @@ function mergeTwoFiles(target, source) { ...target, [INLINE_DIFF_LINES_KEY]: missingInline ? source[INLINE_DIFF_LINES_KEY] : originalInline, parallel_diff_lines: null, - renderIt: source.renderIt, collapsed: source.collapsed, }; } @@ -388,7 +387,6 @@ function prepareDiffFileLines(file) { function finalizeDiffFile(file) { Object.assign(file, { - renderIt: true, isShowingFullFile: false, isLoadingFullFile: false, discussions: [], diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue index 39881979c4f..a72e7df7769 100644 --- a/app/assets/javascripts/emoji/components/category.vue +++ b/app/assets/javascripts/emoji/components/category.vue @@ -33,6 +33,9 @@ export default { this.renderGroup = true; this.$emit('appear', this.category); }, + onClick(emoji) { + this.$emit('click', { category: this.category, emoji }); + }, }, }; </script> @@ -48,7 +51,7 @@ export default { :key="index" :emojis="emojiGroup" :render-group="renderGroup" - :click-emoji="(emoji) => $emit('click', emoji)" + :click-emoji="(emoji) => onClick(emoji)" /> </template> <p v-else> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 840297b870a..0e3dd9f7535 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -2,7 +2,7 @@ import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { findLastIndex } from 'lodash'; import VirtualList from 'vue-virtual-scroll-list'; -import { CATEGORY_NAMES } from '~/emoji'; +import { CATEGORY_NAMES, getEmojiCategoryMap, state } from '~/emoji'; import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from '../constants'; import Category from './category.vue'; import EmojiList from './emoji_list.vue'; @@ -49,6 +49,7 @@ export default { categoryNames() { return CATEGORY_NAMES.filter((c) => { if (c === FREQUENTLY_USED_KEY) return hasFrequentlyUsedEmojis(); + if (c === 'custom') return !state.loading && getEmojiCategoryMap().custom.length > 0; return true; }).map((category) => ({ name: category, @@ -66,10 +67,13 @@ export default { this.$refs.virtualScoller.setScrollTop(top); }, - selectEmoji(name) { - this.$emit('click', name); + selectEmoji({ category, emoji }) { + this.$emit('click', emoji); this.$refs.dropdown.hide(); - addToFrequentlyUsed(name); + + if (category !== 'custom') { + addToFrequentlyUsed(emoji); + } }, getBoundaryElement() { return this.boundary || document.querySelector('.content-wrapper') || 'scrollParent'; @@ -102,7 +106,20 @@ export default { @shown="$emit('shown')" @hidden="$emit('hidden')" > - <template #button-content><slot name="button-content"></slot></template> + <template #button-content> + <slot name="button-content"> + <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" /> + <gl-icon + class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!" + name="smiley" + /> + <gl-icon + class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!" + name="smile" + /> + </slot> + <span class="gl-sr-only">{{ __('Add reaction') }}</span> + </template> <gl-search-box-by-type v-model="searchValue" class="gl-mx-5! gl-mb-2!" diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js index 5eec0992896..2c1c968878b 100644 --- a/app/assets/javascripts/emoji/components/utils.js +++ b/app/assets/javascripts/emoji/components/utils.js @@ -52,7 +52,7 @@ export const getEmojiCategories = memoize(async () => { return Object.freeze( Object.keys(categories) - .filter((c) => c !== FREQUENTLY_USED_KEY) + .filter((c) => c !== FREQUENTLY_USED_KEY && categories[c].length) .reduce((acc, category) => { const emojis = chunk(categories[category], EMOJIS_PER_ROW); const height = generateCategoryHeight(emojis.length); diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index 7970a932095..215ecbfe605 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -3,6 +3,7 @@ export const FREQUENTLY_USED_COOKIE_KEY = 'frequently_used_emojis'; export const CATEGORY_ICON_MAP = { [FREQUENTLY_USED_KEY]: 'history', + custom: 'tanuki', activity: 'dumbbell', people: 'smiley', nature: 'nature', diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 4484bc03737..1fa81a000a5 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,14 +1,22 @@ +import Vue from 'vue'; import { escape, minBy } from 'lodash'; import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; +import createApolloClient from '~/lib/graphql'; import { setAttributes } from '~/lib/utils/dom_utils'; import { getEmojiScoreWithIntent } from '~/emoji/utils'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; +import customEmojiQuery from './queries/custom_emoji.query.graphql'; import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; + +export const state = Vue.observable({ + loading: true, +}); + export const FALLBACK_EMOJI_KEY = 'grey_question'; // Keep the version in sync with `lib/gitlab/emoji.rb` @@ -53,9 +61,43 @@ async function loadEmojiWithNames() { }, {}); } +export async function loadCustomEmojiWithNames() { + if (document.body?.dataset?.groupFullPath && window.gon?.features?.customEmoji) { + const client = createApolloClient(); + const { data } = await client.query({ + query: customEmojiQuery, + variables: { + groupPath: document.body.dataset.groupFullPath, + }, + }); + + return data?.group?.customEmoji?.nodes?.reduce((acc, e) => { + // Map the custom emoji into the format of the normal emojis + acc[e.name] = { + c: 'custom', + d: e.name, + e: undefined, + name: e.name, + src: e.url, + u: 'custom', + }; + + return acc; + }, {}); + } + + return {}; +} + async function prepareEmojiMap() { - emojiMap = await loadEmojiWithNames(); - validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + return Promise.all([loadEmojiWithNames(), loadCustomEmojiWithNames()]).then((values) => { + emojiMap = { + ...values[0], + ...values[1], + }; + validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + state.loading = false; + }); } export function initEmojiMap() { @@ -84,6 +126,10 @@ export function getAllEmoji() { return emojiMap; } +export function findCustomEmoji(name) { + return emojiMap[name]; +} + function getAliasesMatchingQuery(query) { return Object.keys(emojiAliases) .filter((alias) => alias.includes(query)) @@ -176,7 +222,7 @@ export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); let emojiCategoryMap; export function getEmojiCategoryMap() { - if (!emojiCategoryMap) { + if (!emojiCategoryMap && emojiMap) { emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => { if (category === FREQUENTLY_USED_KEY) { return acc; @@ -218,10 +264,11 @@ export function getEmojiInfo(query, fallback = true) { } export function emojiFallbackImageSrc(inputName) { - const { name } = getEmojiInfo(inputName); - return `${gon.asset_host || ''}${ - gon.relative_url_root || '' - }/-/emojis/${EMOJI_VERSION}/${name}.png`; + const { name, src } = getEmojiInfo(inputName); + return ( + src || + `${gon.asset_host || ''}${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/${name}.png` + ); } export function emojiImageTag(name, src) { @@ -232,8 +279,6 @@ export function emojiImageTag(name, src) { title: `:${name}:`, alt: `:${name}:`, src, - width: '16', - height: '16', align: 'absmiddle', }); diff --git a/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql new file mode 100644 index 00000000000..951027ec274 --- /dev/null +++ b/app/assets/javascripts/emoji/queries/custom_emoji.query.graphql @@ -0,0 +1,12 @@ +query getCustomEmoji($groupPath: ID!) { + group(fullPath: $groupPath) { + id + customEmoji { + nodes { + id + name + url + } + } + } +} diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index 7905c5cf572..a2405d23924 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -1,10 +1,10 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getEnvironment from '../graphql/queries/environment.query.graphql'; +import getEnvironmentWithNamespace from '../graphql/queries/environment_with_namespace.graphql'; import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; @@ -14,72 +14,42 @@ export default { EnvironmentForm, }, mixins: [glFeatureFlagsMixin()], - inject: ['projectEnvironmentsPath', 'updateEnvironmentPath', 'projectPath'], - props: { - environment: { - required: true, - type: Object, - }, - }, + inject: ['projectEnvironmentsPath', 'projectPath', 'environmentName'], apollo: { environment: { - query: getEnvironment, + query() { + return this.glFeatures?.kubernetesNamespaceForEnvironment + ? getEnvironmentWithNamespace + : getEnvironment; + }, variables() { return { - environmentName: this.environment.name, + environmentName: this.environmentName, projectFullPath: this.projectPath, }; }, update(data) { - this.formEnvironment = data?.project?.environment || {}; + const result = data?.project?.environment || {}; + this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id }; }, }, }, data() { return { - isQueryLoading: false, loading: false, formEnvironment: null, }; }, - mounted() { - if (this.glFeatures?.environmentSettingsToGraphql) { - this.fetchWithGraphql(); - } else { - this.formEnvironment = { - id: this.environment.id, - name: this.environment.name, - externalUrl: this.environment.external_url, - }; - } + computed: { + isQueryLoading() { + return this.$apollo.queries.environment.loading; + }, }, methods: { - async fetchWithGraphql() { - this.$apollo.addSmartQuery('environmentData', { - variables() { - return { environmentName: this.environment.name, projectFullPath: this.projectPath }; - }, - query: getEnvironment, - update(data) { - const result = data?.project?.environment || {}; - this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id }; - }, - watchLoading: (isLoading) => { - this.isQueryLoading = isLoading; - }, - }); - }, onChange(environment) { this.formEnvironment = environment; }, - onSubmit() { - if (this.glFeatures?.environmentSettingsToGraphql) { - this.updateWithGraphql(); - } else { - this.updateWithAxios(); - } - }, - async updateWithGraphql() { + async onSubmit() { this.loading = true; try { const { data } = await this.$apollo.mutate({ @@ -89,6 +59,7 @@ export default { id: this.formEnvironment.id, externalUrl: this.formEnvironment.externalUrl, clusterAgentId: this.formEnvironment.clusterAgentId, + kubernetesNamespace: this.formEnvironment.kubernetesNamespace, }, }, }); @@ -111,20 +82,6 @@ export default { this.loading = false; } }, - updateWithAxios() { - this.loading = true; - axios - .put(this.updateEnvironmentPath, { - id: this.formEnvironment.id, - external_url: this.formEnvironment.externalUrl, - }) - .then(({ data: { path } }) => visitUrl(path)) - .catch((error) => { - const message = error.response.data.message[0]; - createAlert({ message }); - this.loading = false; - }); - }, }, }; </script> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 266b221b481..1bff013b9c2 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -7,6 +7,7 @@ import { GlCollapsibleListbox, GlLink, GlSprintf, + GlAlert, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { isAbsolute } from '~/lib/utils/url_utility'; @@ -15,7 +16,10 @@ import { ENVIRONMENT_NEW_HELP_TEXT, ENVIRONMENT_EDIT_HELP_TEXT, } from 'ee_else_ce/environments/constants'; +import csrf from '~/lib/utils/csrf'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql'; import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql'; export default { @@ -27,11 +31,13 @@ export default { GlCollapsibleListbox, GlLink, GlSprintf, + GlAlert, }, mixins: [glFeatureFlagsMixin()], inject: { protectedEnvironmentSettingsPath: { default: '' }, projectPath: { default: '' }, + kasTunnelUrl: { default: '' }, }, props: { environment: { @@ -64,11 +70,13 @@ export default { urlFeedback: __('The URL should start with http:// or https://'), agentLabel: s__('Environments|GitLab agent'), agentHelpText: s__('Environments|Select agent'), + namespaceLabel: s__('Environments|Kubernetes namespace (optional)'), + namespaceHelpText: s__('Environments|Select namespace'), save: __('Save'), cancel: __('Cancel'), reset: __('Reset'), }, - helpPagePath: helpPagePath('ci/environments/index.md'), + environmentsHelpPagePath: helpPagePath('ci/environments/index.md'), renamingDisabledHelpPagePath: helpPagePath('ci/environments/index.md', { anchor: 'rename-an-environment', }), @@ -81,10 +89,41 @@ export default { userAccessAuthorizedAgents: [], loadingAgentsList: false, selectedAgentId: this.environment.clusterAgentId, - searchTerm: '', + agentSearchTerm: '', + selectedNamespace: this.environment.kubernetesNamespace, + k8sNamespaces: [], + namespaceSearchTerm: '', + kubernetesError: '', }; }, + apollo: { + k8sNamespaces: { + query: getNamespacesQuery, + skip() { + return !this.showNamespaceSelector; + }, + variables() { + return { + configuration: this.k8sAccessConfiguration, + }; + }, + update(data) { + return data?.k8sNamespaces || []; + }, + error(error) { + this.kubernetesError = error.message; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.kubernetesError = null; + } + }, + }, + }, computed: { + loadingNamespacesList() { + return this.$apollo.queries.k8sNamespaces.loading; + }, isNameDisabled() { return Boolean(this.environment.id); }, @@ -105,7 +144,7 @@ export default { }; }); }, - dropdownToggleText() { + agentDropdownToggleText() { if (!this.selectedAgentId) { return this.$options.i18n.agentHelpText; } @@ -115,13 +154,48 @@ export default { return selectedAgentById?.text || this.environment.clusterAgent?.name; }, filteredAgentsList() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + const lowerCasedSearchTerm = this.agentSearchTerm.toLowerCase(); return this.agentsList.filter((item) => item.text.toLowerCase().includes(lowerCasedSearchTerm), ); }, - showAgentsSelect() { - return this.glFeatures?.environmentSettingsToGraphql; + namespacesList() { + return this.k8sNamespaces.map((item) => { + return { + value: item.metadata.name, + text: item.metadata.name, + }; + }); + }, + filteredNamespacesList() { + const lowerCasedSearchTerm = this.namespaceSearchTerm.toLowerCase(); + return this.namespacesList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + isKasKubernetesNamespaceAvailable() { + return this.glFeatures?.kubernetesNamespaceForEnvironment; + }, + showNamespaceSelector() { + return Boolean(this.isKasKubernetesNamespaceAvailable && this.selectedAgentId); + }, + namespaceDropdownToggleText() { + return this.selectedNamespace || this.$options.i18n.namespaceHelpText; + }, + k8sAccessConfiguration() { + if (!this.showNamespaceSelector) { + return null; + } + return { + basePath: this.kasTunnelUrl, + baseOptions: { + headers: { + 'GitLab-Agent-Id': getIdFromGraphQLId(this.selectedAgentId), + ...csrf.headers, + }, + withCredentials: true, + }, + }; }, }, watch: { @@ -151,7 +225,14 @@ export default { }); }, onAgentSearch(search) { - this.searchTerm = search; + this.agentSearchTerm = search; + }, + onAgentChange($event) { + this.selectedNamespace = null; + this.onChange({ ...this.environment, clusterAgentId: $event, kubernetesNamespace: null }); + }, + onNamespaceSearch(search) { + this.namespaceSearchTerm = search; }, }, }; @@ -171,7 +252,9 @@ export default { > <template #link="{ content }"> <gl-link - :href="showEditHelp ? protectedEnvironmentSettingsPath : $options.helpPagePath" + :href=" + showEditHelp ? protectedEnvironmentSettingsPath : $options.environmentsHelpPagePath + " >{{ content }}</gl-link > </template> @@ -223,29 +306,53 @@ export default { /> </gl-form-group> - <gl-form-group - v-if="showAgentsSelect" - :label="$options.i18n.agentLabel" - label-for="environment_agent" - > + <gl-form-group :label="$options.i18n.agentLabel" label-for="environment_agent"> <gl-collapsible-listbox id="environment_agent" v-model="selectedAgentId" class="gl-w-full" + data-testid="agent-selector" block :items="filteredAgentsList" :loading="loadingAgentsList" - :toggle-text="dropdownToggleText" + :toggle-text="agentDropdownToggleText" :header-text="$options.i18n.agentHelpText" :reset-button-label="$options.i18n.reset" :searchable="true" @shown="getAgentsList" @search="onAgentSearch" - @select="onChange({ ...environment, clusterAgentId: $event })" + @select="onAgentChange" @reset="onChange({ ...environment, clusterAgentId: null })" /> </gl-form-group> + <gl-form-group + v-if="showNamespaceSelector" + :label="$options.i18n.namespaceLabel" + label-for="environment_namespace" + > + <gl-alert v-if="kubernetesError" variant="warning" :dismissible="false" class="gl-mb-5"> + {{ kubernetesError }} + </gl-alert> + <gl-collapsible-listbox + v-else + id="environment_namespace" + v-model="selectedNamespace" + class="gl-w-full" + data-testid="namespace-selector" + block + :items="filteredNamespacesList" + :loading="loadingNamespacesList" + :toggle-text="namespaceDropdownToggleText" + :header-text="$options.i18n.namespaceHelpText" + :reset-button-label="$options.i18n.reset" + :searchable="true" + @search="onNamespaceSearch" + @select="onChange({ ...environment, kubernetesNamespace: $event })" + @reset="onChange({ ...environment, kubernetesNamespace: null })" + /> + </gl-form-group> + <div class="gl-mr-6"> <gl-button :loading="loading" diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue index 3e5f4070066..c6bc94b0b80 100644 --- a/app/assets/javascripts/environments/components/new_environment.vue +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -1,8 +1,6 @@ <script> import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import createEnvironment from '../graphql/mutations/create_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; @@ -10,13 +8,13 @@ export default { components: { EnvironmentForm, }, - mixins: [glFeatureFlagsMixin()], inject: ['projectEnvironmentsPath', 'projectPath'], data() { return { environment: { name: '', externalUrl: '', + clusterAgentId: null, }, loading: false, }; @@ -25,14 +23,7 @@ export default { onChange(env) { this.environment = env; }, - onSubmit() { - if (this.glFeatures?.environmentSettingsToGraphql) { - this.createWithGraphql(); - } else { - this.createWithAxios(); - } - }, - async createWithGraphql() { + async onSubmit() { this.loading = true; try { const { data } = await this.$apollo.mutate({ @@ -43,6 +34,7 @@ export default { externalUrl: this.environment.externalUrl, projectPath: this.projectPath, clusterAgentId: this.environment.clusterAgentId, + kubernetesNamespace: this.environment.kubernetesNamespace, }, }, }); @@ -65,20 +57,6 @@ export default { this.loading = false; } }, - createWithAxios() { - this.loading = true; - axios - .post(this.projectEnvironmentsPath, { - name: this.environment.name, - external_url: this.environment.externalUrl, - }) - .then(({ data: { path } }) => visitUrl(path)) - .catch((error) => { - const message = error.response.data.message[0]; - createAlert({ message }); - this.loading = false; - }); - }, }, }; </script> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 1f3d429cc3e..fda1c85f739 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -14,6 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql'; +import getEnvironmentClusterAgentWithNamespace from '../graphql/queries/environment_cluster_agent_with_namespace.query.graphql'; import ExternalUrl from './environment_external_url.vue'; import Actions from './environment_actions.vue'; import StopComponent from './environment_stop.vue'; @@ -82,7 +83,7 @@ export default { tierTooltip: s__('Environment|Deployment tier'), }, data() { - return { visible: false, clusterAgent: null }; + return { visible: false, clusterAgent: null, kubernetesNamespace: '' }; }, computed: { icon() { @@ -164,11 +165,8 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, - isKubernetesOverviewAvailable() { - return this.glFeatures?.kasUserAccessProject; - }, - showKubernetesOverview() { - return Boolean(this.isKubernetesOverviewAvailable && this.clusterAgent); + isKubernetesNamespaceAvailable() { + return this.glFeatures?.kubernetesNamespaceForEnvironment; }, }, methods: { @@ -180,15 +178,20 @@ export default { } }, getClusterAgent() { - if (!this.isKubernetesOverviewAvailable || this.clusterAgent) return; + if (this.clusterAgent) return; this.$apollo.addSmartQuery('environmentClusterAgent', { variables() { return { environmentName: this.environment.name, projectFullPath: this.projectPath }; }, - query: getEnvironmentClusterAgent, + query() { + return this.isKubernetesNamespaceAvailable + ? getEnvironmentClusterAgentWithNamespace + : getEnvironmentClusterAgent; + }, update(data) { this.clusterAgent = data?.project?.environment?.clusterAgent; + this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || ''; }, }); }, @@ -368,11 +371,8 @@ export default { </template> </gl-sprintf> </div> - <div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses"> - <kubernetes-overview - :cluster-agent="clusterAgent" - :namespace="environment.kubernetesNamespace" - /> + <div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses"> + <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> <deploy-board-wrapper diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 95ece2b653e..b583694e154 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,10 +1,14 @@ <script> import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import eventHub from '../event_hub'; import stopEnvironmentMutation from '../graphql/mutations/stop_environment.mutation.graphql'; export default { + yamlDocsLink: `${DOCS_URL_IN_EE_DIR}/ee/ci/yaml/`, + stoppingEnvironmentDocsLink: `${DOCS_URL_IN_EE_DIR}/environments/#stopping-an-environment`, + id: 'stop-environment-modal', name: 'StopEnvironmentModal', @@ -98,18 +102,15 @@ export default { <strong>{{ content }}</strong> </template> <template #ciConfigLink="{ content }"> - <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer"> + <a :href="$options.yamlDocsLink" target="_blank" rel="noopener noreferrer"> {{ content }}</a > </template> </gl-sprintf> </p> - <a - href="https://docs.gitlab.com/ee/ci/environments/#stopping-an-environment" - target="_blank" - rel="noopener noreferrer" - >{{ s__('Environments|Learn more about stopping environments') }}</a - > + <a :href="$options.stoppingEnvironmentDocsLink" target="_blank" rel="noopener noreferrer">{{ + s__('Environments|Learn more about stopping environments') + }}</a> </div> </gl-modal> </template> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 2b178964c37..dc9481a5429 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -108,3 +108,19 @@ export const PHASE_RUNNING = 'Running'; export const PHASE_PENDING = 'Pending'; export const PHASE_SUCCEEDED = 'Succeeded'; export const PHASE_FAILED = 'Failed'; + +const ERROR_UNAUTHORIZED = 'unauthorized'; +const ERROR_FORBIDDEN = 'forbidden'; +const ERROR_NOT_FOUND = 'not found'; +const ERROR_OTHER = 'other'; + +export const CLUSTER_AGENT_ERROR_MESSAGES = { + [ERROR_UNAUTHORIZED]: s__( + 'Environment|Unauthorized to access the cluster agent from this environment. Check your authentication and try again.', + ), + [ERROR_FORBIDDEN]: s__( + 'Environment|Forbidden to access the cluster agent from this environment.', + ), + [ERROR_NOT_FOUND]: s__('Environment|Cluster agent not found.'), + [ERROR_OTHER]: s__('Environment|There was an error connecting to the cluster agent.'), +}; diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js index b26d96e15bd..3f22b83e618 100644 --- a/app/assets/javascripts/environments/edit.js +++ b/app/assets/javascripts/environments/edit.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import EditEnvironment from './components/edit_environment.vue'; import { apolloProvider } from './graphql/client'; @@ -12,10 +13,10 @@ export default (el) => { const { projectEnvironmentsPath, - updateEnvironmentPath, protectedEnvironmentSettingsPath, projectPath, - environment, + environmentName, + kasTunnelUrl, } = el.dataset; return new Vue({ @@ -23,16 +24,13 @@ export default (el) => { apolloProvider: apolloProvider(), provide: { projectEnvironmentsPath, - updateEnvironmentPath, protectedEnvironmentSettingsPath, projectPath, + environmentName, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), }, render(h) { - return h(EditEnvironment, { - props: { - environment: JSON.parse(environment), - }, - }); + return h(EditEnvironment); }, }); }; diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 6d06cff06b9..553b06e632f 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -8,6 +8,7 @@ import environmentToStopQuery from './queries/environment_to_stop.query.graphql' import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import k8sServicesQuery from './queries/k8s_services.query.graphql'; import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql'; +import k8sNamespacesQuery from './queries/k8s_namespaces.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -161,6 +162,14 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: k8sNamespacesQuery, + data: { + metadata: { + name: null, + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql new file mode 100644 index 00000000000..5e72c2dac20 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql @@ -0,0 +1,20 @@ +query getEnvironmentClusterAgentWithNamespace($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + kubernetesNamespace + clusterAgent { + id + name + webPath + tokens { + nodes { + id + lastUsedAt + } + } + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql new file mode 100644 index 00000000000..42796f982b6 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_with_namespace.graphql @@ -0,0 +1,15 @@ +query getEnvironmentWithNamespace($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + name + externalUrl + kubernetesNamespace + clusterAgent { + id + name + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql new file mode 100644 index 00000000000..c05d09b6ca2 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/k8s_namespaces.query.graphql @@ -0,0 +1,7 @@ +query getK8sNamespaces($configuration: LocalConfiguration) { + k8sNamespaces(configuration: $configuration) @client { + metadata { + name + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 044e7927606..8cfe44c5a05 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -6,6 +6,7 @@ import { parseIntPagination, normalizeHeaders, } from '~/lib/utils/common_utils'; +import { humanizeClusterErrors } from '../helpers/k8s_integration_helper'; import pollIntervalQuery from './queries/poll_interval.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; @@ -72,6 +73,11 @@ const mapWorkloadItems = (items, kind) => { }); }; +const handleClusterError = (err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; +}; + export const resolvers = (endpoint) => ({ Query: { environmentApp(_context, { page, scope, search }, { cache }) { @@ -124,8 +130,7 @@ export const resolvers = (endpoint) => ({ return podsApi .then((res) => res?.data?.items || []) .catch((err) => { - const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; - throw error; + handleClusterError(err); }); }, k8sServices(_, { configuration }) { @@ -148,8 +153,7 @@ export const resolvers = (endpoint) => ({ }); }) .catch((err) => { - const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; - throw error; + handleClusterError(err); }); }, k8sWorkloads(_, { configuration, namespace }) { @@ -206,6 +210,19 @@ export const resolvers = (endpoint) => ({ return summaryList; }); }, + k8sNamespaces(_, { configuration }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const namespacesApi = coreV1Api.listCoreV1Namespace(); + + return namespacesApi + .then((res) => { + return res?.data?.items || []; + }) + .catch((err) => { + const error = err?.response?.data?.reason || err; + throw new Error(humanizeClusterErrors(error)); + }); + }, }, Mutation: { stopEnvironmentREST(_, { environment }, { client }) { diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 7e46385946f..e2c22dda554 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -160,6 +160,12 @@ type LocalK8sWorkloads { JobList: [localK8sJob] CronJobList: [localK8sCronJob] } +type k8sNamespaceMetadata { + name: String +} +type LocalK8sNamespaces { + metadata: k8sNamespaceMetadata +} extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index 45c65c93a91..e49f1451759 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -1,4 +1,5 @@ import { differenceInSeconds } from '~/lib/utils/datetime_utility'; +import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants'; export function generateServicePortsString(ports) { if (!ports?.length) return ''; @@ -139,3 +140,9 @@ export function getCronJobsStatuses(items) { ...(ready.length && { ready }), }; } + +export function humanizeClusterErrors(reason) { + const errorReason = reason.toLowerCase(); + const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason]; + return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other; +} diff --git a/app/assets/javascripts/environments/new.js b/app/assets/javascripts/environments/new.js index 5dd112ac5e6..652085b1f28 100644 --- a/app/assets/javascripts/environments/new.js +++ b/app/assets/javascripts/environments/new.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import NewEnvironment from './components/new_environment.vue'; import { apolloProvider } from './graphql/client'; @@ -10,12 +11,16 @@ export default (el) => { return null; } - const { projectEnvironmentsPath, projectPath } = el.dataset; + const { projectEnvironmentsPath, projectPath, kasTunnelUrl } = el.dataset; return new Vue({ el, apolloProvider: apolloProvider(), - provide: { projectEnvironmentsPath, projectPath }, + provide: { + projectEnvironmentsPath, + projectPath, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), + }, render(h) { return h(NewEnvironment); }, diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 0151dbb0bf7..bd8a7257d0c 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -6,9 +6,7 @@ import { GlBadge, GlAlert, GlSprintf, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_WARNING } from '~/alert'; @@ -38,9 +36,7 @@ export default { GlBadge, GlAlert, GlSprintf, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, TimeAgoTooltip, ErrorDetailsInfo, TimelineChart, @@ -167,6 +163,52 @@ export default { showEmptyStacktraceAlert() { return !this.loadingStacktrace && !this.showStacktrace && this.isStacktraceEmptyAlertVisible; }, + updateDropdownItems() { + return [ + { + text: this.ignoreBtnLabel, + action: this.onIgnoreStatusUpdate, + extraAttrs: { + 'data-qa-selector': 'update_ignore_status_button', + }, + }, + { + text: this.resolveBtnLabel, + action: this.onResolveStatusUpdate, + extraAttrs: { + 'data-qa-selector': 'update_resolve_status_button', + }, + }, + ]; + }, + viewIssueDropdownItem() { + return { + text: __('View issue'), + href: this.error.gitlabIssuePath, + extraAttrs: { + 'data-qa-selector': 'view_issue_button', + }, + }; + }, + createIssueDropdownItem() { + return { + text: __('Create issue'), + action: this.createIssue, + extraAttrs: { + 'data-qa-selector': 'create_issue_button', + }, + }; + }, + dropdownItems() { + return [ + { items: this.updateDropdownItems }, + { + items: [ + this.error.gitlabIssuePath ? this.viewIssueDropdownItem : this.createIssueDropdownItem, + ], + }, + ]; + }, }, watch: { error(val) { @@ -331,37 +373,14 @@ export default { </gl-button> </form> </div> - <gl-dropdown - text="Options" - class="gl-w-full gl-md-display-none" - right + <gl-disclosure-dropdown + block + :toggle-text="__('Options')" + toggle-class="gl-md-display-none" + placement="right" :disabled="issueUpdateInProgress" - > - <gl-dropdown-item - data-qa-selector="update_ignore_status_button" - @click="onIgnoreStatusUpdate" - >{{ ignoreBtnLabel }}</gl-dropdown-item - > - <gl-dropdown-item - data-qa-selector="update_resolve_status_button" - @click="onResolveStatusUpdate" - >{{ resolveBtnLabel }}</gl-dropdown-item - > - <gl-dropdown-divider /> - <gl-dropdown-item - v-if="error.gitlabIssuePath" - data-qa-selector="view_issue_button" - :href="error.gitlabIssuePath" - >{{ __('View issue') }}</gl-dropdown-item - > - <gl-dropdown-item - v-if="!error.gitlabIssuePath" - :loading="issueCreationInProgress" - data-qa-selector="create_issue_button" - @click="createIssue" - >{{ __('Create issue') }}</gl-dropdown-item - > - </gl-dropdown> + :items="dropdownItems" + /> </div> </div> <div> diff --git a/app/assets/javascripts/error_tracking/components/timeline_chart.vue b/app/assets/javascripts/error_tracking/components/timeline_chart.vue index 51e0c900e4b..907a6c8557f 100644 --- a/app/assets/javascripts/error_tracking/components/timeline_chart.vue +++ b/app/assets/javascripts/error_tracking/components/timeline_chart.vue @@ -1,6 +1,6 @@ <script> import { GlChart } from '@gitlab/ui/dist/charts'; -import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables'; +import { DATA_VIZ_BLUE_500 } from '@gitlab/ui/dist/tokens/js/tokens'; import { hexToRgba } from '@gitlab/ui/dist/utils/utils'; import { isNumber } from 'lodash'; import { formatDate } from '~/lib/utils/datetime/date_format_utility'; @@ -109,7 +109,7 @@ export default { { data: yData, type: 'bar', - itemStyle: { color: hexToRgba(dataVizBlue500, 0.5) }, + itemStyle: { color: hexToRgba(DATA_VIZ_BLUE_500, 0.5) }, }, ], tooltip: { diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index 37a0c679287..257c482cf1d 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -125,13 +125,13 @@ export default { > <div class="table-section section-10" role="gridcell"> <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div> - <div class="table-mobile-content js-feature-flag-id"> + <div class="table-mobile-content gl-text-left js-feature-flag-id"> {{ featureFlagIidText(featureFlag) }} </div> </div> <div class="table-section section-10" role="gridcell"> <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div> - <div class="table-mobile-content"> + <div class="table-mobile-content gl-text-left"> <gl-toggle v-if="featureFlag.update_path" :value="featureFlag.active" @@ -156,9 +156,11 @@ export default { <div class="table-mobile-header" role="rowheader"> {{ s__('FeatureFlags|Feature flag') }} </div> - <div class="table-mobile-content d-flex flex-column js-feature-flag-title"> + <div + class="table-mobile-content gl-text-left gl-display-flex flex-column js-feature-flag-title gl-mr-5" + > <div class="gl-display-flex gl-align-items-center"> - <div class="feature-flag-name text-monospace text-truncate"> + <div class="feature-flag-name text-monospace text-wrap gl-word-break-word"> {{ featureFlag.name }} </div> <div class="feature-flag-description"> @@ -178,7 +180,7 @@ export default { {{ s__('FeatureFlags|Environment Specs') }} </div> <div - class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" + class="table-mobile-content gl-text-left d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" > <strategy-label v-for="strategy in featureFlag.strategies" diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index 65f54e6ed05..9882bef444a 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -53,7 +53,7 @@ export default { }); }, [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { - const rawItems = results.data ? results.data : results; // Api.groups returns array, Api.projects returns object + const rawItems = results.data; Object.assign(state, { items: rawItems.map((rawItem) => ({ id: rawItem.id, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b778e05c7b1..9e7006bb6e7 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -959,7 +959,7 @@ GfmAutoComplete.Emoji = { return `<li>${escapedFieldValue}</li>`; } - return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`; + return `<li>${GfmAutoComplete.glEmojiTag(item.emoji.name)} ${escapedFieldValue}</li>`; }, filter(query) { if (query.length === 0) { diff --git a/app/assets/javascripts/gitlab_version_check/constants.js b/app/assets/javascripts/gitlab_version_check/constants.js index 049397148ab..5cef29d73f3 100644 --- a/app/assets/javascripts/gitlab_version_check/constants.js +++ b/app/assets/javascripts/gitlab_version_check/constants.js @@ -1,4 +1,5 @@ import { helpPagePath } from '~/helpers/help_page_helper'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; export const STATUS_TYPES = { SUCCESS: 'success', @@ -8,7 +9,7 @@ export const STATUS_TYPES = { export const UPGRADE_DOCS_URL = helpPagePath('update/index'); -export const ABOUT_RELEASES_PAGE = 'https://about.gitlab.com/releases/categories/releases/'; +export const ABOUT_RELEASES_PAGE = `${PROMO_URL}/releases/categories/releases/`; export const ALERT_MODAL_ID = 'security-patch-upgrade-alert-modal'; diff --git a/app/assets/javascripts/google_cloud/service_accounts/list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue index c9d9a9a3e8c..4ac788aafbe 100644 --- a/app/assets/javascripts/google_cloud/service_accounts/list.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue @@ -1,6 +1,6 @@ <script> import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; -import { setUrlParams } from '~/lib/utils/url_utility'; +import { setUrlParams, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import { __ } from '~/locale'; const GOOGLE_CONSOLE_URL = 'https://console.cloud.google.com/iam-admin/serviceaccounts'; @@ -49,6 +49,7 @@ export default { }, }, GOOGLE_CONSOLE_URL, + secretsDocsLink: `${DOCS_URL_IN_EE_DIR}/ci/secrets/`, }; </script> @@ -86,7 +87,7 @@ export default { <gl-alert class="gl-mt-5" :dismissible="false" variant="tip"> <gl-sprintf :message="$options.i18n.secretManagersDescription"> <template #docLink="{ content }"> - <gl-link href="https://docs.gitlab.com/ee/ci/secrets/"> + <gl-link :href="$options.secretsDocsLink"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index 0a1a7a74d21..a9ae9a5af82 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -129,6 +129,9 @@ export const trackSaasTrialGroup = () => { } const form = document.querySelector('.js-saas-trial-group'); + + if (!form) return; + form.addEventListener('submit', () => { pushEvent('saasTrialGroup'); }); diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index ae7676a3e9e..08733bbe620 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -2,10 +2,11 @@ import produce from 'immer'; import VueApollo from 'vue-apollo'; import { defaultDataIdFromObject } from '@apollo/client/core'; import { concatPagination } from '@apollo/client/utilities'; +import errorQuery from '~/boards/graphql/client/error.query.graphql'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; -import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { WIDGET_TYPE_NOTES, WIDGET_TYPE_AWARD_EMOJI } from '~/work_items/constants'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; export const config = { @@ -35,6 +36,15 @@ export const config = { }, }, }, + WorkItemWidgetAwardEmoji: { + fields: { + // If we add any key args, the awardEmoji field becomes awardEmoji({"first":10}) and + // kills any possibility to handle it on the widget level without hardcoding a string. + awardEmoji: { + keyArgs: false, + }, + }, + }, WorkItemWidgetProgress: { fields: { progress: { @@ -67,10 +77,30 @@ export const config = { const incomingWidget = incoming.find( (w) => w.type && w.type === existingWidget.type, ); - // We don't want to override existing notes with empty widget on work item updates - if (incomingWidget?.type === WIDGET_TYPE_NOTES && !context.variables.pageSize) { + // We don't want to override existing notes or award emojis with empty widget on work item updates + if ( + (incomingWidget?.type === WIDGET_TYPE_NOTES || + incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI) && + !context.variables.pageSize + ) { return existingWidget; } + + // we want to concat next page of awardEmoji to the existing ones + if (incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI && context.variables.after) { + // concatPagination won't work because we were placing new widget here so we have to do this manually + return { + ...incomingWidget, + awardEmoji: { + ...incomingWidget.awardEmoji, + nodes: [ + ...existingWidget.awardEmoji.nodes, + ...incomingWidget.awardEmoji.nodes, + ], + }, + }; + } + // we want to concat next page of discussions to the existing ones if (incomingWidget?.type === WIDGET_TYPE_NOTES && context.variables.after) { // concatPagination won't work because we were placing new widget here so we have to do this manually @@ -195,6 +225,13 @@ export const resolvers = { }); return boardItem; }, + setError(_, { error }, { cache }) { + cache.writeQuery({ + query: errorQuery, + data: { boardsAppError: error }, + }); + return error; + }, clientToggleListCollapsed(_, { list = {}, collapsed = false }) { return { list: { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index ebfffdaaf50..c6fe16b13b5 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -114,9 +114,9 @@ export default { showModal() { this.isModalVisible = true; }, - fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) { + fetchGroups({ parentId, page, filterGroupsBy, sortBy, updatePagination }) { return this.service - .getGroups(parentId, page, filterGroupsBy, sortBy, archived) + .getGroups(parentId, page, filterGroupsBy, sortBy) .then((res) => { if (updatePagination) { this.updatePagination(res.headers); @@ -133,7 +133,6 @@ export default { fetchAllGroups() { const page = getParameterByName('page') || null; const sortBy = getParameterByName('sort') || null; - const archived = getParameterByName('archived') || null; this.isLoading = true; @@ -141,7 +140,6 @@ export default { page, filterGroupsBy: this.filterGroupsBy, sortBy, - archived, updatePagination: true, }).then((res) => { this.isLoading = false; @@ -160,14 +158,13 @@ export default { this.updateGroups(res, Boolean(filterGroupsBy)); }); }, - fetchPage({ page, filterGroupsBy, sortBy, archived }) { + fetchPage({ page, filterGroupsBy, sortBy }) { this.isLoading = true; return this.fetchGroups({ page, filterGroupsBy, sortBy, - archived, updatePagination: true, }).then((res) => { this.isLoading = false; diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 5674e28f5da..d87190edfd2 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -36,6 +36,9 @@ export default { <template> <div class="stats gl-text-gray-500"> + <div v-if="isProjectPendingRemoval"> + <gl-badge class="gl-mr-2" variant="warning">{{ __('pending deletion') }}</gl-badge> + </div> <item-stats-value v-if="displayValue(item.subgroupCount)" :title="__('Subgroups')" @@ -65,9 +68,6 @@ export default { css-class="project-stars" icon-name="star" /> - <div v-if="isProjectPendingRemoval"> - <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> - </div> <div v-if="isProject" class="last-updated"> <time-ago-tooltip :time="item.lastActivityAt" tooltip-placement="bottom" /> </div> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 982dab45117..90a0582cc9f 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -3,13 +3,17 @@ import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@git import { isString, debounce } from 'lodash'; import { __ } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { markRaw } from '~/lib/utils/vue3compat/mark_raw'; import GroupsStore from '../store/groups_store'; import GroupsService from '../service/groups_service'; +import ArchivedProjectsService from '../service/archived_projects_service'; import { ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED, + SORTING_ITEM_NAME, OVERVIEW_TABS_SORTING_ITEMS, + OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS, } from '../constants'; import eventHub from '../event_hub'; import GroupsApp from './app.vue'; @@ -17,7 +21,6 @@ import SubgroupsAndProjectsEmptyState from './empty_states/subgroups_and_project import SharedProjectsEmptyState from './empty_states/shared_projects_empty_state.vue'; import ArchivedProjectsEmptyState from './empty_states/archived_projects_empty_state.vue'; -const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS; const MIN_SEARCH_LENGTH = 3; export default { @@ -32,32 +35,38 @@ export default { SharedProjectsEmptyState, ArchivedProjectsEmptyState, }, - inject: ['endpoints', 'initialSort'], + inject: ['endpoints', 'initialSort', 'groupId'], data() { const tabs = [ { title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - emptyStateComponent: SubgroupsAndProjectsEmptyState, + emptyStateComponent: markRaw(SubgroupsAndProjectsEmptyState), lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + service: new GroupsService( + this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], + this.initialSort, + ), store: new GroupsStore({ showSchemaMarkup: true }), + sortingItems: OVERVIEW_TABS_SORTING_ITEMS, }, { title: this.$options.i18n[ACTIVE_TAB_SHARED], key: ACTIVE_TAB_SHARED, - emptyStateComponent: SharedProjectsEmptyState, + emptyStateComponent: markRaw(SharedProjectsEmptyState), lazy: this.$route.name !== ACTIVE_TAB_SHARED, - service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), + service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED], this.initialSort), store: new GroupsStore(), + sortingItems: OVERVIEW_TABS_SORTING_ITEMS, }, { title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], key: ACTIVE_TAB_ARCHIVED, - emptyStateComponent: ArchivedProjectsEmptyState, + emptyStateComponent: markRaw(ArchivedProjectsEmptyState), lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED, - service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), + service: new ArchivedProjectsService(this.groupId, this.initialSort), store: new GroupsStore(), + sortingItems: OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS, }, ]; return { @@ -79,15 +88,30 @@ export default { mounted() { this.search = this.$route.query?.filter || ''; - const sortQueryStringValue = this.$route.query?.sort || this.initialSort; - const sort = - OVERVIEW_TABS_SORTING_ITEMS.find((sortOption) => - [sortOption.asc, sortOption.desc].includes(sortQueryStringValue), - ) || SORTING_ITEM_NAME; + const { sort, isAscending } = this.getActiveSort(); + this.sort = sort; - this.isAscending = sort.asc === sortQueryStringValue; + this.isAscending = isAscending; }, methods: { + getActiveSort() { + const sortQueryStringValue = this.$route.query?.sort || this.initialSort; + const sort = this.activeTab.sortingItems.find((sortOption) => + [sortOption.asc, sortOption.desc].includes(sortQueryStringValue), + ); + + if (!sort) { + return { + sort: SORTING_ITEM_NAME, + isAscending: true, + }; + } + + return { + sort, + isAscending: sort.asc === sortQueryStringValue, + }; + }, handleTabInput(tabIndex) { if (tabIndex === this.activeTabIndex) { return; @@ -105,7 +129,23 @@ export default { ? this.$route.params.group.split('/') : this.$route.params.group; - this.$router.push({ name: tab.key, params: { group: groupParam }, query: this.$route.query }); + const { sort, isAscending } = this.getActiveSort(); + + this.sort = sort; + this.isAscending = isAscending; + + const sortQuery = isAscending ? sort.asc : sort.desc; + + const query = { + ...this.$route.query, + ...(this.$route.query?.sort && { sort: sortQuery }), + }; + + this.$router.push({ + name: tab.key, + params: { group: groupParam }, + query, + }); }, handleSearchOrSortChange() { // Update query string @@ -164,7 +204,6 @@ export default { [ACTIVE_TAB_ARCHIVED]: __('Archived projects'), searchPlaceholder: __('Search'), }, - OVERVIEW_TABS_SORTING_ITEMS, }; </script> @@ -203,7 +242,7 @@ export default { @sortDirectionChange="handleSortDirectionChange" > <gl-sorting-item - v-for="sortingItem in $options.OVERVIEW_TABS_SORTING_ITEMS" + v-for="sortingItem in activeTab.sortingItems" :key="sortingItem.label" :active="sortingItem === sort" @click="handleSortingItemClick(sortingItem)" diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index a5854632040..574ec8e4e49 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -25,25 +25,39 @@ export const ITEM_TYPE = { GROUP: 'group', }; +export const SORTING_ITEM_NAME = { + label: __('Name'), + asc: 'name_asc', + desc: 'name_desc', +}; + +export const SORTING_ITEM_CREATED = { + label: __('Created'), + asc: 'created_asc', + desc: 'created_desc', +}; + +export const SORTING_ITEM_UPDATED = { + label: __('Updated'), + asc: 'latest_activity_asc', + desc: 'latest_activity_desc', +}; + +export const SORTING_ITEM_STARS = { + label: __('Stars'), + asc: 'stars_asc', + desc: 'stars_desc', +}; + export const OVERVIEW_TABS_SORTING_ITEMS = [ - { - label: __('Name'), - asc: 'name_asc', - desc: 'name_desc', - }, - { - label: __('Created'), - asc: 'created_asc', - desc: 'created_desc', - }, - { - label: __('Updated'), - asc: 'latest_activity_asc', - desc: 'latest_activity_desc', - }, - { - label: __('Stars'), - asc: 'stars_asc', - desc: 'stars_desc', - }, + SORTING_ITEM_NAME, + SORTING_ITEM_CREATED, + SORTING_ITEM_UPDATED, + SORTING_ITEM_STARS, +]; + +export const OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS = [ + SORTING_ITEM_NAME, + SORTING_ITEM_CREATED, + SORTING_ITEM_UPDATED, ]; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index ced5d76d8b9..b831ae7b9d6 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -40,6 +40,7 @@ export const initGroupOverviewTabs = () => { const router = createRouter(); const { + groupId, newSubgroupPath, newProjectPath, newSubgroupIllustration, @@ -59,6 +60,7 @@ export const initGroupOverviewTabs = () => { el, router, provide: { + groupId, newSubgroupPath, newProjectPath, newSubgroupIllustration, diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js new file mode 100644 index 00000000000..5ffa3f91b06 --- /dev/null +++ b/app/assets/javascripts/groups/service/archived_projects_service.js @@ -0,0 +1,56 @@ +import Api from '~/api'; + +export default class ArchivedProjectsService { + constructor(groupId, initialSort) { + this.groupId = groupId; + this.initialSort = initialSort; + } + + async getGroups(parentId, page, query, sortParam) { + const supportedOrderBy = { + name: 'name', + created: 'created_at', + latest_activity: 'last_activity_at', + }; + + const [, orderBy, sort] = (sortParam || this.initialSort)?.match(/(\w+)_(asc|desc)/) || []; + + const { data: projects, headers } = await Api.groupProjects(this.groupId, query, { + archived: true, + page, + order_by: supportedOrderBy[orderBy], + sort, + }); + + return { + data: projects.map((project) => { + return { + id: project.id, + name: project.name, + full_name: project.name_with_namespace, + markdown_description: project.description_html, + visibility: project.visibility, + avatar_url: project.avatar_url, + relative_path: `/${project.path_with_namespace}`, + edit_path: null, + leave_path: null, + can_edit: false, + can_leave: false, + can_remove: false, + type: 'project', + permission: null, + children: [], + parent_id: project.namespace.id, + project_count: 0, + subgroup_count: 0, + number_users_with_delimiter: 0, + star_count: project.star_count, + updated_at: project.updated_at, + marked_for_deletion: project.marked_for_deletion_at !== null, + last_activity_at: project.last_activity_at, + }; + }), + headers, + }; + } +} diff --git a/app/assets/javascripts/groups/service/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js index 790b581a7c0..28d203bc9c6 100644 --- a/app/assets/javascripts/groups/service/groups_service.js +++ b/app/assets/javascripts/groups/service/groups_service.js @@ -1,11 +1,12 @@ import axios from '~/lib/utils/axios_utils'; export default class GroupsService { - constructor(endpoint) { + constructor(endpoint, initialSort) { this.endpoint = endpoint; + this.initialSort = initialSort; } - getGroups(parentId, page, filterGroups, sort, archived) { + getGroups(parentId, page, filterGroups, sort) { const params = {}; if (parentId) { @@ -20,12 +21,8 @@ export default class GroupsService { params.filter = filterGroups; } - if (sort) { - params.sort = sort; - } - - if (archived) { - params.archived = archived; + if (sort || this.initialSort) { + params.sort = sort || this.initialSort; } } diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js index 24419280fc0..4da38e0e641 100644 --- a/app/assets/javascripts/groups/settings/init_access_dropdown.js +++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js @@ -4,7 +4,7 @@ import AccessDropdown from './components/access_dropdown.vue'; export const initAccessDropdown = (el) => { if (!el) { - return false; + return null; } const { label, disabled, preselectedItems } = el.dataset; diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 8c7612f37ff..3cb0963e561 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -225,7 +225,7 @@ export default { v-model="searchText" role="searchbox" class="gl-z-index-1" - data-qa-selector="global_search_input" + data-testid="global_search_input" autocomplete="off" :placeholder="$options.i18n.SEARCH_GITLAB" :aria-activedescendant="currentFocusedId" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 8962bb76926..edc6cc3dcdc 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -105,6 +105,7 @@ export default { :title="lastCommit.message" :href="getCommitPath(lastCommit.short_id)" class="commit-sha" + data-testid="commit-sha-content" data-qa-selector="commit_sha_content" >{{ lastCommit.short_id }}</a > 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 fe50cb77eb8..cd07e9fbdd9 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 @@ -664,6 +664,7 @@ export default { <gl-search-box-by-click class="gl-ml-auto" + data-testid="filter-groups" :placeholder="s__('BulkImport|Filter by source group')" @submit="filter = $event" @clear="filter = ''" diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 0e9781d77fe..1369deae3f9 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -1,29 +1,25 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce } from 'lodash'; import { s__ } from '~/locale'; import { getGroups, getDescendentGroups } from '~/rest_api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { SEARCH_DELAY, GROUP_FILTERS } from '../constants'; export default { name: 'GroupSelect', components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, model: { prop: 'selectedGroup', }, props: { + selectedGroup: { + type: Object, + required: true, + }, groupsFilter: { type: String, required: false, @@ -43,40 +39,43 @@ export default { return { isFetching: false, groups: [], - selectedGroup: {}, searchTerm: '', + pagination: {}, + infiniteScrollLoading: false, }; }, computed: { - selectedGroupName() { + toggleText() { return this.selectedGroup.name || this.$options.i18n.dropdownText; }, isFetchResultEmpty() { return this.groups.length === 0; }, - }, - watch: { - searchTerm() { - this.retrieveGroups(); + infiniteScroll() { + return Boolean(this.pagination.nextPage); }, }, mounted() { this.retrieveGroups(); }, methods: { - retrieveGroups: debounce(function debouncedRetrieveGroups() { + retrieveGroups: debounce(async function debouncedRetrieveGroups() { this.isFetching = true; - return this.fetchGroups() - .then((response) => { - this.groups = this.processGroups(response); - this.isFetching = false; - }) - .catch(() => { - this.isFetching = false; - }); + + try { + const response = await this.fetchGroups(); + this.pagination = this.processPagination(response); + this.groups = this.processGroups(response); + } catch { + this.onApiError(); + } finally { + this.isFetching = false; + } }, SEARCH_DELAY), - processGroups(response) { - const rawGroups = response.map((group) => ({ + processGroups({ data }) { + const rawGroups = data.map((group) => ({ + // `value` is needed for `GlCollapsibleListbox` + value: group.id, id: group.id, name: group.full_name, path: group.path, @@ -85,31 +84,56 @@ export default { return this.filterOutInvalidGroups(rawGroups); }, + processPagination({ headers }) { + return parseIntPagination(normalizeHeaders(headers)); + }, filterOutInvalidGroups(groups) { return groups.filter((group) => this.invalidGroups.indexOf(group.id) === -1); }, - selectGroup(group) { - this.selectedGroup = group; - - this.$emit('input', this.selectedGroup); + onSelect(id) { + this.$emit('input', this.groups.find((group) => group.value === id) || {}); }, - fetchGroups() { + onSearch(searchTerm) { + this.searchTerm = searchTerm; + this.retrieveGroups(); + }, + fetchGroups(options = {}) { + const combinedOptions = { + ...this.$options.defaultFetchOptions, + ...options, + }; + switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: - return getDescendentGroups( - this.parentGroupId, - this.searchTerm, - this.$options.defaultFetchOptions, - ); + return getDescendentGroups(this.parentGroupId, this.searchTerm, combinedOptions); default: - return getGroups(this.searchTerm, this.$options.defaultFetchOptions); + return getGroups(this.searchTerm, combinedOptions); } }, + async onBottomReached() { + this.infiniteScrollLoading = true; + + try { + const response = await this.fetchGroups({ page: this.pagination.page + 1 }); + this.pagination = this.processPagination(response); + this.groups.push(...this.processGroups(response)); + } catch { + this.onApiError(); + } finally { + this.infiniteScrollLoading = false; + } + }, + onApiError() { + this.$emit('error', this.$options.i18n.errorMessage); + }, }, i18n: { dropdownText: s__('GroupSelect|Select a group'), searchPlaceholder: s__('GroupSelect|Search groups'), emptySearchResult: s__('GroupSelect|No matching results'), + errorMessage: s__( + 'GroupSelect|An error occurred fetching the groups. Please refresh the page to try again.', + ), }, defaultFetchOptions: { exclude_internal: true, @@ -120,37 +144,34 @@ export default { </script> <template> <div> - <gl-dropdown + <gl-collapsible-listbox data-testid="group-select-dropdown" - :text="selectedGroupName" + :selected="selectedGroup.value" + :items="groups" + :toggle-text="toggleText" + searchable + :search-placeholder="$options.i18n.searchPlaceholder" block - toggle-class="gl-mb-2" - menu-class="gl-w-full!" + fluid-width + is-check-centered + :searching="isFetching" + :no-results-text="$options.i18n.emptySearchResult" + :infinite-scroll="infiniteScroll" + :infinite-scroll-loading="infiniteScrollLoading" + :total-items="pagination.total" + @bottom-reached="onBottomReached" + @select="onSelect" + @search="onSearch" > - <gl-search-box-by-type - v-model="searchTerm" - :is-loading="isFetching" - :placeholder="$options.i18n.searchPlaceholder" - data-qa-selector="group_select_dropdown_search_field" - /> - <gl-dropdown-item - v-for="group in groups" - :key="group.id" - :name="group.name" - data-qa-selector="group_select_dropdown_item" - @click="selectGroup(group)" - > + <template #list-item="{ item }"> <gl-avatar-labeled - :label="group.name" - :src="group.avatarUrl" - :entity-id="group.id" - :entity-name="group.name" + :label="item.name" + :src="item.avatarUrl" + :entity-id="item.value" + :entity-name="item.name" :size="32" /> - </gl-dropdown-item> - <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index cc95027f0db..66d4a9ccc07 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -209,7 +209,6 @@ export default { :invalid-feedback="invalidFeedbackMessage" :state="validationState" data-testid="form-group" - label-cols="auto" label-class="gl-pt-3!" :label="$options.i18n.projectLabel" :label-for="$options.projectSelectLabelId" diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 51355baef99..91dbd86418c 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -1,4 +1,6 @@ <script> +import * as Sentry from '@sentry/browser'; +import { GlAlert } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import Api from '~/api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; @@ -19,6 +21,7 @@ export default { GroupSelect, InviteModalBase, InviteGroupNotification, + GlAlert, }, props: { id: { @@ -83,6 +86,7 @@ export default { isLoading: false, modalId: uniqueId('invite-groups-modal-'), groupToBeSharedWith: {}, + groupSelectError: '', }; }, computed: { @@ -165,6 +169,10 @@ export default { clearValidation() { this.invalidFeedbackMessage = ''; }, + onGroupSelectError(error) { + this.groupSelectError = error; + Sentry.captureException(error); + }, }, labels: GROUP_MODAL_LABELS, }; @@ -197,6 +205,9 @@ export default { :notification-link="$options.labels[inviteTo].notificationLink" class="gl-mb-5" /> + <gl-alert v-if="groupSelectError" class="gl-mb-5" variant="danger" :dismissible="false">{{ + groupSelectError + }}</gl-alert> </template> <template #select> @@ -206,6 +217,7 @@ export default { :parent-group-id="groupSelectParentId" :invalid-groups="invalidGroups" @input="clearValidation" + @error="onGroupSelectError" /> </template> </invite-modal-base> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index e0bfa1111e8..8493787f075 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -59,6 +59,7 @@ export default { return { loading: false, query: '', + originalInput: '', users: [], selectedTokens: [], hasBeenFocused: false, @@ -67,9 +68,9 @@ export default { }, computed: { emailIsValid() { - const regex = /.+@/; + const regex = /^\S+@\S+$/; - return this.query.match(regex) !== null; + return this.originalInput.match(regex) !== null; }, placeholderText() { if (this.selectedTokens.length === 0) { @@ -116,6 +117,7 @@ export default { methods: { handleTextInput(inputQuery) { this.hideDropdownWithNoItems = false; + this.originalInput = inputQuery; this.query = inputQuery.trim(); this.loading = true; this.retrieveUsers(); diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index 799c0a18444..0d7d0f020dd 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -4,7 +4,13 @@ import Vue from 'vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { fetchPolicies } from '~/lib/graphql'; import { __ } from '~/locale'; -import { STATUS_CLOSED, STATUS_OPEN, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; +import { + STATUS_CLOSED, + STATUS_OPEN, + TYPE_ISSUE, + TYPE_MERGE_REQUEST, + TYPE_EPIC, +} from '~/issues/constants'; export const badgeState = Vue.observable({ state: '', @@ -18,17 +24,22 @@ const CLASSES = { merged: 'issuable-status-badge-merged', }; -const ISSUE_ICONS = { - opened: 'issues', - locked: 'issues', - closed: 'issue-closed', -}; - -const MERGE_REQUEST_ICONS = { - opened: 'merge-request-open', - locked: 'merge-request-open', - closed: 'merge-request-close', - merged: 'merge', +const ICONS = { + [TYPE_EPIC]: { + opened: 'epic', + closed: 'epic-closed', + }, + [TYPE_ISSUE]: { + opened: 'issues', + locked: 'issues', + closed: 'issue-closed', + }, + [TYPE_MERGE_REQUEST]: { + opened: 'merge-request-open', + locked: 'merge-request-open', + closed: 'merge-request-close', + merged: 'merge', + }, }; const STATUS = { @@ -91,10 +102,8 @@ export default { return STATUS[this.state]; }, badgeIcon() { - if (this.issuableType === TYPE_ISSUE) { - return ISSUE_ICONS[this.state]; - } - return MERGE_REQUEST_ICONS[this.state]; + const type = this.issuableType || TYPE_MERGE_REQUEST; + return ICONS[type][this.state]; }, }, created() { @@ -126,7 +135,7 @@ export default { <template> <gl-badge - class="issuable-status-badge gl-mr-3" + class="issuable-status-badge gl-mr-3 gl-align-self-center" :class="badgeClass" :variant="badgeVariant" :aria-label="badgeText" diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index a1525ad2bec..1c1acddb90b 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -7,6 +7,9 @@ import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import ZenMode from '~/zen_mode'; import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import { EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants'; +import { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; @@ -47,6 +50,13 @@ function getFallbackKey() { return ['autosave', document.location.pathname, searchTerm].join('/'); } +function getIssuableType() { + if (document.location.pathname.includes('merge_requests')) return MERGE_REQUEST_NOTEABLE_TYPE; + if (document.location.pathname.includes('issues')) return ISSUE_NOTEABLE_TYPE; + // eslint-disable-next-line @gitlab/require-i18n-strings + return 'Other'; +} + export default class IssuableForm { static addAutosave(map, id, element, searchTerm, fallbackKey) { if (!element) return; @@ -144,6 +154,11 @@ export default class IssuableForm { async handleSubmit(event) { event.preventDefault(); + trackSavedUsingEditor( + localStorage.getItem('gl-markdown-editor-mode') === EDITING_MODE_CONTENT_EDITOR, + getIssuableType(), + ); + const form = event.target; const descriptionText = this.descriptionField().val(); diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js index b4e277a0b31..fc6d850c341 100644 --- a/app/assets/javascripts/issuable/issuable_label_selector.js +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -45,6 +45,7 @@ export default () => { labelsManagePath, variant: VARIANT_EMBEDDED, workspaceType: WORKSPACE_PROJECT, + toggleAttrs: { 'data-testid': 'issuable_label_dropdown' }, }, render(createElement) { return createElement(IssuableLabelSelector); diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue index 55fb3958e82..044a1bba7ad 100644 --- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue @@ -28,7 +28,7 @@ export default { type: HTMLAnchorElement, required: true, }, - projectPath: { + namespacePath: { type: String, required: true, }, @@ -65,10 +65,10 @@ export default { query, update: (data) => data.project.issue, variables() { - const { projectPath, iid } = this; + const { namespacePath, iid } = this; return { - projectPath, + projectPath: namespacePath, iid, }; }, @@ -100,7 +100,7 @@ export default { <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <div> <work-item-type-icon v-if="!$apollo.queries.issue.loading" :work-item-type="issue.type" /> - <span class="gl-text-secondary">{{ `${projectPath}#${iid}` }}</span> + <span class="gl-text-secondary">{{ `${namespacePath}#${iid}` }}</span> </div> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> diff --git a/app/assets/javascripts/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue index af93430963e..e2c2181684f 100644 --- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue @@ -19,7 +19,7 @@ export default { type: HTMLAnchorElement, required: true, }, - projectPath: { + namespacePath: { type: String, required: true, }, @@ -76,10 +76,10 @@ export default { query, update: (data) => data.project.mergeRequest, variables() { - const { projectPath, iid } = this; + const { namespacePath, iid } = this; return { - projectPath, + projectPath: namespacePath, iid, }; }, @@ -108,7 +108,7 @@ export default { <h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <div class="gl-text-secondary"> - {{ `${projectPath}!${iid}` }} + {{ `${namespacePath}!${iid}` }} </div> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> </div> diff --git a/app/assets/javascripts/issuable/popover/index.js b/app/assets/javascripts/issuable/popover/index.js index 9430419685b..58f015fe40e 100644 --- a/app/assets/javascripts/issuable/popover/index.js +++ b/app/assets/javascripts/issuable/popover/index.js @@ -4,7 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import IssuePopover from './components/issue_popover.vue'; import MRPopover from './components/mr_popover.vue'; -const componentsByReferenceType = { +export const componentsByReferenceTypeMap = { issue: IssuePopover, work_item: IssuePopover, merge_request: MRPopover, @@ -26,9 +26,10 @@ const popoverMountedAttr = 'data-popover-mounted'; * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes. * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover */ -const handleIssuablePopoverMount = ({ +export const handleIssuablePopoverMount = ({ + componentsByReferenceType = componentsByReferenceTypeMap, apolloProvider, - projectPath, + namespacePath, title, iid, referenceType, @@ -42,7 +43,7 @@ const handleIssuablePopoverMount = ({ new PopoverComponent({ propsData: { target, - projectPath, + namespacePath, iid, cachedTitle: title, }, @@ -53,7 +54,7 @@ const handleIssuablePopoverMount = ({ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call }; -export default (elements) => { +export default (elements, issuablePopoverMount = handleIssuablePopoverMount) => { if (elements.length > 0) { Vue.use(VueApollo); @@ -63,15 +64,16 @@ export default (elements) => { const listenerAddedAttr = 'data-popover-listener-added'; elements.forEach((el) => { - const { projectPath, iid, referenceType } = el.dataset; + const { projectPath, groupPath, iid, referenceType } = el.dataset; const title = el.dataset.mrTitle || el.title; + const namespacePath = groupPath || projectPath; - if (!el.getAttribute(listenerAddedAttr) && projectPath && title && iid && referenceType) { + if (!el.getAttribute(listenerAddedAttr) && namespacePath && title && iid && referenceType) { el.addEventListener('mouseenter', ({ target }) => { if (!el.getAttribute(popoverMountedAttr)) { - handleIssuablePopoverMount({ + issuablePopoverMount({ apolloProvider, - projectPath, + namespacePath, title, iid, referenceType, diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 444ee704521..0a1a1324d7d 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -31,4 +31,7 @@ export const issuableStatusText = { export const IssuableTypeText = { [TYPE_ISSUE]: __('issue'), [TYPE_MERGE_REQUEST]: __('merge request'), + [TYPE_ALERT]: __('alert'), + [TYPE_INCIDENT]: __('incident'), + [TYPE_TEST_CASE]: __('test case'), }; 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 14fe88b8f61..eb73f8e0182 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -81,6 +81,7 @@ export default { }, inject: [ 'autocompleteAwardEmojisPath', + 'autocompleteUsersPath', 'calendarPath', 'dashboardLabelsPath', 'dashboardMilestonesPath', @@ -233,6 +234,7 @@ export default { title: TOKEN_TITLE_ASSIGNEE, icon: 'user', token: UserToken, + dataType: 'user', operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, preloadedUsers, @@ -243,6 +245,7 @@ export default { title: TOKEN_TITLE_AUTHOR, icon: 'pencil', token: UserToken, + dataType: 'user', operators: OPERATORS_IS_NOT_OR, fetchUsers: this.fetchUsers, defaultUsers: [], @@ -382,7 +385,9 @@ export default { }); }, fetchUsers(search) { - return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); + return axios.get(this.autocompleteUsersPath, { + params: { active: true, search }, + }); }, getStatus(issue) { if (issue.state === STATUS_CLOSED && issue.moved) { diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index 999f07781b2..74633b251b2 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -15,6 +15,7 @@ export async function mountIssuesDashboardApp() { const { autocompleteAwardEmojisPath, + autocompleteUsersPath, calendarPath, dashboardLabelsPath, dashboardMilestonesPath, @@ -38,6 +39,7 @@ export async function mountIssuesDashboardApp() { }), provide: { autocompleteAwardEmojisPath, + autocompleteUsersPath, calendarPath, dashboardLabelsPath, dashboardMilestonesPath, 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 3f29fc66abb..9f7fca0ceca 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 @@ -28,6 +28,7 @@ export default { 'newProjectPath', 'showNewIssueLink', 'signInPath', + 'groupId', ], props: { currentTabCount: { @@ -95,6 +96,7 @@ export default { :query="$options.searchProjectsQuery" :query-variables="newIssueDropdownQueryVariables" :extract-projects="extractProjects" + :group-id="groupId" /> </template> </gl-empty-state> 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 83b0bcebe67..f7693dd7102 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -166,6 +166,7 @@ export default { 'releasesPath', 'rssPath', 'showNewIssueLink', + 'groupId', ], props: { eeSearchTokens: { @@ -365,6 +366,7 @@ export default { title: TOKEN_TITLE_AUTHOR, icon: 'pencil', token: UserToken, + dataType: 'user', defaultUsers: [], operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, fetchUsers: this.fetchUsers, @@ -376,6 +378,7 @@ export default { title: TOKEN_TITLE_ASSIGNEE, icon: 'user', token: UserToken, + dataType: 'user', operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, @@ -893,6 +896,7 @@ export default { :query="$options.searchProjectsQuery" :query-variables="newIssueDropdownQueryVariables" :extract-projects="extractProjects" + :group-id="groupId" /> <gl-disclosure-dropdown v-gl-tooltip.hover="$options.i18n.actionsLabel" diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index a97b59c1e4f..d1b45294026 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -94,6 +94,7 @@ export async function mountIssuesListApp() { rssPath, showNewIssueLink, signInPath, + groupId = '', } = el.dataset; return new Vue({ @@ -153,6 +154,7 @@ export async function mountIssuesListApp() { markdownHelpPath, quickActionsHelpPath, resetPath, + groupId, }, render: (createComponent) => createComponent(IssuesListApp), }); diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index 8490ffd33cd..cbec10b4ebe 100644 --- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -65,61 +65,65 @@ export default { <template> <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> - <div class="card card-slim gl-mt-5 gl-mb-0 gl-bg-gray-10"> - <div class="card-header gl-px-5 gl-py-4 gl-bg-white"> - <div - class="card-title gl-relative gl-display-flex gl-flex-wrap gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" - > + <div class="gl-new-card"> + <div class="gl-new-card-header gl-flex-direction-column"> + <div class="gl-new-card-title-wrapper"> <gl-link class="anchor gl-absolute gl-text-decoration-none" href="#related-merge-requests" aria-labelledby="related-merge-requests" /> - <h3 id="related-merge-requests" class="gl-font-base gl-m-0"> + <h3 id="related-merge-requests" class="gl-new-card-title"> {{ __('Related merge requests') }} </h3> - <template v-if="totalCount"> - <gl-icon name="merge-request" class="gl-ml-3 gl-mr-2 gl-text-gray-500" /> - <span data-testid="count" class="gl-text-gray-500">{{ totalCount }}</span> - </template> - <p - v-if="hasClosingMergeRequest && !isFetchingMergeRequests" - class="gl-font-sm gl-font-weight-normal gl-flex-basis-full gl-mb-0 gl-text-gray-500" - > - {{ closingMergeRequestsText }} - </p> + <div class="gl-new-card-count"> + <template v-if="totalCount"> + <gl-icon name="merge-request" class="gl-mr-2" /> + <span data-testid="count">{{ totalCount }}</span> + </template> + </div> </div> - </div> - <gl-loading-icon - v-if="isFetchingMergeRequests" - size="sm" - label="Fetching related merge requests" - class="gl-py-4" - /> - <ul v-else class="content-list related-items-list gl-px-4! gl-py-3!"> - <li - v-for="mr in mergeRequests" - :key="mr.id" - class="list-item gl-m-0! gl-p-0! gl-border-b-0!" + <p + v-if="hasClosingMergeRequest && !isFetchingMergeRequests" + class="gl-new-card-description" > - <related-issuable-item - :id-key="mr.id" - :display-reference="mr.reference" - :title="mr.title" - :milestone="mr.milestone" - :assignees="getAssignees(mr)" - :created-at="mr.created_at" - :closed-at="mr.closed_at" - :merged-at="mr.merged_at" - :path="mr.web_url" - :state="mr.state" - :is-merge-request="true" - :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" - path-id-separator="!" - class="gl-mx-n2" + {{ closingMergeRequestsText }} + </p> + </div> + <div class="gl-new-card-body"> + <div class="gl-new-card-content"> + <gl-loading-icon + v-if="isFetchingMergeRequests" + size="sm" + label="Fetching related merge requests" + class="gl-py-2" /> - </li> - </ul> + <ul class="content-list related-items-list"> + <li + v-for="mr in mergeRequests" + :key="mr.id" + class="list-item gl-m-0! gl-p-0! gl-border-b-0!" + > + <related-issuable-item + :id-key="mr.id" + :display-reference="mr.reference" + :title="mr.title" + :milestone="mr.milestone" + :assignees="getAssignees(mr)" + :created-at="mr.created_at" + :closed-at="mr.closed_at" + :merged-at="mr.merged_at" + :path="mr.web_url" + :state="mr.state" + :is-merge-request="true" + :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" + path-id-separator="!" + class="gl-mx-n2" + /> + </li> + </ul> + </div> + </div> </div> </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 c8ea8fb7ab2..a1463d0e911 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,14 +1,13 @@ <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'; import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import { ISSUE_NOTEABLE_TYPE } from '~/notes/constants'; import updateMixin from '../../mixins/update'; export default { components: { - MarkdownField, MarkdownEditor, }, mixins: [updateMixin, glFeaturesFlagMixin()], @@ -47,8 +46,8 @@ export default { }; }, computed: { - quickActionsDocsPath() { - return helpPagePath('user/project/quick_actions'); + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; }, }, mounted() { @@ -58,6 +57,10 @@ export default { focus() { this.$refs.textarea?.focus(); }, + saveIssuable() { + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, ISSUE_NOTEABLE_TYPE); + this.updateIssuable(); + }, }, }; </script> @@ -66,45 +69,21 @@ export default { <div class="common-note-form"> <label class="sr-only" for="issue-description">{{ __('Description') }}</label> <markdown-editor - v-if="glFeatures.contentEditorOnIssues" + ref="markdownEditor" + :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" class="gl-mt-3" :value="value" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :form-field-props="formFieldProps" - :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" supports-quick-actions autofocus + data-qa-selector="description_field" @input="$emit('input', $event)" - @keydown.meta.enter="updateIssuable" - @keydown.ctrl.enter="updateIssuable" + @keydown.meta.enter="saveIssuable" + @keydown.ctrl.enter="saveIssuable" /> - <markdown-field - v-else - class="gl-mt-3" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :can-attach-file="canAttachFile" - :enable-autocomplete="enableAutocomplete" - :textarea-value="value" - > - <template #textarea> - <textarea - 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" - @input="$emit('input', $event.target.value)" - @keydown.meta.enter="updateIssuable" - @keydown.ctrl.enter="updateIssuable" - > - </textarea> - </template> - </markdown-field> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index c9e21b296e4..831248d9603 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -1,6 +1,5 @@ <script> import { GlAlert } from '@gitlab/ui'; -import ConvertDescriptionModal from 'ee_component/issues/show/components/convert_description_modal.vue'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants'; @@ -16,7 +15,6 @@ import LockedWarning from './locked_warning.vue'; export default { components: { - ConvertDescriptionModal, DescriptionField, DescriptionTemplateField, EditActions, @@ -175,9 +173,6 @@ export default { updateDraft(this.descriptionAutosaveKey, description, this.formState.lock_version); } }, - setDescription(desc) { - this.formData.description = desc; - }, }, }; </script> @@ -219,14 +214,6 @@ export default { :project-namespace="projectNamespace" /> </div> - - <convert-description-modal - v-if="issueId && glFeatures.generateDescriptionAi" - class="gl-pl-5 gl-md-pl-0" - :resource-id="resourceId" - :user-id="userId" - @contentGenerated="setDescription" - /> </div> <description-field diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index a36b0c46927..719f252781d 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -13,7 +13,7 @@ import * as Sentry from '@sentry/browser'; import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants'; +import { STATUS_CLOSED, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN, @@ -22,7 +22,7 @@ import { import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { getCookie, parseBoolean, setCookie, isLoggedIn } from '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import { s__, __, sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; import toast from '~/vue_shared/plugins/global_toast'; @@ -172,12 +172,9 @@ export default { return this.openState === STATUS_CLOSED; }, issueTypeText() { - const issueTypeTexts = { - [TYPE_ISSUE]: s__('HeaderAction|issue'), - [TYPE_INCIDENT]: s__('HeaderAction|incident'), - }; + const { issueType } = this; - return issueTypeTexts[this.issueType] ?? this.issueType; + return IssuableTypeText[issueType] ?? issueType; }, buttonText() { return this.isClosed @@ -192,11 +189,11 @@ export default { }, dropdownText() { return sprintf(__('%{issueType} actions'), { - issueType: capitalizeFirstCharacter(this.issueType), + issueType: capitalizeFirstCharacter(this.issueTypeText), }); }, newIssueTypeText() { - return sprintf(__('New related %{issueType}'), { issueType: this.issueType }); + return sprintf(__('New related %{issueType}'), { issueType: this.issueTypeText }); }, showToggleIssueStateButton() { const canClose = !this.isClosed && this.canUpdateIssue; @@ -217,7 +214,7 @@ export default { }, copyMailAddressText() { return sprintf(__('Copy %{issueType} email address'), { - issueType: IssuableTypeText[this.issueType], + issueType: this.issueTypeText, }); }, isMrSidebarMoved() { @@ -429,7 +426,7 @@ export default { </gl-button> <gl-button - v-if="showToggleIssueStateButton" + v-if="showToggleIssueStateButton && !glFeatures.moveCloseIntoDropdown" class="gl-display-none gl-sm-display-inline-flex!" :data-qa-selector="qaSelector" :loading="isToggleStateButtonLoading" @@ -465,7 +462,12 @@ export default { <gl-dropdown-divider /> </template> - + <gl-dropdown-item + v-if="showToggleIssueStateButton && glFeatures.moveCloseIntoDropdown" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-dropdown-item> <gl-dropdown-item v-if="canCreateIssue && isUserSignedIn" :href="newIssuePath"> {{ newIssueTypeText }} </gl-dropdown-item> @@ -495,6 +497,7 @@ export default { >{{ copyMailAddressText }}</gl-dropdown-item > </template> + <gl-dropdown-divider v-if="showToggleIssueStateButton || canDestroyIssue || canReportSpam" /> <gl-dropdown-item v-if="canReportSpam" :href="submitAsSpamPath" @@ -503,8 +506,14 @@ export default { > {{ __('Submit as spam') }} </gl-dropdown-item> + <gl-dropdown-item + v-if="!isIssueAuthor && isUserSignedIn" + data-testid="report-abuse-item" + @click="toggleReportAbuseDrawer(true)" + > + {{ $options.i18n.reportAbuse }} + </gl-dropdown-item> <template v-if="canDestroyIssue"> - <gl-dropdown-divider /> <gl-dropdown-item v-gl-modal="$options.deleteModalId" variant="danger" @@ -514,13 +523,6 @@ export default { {{ deleteButtonText }} </gl-dropdown-item> </template> - <gl-dropdown-item - v-if="!isIssueAuthor && isUserSignedIn" - data-testid="report-abuse-item" - @click="toggleReportAbuseDrawer(true)" - > - {{ $options.i18n.reportAbuse }} - </gl-dropdown-item> </gl-dropdown> <new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" /> 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 8267c0130a3..2a59b7a2042 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 @@ -229,7 +229,7 @@ export default { <template #textarea> <textarea v-model="timelineText" - class="note-textarea js-gfm-input js-autosize markdown-area" + class="note-textarea note-textarea-rounded-bottom js-gfm-input js-autosize markdown-area gl-bordered" data-testid="input-note" dir="auto" data-supports-quick-actions="false" 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 d33f3146d64..b776822bd9a 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { formatDate } from '~/lib/utils/datetime_utility'; import { timelineItemI18n } from './constants'; @@ -9,8 +9,7 @@ export default { name: 'IncidentTimelineEventListItem', i18n: timelineItemI18n, components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlIcon, GlSprintf, GlBadge, @@ -45,6 +44,25 @@ export default { canEditEvent() { return this.action === 'comment'; }, + items() { + const items = []; + + if (this.canEditEvent) { + items.push({ + text: this.$options.i18n.edit, + action: () => { + this.$emit('edit'); + }, + }); + } + items.push({ + text: this.$options.i18n.delete, + action: () => { + this.$emit('delete'); + }, + }); + return items; + }, }, methods: { getEventIcon, @@ -76,22 +94,16 @@ export default { </div> <div v-safe-html="noteHtml" class="md"></div> </div> - <gl-dropdown + <gl-disclosure-dropdown v-if="canUpdateTimelineEvent" - right - class="event-note-actions gl-ml-auto gl-align-self-start" + placement="right" + class="event-note-actions gl-align-self-start" icon="ellipsis_v" text-sr-only - :text="$options.i18n.moreActions" + :toggle-text="$options.i18n.moreActions" category="tertiary" no-caret - > - <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')"> - {{ $options.i18n.edit }} - </gl-dropdown-item> - <gl-dropdown-item @click="$emit('delete')"> - {{ $options.i18n.delete }} - </gl-dropdown-item> - </gl-dropdown> + :items="items" + /> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue index 58b15b3eed1..4cd0d1edbcd 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue @@ -17,7 +17,7 @@ export default { <template> <div> - <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info"> + <gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="confirm"> {{ s__('JiraConnect|Link groups') }} </gl-button> <add-namespace-modal /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 7e79572f76d..c5f6f736626 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -10,6 +10,7 @@ import SignInPage from '../pages/sign_in/sign_in_page.vue'; import SubscriptionsPage from '../pages/subscriptions_page.vue'; import UserLink from './user_link.vue'; import BrowserSupportAlert from './browser_support_alert.vue'; +import FeedbackBanner from './feedback_banner.vue'; export default { name: 'JiraConnectApp', @@ -18,6 +19,7 @@ export default { GlLink, GlSprintf, BrowserSupportAlert, + FeedbackBanner, SignInPage, SubscriptionsPage, UserLink, @@ -103,39 +105,47 @@ export default { <user-link v-if="userSignedIn" :user="currentUser" class="gl-fixed gl-right-4" /> </header> - <main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto"> - <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" /> - <div v-else data-testid="jira-connect-app"> - <gl-alert - v-if="shouldShowAlert" - :variant="alert.variant" - :title="alert.title" - class="gl-mb-5" - data-testid="jira-connect-persisted-alert" - @dismiss="setAlert" - > - <gl-sprintf v-if="alert.linkUrl" :message="alert.message"> - <template #link="{ content }"> - <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <main + class="jira-connect-app gl-px-5 gl-pt-7 gl-pb-7 gl-mx-auto gl-display-flex gl-flex-direction-column gl-gap-7" + > + <div class="gl-flex-grow-1"> + <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" /> + <div v-else data-testid="jira-connect-app"> + <gl-alert + v-if="shouldShowAlert" + :variant="alert.variant" + :title="alert.title" + class="gl-mb-5" + data-testid="jira-connect-persisted-alert" + @dismiss="setAlert" + > + <gl-sprintf v-if="alert.linkUrl" :message="alert.message"> + <template #link="{ content }"> + <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> - <template v-else> - {{ alert.message }} - </template> - </gl-alert> + <template v-else> + {{ alert.message }} + </template> + </gl-alert> - <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> - <sign-in-page - v-show="!userSignedIn" - :has-subscriptions="hasSubscriptions" - :public-key-storage-enabled="publicKeyStorageEnabled" - @sign-in-oauth="onSignInOauth" - @error="onSignInError" - /> - <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" /> + <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> + <sign-in-page + v-show="!userSignedIn" + :has-subscriptions="hasSubscriptions" + :public-key-storage-enabled="publicKeyStorageEnabled" + @sign-in-oauth="onSignInOauth" + @error="onSignInError" + /> + <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" /> + </div> </div> </div> + + <div class="gl-flex-grow-2"> + <feedback-banner class="gl-max-w-80 gl-mx-auto" /> + </div> </main> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue new file mode 100644 index 00000000000..5d6117b836d --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/feedback_banner.vue @@ -0,0 +1,57 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import ChatBubbleSvg from '@gitlab/svgs/dist/illustrations/chat-bubble-sm.svg?url'; +import { s__, __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +export default { + components: { + GlBanner, + LocalStorageSync, + }, + + data() { + return { + feedbackBannerDismissed: false, + }; + }, + + methods: { + handleBannerClose() { + this.feedbackBannerDismissed = true; + }, + }, + + i18n: { + title: s__('JiraConnect|Tell us what you think!'), + body: s__( + 'JiraConnect|We would love to learn more about your experience with the GitLab for Jira Cloud App.', + ), + dismissLabel: __('Dismiss'), + buttonText: __('Give feedback'), + }, + feedbackBannerKey: 'jira_connect_feedback_banner', + feedbackIssueUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413652', + buttonAttributes: { + target: '_blank', + }, + ChatBubbleSvg, +}; +</script> + +<template> + <local-storage-sync v-model="feedbackBannerDismissed" :storage-key="$options.feedbackBannerKey"> + <gl-banner + v-if="!feedbackBannerDismissed" + :title="$options.i18n.title" + :button-attributes="$options.buttonAttributes" + :button-text="$options.i18n.buttonText" + :button-link="$options.feedbackIssueUrl" + :dismiss-label="$options.i18n.dismissLabel" + :svg-path="$options.ChatBubbleSvg" + @close="handleBannerClose" + > + <p>{{ $options.i18n.body }}</p> + </gl-banner> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index 45a39fa5fab..ba264d0be34 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -187,7 +187,7 @@ export default { <template> <gl-button v-bind="$attrs" - variant="info" + variant="confirm" :loading="loading" :disabled="!canUseCrypto" @click="startOAuthFlow" diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index d93b8a8de29..a5a92a3c4ff 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; +import LogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; @@ -13,7 +14,6 @@ import { MANUAL_STATUS } from '~/jobs/constants'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; -import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar/sidebar.vue'; diff --git a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue index ea7e13418f2..efd4eed2a9f 100644 --- a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue @@ -178,6 +178,7 @@ export default { </script> <template> <div class="top-bar gl-display-flex gl-justify-content-space-between"> + <slot name="drawers"></slot> <!-- truncate information --> <div class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center" @@ -197,6 +198,7 @@ export default { <!-- eo truncate information --> <div class="controllers"> + <slot name="controllers"> </slot> <gl-search-box-by-click v-model="searchTerm" class="gl-mr-3" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index 7183a8b5d03..e70f9199b55 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; @@ -10,8 +10,7 @@ export default { }, components: { GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, directives: { GlModal: GlModalDirective, @@ -32,6 +31,21 @@ export default { }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), + dropdownItems() { + return [ + { + text: this.$options.i18n.runAgainJobButtonLabel, + href: this.href, + extraAttrs: { + 'data-method': 'post', + }, + }, + { + text: this.$options.i18n.updateVariables, + action: () => this.$emit('updateVariablesClicked'), + }, + ]; + }, }, }; </script> @@ -45,20 +59,14 @@ export default { icon="retry" data-testid="retry-job-button" /> - <gl-dropdown + <gl-disclosure-dropdown v-else-if="isManualJob" icon="retry" category="primary" - :right="true" + placement="right" variant="confirm" - > - <gl-dropdown-item :href="href" data-method="post"> - {{ $options.i18n.runAgainJobButtonLabel }} - </gl-dropdown-item> - <gl-dropdown-item @click="$emit('updateVariablesClicked')"> - {{ $options.i18n.updateVariables }} - </gl-dropdown-item> - </gl-dropdown> + :items="dropdownItems" + /> <gl-button v-else :href="href" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue index 9a88018205b..c1f84adf664 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue @@ -161,6 +161,11 @@ export default { </gl-sprintf> </div> - <gl-disclosure-dropdown :toggle-text="selectedStage" :items="dropdownItems" class="gl-mt-3" /> + <gl-disclosure-dropdown + :toggle-text="selectedStage" + :items="dropdownItems" + block + class="gl-mt-3" + /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/stuck_block.vue b/app/assets/javascripts/jobs/components/job/stuck_block.vue index d7a26d22406..1a678ce69a8 100644 --- a/app/assets/javascripts/jobs/components/job/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/job/stuck_block.vue @@ -1,6 +1,7 @@ <script> import { GlAlert, GlBadge, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; /** * Renders Stuck Runners block for job's view. */ @@ -31,7 +32,7 @@ export default { return this.tags.length > 0; }, protectedBranchSettingsDocsLink() { - return 'https://docs.gitlab.com/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers'; + return `${DOCS_URL}/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers`; }, stuckData() { if (this.hasNoRunnersWithCorrespondingTags) { diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue index b692553fdc2..27d286fc766 100644 --- a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue @@ -71,7 +71,7 @@ export default { <template> <div> - <div class="gl-text-truncate"> + <div class="gl-text-truncate gl-mb-2"> <gl-link v-if="canReadJob" class="gl-text-blue-600!" @@ -92,7 +92,7 @@ export default { /> <div - class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2" > <div v-if="jobRef" diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 44bb1ffb1bc..8cd69f25218 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -2,6 +2,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import JobApp from './components/job/job_app.vue'; import createStore from './store'; @@ -29,6 +30,7 @@ const initializeJobPage = (element) => { buildStatus, projectPath, retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable, } = element.dataset; return new Vue({ @@ -41,6 +43,7 @@ const initializeJobPage = (element) => { provide: { projectPath, retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), }, render(createElement) { return createElement('job-app', { diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 42682d9b79f..670170ec9b9 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -66,15 +66,15 @@ export function initScrollingTabs() { function initInviteMembers() { const modalEl = document.querySelector('.js-invite-members-modal'); - if (!modalEl) return; - - import( - /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal' - ) - .then(({ default: initInviteMembersModal }) => { - initInviteMembersModal(); - }) - .catch(() => {}); + if (modalEl) { + import( + /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal' + ) + .then(({ default: initInviteMembersModal }) => { + initInviteMembersModal(); + }) + .catch(() => {}); + } const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger'); if (!inviteTriggers) return; diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js index ccfdfe91e60..4ad99ec09d8 100644 --- a/app/assets/javascripts/lib/logger/hello.js +++ b/app/assets/javascripts/lib/logger/hello.js @@ -1,4 +1,5 @@ import { s__, sprintf } from '~/locale'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; const HANDSHAKE = String.fromCodePoint(0x1f91d); const MAG = String.fromCodePoint(0x1f50e); @@ -15,7 +16,7 @@ ${s__( ${sprintf(s__('HelloMessage|%{handshake_emoji} Contribute to GitLab: %{contribute_link}'), { handshake_emoji: `${HANDSHAKE}`, - contribute_link: 'https://about.gitlab.com/community/contribute/', + contribute_link: `${PROMO_URL}/community/contribute/`, })} ${sprintf(s__('HelloMessage|%{magnifier_emoji} Create a new GitLab issue: %{new_issue_link}'), { magnifier_emoji: `${MAG}`, @@ -27,7 +28,7 @@ ${ s__( 'HelloMessage|%{rocket_emoji} We like your curiosity! Help us improve GitLab by joining the team: %{jobs_page_link}', ), - { rocket_emoji: `${ROCKET}`, jobs_page_link: 'https://about.gitlab.com/jobs/' }, + { rocket_emoji: `${ROCKET}`, jobs_page_link: `${PROMO_URL}/jobs/` }, )}` : '' }`, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7795dac18bc..cca4cf68f5e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -721,3 +721,13 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; + +export const isCurrentUser = (userId) => { + const currentUserId = window.gon?.current_user_id; + + if (!currentUserId) { + return false; + } + + return Number(userId) === currentUserId; +}; diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index 24be1485379..b61f01590cd 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -68,7 +68,7 @@ export default { text: this.primaryText, attributes: { variant: this.primaryVariant, - 'data-qa-selector': 'confirm_ok_button', + 'data-testid': 'confirm-ok-button', }, }; }, @@ -110,6 +110,7 @@ export default { ref="modal" modal-id="confirmationModal" body-class="gl-display-flex" + data-testid="confirmation-modal" :size="size" :title="title" :action-primary="primaryAction" diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index e1a57bf4589..b0264796d90 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -3,6 +3,7 @@ import dateFormat from '~/lib/dateformat'; import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { sanitize } from '~/lib/dompurify'; import { s__, n__, __, sprintf } from '~/locale'; +import { parsePikadayDate } from './pikaday_utility'; /** * Returns i18n month names array. @@ -420,3 +421,34 @@ export const formatUtcOffset = (offset) => { * @returns {String} the UTC timezone with the offset, e.g. `[UTC+2] Berlin, [UTC 0] London` */ export const formatTimezone = ({ offset, name }) => `[UTC${formatUtcOffset(offset)}] ${name}`; + +/** + * Returns humanized string showing date range from provided start and due dates. + * + * @param {Date} startDate + * @param {Date} dueDate + * @returns + */ +export const humanTimeframe = (startDate, dueDate) => { + const start = startDate ? parsePikadayDate(startDate) : null; + const due = dueDate ? parsePikadayDate(dueDate) : null; + + if (startDate && dueDate) { + const startDateInWords = dateInWords(start, true, start.getFullYear() === due.getFullYear()); + const dueDateInWords = dateInWords(due, true); + + return sprintf(__('%{startDate} – %{dueDate}'), { + startDate: startDateInWords, + dueDate: dueDateInWords, + }); + } else if (startDate && !dueDate) { + return sprintf(__('%{startDate} – No due date'), { + startDate: dateInWords(start, true, false), + }); + } else if (!startDate && dueDate) { + return sprintf(__('No start date – %{dueDate}'), { + dueDate: dateInWords(due, true, false), + }); + } + return ''; +}; diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js index 1d8c6ee23fc..652ae337506 100644 --- a/app/assets/javascripts/lib/utils/forms.js +++ b/app/assets/javascripts/lib/utils/forms.js @@ -18,18 +18,69 @@ export const serializeForm = (form) => { }; /** + * Like trim but without the error for non-string values. + * + * @param {String, Number, Array} - value + * @returns {String, Number, Array} - the trimmed string or the value if it isn't a string + */ +export const safeTrim = (value) => (typeof value === 'string' ? value.trim() : value); + +/** * Check if the value provided is empty or not * * It is being used to check if a form input - * value has been set or not + * value has been set or not. * * @param {String, Number, Array} - Any form value * @returns {Boolean} - returns false if a value is set * * @example - * returns true for '', [], null, undefined + * returns true for '', ' ', [], null, undefined + */ +export const isEmptyValue = (value) => value == null || safeTrim(value).length === 0; + +/** + * Check if the value has a minimum string length + * + * @param {String, Number, Array} - Any form value + * @param {Number} - minLength + * @returns {Boolean} + */ +export const hasMinimumLength = (value, minLength) => + !isEmptyValue(value) && value.length >= minLength; + +/** + * Checks if the given value can be parsed as an integer as it is (without cutting off decimals etc.) + * + * @param {String, Number, Array} - Any form value + * @returns {Boolean} + */ +export const isParseableAsInteger = (value) => + !isEmptyValue(value) && Number.isInteger(Number(safeTrim(value))); + +/** + * Checks if the parsed integer value from the given input is greater than a certain number + * + * @param {String, Number, Array} - Any form value + * @param {Number} - greaterThan + * @returns {Boolean} + */ +export const isIntegerGreaterThan = (value, greaterThan) => + isParseableAsInteger(value) && parseInt(value, 10) > greaterThan; + +/** + * Regexp that matches email structure. + * Taken from app/models/service_desk_setting.rb custom_email + */ +export const EMAIL_REGEXP = /^[\w\-._]+@[\w\-.]+\.[a-zA-Z]{2,}$/; + +/** + * Checks if the input is a valid email address + * + * @param {String} - value + * @returns {Boolean} */ -export const isEmptyValue = (value) => value == null || value.length === 0; +export const isEmail = (value) => EMAIL_REGEXP.test(value); /** * A form object serializer diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index a2873622682..e6eb74834c0 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -376,8 +376,8 @@ export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select textArea = $textArea.get(0); const text = $textArea.val(); const selected = selectedText(text, textArea) || tagContent; - $textArea.focus(); - return insertMarkdownText({ + textArea.focus(); + insertMarkdownText({ textArea, text, tag, @@ -387,6 +387,7 @@ export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select wrap, select, }); + textArea.click(); } /** @@ -596,6 +597,7 @@ export function compositionEndNoteText() { export function updateTextForToolbarBtn($toolbarBtn) { const $textArea = $toolbarBtn.closest('.md-area').find('textarea'); + if (!$textArea.length) return; switch ($toolbarBtn.data('mdCommand')) { case 'indentLines': diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js index aa2963ece31..62054d5a80d 100644 --- a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js @@ -26,24 +26,26 @@ const mode = (value, options) => { const base = () => null; -const toNewCatchAllPath = (path) => { - if (path === '*') return '/:pathMatch(.*)*'; +const toNewCatchAllPath = (path, { isRoot } = {}) => { + if (path === '*') { + const prefix = isRoot ? '/' : ''; + return `${prefix}:pathMatch(.*)*`; + } return path; }; -const routes = (value) => { +const transformRoutes = (value, _routerOptions, transformOptions = { isRoot: true }) => { if (!value) return null; - const newRoutes = value.reduce(function handleRoutes(acc, route) { + const newRoutes = value.map(function handleRoutes(route) { const newRoute = { ...route, - path: toNewCatchAllPath(route.path), + path: toNewCatchAllPath(route.path, transformOptions), }; if (route.children) { - newRoute.children = route.children.reduce(handleRoutes, []); + newRoute.children = transformRoutes(route.children, _routerOptions, { isRoot: false }).routes; } - acc.push(newRoute); - return acc; - }, []); + return newRoute; + }); return { routes: newRoutes }; }; @@ -59,7 +61,7 @@ const scrollBehavior = (value) => { const transformers = { mode, base, - routes, + routes: transformRoutes, scrollBehavior, }; @@ -107,7 +109,15 @@ export default class VueRouterCompat { installed.set(app, new WeakSet()); } installed.get(app).add(router); + + // Since we're doing "late initialization" we might already have RouterLink + // for example, from router stubs. We need to maintain it + const originalRouterLink = this.$.appContext.components.RouterLink; + delete this.$.appContext.components.RouterLink; this.$.appContext.app.use(this.$options.router); + if (originalRouterLink) { + this.$.appContext.components.RouterLink = originalRouterLink; + } } }, }); diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index c973d58fcd2..f6fd84c46cb 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -127,10 +127,15 @@ export default { }, actionsFieldTdClass(value, key, member) { if (this.hasActionButtons(member)) { - return 'col-actions'; + return ['col-actions', 'gl-vertical-align-middle!']; } - return ['col-actions', 'gl-display-none!', 'gl-lg-display-table-cell!']; + return [ + 'col-actions', + 'gl-display-none!', + 'gl-lg-display-table-cell!', + 'gl-vertical-align-middle!', + ]; }, tbodyTrAttr(member) { return { diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 4571c4172e5..c854d865869 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -76,6 +76,7 @@ export default { newRoleName, ); if (!confirmed) { + this.selectedRoleValue = currentRoleValue; this.busy = false; return; } diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 8e5b88d362e..e1f7e81d831 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -32,12 +32,13 @@ export const FIELDS = [ asc: 'name_asc', desc: 'name_desc', }, + tdClass: 'gl-vertical-align-middle!', }, { key: FIELD_KEY_SOURCE, label: __('Source'), thClass: 'col-meta', - tdClass: 'col-meta', + tdClass: 'col-meta gl-vertical-align-middle!', }, { key: FIELD_KEY_GRANTED, @@ -46,24 +47,25 @@ export const FIELDS = [ asc: 'last_joined', desc: 'oldest_joined', }, + tdClass: 'gl-vertical-align-middle!', }, { key: FIELD_KEY_INVITED, label: __('Invited'), thClass: 'col-meta', - tdClass: 'col-meta', + tdClass: 'col-meta gl-vertical-align-middle!', }, { key: FIELD_KEY_REQUESTED, label: __('Requested'), thClass: 'col-meta', - tdClass: 'col-meta', + tdClass: 'col-meta gl-vertical-align-middle!', }, { key: FIELD_KEY_MAX_ROLE, label: __('Max role'), thClass: 'col-max-role', - tdClass: 'col-max-role', + tdClass: 'col-max-role gl-vertical-align-middle!', sort: { asc: 'access_level_asc', desc: 'access_level_desc', @@ -73,13 +75,13 @@ export const FIELDS = [ key: FIELD_KEY_EXPIRATION, label: __('Expiration'), thClass: 'col-expiration', - tdClass: 'col-expiration', + tdClass: 'col-expiration gl-vertical-align-middle!', }, { key: FIELD_KEY_ACTIVITY, label: s__('Members|Activity'), thClass: 'col-activity', - tdClass: 'col-activity', + tdClass: 'col-activity gl-vertical-align-middle!', }, { key: FIELD_KEY_USER_CREATED_AT, diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 8307d0a9eed..883b9e6919b 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -21,7 +21,12 @@ import syntaxHighlight from './syntax_highlight'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + useGet: true, + }, + ), }); // MergeRequestTabs @@ -96,6 +101,7 @@ function mountPipelines() { artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, targetProjectFullPath: mrWidgetData?.target_project_full_path || '', fullPath: pipelineTableViewEl.dataset.fullPath, + graphqlPath: pipelineTableViewEl.dataset.graphqlPath, manualActionsLimit: 50, withFailedJobsDetails: true, }, diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index c6e8a9ea582..362ecca6d6c 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -113,14 +113,14 @@ export default { class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5 gl-w-full" :class="{ 'gl-max-w-container-xl': !isFluidLayout }" > - <div class="gl-w-full gl-display-flex gl-align-items-center"> + <div class="gl-w-full gl-display-flex gl-align-items-baseline"> <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" /> <a v-safe-html:[$options.safeHtmlConfig]="titleHtml" href="#top" class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4 gl-text-black-normal" ></a> - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-baseline"> <gl-sprintf :message="__('%{source} %{copyButton} into %{target}')"> <template #copyButton> <clipboard-button @@ -129,7 +129,7 @@ export default { size="small" category="tertiary" tooltip-placement="bottom" - class="gl-m-0! gl-mx-1! js-source-branch-copy" + class="gl-m-0! gl-mx-1! js-source-branch-copy gl-align-self-center" /> </template> <template #source> diff --git a/app/assets/javascripts/merge_requests/generated_content.js b/app/assets/javascripts/merge_requests/generated_content.js new file mode 100644 index 00000000000..0184801ce80 --- /dev/null +++ b/app/assets/javascripts/merge_requests/generated_content.js @@ -0,0 +1,64 @@ +export class MergeRequestGeneratedContent { + constructor({ editor } = {}) { + this.warningElement = document.querySelector('.js-ai-description-warning'); + this.markdownEditor = editor; + this.generatedContent = null; + + this.connectToDOM(); + } + + get hasEditor() { + return Boolean(this.markdownEditor); + } + get hasWarning() { + return Boolean(this.warningElement); + } + get canReplaceContent() { + return this.hasEditor && Boolean(this.generatedContent); + } + + connectToDOM() { + let close; + let cancel; + let approve; + + if (this.hasWarning) { + approve = this.warningElement.querySelector('.js-ai-override-description'); + cancel = this.warningElement.querySelector('.js-cancel-btn'); + close = this.warningElement.querySelector('.js-close-btn'); + + approve.addEventListener('click', () => { + this.replaceDescription(); + this.hideWarning(); + }); + + cancel.addEventListener('click', () => this.hideWarning()); + close.addEventListener('click', () => this.hideWarning()); + } + } + + setEditor(markdownEditor) { + this.markdownEditor = markdownEditor; + } + setGeneratedContent(newContent) { + this.generatedContent = newContent; + } + clearGeneratedContent() { + this.generatedContent = null; + } + + showWarning() { + if (this.canReplaceContent) { + this.warningElement?.classList.remove('hidden'); + } + } + hideWarning() { + this.warningElement?.classList.add('hidden'); + } + replaceDescription() { + if (this.canReplaceContent) { + this.markdownEditor.setValue(this.generatedContent); + this.clearGeneratedContent(); + } + } +} diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index 420f7cee4d2..403db0865f0 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -1,10 +1,9 @@ -import $ from 'jquery'; import Vue from 'vue'; import initDatePicker from '~/behaviors/date_picker'; -import GLForm from '~/gl_form'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Milestone from '~/milestones/milestone'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; import Translate from '~/vue_shared/translate'; @@ -22,22 +21,10 @@ export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description'; export const MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT = `${MILESTONE_DESCRIPTION_ELEMENT}.js-task-list-container`; export const MILESTONE_DETAIL_ELEMENT = '.milestone-detail'; -export function initForm(initGFM = true) { +export function initForm() { + mountMarkdownEditor(); new ZenMode(); // eslint-disable-line no-new initDatePicker(); - - // eslint-disable-next-line no-new - new GLForm($('.milestone-form'), { - emojis: true, - members: initGFM, - issues: initGFM, - mergeRequests: initGFM, - epics: initGFM, - milestones: initGFM, - labels: initGFM, - snippets: initGFM, - vulnerabilities: initGFM, - }); } export function initShow() { diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue new file mode 100644 index 00000000000..37e5877ec52 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue @@ -0,0 +1,34 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import * as translations from '~/ml/model_registry/routes/models/index/translations'; + +export default { + name: 'MlExperimentsIndexApp', + components: { + GlLink, + }, + props: { + models: { + type: Array, + required: true, + }, + }, + i18n: translations, +}; +</script> + +<template> + <div> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <div class="page-title gl-flex-grow-1 gl-display-flex gl-align-items-center"> + <h2 class="gl-font-size-h-display gl-my-0">{{ $options.i18n.TITLE_LABEL }}</h2> + </div> + </div> + </div> + + <div v-for="model in models" :key="model.name"> + <gl-link :href="model.path"> {{ model.name }} / {{ model.version }} </gl-link> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/index.js b/app/assets/javascripts/ml/model_registry/routes/models/index/index.js new file mode 100644 index 00000000000..d303d9716af --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/index.js @@ -0,0 +1,3 @@ +import MlModelsIndex from './components/ml_models_index.vue'; + +export default MlModelsIndex; diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js new file mode 100644 index 00000000000..f0f45f9424e --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const TITLE_LABEL = s__('MlExperimentTracking|Model registry'); diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js deleted file mode 100644 index aac9d2f8a01..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/annotations.js +++ /dev/null @@ -1,133 +0,0 @@ -import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants'; - -/** - * Annotations and deployments are decoration layers on - * top of the actual chart data. We use a scatter plot to - * display this information. Each chart has its coordinate - * system based on data and irrespective of the data, these - * decorations have to be placed in specific locations. - * For this reason, annotations have their own coordinate system, - * - * As of %12.9, only deployment icons, a type of annotations, need - * to be displayed on the chart. - * - * Annotations and deployments co-exist in the same series as - * they logically belong together. Annotations are passed as - * markLines and markPoints while deployments are passed as - * data points with custom icons. - */ - -/** - * Deployment icons, a type of annotation, are displayed - * along the [min, max] range at height `pos`. - */ -const annotationsYAxisCoords = { - min: 0, - pos: 3, // 3% height of chart's grid - max: 100, -}; - -/** - * Annotation y axis min & max allows the deployment - * icons to position correctly in the chart - */ -export const annotationsYAxis = { - show: false, - min: annotationsYAxisCoords.min, - max: annotationsYAxisCoords.max, - axisLabel: { - // formatter fn required to trigger tooltip re-positioning - formatter: () => {}, - }, -}; - -/** - * Fetched list of annotations are parsed into a - * format the eCharts accepts to draw markLines - * - * If Annotation is a single line, the `startingAt` property - * has a value and the `endingAt` is null. Because annotations - * only supports lines the `endingAt` value does not exist yet. - * - * @param {Object} annotation object - * @returns {Object} markLine object - */ -export const parseAnnotations = (annotations) => - annotations.reduce( - (acc, annotation) => { - acc.lines.push({ - xAxis: annotation.startingAt, - lineStyle: { - color: colorValues.primaryColor, - }, - }); - - acc.points.push({ - name: 'annotations', - xAxis: annotation.startingAt, - yAxis: annotationsYAxisCoords.min, - tooltipData: { - title: annotation.startingAt, - content: annotation.description, - }, - }); - - return acc; - }, - { lines: [], points: [] }, - ); - -/** - * This method generates a decorative series that has - * deployments as data points with custom icons and - * annotations as markLines and markPoints - * - * @param {Array} deployments deployments data - * @returns {Object} annotation series object - */ -export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => { - // deployment data points - const data = deployments.map((deployment) => { - return { - name: 'deployments', - value: [deployment.createdAt, annotationsYAxisCoords.pos], - // style options - symbol: deployment.icon, - symbolSize: symbolSizes.default, - itemStyle: { - color: deployment.color, - }, - // metadata that are accessible in `formatTooltipText` method - tooltipData: { - sha: deployment.sha.substring(0, 8), - commitUrl: deployment.commitUrl, - }, - }; - }); - - const parsedAnnotations = parseAnnotations(annotations); - - // markLine option draws the annotations dotted line - const markLine = { - symbol: 'none', - silent: true, - data: parsedAnnotations.lines, - }; - - // markPoints are the arrows under the annotations lines - const markPoint = { - symbol: annotationsSymbolIcon, - symbolSize: '8', - symbolOffset: [0, ' 60%'], - data: parsedAnnotations.points, - }; - - return { - name: 'annotations', - type: graphTypes.annotationsData, - yAxisIndex: 1, // annotationsYAxis index - data, - markLine, - markPoint, - }; -}; diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue deleted file mode 100644 index b6eb1a23f87..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ /dev/null @@ -1,230 +0,0 @@ -<script> -import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; -import { hexToRgba } from '@gitlab/ui/dist/utils/utils'; - -import produce from 'immer'; -import { flattenDeep, isNumber } from 'lodash'; -import { roundOffFloat } from '~/lib/utils/common_utils'; -import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants'; -import { graphDataValidatorForAnomalyValues } from '../../utils'; -import MonitorTimeSeriesChart from './time_series.vue'; - -/** - * Series indexes - */ -const METRIC = 0; -const UPPER = 1; -const LOWER = 2; - -/** - * Boundary area appearance - */ -const AREA_COLOR = colorValues.anomalyAreaColor; -const AREA_OPACITY = areaOpacityValues.default; -const AREA_COLOR_RGBA = hexToRgba(AREA_COLOR, AREA_OPACITY); - -/** - * The anomaly component highlights when a metric shows - * some anomalous behavior. - * - * It shows both a metric line and a boundary band in a - * time series chart, the boundary band shows the normal - * range of values the metric should take. - * - * This component accepts 3 metrics, which contain the - * "metric", "upper" limit and "lower" limit. - * - * The upper and lower series are "stacked areas" visually - * to create the boundary band, and if any "metric" value - * is outside this band, it is highlighted to warn users. - * - * The boundary band stack must be painted above the 0 line - * so the area is shown correctly. If any of the values of - * the data are negative, the chart data is shifted to be - * above 0 line. - * - * The data passed to the time series is will always be - * positive, but reformatted to show the original values of - * data. - * - */ -export default { - components: { - GlChartSeriesLabel, - MonitorTimeSeriesChart, - }, - inheritAttrs: false, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForAnomalyValues, - }, - }, - computed: { - series() { - return this.graphData.metrics.map((metric) => { - const values = metric.result && metric.result[0] ? metric.result[0].values : []; - return { - label: metric.label, - // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out - data: values.filter(([, value]) => !Number.isNaN(value)), - }; - }); - }, - /** - * If any of the values of the data is negative, the - * chart data is shifted to the lowest value - * - * This offset is the lowest value. - */ - yOffset() { - const values = flattenDeep(this.series.map((ser) => ser.data.map(([, y]) => y))); - const min = values.length ? Math.floor(Math.min(...values)) : 0; - return min < 0 ? -min : 0; - }, - metricData() { - const originalMetricQuery = this.graphData.metrics[0]; - - const metricQuery = produce(originalMetricQuery, (draftQuery) => { - draftQuery.result[0].values = draftQuery.result[0].values.map(([x, y]) => [ - x, - y + this.yOffset, - ]); - }); - return { - ...this.graphData, - type: panelTypes.LINE_CHART, - metrics: [metricQuery], - }; - }, - metricSeriesConfig() { - return { - type: 'line', - symbol: 'circle', - symbolSize: (val, params) => { - if (this.isDatapointAnomaly(params.dataIndex)) { - return symbolSizes.anomaly; - } - // 0 causes echarts to throw an error, use small number instead - // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423 - return 0.001; - }, - showSymbol: true, - itemStyle: { - color: (params) => { - if (this.isDatapointAnomaly(params.dataIndex)) { - return colorValues.anomalySymbol; - } - return colorValues.primaryColor; - }, - }, - }; - }, - chartOptions() { - const [, upperSeries, lowerSeries] = this.series; - const calcOffsetY = (data, offsetCallback) => - data.map((value, dataIndex) => { - const [x, y] = value; - return [x, y + offsetCallback(dataIndex)]; - }); - - const yAxisWithOffset = { - axisLabel: { - formatter: (num) => roundOffFloat(num - this.yOffset, 3).toString(), - }, - }; - - /** - * Boundary is rendered by 2 series: An invisible - * series (opacity: 0) stacked on a visible one. - * - * Order is important, lower boundary is stacked - * *below* the upper boundary. - */ - const boundarySeries = []; - - if (upperSeries.data.length && lowerSeries.data.length) { - // Lower boundary, plus the offset if negative values - boundarySeries.push( - this.makeBoundarySeries({ - name: this.formatLegendLabel(lowerSeries), - data: calcOffsetY(lowerSeries.data, () => this.yOffset), - }), - ); - // Upper boundary, minus the lower boundary - boundarySeries.push( - this.makeBoundarySeries({ - name: this.formatLegendLabel(upperSeries), - data: calcOffsetY(upperSeries.data, (i) => -this.yValue(LOWER, i)), - areaStyle: { - color: AREA_COLOR, - opacity: AREA_OPACITY, - }, - }), - ); - } - - return { yAxis: yAxisWithOffset, series: boundarySeries }; - }, - }, - methods: { - formatLegendLabel(query) { - return query.label; - }, - yValue(seriesIndex, dataIndex) { - const d = this.series[seriesIndex].data[dataIndex]; - return d && d[1]; - }, - yValueFormatted(seriesIndex, dataIndex) { - const y = this.yValue(seriesIndex, dataIndex); - return isNumber(y) ? y.toFixed(3) : ''; - }, - isDatapointAnomaly(dataIndex) { - const yVal = this.yValue(METRIC, dataIndex); - const yUpper = this.yValue(UPPER, dataIndex); - const yLower = this.yValue(LOWER, dataIndex); - return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower); - }, - makeBoundarySeries(series) { - const stackKey = 'anomaly-boundary-series-stack'; - return { - type: 'line', - stack: stackKey, - lineStyle: { - width: 0, - color: AREA_COLOR_RGBA, // legend color - }, - color: AREA_COLOR_RGBA, // tooltip color - symbol: 'none', - ...series, - }; - }, - }, -}; -</script> - -<template> - <monitor-time-series-chart - v-bind="$attrs" - :graph-data="metricData" - :option="chartOptions" - :series-config="metricSeriesConfig" - > - <slot></slot> - <template #tooltip-content="slotProps"> - <div - v-for="(content, seriesIndex) in slotProps.tooltip.content" - :key="seriesIndex" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="content.color"> - {{ content.name }} - </gl-chart-series-label> - <div class="gl-ml-7"> - {{ yValueFormatted(seriesIndex, content.dataIndex) }} - </div> - </div> - </template> - </monitor-time-series-chart> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/bar.vue b/app/assets/javascripts/monitoring/components/charts/bar.vue deleted file mode 100644 index df91bd078d1..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/bar.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { GlBarChart } from '@gitlab/ui/dist/charts'; -import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { chartHeight } from '../../constants'; -import { barChartsDataParser, graphDataValidatorForValues } from '../../utils'; - -export default { - components: { - GlBarChart, - }, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, false), - }, - }, - data() { - return { - width: 0, - height: chartHeight, - svgs: {}, - }; - }, - computed: { - chartData() { - return barChartsDataParser(this.graphData.metrics); - }, - chartOptions() { - return { - dataZoom: [this.dataZoomConfig], - }; - }, - xAxisTitle() { - const { xLabel = '' } = this.graphData; - return xLabel; - }, - yAxisTitle() { - const { y_label: yLabel = '' } = this.graphData; - return yLabel; - }, - xAxisType() { - const { x_type: xType = 'value' } = this.graphData; - return xType; - }, - dataZoomConfig() { - const handleIcon = this.svgs['scroll-handle']; - - return handleIcon ? { handleIcon } : {}; - }, - }, - created() { - this.setSvg('scroll-handle'); - }, - methods: { - formatLegendLabel(query) { - return query.label; - }, - setSvg(name) { - getSvgIconPathContent(name) - .then((path) => { - if (path) { - this.$set(this.svgs, name, `path://${path}`); - } - }) - .catch((e) => { - // eslint-disable-next-line no-console, @gitlab/require-i18n-strings - console.error('SVG could not be rendered correctly: ', e); - }); - }, - }, -}; -</script> -<template> - <gl-bar-chart - ref="barChart" - v-bind="$attrs" - :responsive="true" - :data="chartData" - :option="chartOptions" - :width="width" - :height="height" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - /> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue deleted file mode 100644 index e8f54b1fa34..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ /dev/null @@ -1,107 +0,0 @@ -<script> -import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { makeDataSeries } from '~/helpers/monitor_helper'; -import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { chartHeight } from '../../constants'; -import { timezones } from '../../format_date'; -import { graphDataValidatorForValues } from '../../utils'; -import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options'; - -export default { - components: { - GlColumnChart, - }, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, false), - }, - timezone: { - type: String, - required: false, - default: timezones.LOCAL, - }, - }, - data() { - return { - width: 0, - height: chartHeight, - svgs: {}, - }; - }, - computed: { - barChartData() { - return this.graphData.metrics.reduce((acc, query) => { - const series = makeDataSeries(query.result || [], { - name: this.formatLegendLabel(query), - }); - - return acc.concat(series); - }, []); - }, - chartOptions() { - const xAxis = getTimeAxisOptions({ timezone: this.timezone }); - - const yAxis = { - ...getYAxisOptions(this.graphData.yAxis), - scale: false, - }; - - return { - grid: getChartGrid(), - xAxis, - yAxis, - dataZoom: [this.dataZoomConfig], - }; - }, - xAxisTitle() { - return this.graphData.metrics[0].result[0].x_label !== undefined - ? this.graphData.metrics[0].result[0].x_label - : ''; - }, - yAxisTitle() { - return this.chartOptions.yAxis.name; - }, - xAxisType() { - return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category'; - }, - dataZoomConfig() { - const handleIcon = this.svgs['scroll-handle']; - - return handleIcon ? { handleIcon } : {}; - }, - }, - created() { - this.setSvg('scroll-handle'); - }, - methods: { - formatLegendLabel(query) { - return query.label; - }, - setSvg(name) { - getSvgIconPathContent(name) - .then((path) => { - if (path) { - this.$set(this.svgs, name, `path://${path}`); - } - }) - .catch(() => {}); - }, - }, -}; -</script> -<template> - <gl-column-chart - ref="columnChart" - v-bind="$attrs" - :responsive="true" - :bars="barChartData" - :option="chartOptions" - :width="width" - :height="height" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - /> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue deleted file mode 100644 index 6419c45c20c..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg?raw'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { chartHeight } from '../../constants'; - -export default { - directives: { - SafeHtml, - }, - data() { - return { - height: chartHeight, - }; - }, - computed: { - svgContainerStyle() { - return { - height: `${this.height}px`, - }; - }, - }, - created() { - this.chartEmptyStateIllustration = chartEmptyStateIllustration; - }, - safeHtmlConfig: { ADD_TAGS: ['use'] }, -}; -</script> -<template> - <div class="d-flex flex-column justify-content-center"> - <div - v-safe-html:[$options.safeHtmlConfig]="chartEmptyStateIllustration" - class="gl-mt-3 svg-w-100 d-flex align-items-center" - :style="svgContainerStyle" - ></div> - <h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue deleted file mode 100644 index 0477ff19ffe..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/gauge.vue +++ /dev/null @@ -1,110 +0,0 @@ -<script> -import { GlGaugeChart } from '@gitlab/ui/dist/charts'; -import { isFinite, isArray, isInteger } from 'lodash'; -import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import { graphDataValidatorForValues } from '../../utils'; -import { getValidThresholds } from './options'; - -export default { - components: { - GlGaugeChart, - }, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, true), - }, - }, - data() { - return { - width: 0, - }; - }, - computed: { - rangeValues() { - let min = 0; - let max = 100; - - const { minValue, maxValue } = this.graphData; - - const isValidMinMax = () => { - return isFinite(minValue) && isFinite(maxValue) && minValue < maxValue; - }; - - if (isValidMinMax()) { - min = minValue; - max = maxValue; - } - - return { - min, - max, - }; - }, - validThresholds() { - const { mode, values } = this.graphData?.thresholds || {}; - const range = this.rangeValues; - - if (!isArray(values)) { - return []; - } - - return getValidThresholds({ mode, range, values }); - }, - queryResult() { - return this.graphData?.metrics[0]?.result[0]?.value[1]; - }, - splitValue() { - const { split } = this.graphData; - const defaultValue = 10; - - return isInteger(split) && split > 0 ? split : defaultValue; - }, - textValue() { - const formatFromPanel = this.graphData.format; - const defaultFormat = SUPPORTED_FORMATS.engineering; - const format = SUPPORTED_FORMATS[formatFromPanel] ?? defaultFormat; - const { queryResult } = this; - - const formatter = getFormatter(format); - - return isFinite(queryResult) ? formatter(queryResult) : '--'; - }, - thresholdsValue() { - /** - * If there are no valid thresholds, a default threshold - * will be set at 90% of the gauge arcs' max value - */ - const { min, max } = this.rangeValues; - - const defaultThresholdValue = [(max - min) * 0.95]; - return this.validThresholds.length ? this.validThresholds : defaultThresholdValue; - }, - value() { - /** - * The gauge chart gitlab-ui component expects a value - * of type number. - * - * So, if the query result is undefined, - * we pass the gauge chart a value of NaN. - */ - return this.queryResult || NaN; - }, - }, -}; -</script> -<template> - <gl-gauge-chart - ref="gaugeChart" - v-bind="$attrs" - :responsive="true" - :value="value" - :min="rangeValues.min" - :max="rangeValues.max" - :thresholds="thresholdsValue" - :text="textValue" - :split-number="splitValue" - :width="width" - /> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue deleted file mode 100644 index 12add274a90..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ /dev/null @@ -1,74 +0,0 @@ -<script> -import { GlHeatmap } from '@gitlab/ui/dist/charts'; -import { formatDate, timezones, formats } from '../../format_date'; -import { graphDataValidatorForValues } from '../../utils'; - -export default { - components: { - GlHeatmap, - }, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, false), - }, - timezone: { - type: String, - required: false, - default: timezones.LOCAL, - }, - }, - data() { - return { - width: 0, - }; - }, - computed: { - chartData() { - return this.metrics.result.reduce( - (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], - [], - ); - }, - xAxisName() { - return this.graphData.xLabel || ''; - }, - yAxisName() { - return this.graphData.y_label || ''; - }, - xAxisLabels() { - return this.metrics.result.map((res) => Object.values(res.metric)[0]); - }, - yAxisLabels() { - return this.result.values.map((val) => { - const [yLabel] = val; - - return formatDate(new Date(yLabel), { - format: formats.shortTime, - timezone: this.timezone, - }); - }); - }, - result() { - return this.metrics.result[0]; - }, - metrics() { - return this.graphData.metrics[0]; - }, - }, -}; -</script> -<template> - <gl-heatmap - ref="heatmapChart" - v-bind="$attrs" - :responsive="true" - :data-series="chartData" - :x-axis-name="xAxisName" - :y-axis-name="yAxisName" - :x-axis-labels="xAxisLabels" - :y-axis-labels="yAxisLabels" - :width="width" - /> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js deleted file mode 100644 index 643550a7144..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ /dev/null @@ -1,175 +0,0 @@ -import { isFinite, uniq, sortBy, includes } from 'lodash'; -import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; -import { __, s__ } from '~/locale'; -import { thresholdModeTypes } from '../../constants'; -import { formatDate, timezones, formats } from '../../format_date'; - -const yAxisBoundaryGap = [0.1, 0.1]; -/** - * Max string length of formatted axis tick - */ -const maxDataAxisTickLength = 8; -// Defaults -const defaultFormat = SUPPORTED_FORMATS.engineering; - -const defaultYAxisFormat = defaultFormat; -const defaultYAxisPrecision = 2; - -const defaultTooltipFormat = defaultFormat; -const defaultTooltipPrecision = 3; - -// Give enough space for y-axis with units and name. -const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers -const chartGridRight = 10; // half of the scroll-handle icon for data zoom -const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height - -// Axis options - -/** - * Axis types - * @see https://echarts.apache.org/en/option.html#xAxis.type - */ -export const axisTypes = { - /** - * Category axis, suitable for discrete category data. - */ - category: 'category', - /** - * Time axis, suitable for continuous time series data. - */ - time: 'time', -}; - -/** - * Converts .yml parameters to echarts axis options for data axis - * @param {Object} param - Dashboard .yml definition options - */ -const getDataAxisOptions = ({ format, precision, name }) => { - const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui - return { - name, - nameLocation: 'center', // same as gitlab-ui's default - scale: true, - axisLabel: { - formatter: (val) => formatter(val, precision, maxDataAxisTickLength), - }, - }; -}; - -/** - * Converts .yml parameters to echarts y-axis options - * @param {Object} param - Dashboard .yml definition options - */ -export const getYAxisOptions = ({ - name = s__('Metrics|Values'), - format = defaultYAxisFormat, - precision = defaultYAxisPrecision, -} = {}) => { - return { - nameGap: yAxisNameGap, - scale: true, - boundaryGap: yAxisBoundaryGap, - - ...getDataAxisOptions({ - name, - format, - precision, - }), - }; -}; - -export const getTimeAxisOptions = ({ - timezone = timezones.LOCAL, - format = formats.shortDateTime, -} = {}) => ({ - name: __('Time'), - type: axisTypes.time, - axisLabel: { - formatter: (date) => formatDate(date, { format, timezone }), - }, - axisPointer: { - snap: false, - }, -}); - -// Chart grid - -/** - * Grid with enough room to display chart. - */ -export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({ - left, - right, -}); - -// Tooltip options - -export const getTooltipFormatter = ({ - format = defaultTooltipFormat, - precision = defaultTooltipPrecision, -} = {}) => { - const formatter = getFormatter(format); - return (num) => formatter(num, precision); -}; - -// Thresholds - -/** - * - * Used to find valid thresholds for the gauge chart - * - * An array of thresholds values is - * - duplicate values are removed; - * - filtered for invalid values; - * - sorted in ascending order; - * - only first two values are used. - */ -export const getValidThresholds = ({ mode, range = {}, values = [] } = {}) => { - const supportedModes = [thresholdModeTypes.ABSOLUTE, thresholdModeTypes.PERCENTAGE]; - const { min, max } = range; - - /** - * return early if min and max have invalid values - * or mode has invalid value - */ - if (!isFinite(min) || !isFinite(max) || min >= max || !includes(supportedModes, mode)) { - return []; - } - - const uniqueThresholds = uniq(values); - - const numberThresholds = uniqueThresholds.filter((threshold) => isFinite(threshold)); - - const validThresholds = numberThresholds.filter((threshold) => { - let isValid; - - if (mode === thresholdModeTypes.PERCENTAGE) { - isValid = threshold > 0 && threshold < 100; - } else if (mode === thresholdModeTypes.ABSOLUTE) { - isValid = threshold > min && threshold < max; - } - - return isValid; - }); - - const transformedThresholds = validThresholds.map((threshold) => { - let transformedThreshold; - - if (mode === 'percentage') { - transformedThreshold = (threshold / 100) * (max - min); - } else { - transformedThreshold = threshold; - } - - return transformedThreshold; - }); - - const sortedThresholds = sortBy(transformedThresholds); - - const reducedThresholdsArray = - sortedThresholds.length > 2 - ? [sortedThresholds[0], sortedThresholds[1]] - : [...sortedThresholds]; - - return reducedThresholdsArray; -}; diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue deleted file mode 100644 index 6d6a7af600b..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; -import { __ } from '~/locale'; -import { graphDataValidatorForValues } from '../../utils'; - -const defaultPrecision = 2; -const emptyStateMsg = __('No data to display'); - -export default { - components: { - GlSingleStat, - }, - inheritAttrs: false, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, true), - }, - }, - computed: { - queryInfo() { - return this.graphData.metrics[0]; - }, - queryMetric() { - return this.queryInfo.result[0]?.metric; - }, - queryResult() { - return this.queryInfo.result[0]?.value[1]; - }, - /** - * This method formats the query result from a promQL expression - * allowing a user to format the data in percentile values - * by using the `maxValue` inner property from the graphData prop - * @returns {(String)} - */ - statValue() { - let formatter; - - // if field is present the metric value is not displayed. Hence - // the early exit without formatting. - if (this.graphData?.field) { - return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg; - } - - if (this.graphData?.maxValue) { - formatter = getFormatter(SUPPORTED_FORMATS.number); - return formatter( - (this.queryResult / Number(this.graphData.maxValue)) * 100, - defaultPrecision, - ); - } - - formatter = getFormatter(SUPPORTED_FORMATS.number); - return `${formatter(this.queryResult, defaultPrecision)}`; - }, - unit() { - return this.graphData?.maxValue ? '%' : this.queryInfo.unit; - }, - graphTitle() { - return this.queryInfo.label; - }, - }, -}; -</script> -<template> - <div> - <gl-single-stat :value="statValue" :title="graphTitle" :unit="unit" variant="success" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue deleted file mode 100644 index 0cf39448d6b..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ /dev/null @@ -1,146 +0,0 @@ -<script> -import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; -import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { s__ } from '~/locale'; -import { chartHeight, legendLayoutTypes } from '../../constants'; -import { formats, timezones } from '../../format_date'; -import { graphDataValidatorForValues } from '../../utils'; -import { getTimeAxisOptions, axisTypes } from './options'; - -export default { - components: { - GlStackedColumnChart, - }, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, false), - }, - timezone: { - type: String, - required: false, - default: timezones.LOCAL, - }, - legendLayout: { - type: String, - required: false, - default: legendLayoutTypes.table, - }, - legendAverageText: { - type: String, - required: false, - default: s__('Metrics|Avg'), - }, - legendCurrentText: { - type: String, - required: false, - default: s__('Metrics|Current'), - }, - legendMaxText: { - type: String, - required: false, - default: s__('Metrics|Max'), - }, - legendMinText: { - type: String, - required: false, - default: s__('Metrics|Min'), - }, - }, - data() { - return { - width: 0, - height: chartHeight, - svgs: {}, - }; - }, - computed: { - chartData() { - return this.graphData.metrics - .map(({ label: name, result }) => { - // This needs a fix. Not only metrics[0] should be shown. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492 - if (!result || result.length === 0) { - return []; - } - return { name, data: result[0].values.map((val) => val[1]) }; - }) - .slice(0, 1); - }, - xAxisTitle() { - return this.graphData.x_label !== undefined ? this.graphData.x_label : ''; - }, - yAxisTitle() { - return this.graphData.y_label !== undefined ? this.graphData.y_label : ''; - }, - xAxisType() { - // stacked-column component requires the x-axis to be of type `category` - return axisTypes.category; - }, - groupBy() { - // This needs a fix. Not only metrics[0] should be shown. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492 - const { result } = this.graphData.metrics[0]; - if (!result || result.length === 0) { - return []; - } - return result[0].values.map((val) => val[0]); - }, - dataZoomConfig() { - const handleIcon = this.svgs['scroll-handle']; - - return handleIcon ? { handleIcon } : {}; - }, - chartOptions() { - return { - xAxis: { - ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }), - type: this.xAxisType, - }, - dataZoom: [this.dataZoomConfig], - }; - }, - seriesNames() { - return this.graphData.metrics.map((metric) => metric.label); - }, - }, - created() { - this.setSvg('scroll-handle'); - }, - methods: { - setSvg(name) { - getSvgIconPathContent(name) - .then((path) => { - if (path) { - this.$set(this.svgs, name, `path://${path}`); - } - }) - .catch((e) => { - // eslint-disable-next-line no-console, @gitlab/require-i18n-strings - console.error('SVG could not be rendered correctly: ', e); - }); - }, - }, -}; -</script> -<template> - <gl-stacked-column-chart - ref="chart" - v-bind="$attrs" - :responsive="true" - :bars="chartData" - :option="chartOptions" - :x-axis-title="xAxisTitle" - :y-axis-title="yAxisTitle" - :x-axis-type="xAxisType" - :group-by="groupBy" - :width="width" - :height="height" - :legend-layout="legendLayout" - :legend-average-text="legendAverageText" - :legend-current-text="legendCurrentText" - :legend-max-text="legendMaxText" - :legend-min-text="legendMinText" - /> -</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue deleted file mode 100644 index b74da3ee89b..00000000000 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ /dev/null @@ -1,420 +0,0 @@ -<script> -import { GlLink, GlTooltip, GlIcon } from '@gitlab/ui'; -import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; -import { isEmpty, omit, throttle } from 'lodash'; -import { makeDataSeries } from '~/helpers/monitor_helper'; -import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { s__ } from '~/locale'; -import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants'; -import { formatDate, timezones } from '../../format_date'; -import { graphDataValidatorForValues } from '../../utils'; -import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; -import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options'; - -export const timestampToISODate = (timestamp) => new Date(timestamp).toISOString(); - -const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds - -const events = { - datazoom: 'datazoom', -}; - -export default { - components: { - GlAreaChart, - GlLineChart, - GlTooltip, - GlChartSeriesLabel, - GlLink, - GlIcon, - }, - inheritAttrs: false, - props: { - graphData: { - type: Object, - required: true, - validator: graphDataValidatorForValues.bind(null, false), - }, - option: { - type: Object, - required: false, - default: () => ({}), - }, - timeRange: { - type: Object, - required: false, - default: () => ({}), - }, - seriesConfig: { - type: Object, - required: false, - default: () => ({}), - }, - deploymentData: { - type: Array, - required: false, - default: () => [], - }, - annotations: { - type: Array, - required: false, - default: () => [], - }, - projectPath: { - type: String, - required: false, - default: '', - }, - height: { - type: Number, - required: false, - default: chartHeight, - }, - legendLayout: { - type: String, - required: false, - default: legendLayoutTypes.table, - }, - legendAverageText: { - type: String, - required: false, - default: s__('Metrics|Avg'), - }, - legendCurrentText: { - type: String, - required: false, - default: s__('Metrics|Current'), - }, - legendMaxText: { - type: String, - required: false, - default: s__('Metrics|Max'), - }, - legendMinText: { - type: String, - required: false, - default: s__('Metrics|Min'), - }, - groupId: { - type: String, - required: false, - default: '', - }, - timezone: { - type: String, - required: false, - default: timezones.LOCAL, - }, - }, - data() { - return { - tooltip: { - type: '', - title: '', - content: [], - commitUrl: '', - sha: '', - }, - width: 0, - svgs: {}, - primaryColor: null, - throttledDatazoom: null, - }; - }, - computed: { - chartData() { - // Transforms & supplements query data to render appropriate labels & styles - // Input: [{ queryAttributes1 }, { queryAttributes2 }] - // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] - return this.graphData.metrics.reduce((acc, query) => { - const { appearance } = query; - const lineType = - appearance && appearance.line && appearance.line.type - ? appearance.line.type - : lineTypes.default; - const lineWidth = - appearance && appearance.line && appearance.line.width - ? appearance.line.width - : lineWidths.default; - const areaStyle = { - opacity: - appearance && appearance.area && typeof appearance.area.opacity === 'number' - ? appearance.area.opacity - : undefined, - }; - const series = makeDataSeries(query.result || [], { - name: this.formatLegendLabel(query), - lineStyle: { - type: lineType, - width: lineWidth, - }, - showSymbol: false, - areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, - ...this.seriesConfig, - }); - - return acc.concat(series); - }, []); - }, - chartOptionSeries() { - // After https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is implemented, - // this method will have access to annotations data - return (this.option.series || []).concat( - generateAnnotationsSeries({ - deployments: this.recentDeployments, - annotations: this.annotations, - }), - ); - }, - chartOptions() { - const { yAxis, xAxis } = this.option; - const option = omit(this.option, ['series', 'yAxis', 'xAxis']); - const xAxisBounds = isEmpty(this.timeRange) - ? {} - : { - min: this.timeRange.start, - max: this.timeRange.end, - }; - - const timeXAxis = { - ...getTimeAxisOptions({ timezone: this.timezone }), - ...xAxis, - ...xAxisBounds, - }; - - const dataYAxis = { - ...getYAxisOptions(this.graphData.yAxis), - ...yAxis, - }; - - return { - series: this.chartOptionSeries, - xAxis: timeXAxis, - yAxis: [dataYAxis, annotationsYAxis], - grid: getChartGrid(), - dataZoom: [this.dataZoomConfig], - ...option, - }; - }, - dataZoomConfig() { - const handleIcon = this.svgs['scroll-handle']; - - return handleIcon ? { handleIcon } : {}; - }, - /** - * This method returns the earliest time value in all series of a chart. - * Takes a chart data with data to populate a timeseries. - * data should be an array of data points [t, y] where t is a ISO formatted date, - * and is sorted by t (time). - * @returns {(String|null)} earliest x value from all series, or null when the - * chart series data is empty. - */ - earliestDatapoint() { - return this.chartData.reduce((acc, series) => { - const { data } = series; - const { length } = data; - if (!length) { - return acc; - } - - const [first] = data[0]; - const [last] = data[length - 1]; - const seriesEarliest = first < last ? first : last; - - return seriesEarliest < acc || acc === null ? seriesEarliest : acc; - }, null); - }, - glChartComponent() { - const chartTypes = { - [panelTypes.AREA_CHART]: GlAreaChart, - [panelTypes.LINE_CHART]: GlLineChart, - }; - return chartTypes[this.graphData.type] || GlAreaChart; - }, - isMultiSeries() { - return this.tooltip.content.length > 1; - }, - recentDeployments() { - return this.deploymentData.reduce((acc, deployment) => { - if (deployment.created_at >= this.earliestDatapoint) { - const { id, created_at: createdAt, sha, ref, tag } = deployment; - acc.push({ - id, - createdAt, - sha, - commitUrl: `${this.projectPath}/-/commit/${sha}`, - tag, - tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null, - ref: ref.name, - showDeploymentFlag: false, - icon: this.svgs.rocket, - color: this.primaryColor, - }); - } - - return acc; - }, []); - }, - tooltipYFormatter() { - // Use same format as y-axis - return getTooltipFormatter({ format: this.graphData.yAxis?.format }); - }, - }, - created() { - this.setSvg('rocket'); - this.setSvg('scroll-handle'); - }, - destroyed() { - if (this.throttledDatazoom) { - this.throttledDatazoom.cancel(); - } - }, - methods: { - formatLegendLabel(query) { - return query.label; - }, - isTooltipOfType(tooltipType, defaultType) { - return tooltipType === defaultType; - }, - /** - * This method is triggered when hovered over a single markPoint. - * - * The annotations title timestamp should match the data tooltip - * title. - * - * @params {Object} params markPoint object - * @returns {Object} - */ - formatAnnotationsTooltipText(params) { - return { - title: formatDate(params.data?.tooltipData?.title, { timezone: this.timezone }), - content: params.data?.tooltipData?.content, - }; - }, - formatTooltipText(params) { - this.tooltip.title = formatDate(params.value, { timezone: this.timezone }); - - this.tooltip.content = []; - - params.seriesData.forEach((dataPoint) => { - if (dataPoint.value) { - const [, yVal] = dataPoint.value; - this.tooltip.type = dataPoint.name; - if (this.tooltip.type === 'deployments') { - const { data = {} } = dataPoint; - this.tooltip.sha = data?.tooltipData?.sha; - this.tooltip.commitUrl = data?.tooltipData?.commitUrl; - } else { - const { seriesName, color, dataIndex } = dataPoint; - - this.tooltip.content.push({ - name: seriesName, - dataIndex, - value: this.tooltipYFormatter(yVal), - color, - }); - } - } - }); - }, - setSvg(name) { - getSvgIconPathContent(name) - .then((path) => { - if (path) { - this.$set(this.svgs, name, `path://${path}`); - } - }) - .catch((e) => { - // eslint-disable-next-line no-console, @gitlab/require-i18n-strings - console.error('SVG could not be rendered correctly: ', e); - }); - }, - onChartUpdated(eChart) { - [this.primaryColor] = eChart.getOption().color; - }, - onChartCreated(eChart) { - // Emit a datazoom event that corresponds to the eChart - // `datazoom` event. - - if (this.throttledDatazoom) { - // Chart can be created multiple times in this component's - // lifetime, remove previous handlers every time - // chart is created. - this.throttledDatazoom.cancel(); - } - - // Emitting is throttled to avoid flurries of calls when - // the user changes or scrolls the zoom bar. - this.throttledDatazoom = throttle( - () => { - const { startValue, endValue } = eChart.getOption().dataZoom[0]; - this.$emit(events.datazoom, { - start: timestampToISODate(startValue), - end: timestampToISODate(endValue), - }); - }, - THROTTLED_DATAZOOM_WAIT, - { - leading: false, - }, - ); - - // eslint-disable-next-line @gitlab/no-global-event-off - eChart.off('datazoom'); - eChart.on('datazoom', this.throttledDatazoom); - }, - }, -}; -</script> - -<template> - <component - :is="glChartComponent" - ref="chart" - v-bind="$attrs" - :responsive="true" - :group-id="groupId" - :data="chartData" - :option="chartOptions" - :format-tooltip-text="formatTooltipText" - :format-annotations-tooltip-text="formatAnnotationsTooltipText" - :width="width" - :height="height" - :legend-layout="legendLayout" - :legend-average-text="legendAverageText" - :legend-current-text="legendCurrentText" - :legend-max-text="legendMaxText" - :legend-min-text="legendMinText" - @created="onChartCreated" - @updated="onChartUpdated" - > - <template #tooltip-title> - <template v-if="tooltip.type === 'deployments'"> - {{ __('Deployed') }} - </template> - <div v-else class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <template #tooltip-content> - <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center"> - <gl-icon name="commit" class="mr-2" /> - <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> - </div> - <template v-else> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="gl-ml-7"> - {{ content.value }} - </div> - </div> - </template> - </template> - </component> -</template> diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue deleted file mode 100644 index 10178366db5..00000000000 --- a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; -import { isSafeURL } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; - -export default { - components: { GlButton, GlModal, GlSprintf }, - props: { - modalId: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: true, - validator: isSafeURL, - }, - addDashboardDocumentationPath: { - type: String, - required: true, - }, - }, - methods: { - cancelHandler() { - this.$refs.modal.hide(); - }, - }, - i18n: { - titleText: s__('Metrics|Create your dashboard configuration file'), - mainText: s__( - 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', - ), - }, -}; -</script> - -<template> - <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText"> - <p> - <gl-sprintf :message="$options.i18n.mainText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - <template #modal-footer> - <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button> - <gl-button - category="secondary" - variant="confirm" - target="_blank" - :href="addDashboardDocumentationPath" - data-testid="create-dashboard-modal-docs-button" - > - {{ s__('Metrics|View documentation') }} - </gl-button> - <gl-button - variant="confirm" - data-testid="create-dashboard-modal-repo-button" - :href="projectPath" - > - {{ s__('Metrics|Open repository') }} - </gl-button> - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue deleted file mode 100644 index cfc20b7b95f..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ /dev/null @@ -1,510 +0,0 @@ -<script> -import { - GlButton, - GlModalDirective, - GlTooltipDirective, - GlIcon, - GlAlert, - GlSprintf, - GlLink, -} from '@gitlab/ui'; -import VueDraggable from 'vuedraggable'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { createAlert } from '~/alert'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { ESC_KEY } from '~/lib/utils/keys'; -import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { metricStates } from '../constants'; -import { - timeRangeFromUrl, - panelToUrl, - expandedPanelPayloadFromUrl, - convertVariablesForURL, -} from '../utils'; -import DashboardHeader from './dashboard_header.vue'; -import DashboardPanel from './dashboard_panel.vue'; - -import EmptyState from './empty_state.vue'; -import GraphGroup from './graph_group.vue'; -import GroupEmptyState from './group_empty_state.vue'; -import LinksSection from './links_section.vue'; -import VariablesSection from './variables_section.vue'; - -export default { - components: { - VueDraggable, - DashboardHeader, - DashboardPanel, - GlIcon, - GlButton, - GraphGroup, - EmptyState, - GroupEmptyState, - VariablesSection, - LinksSection, - GlAlert, - GlSprintf, - GlLink, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, - }, - props: { - hasMetrics: { - type: Boolean, - required: false, - default: true, - }, - showHeader: { - type: Boolean, - required: false, - default: true, - }, - showPanels: { - type: Boolean, - required: false, - default: true, - }, - documentationPath: { - type: String, - required: true, - }, - settingsPath: { - type: String, - required: true, - }, - clustersPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - defaultBranch: { - type: String, - required: false, - default: '', - }, - emptyGettingStartedSvgPath: { - type: String, - required: true, - }, - emptyLoadingSvgPath: { - type: String, - required: true, - }, - emptyNoDataSvgPath: { - type: String, - required: true, - }, - emptyNoDataSmallSvgPath: { - type: String, - required: true, - }, - emptyUnableToConnectSvgPath: { - type: String, - required: true, - }, - customMetricsAvailable: { - type: Boolean, - required: false, - default: false, - }, - customMetricsPath: { - type: String, - required: false, - default: invalidUrl, - }, - validateQueryPath: { - type: String, - required: false, - default: invalidUrl, - }, - smallEmptyState: { - type: Boolean, - required: false, - default: false, - }, - rearrangePanelsAvailable: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, - isRearrangingPanels: false, - originalDocumentTitle: document.title, - hoveredPanel: '', - isDeprecationNoticeDismissed: false, - }; - }, - computed: { - ...mapState('monitoringDashboard', [ - 'dashboard', - 'emptyState', - 'expandedPanel', - 'variables', - 'links', - 'currentDashboard', - 'hasDashboardValidationWarnings', - ]), - ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']), - shouldShowEmptyState() { - return Boolean(this.emptyState); - }, - shouldShowVariablesSection() { - return Boolean(this.variables.length); - }, - shouldShowLinksSection() { - return Object.keys(this.links).length > 0; - }, - }, - watch: { - dashboard(newDashboard) { - try { - const expandedPanel = expandedPanelPayloadFromUrl(newDashboard); - if (expandedPanel) { - this.setExpandedPanel(expandedPanel); - } - } catch { - createAlert({ - message: s__( - 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.', - ), - }); - } - }, - expandedPanel: { - handler({ group, panel }) { - const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; - updateHistory({ - url: panelToUrl(dashboardPath, convertVariablesForURL(this.variables), group, panel), - title: document.title, - }); - }, - deep: true, - }, - selectedDashboard(dashboard) { - this.prependToDocumentTitle(dashboard?.display_name); - }, - hasDashboardValidationWarnings(hasWarnings) { - /** - * This watcher is set for future SPA behaviour of the dashboard - */ - if (hasWarnings) { - createAlert({ - message: s__( - 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.', - ), - - type: 'warning', - }); - } - }, - }, - mounted() { - if (!this.hasMetrics) { - this.setGettingStartedEmptyState(); - } else { - this.setTimeRange(this.selectedTimeRange); - this.fetchData(); - } - }, - methods: { - ...mapActions('monitoringDashboard', [ - 'setTimeRange', - 'fetchData', - 'setGettingStartedEmptyState', - 'setPanelGroupMetrics', - 'setExpandedPanel', - 'clearExpandedPanel', - ]), - updatePanels(key, panels) { - this.setPanelGroupMetrics({ - panels, - key, - }); - }, - removePanel(key, panels, graphIndex) { - this.setPanelGroupMetrics({ - panels: panels.filter((v, i) => i !== graphIndex), - key, - }); - }, - generatePanelUrl(groupKey, panel) { - const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; - return panelToUrl(dashboardPath, convertVariablesForURL(this.variables), groupKey, panel); - }, - /** - * Return a single empty state for a group. - * - * If all states are the same a single state is returned to be displayed - * Except if the state is OK, in which case the group is displayed. - * - * @param {String} groupKey - Identifier for group - * @returns {String} state code from `metricStates` - */ - groupSingleEmptyState(groupKey) { - const states = this.getMetricStates(groupKey); - if (states.length === 1 && states[0] !== metricStates.OK) { - return states[0]; - } - return null; - }, - /** - * Return true if the entire group is loading. - * @param {String} groupKey - Identifier for group - * @returns {boolean} - */ - isGroupLoading(groupKey) { - return this.groupSingleEmptyState(groupKey) === metricStates.LOADING; - }, - /** - * A group should be not collapsed if any metric is loaded (OK) - * - * @param {String} groupKey - Identifier for group - * @returns {Boolean} If the group should be collapsed - */ - collapseGroup(groupKey) { - // Collapse group if no data is available - return !this.getMetricStates(groupKey).includes(metricStates.OK); - }, - prependToDocumentTitle(text) { - if (text) { - document.title = `${text} · ${this.originalDocumentTitle}`; - } - }, - onTimeRangeZoom({ start, end }) { - updateHistory({ - url: mergeUrlParams({ start, end }, window.location.href), - title: document.title, - }); - this.selectedTimeRange = { start, end }; - // keep the current dashboard time range - // in sync with the Vuex store - this.setTimeRange(this.selectedTimeRange); - }, - onExpandPanel(group, panel) { - this.setExpandedPanel({ group, panel }); - }, - onGoBack() { - this.clearExpandedPanel(); - }, - onKeyup(event) { - const { key } = event; - if (key === ESC_KEY) { - this.clearExpandedPanel(); - } - }, - onSetRearrangingPanels(isRearrangingPanels) { - this.isRearrangingPanels = isRearrangingPanels; - }, - onDateTimePickerInvalid() { - createAlert({ - message: s__( - 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', - ), - }); - // As a fallback, switch to default time range instead - this.selectedTimeRange = defaultTimeRange; - }, - isPanelHalfWidth(panelIndex, totalPanels) { - /** - * A single panel on a row should take the full width of its parent. - * All others should have half the width their parent. - */ - const isNumberOfPanelsEven = totalPanels % 2 === 0; - const isLastPanel = panelIndex === totalPanels - 1; - - return isNumberOfPanelsEven || !isLastPanel; - }, - /** - * TODO: Investigate this to utilize the eventBus from Vue - * The intention behind this cleanup is to allow for better tests - * as well as use the correct eventBus facilities that are compatible - * with Vue 3 - * https://gitlab.com/gitlab-org/gitlab/-/issues/225583 - */ - // - runShortcut(actionToRun) { - const panel = this.$refs[this.hoveredPanel]; - - if (!panel) return; - - const [panelInstance] = panel; - panelInstance[actionToRun](); - }, - setHoveredPanel(groupKey, graphIndex) { - this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`; - }, - clearHoveredPanel() { - this.hoveredPanel = ''; - }, - }, - i18n: { - collapsePanelLabel: s__('Metrics|Collapse panel'), - collapsePanelTooltip: s__('Metrics|Collapse panel (Esc)'), - }, -}; -</script> -<template> - <div class="prometheus-graphs" data-testid="prometheus-graphs"> - <div> - <gl-alert - v-if="!isDeprecationNoticeDismissed" - :title="__('Feature deprecation')" - class="mb-3" - variant="warning" - @dismiss="isDeprecationNoticeDismissed = true" - > - <gl-sprintf - :message="s__('Deprecations|The metrics feature was deprecated in GitLab 14.7.')" - > - <template #epic="{ content }"> - <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - <gl-sprintf - :message=" - s__( - 'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.', - ) - " - > - <template #epic="{ content }"> - <gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - </div> - <dashboard-header - v-if="showHeader" - ref="prometheusGraphsHeader" - class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" - :default-branch="defaultBranch" - :rearrange-panels-available="rearrangePanelsAvailable" - :custom-metrics-available="customMetricsAvailable" - :custom-metrics-path="customMetricsPath" - :validate-query-path="validateQueryPath" - :is-rearranging-panels="isRearrangingPanels" - :selected-time-range="selectedTimeRange" - @dateTimePickerInvalid="onDateTimePickerInvalid" - @setRearrangingPanels="onSetRearrangingPanels" - /> - <template v-if="!shouldShowEmptyState"> - <variables-section v-if="shouldShowVariablesSection" /> - <links-section v-if="shouldShowLinksSection" /> - <dashboard-panel - v-show="expandedPanel.panel" - ref="expandedPanel" - :settings-path="settingsPath" - :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)" - :graph-data="expandedPanel.panel" - :height="600" - @timerangezoom="onTimeRangeZoom" - > - <template #top-left> - <gl-button - ref="goBackBtn" - v-gl-tooltip - class="mr-3 my-3" - :title="$options.i18n.collapsePanelTooltip" - @click="onGoBack" - > - {{ $options.i18n.collapsePanelLabel }} - </gl-button> - </template> - </dashboard-panel> - - <div v-show="!expandedPanel.panel"> - <graph-group - v-for="groupData in dashboard.panelGroups" - :key="`${groupData.group}.${groupData.priority}`" - :name="groupData.group" - :show-panels="showPanels" - :is-loading="isGroupLoading(groupData.key)" - :collapse-group="collapseGroup(groupData.key)" - > - <vue-draggable - v-if="!groupSingleEmptyState(groupData.key)" - :value="groupData.panels" - group="metrics-dashboard" - :component-data="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - attrs: { class: 'row mx-0 w-100' }, - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - :disabled="!isRearrangingPanels" - @input="updatePanels(groupData.key, $event)" - > - <div - v-for="(graphData, graphIndex) in groupData.panels" - :key="`dashboard-panel-${graphIndex}`" - data-testid="dashboard-panel-layout-wrapper" - class="col-12 px-2 mb-2 draggable" - :class="{ - 'draggable-enabled': isRearrangingPanels, - 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length), - }" - @mouseover="setHoveredPanel(groupData.key, graphIndex)" - @mouseout="clearHoveredPanel" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removePanel(groupData.key, groupData.panels, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> - <gl-icon name="close" /> - </a> - </div> - - <dashboard-panel - :ref="`dashboard-panel-${groupData.key}-${graphIndex}`" - :settings-path="settingsPath" - :clipboard-text="generatePanelUrl(groupData.group, graphData)" - :graph-data="graphData" - @timerangezoom="onTimeRangeZoom" - @expand="onExpandPanel(groupData.group, graphData)" - /> - </div> - </div> - </vue-draggable> - <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> - <group-empty-state - ref="empty-group" - :documentation-path="documentationPath" - :settings-path="settingsPath" - :selected-state="groupSingleEmptyState(groupData.key)" - :svg-path="emptyNoDataSmallSvgPath" - /> - </div> - </graph-group> - </div> - </template> - <empty-state - v-else - :selected-state="emptyState" - :documentation-path="documentationPath" - :settings-path="settingsPath" - :clusters-path="clustersPath" - :empty-getting-started-svg-path="emptyGettingStartedSvgPath" - :empty-loading-svg-path="emptyLoadingSvgPath" - :empty-no-data-svg-path="emptyNoDataSvgPath" - :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath" - :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" - :compact="smallEmptyState" - /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue deleted file mode 100644 index 29ce8572e9a..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue +++ /dev/null @@ -1,291 +0,0 @@ -<script> -import { - GlButton, - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlModal, - GlIcon, - GlModalDirective, - GlTooltipDirective, -} from '@gitlab/ui'; -import { mapState, mapGetters, mapActions } from 'vuex'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { s__ } from '~/locale'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { PANEL_NEW_PAGE } from '../router/constants'; -import { getAddMetricTrackingOptions } from '../utils'; -import CreateDashboardModal from './create_dashboard_modal.vue'; -import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; - -export default { - components: { - GlButton, - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - GlModal, - GlIcon, - DuplicateDashboardModal, - CreateDashboardModal, - CustomMetricsFormFields, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, - }, - props: { - addingMetricsAvailable: { - type: Boolean, - required: false, - default: false, - }, - customMetricsPath: { - type: String, - required: false, - default: invalidUrl, - }, - validateQueryPath: { - type: String, - required: false, - default: invalidUrl, - }, - defaultBranch: { - type: String, - required: true, - }, - isOotbDashboard: { - type: Boolean, - required: true, - }, - }, - data() { - return { customMetricsFormIsValid: null }; - }, - computed: { - ...mapState('monitoringDashboard', [ - 'projectPath', - 'isUpdatingStarredValue', - 'addDashboardDocumentationPath', - ]), - ...mapGetters('monitoringDashboard', ['selectedDashboard']), - isOutOfTheBoxDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard; - }, - isMenuItemEnabled() { - return { - addPanel: !this.isOotbDashboard, - createDashboard: Boolean(this.projectPath), - editDashboard: this.selectedDashboard?.can_edit, - }; - }, - isMenuItemShown() { - return { - duplicateDashboard: this.isOutOfTheBoxDashboard, - }; - }, - newPanelPageLocation() { - // Retains params/query if any - const { params, query } = this.$route ?? {}; - return { name: PANEL_NEW_PAGE, params, query }; - }, - }, - methods: { - ...mapActions('monitoringDashboard', ['toggleStarredValue']), - setFormValidity(isValid) { - this.customMetricsFormIsValid = isValid; - }, - hideAddMetricModal() { - this.$refs.addMetricModal.hide(); - }, - getAddMetricTrackingOptions, - submitCustomMetricsForm() { - this.$refs.customMetricsForm.submit(); - }, - selectDashboard(dashboard) { - // Once the sidebar See metrics link is updated to the new URL, - // this sort of hardcoding will not be necessary. - // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 - const baseURL = `${this.projectPath}/-/metrics`; - const dashboardPath = encodeURIComponent( - dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, - ); - redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated - }, - }, - - modalIds: { - addMetric: 'addMetric', - createDashboard: 'createDashboard', - duplicateDashboard: 'duplicateDashboard', - }, - i18n: { - actionsMenu: s__('Metrics|More actions'), - duplicateDashboard: s__('Metrics|Duplicate current dashboard'), - starDashboard: s__('Metrics|Star dashboard'), - unstarDashboard: s__('Metrics|Unstar dashboard'), - addMetric: s__('Metrics|Add metric'), - addPanel: s__('Metrics|Add panel'), - addPanelInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), - editDashboardInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), - editDashboard: s__('Metrics|Edit dashboard YAML'), - createDashboard: s__('Metrics|Create new dashboard'), - }, -}; -</script> - -<template> - <!-- - This component should be replaced with a variant developed - as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 - The variant will create a dropdown with an icon, no text and no caret - --> - <gl-dropdown - v-gl-tooltip - data-testid="actions-menu" - right - no-caret - toggle-class="gl-px-3!" - :title="$options.i18n.actionsMenu" - > - <template #button-content> - <gl-icon class="gl-mr-0!" name="ellipsis_v" /> - </template> - - <template v-if="addingMetricsAvailable"> - <gl-dropdown-item - v-gl-modal="$options.modalIds.addMetric" - data-qa-selector="add_metric_button" - data-testid="add-metric-item" - > - {{ $options.i18n.addMetric }} - </gl-dropdown-item> - <gl-modal - ref="addMetricModal" - :modal-id="$options.modalIds.addMetric" - :title="$options.i18n.addMetric" - data-testid="add-metric-modal" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <template #modal-footer> - <div> - <gl-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-button> - <gl-button - v-track-event="getAddMetricTrackingOptions()" - data-testid="add-metric-modal-submit-button" - :disabled="!customMetricsFormIsValid" - variant="confirm" - @click="submitCustomMetricsForm" - > - {{ __('Save changes') }} - </gl-button> - </div> - </template> - </gl-modal> - </template> - - <gl-dropdown-item - v-if="isMenuItemEnabled.addPanel" - data-testid="add-panel-item-enabled" - :to="newPanelPageLocation" - > - {{ $options.i18n.addPanel }} - </gl-dropdown-item> - - <!-- - wrapper for tooltip as button can be `disabled` - https://bootstrap-vue.org/docs/components/tooltip#disabled-elements - --> - <div v-else v-gl-tooltip :title="$options.i18n.addPanelInfo"> - <gl-dropdown-item - :alt="$options.i18n.addPanelInfo" - :to="newPanelPageLocation" - data-testid="add-panel-item-disabled" - disabled - class="gl-cursor-not-allowed" - > - <span class="gl-text-gray-400">{{ $options.i18n.addPanel }}</span> - </gl-dropdown-item> - </div> - - <gl-dropdown-item - v-if="isMenuItemEnabled.editDashboard" - :href="selectedDashboard ? selectedDashboard.project_blob_path : null" - data-testid="edit-dashboard-item-enabled" - > - {{ $options.i18n.editDashboard }} - </gl-dropdown-item> - - <!-- - wrapper for tooltip as button can be `disabled` - https://bootstrap-vue.org/docs/components/tooltip#disabled-elements - --> - <div v-else v-gl-tooltip :title="$options.i18n.editDashboardInfo"> - <gl-dropdown-item - :alt="$options.i18n.editDashboardInfo" - :href="selectedDashboard ? selectedDashboard.project_blob_path : null" - data-testid="edit-dashboard-item-disabled" - disabled - class="gl-cursor-not-allowed" - > - <span class="gl-text-gray-400">{{ $options.i18n.editDashboard }}</span> - </gl-dropdown-item> - </div> - - <template v-if="isMenuItemShown.duplicateDashboard"> - <gl-dropdown-item - v-gl-modal="$options.modalIds.duplicateDashboard" - data-testid="duplicate-dashboard-item" - > - {{ $options.i18n.duplicateDashboard }} - </gl-dropdown-item> - - <duplicate-dashboard-modal - :default-branch="defaultBranch" - :modal-id="$options.modalIds.duplicateDashboard" - data-testid="duplicate-dashboard-modal" - @dashboardDuplicated="selectDashboard" - /> - </template> - - <gl-dropdown-item - v-if="selectedDashboard" - data-testid="star-dashboard-item" - :disabled="isUpdatingStarredValue" - @click="toggleStarredValue()" - > - {{ selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard }} - </gl-dropdown-item> - - <gl-dropdown-divider /> - - <gl-dropdown-item - v-gl-modal="$options.modalIds.createDashboard" - data-testid="create-dashboard-item" - :disabled="!isMenuItemEnabled.createDashboard" - :class="{ 'monitoring-actions-item-disabled': !isMenuItemEnabled.createDashboard }" - > - {{ $options.i18n.createDashboard }} - </gl-dropdown-item> - - <template v-if="isMenuItemEnabled.createDashboard"> - <create-dashboard-modal - data-testid="create-dashboard-modal" - :add-dashboard-documentation-path="addDashboardDocumentationPath" - :modal-id="$options.modalIds.createDashboard" - :project-path="projectPath" - /> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue deleted file mode 100644 index f4dc29f2184..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ /dev/null @@ -1,294 +0,0 @@ -<script> -import { - GlButton, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlModalDirective, - GlTooltipDirective, - GlIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { s__ } from '~/locale'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; - -import { timeRanges } from '~/vue_shared/constants'; -import { timezones } from '../format_date'; -import { timeRangeToUrl } from '../utils'; -import ActionsMenu from './dashboard_actions_menu.vue'; -import DashboardsDropdown from './dashboards_dropdown.vue'; -import RefreshButton from './refresh_button.vue'; - -export default { - i18n: { - metricsSettings: s__('Metrics|Metrics Settings'), - }, - components: { - GlIcon, - GlButton, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownSectionHeader, - - GlSearchBoxByType, - - DateTimePicker, - DashboardsDropdown, - RefreshButton, - - ActionsMenu, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - props: { - defaultBranch: { - type: String, - required: true, - }, - rearrangePanelsAvailable: { - type: Boolean, - required: false, - default: false, - }, - customMetricsAvailable: { - type: Boolean, - required: false, - default: false, - }, - customMetricsPath: { - type: String, - required: false, - default: invalidUrl, - }, - validateQueryPath: { - type: String, - required: false, - default: invalidUrl, - }, - isRearrangingPanels: { - type: Boolean, - required: true, - }, - selectedTimeRange: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState('monitoringDashboard', [ - 'emptyState', - 'environmentsLoading', - 'currentEnvironmentName', - 'dashboardTimezone', - 'projectPath', - 'canAccessOperationsSettings', - 'operationsSettingsPath', - 'currentDashboard', - 'externalDashboardUrl', - ]), - ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), - shouldShowEmptyState() { - return Boolean(this.emptyState); - }, - shouldShowEnvironmentsDropdownNoMatchedMsg() { - return !this.environmentsLoading && this.filteredEnvironments.length === 0; - }, - addingMetricsAvailable() { - return ( - this.customMetricsAvailable && - !this.shouldShowEmptyState && - // Custom metrics only available on system dashboards because - // they are stored in the database. This can be improved. See: - // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 - this.selectedDashboard?.out_of_the_box_dashboard - ); - }, - showRearrangePanelsBtn() { - return !this.shouldShowEmptyState && this.rearrangePanelsAvailable; - }, - environmentDropdownText() { - return this.currentEnvironmentName ?? ''; - }, - displayUtc() { - return this.dashboardTimezone === timezones.UTC; - }, - shouldShowSettingsButton() { - return this.canAccessOperationsSettings && this.operationsSettingsPath; - }, - isOOTBDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard ?? false; - }, - }, - methods: { - ...mapActions('monitoringDashboard', ['filterEnvironments']), - selectDashboard(dashboard) { - // Once the sidebar See metrics link is updated to the new URL, - // this sort of hardcoding will not be necessary. - // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 - const baseURL = `${this.projectPath}/-/metrics`; - const dashboardPath = encodeURIComponent( - dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, - ); - redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated - }, - debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { - this.filterEnvironments(searchTerm); - }, 500), - onDateTimePickerInput(timeRange) { - redirectTo(timeRangeToUrl(timeRange)); // eslint-disable-line import/no-deprecated - }, - onDateTimePickerInvalid() { - this.$emit('dateTimePickerInvalid'); - }, - - toggleRearrangingPanels() { - this.$emit('setRearrangingPanels', !this.isRearrangingPanels); - }, - getEnvironmentPath(environment) { - // Once the sidebar See metrics link is updated to the new URL, - // this sort of hardcoding will not be necessary. - // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 - const baseURL = `${this.projectPath}/-/metrics`; - const dashboardPath = encodeURIComponent(this.currentDashboard || ''); - // The environment_metrics_spec.rb requires the URL to not have - // slashes. Hence, this additional check. - const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL; - return mergeUrlParams({ environment }, url); - }, - }, - timeRanges, -}; -</script> - -<template> - <div ref="prometheusGraphsHeader"> - <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"> - <dashboards-dropdown - id="monitor-dashboards-dropdown" - class="flex-grow-1" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - @selectDashboard="selectDashboard" - /> - </div> - - <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> - - <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - class="flex-grow-1" - data-testid="environments-dropdown" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="environmentDropdownText" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header> - <gl-search-box-by-type @input="debouncedEnvironmentsSearch" /> - - <gl-loading-icon v-if="environmentsLoading" size="sm" :inline="true" /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - is-check-item - :is-checked="environment.name === currentEnvironmentName" - :href="getEnvironmentPath(environment.id)" - > - {{ environment.name }} - </gl-dropdown-item> - </div> - <div - v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" - ref="monitorEnvironmentsDropdownMsg" - class="text-secondary no-matches-message" - > - {{ __('No matching results') }} - </div> - </div> - </gl-dropdown> - </div> - - <div class="mb-2 pr-2 d-flex d-sm-block"> - <date-time-picker - ref="dateTimePicker" - class="flex-grow-1 show-last-dropdown" - :value="selectedTimeRange" - :options="$options.timeRanges" - :utc="displayUtc" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </div> - - <div class="mb-2 pr-2 d-flex d-sm-block"> - <refresh-button /> - </div> - - <div class="flex-grow-1"></div> - - <div class="d-sm-flex"> - <div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex"> - <gl-button - :pressed="isRearrangingPanels" - variant="default" - class="flex-grow-1 js-rearrange-button" - @click="toggleRearrangingPanels" - > - {{ __('Arrange charts') }} - </gl-button> - </div> - - <div - v-if="externalDashboardUrl && externalDashboardUrl.length" - class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block" - > - <gl-button - class="flex-grow-1 js-external-dashboard-link" - variant="confirm" - category="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} <gl-icon name="external-link" /> - </gl-button> - </div> - - <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> - <actions-menu - :adding-metrics-available="addingMetricsAvailable" - :custom-metrics-path="customMetricsPath" - :validate-query-path="validateQueryPath" - :default-branch="defaultBranch" - :is-ootb-dashboard="isOOTBDashboard" - /> - </div> - - <template v-if="shouldShowSettingsButton"> - <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> - - <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> - <gl-button - v-gl-tooltip - data-testid="metrics-settings-button" - icon="settings" - :href="operationsSettingsPath" - :title="$options.i18n.metricsSettings" - :aria-label="$options.i18n.metricsSettings" - /> - </div> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue deleted file mode 100644 index 9ad6da35d6b..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ /dev/null @@ -1,388 +0,0 @@ -<script> -import { - GlResizeObserverDirective, - GlIcon, - GlLink, - GlLoadingIcon, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlModal, - GlModalDirective, - GlSprintf, - GlTooltip, - GlTooltipDirective, -} from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { isSafeURL } from '~/lib/utils/url_utility'; -import { __, n__ } from '~/locale'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { panelTypes } from '../constants'; - -import { graphDataToCsv } from '../csv_export'; -import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -import MonitorAnomalyChart from './charts/anomaly.vue'; -import MonitorBarChart from './charts/bar.vue'; -import MonitorColumnChart from './charts/column.vue'; -import MonitorEmptyChart from './charts/empty_chart.vue'; -import MonitorGaugeChart from './charts/gauge.vue'; -import MonitorHeatmapChart from './charts/heatmap.vue'; -import MonitorSingleStatChart from './charts/single_stat.vue'; -import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import MonitorTimeSeriesChart from './charts/time_series.vue'; - -const events = { - timeRangeZoom: 'timerangezoom', - expand: 'expand', -}; - -export default { - components: { - MonitorEmptyChart, - GlIcon, - GlLink, - GlLoadingIcon, - GlTooltip, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlModal, - GlSprintf, - }, - directives: { - GlResizeObserver: GlResizeObserverDirective, - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, - }, - props: { - clipboardText: { - type: String, - required: false, - default: '', - }, - graphData: { - type: Object, - required: false, - default: null, - }, - groupId: { - type: String, - required: false, - default: 'dashboard-panel', - }, - namespace: { - type: String, - required: false, - default: 'monitoringDashboard', - }, - settingsPath: { - type: String, - required: false, - default: null, - }, - }, - data() { - return { - showTitleTooltip: false, - zoomedTimeRange: null, - expandBtnAvailable: Boolean(this.$listeners[events.expand]), - }; - }, - computed: { - // Use functions to support dynamic namespaces in mapXXX helpers. Pattern described - // in https://github.com/vuejs/vuex/issues/863#issuecomment-329510765 - ...mapState({ - deploymentData(state) { - return state[this.namespace].deploymentData; - }, - annotations(state) { - return state[this.namespace].annotations; - }, - projectPath(state) { - return state[this.namespace].projectPath; - }, - timeRange(state) { - return state[this.namespace].timeRange; - }, - dashboardTimezone(state) { - return state[this.namespace].dashboardTimezone; - }, - metricsSavedToDb(state, getters) { - return getters[`${this.namespace}/metricsSavedToDb`]; - }, - selectedDashboard(state, getters) { - return getters[`${this.namespace}/selectedDashboard`]; - }, - }), - fixedCurrentTimeRange() { - // convertToFixedRange throws an error if the time range - // is not properly set. - try { - return convertToFixedRange(this.timeRange); - } catch { - return {}; - } - }, - title() { - return this.graphData?.title || ''; - }, - graphDataHasResult() { - const metrics = this.graphData?.metrics || []; - return metrics.some(({ result }) => result?.length > 0); - }, - graphDataIsLoading() { - const metrics = this.graphData?.metrics || []; - return metrics.some(({ loading }) => loading); - }, - csvText() { - if (this.graphData) { - return graphDataToCsv(this.graphData); - } - return null; - }, - downloadCsv() { - const data = new Blob([this.csvText], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, - - /** - * A chart is "basic" if it doesn't support - * the same features as the TimeSeries based components - * such as "annotations". - * - * @returns Vue Component wrapping a basic visualization - */ - basicChartComponent() { - if (this.isPanelType(panelTypes.SINGLE_STAT)) { - return MonitorSingleStatChart; - } - if (this.isPanelType(panelTypes.GAUGE_CHART)) { - return MonitorGaugeChart; - } - if (this.isPanelType(panelTypes.HEATMAP)) { - return MonitorHeatmapChart; - } - if (this.isPanelType(panelTypes.BAR)) { - return MonitorBarChart; - } - if (this.isPanelType(panelTypes.COLUMN)) { - return MonitorColumnChart; - } - if (this.isPanelType(panelTypes.STACKED_COLUMN)) { - return MonitorStackedColumnChart; - } - if (this.isPanelType(panelTypes.ANOMALY_CHART)) { - return MonitorAnomalyChart; - } - return null; - }, - - /** - * In monitoring, Time Series charts typically support - * a larger feature set like "annotations", "deployment - * data" and "datazoom". - * - * This is intentional as Time Series are more frequently - * used. - * - * @returns Vue Component wrapping a time series visualization, - * Area Charts are rendered by default. - */ - timeSeriesChartComponent() { - if (this.isPanelType(panelTypes.ANOMALY_CHART)) { - return MonitorAnomalyChart; - } - return MonitorTimeSeriesChart; - }, - isContextualMenuShown() { - if (!this.graphDataHasResult) { - return false; - } - // Only a few charts have a contextual menu, support - // for more chart types planned at: - // https://gitlab.com/groups/gitlab-org/-/epics/3573 - return ( - this.isPanelType(panelTypes.AREA_CHART) || - this.isPanelType(panelTypes.LINE_CHART) || - this.isPanelType(panelTypes.SINGLE_STAT) || - this.isPanelType(panelTypes.GAUGE_CHART) - ); - }, - editCustomMetricLink() { - if (this.graphData.metrics.length > 1) { - return this.settingsPath; - } - return this.graphData?.metrics[0].edit_path; - }, - editCustomMetricLinkText() { - return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length); - }, - hasMetricsInDb() { - const { metrics = [] } = this.graphData; - return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); - }, - }, - mounted() { - this.refreshTitleTooltip(); - }, - methods: { - isPanelType(type) { - return this.graphData?.type === type; - }, - showToast() { - this.$toast.show(__('Link copied')); - }, - refreshTitleTooltip() { - const { graphTitle } = this.$refs; - this.showTitleTooltip = - Boolean(graphTitle) && graphTitle.scrollWidth > graphTitle.offsetWidth; - }, - - downloadCSVOptions, - generateLinkToChartOptions, - - onResize() { - this.refreshTitleTooltip(); - }, - onDatazoom({ start, end }) { - this.zoomedTimeRange = { start, end }; - this.$emit(events.timeRangeZoom, { start, end }); - }, - onExpand() { - this.$emit(events.expand); - }, - onExpandFromKeyboardShortcut() { - if (this.isContextualMenuShown) { - this.onExpand(); - } - }, - safeUrl(url) { - return isSafeURL(url) ? url : '#'; - }, - downloadCsvFromKeyboardShortcut() { - if (this.csvText && this.isContextualMenuShown) { - this.$refs.downloadCsvLink.$el.firstChild.click(); - } - }, - copyChartLinkFromKeyboardShotcut() { - if (this.clipboardText && this.isContextualMenuShown) { - this.$refs.copyChartLink.$el.firstChild.click(); - } - }, - }, - panelTypes, -}; -</script> -<template> - <div v-gl-resize-observer="onResize" class="prometheus-graph"> - <div class="d-flex align-items-center"> - <slot name="top-left"></slot> - <h5 - ref="graphTitle" - class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3" - > - {{ title }} - </h5> - <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip"> - {{ title }} - </gl-tooltip> - <div class="flex-grow-1"></div> - <div v-if="graphDataIsLoading" class="mx-1 mt-1"> - <gl-loading-icon size="sm" /> - </div> - <div v-if="isContextualMenuShown" ref="contextualMenu"> - <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> - <!-- - This component should be replaced with a variant developed - as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 - The variant will create a dropdown with an icon, no text and no caret - --> - <gl-dropdown - v-gl-tooltip - icon="ellipsis_v" - :text="__('More actions')" - :text-sr-only="true" - toggle-class="gl-px-3!" - no-caret - right - :title="__('More actions')" - > - <gl-dropdown-item v-if="expandBtnAvailable" ref="expandBtn" @click.prevent="onExpand"> - {{ s__('Metrics|Expand panel') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="editCustomMetricLink" - ref="editMetricLink" - :href="editCustomMetricLink" - > - {{ editCustomMetricLinkText }} - </gl-dropdown-item> - - <gl-dropdown-item - v-if="csvText" - ref="downloadCsvLink" - v-track-event="downloadCSVOptions(title)" - :href="downloadCsv" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="clipboardText" - ref="copyChartLink" - v-track-event="generateLinkToChartOptions(clipboardText)" - :data-clipboard-text="clipboardText" - @click="showToast(clipboardText)" - > - {{ __('Copy link to chart') }} - </gl-dropdown-item> - - <template v-if="graphData.links && graphData.links.length"> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="(link, index) in graphData.links" - :key="index" - :href="safeUrl(link.url)" - class="text-break" - >{{ link.title }}</gl-dropdown-item - > - </template> - <template v-if="selectedDashboard && selectedDashboard.can_edit"> - <gl-dropdown-divider /> - <gl-dropdown-item ref="manageLinksItem" :href="selectedDashboard.project_blob_path">{{ - s__('Metrics|Manage chart links') - }}</gl-dropdown-item> - </template> - </gl-dropdown> - </div> - </div> - </div> - - <monitor-empty-chart v-if="!graphDataHasResult" /> - <component - :is="basicChartComponent" - v-else-if="basicChartComponent" - :graph-data="graphData" - :timezone="dashboardTimezone" - v-bind="$attrs" - v-on="$listeners" - /> - <component - :is="timeSeriesChartComponent" - v-else - ref="timeSeriesChart" - :graph-data="graphData" - :deployment-data="deploymentData" - :annotations="annotations" - :project-path="projectPath" - :group-id="groupId" - :timezone="dashboardTimezone" - :time-range="fixedCurrentTimeRange" - v-bind="$attrs" - v-on="$listeners" - @datazoom="onDatazoom" - /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue deleted file mode 100644 index e8a9c24f5c2..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ /dev/null @@ -1,204 +0,0 @@ -<script> -import { - GlCard, - GlForm, - GlFormGroup, - GlFormTextarea, - GlButton, - GlSprintf, - GlAlert, - GlTooltipDirective, -} from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import { timeRanges } from '~/vue_shared/constants'; -import DashboardPanel from './dashboard_panel.vue'; - -const initialYml = `title: Go heap size -type: area-chart -y_axis: - format: 'bytes' -metrics: - - metric_id: 'go_memstats_alloc_bytes_1' - query_range: 'go_memstats_alloc_bytes' -`; - -export default { - i18n: { - refreshButtonLabel: s__('Metrics|Refresh Prometheus data'), - }, - components: { - GlCard, - GlForm, - GlFormGroup, - GlFormTextarea, - GlButton, - GlSprintf, - GlAlert, - DashboardPanel, - DateTimePicker, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - data() { - return { - yml: initialYml, - }; - }, - computed: { - ...mapState('monitoringDashboard', [ - 'panelPreviewIsLoading', - 'panelPreviewError', - 'panelPreviewGraphData', - 'panelPreviewTimeRange', - 'panelPreviewIsShown', - 'projectPath', - 'addDashboardDocumentationPath', - ]), - }, - methods: { - ...mapActions('monitoringDashboard', [ - 'fetchPanelPreview', - 'fetchPanelPreviewMetrics', - 'setPanelPreviewTimeRange', - ]), - onSubmit() { - this.fetchPanelPreview(this.yml); - }, - onDateTimePickerInput(timeRange) { - this.setPanelPreviewTimeRange(timeRange); - // refetch data only if preview has been clicked - // and there are no errors - if (this.panelPreviewIsShown && !this.panelPreviewError) { - this.fetchPanelPreviewMetrics(); - } - }, - onRefresh() { - // refetch data only if preview has been clicked - // and there are no errors - if (this.panelPreviewIsShown && !this.panelPreviewError) { - this.fetchPanelPreviewMetrics(); - } - }, - }, - timeRanges, -}; -</script> -<template> - <div class="prometheus-panel-builder"> - <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3"> - <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5"> - <template #header> - <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2> - </template> - <template #default> - <p>{{ s__('Metrics|Define panel YAML below to preview panel.') }}</p> - <gl-form @submit.prevent="onSubmit"> - <gl-form-group :label="s__('Metrics|Panel YAML')" label-for="panel-yml-input"> - <gl-form-textarea - id="panel-yml-input" - v-model="yml" - class="gl-h-200! gl-font-monospace!" - /> - </gl-form-group> - <div class="gl-text-right"> - <gl-button - ref="clipboardCopyBtn" - variant="confirm" - category="secondary" - :data-clipboard-text="yml" - class="gl-xs-w-full gl-xs-mb-3" - @click="$toast.show(s__('Metrics|Panel YAML copied'))" - > - {{ s__('Metrics|Copy YAML') }} - </gl-button> - <gl-button - type="submit" - variant="confirm" - :disabled="panelPreviewIsLoading" - class="js-no-auto-disable gl-xs-w-full" - > - {{ s__('Metrics|Preview panel') }} - </gl-button> - </div> - </gl-form> - </template> - </gl-card> - - <gl-card - class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5" - body-class="gl-display-flex gl-flex-direction-column" - > - <template #header> - <h2 class="gl-font-size-h2 gl-my-3"> - {{ s__('Metrics|2. Paste panel YAML into dashboard') }} - </h2> - </template> - <template #default> - <div - class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-justify-content-center" - > - <p> - {{ s__('Metrics|Copy and paste the panel YAML into your dashboard YAML file.') }} - <br /> - <gl-sprintf - :message=" - s__( - 'Metrics|Dashboard files can be found in %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', - ) - " - > - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - </div> - - <div class="gl-text-right"> - <gl-button - ref="viewDocumentationBtn" - category="secondary" - class="gl-xs-w-full gl-xs-mb-3" - variant="confirm" - target="_blank" - :href="addDashboardDocumentationPath" - > - {{ s__('Metrics|View documentation') }} - </gl-button> - <gl-button - ref="openRepositoryBtn" - variant="confirm" - :href="projectPath" - class="gl-xs-w-full" - > - {{ s__('Metrics|Open repository') }} - </gl-button> - </div> - </template> - </gl-card> - </div> - - <gl-alert v-if="panelPreviewError" variant="warning" :dismissible="false"> - {{ panelPreviewError }} - </gl-alert> - <date-time-picker - ref="dateTimePicker" - class="gl-flex-grow-1 preview-date-time-picker gl-xs-mb-3" - :value="panelPreviewTimeRange" - :options="$options.timeRanges" - @input="onDateTimePickerInput" - /> - <gl-button - v-gl-tooltip - data-testid="previewRefreshButton" - icon="retry" - :title="$options.i18n.refreshButtonLabel" - :aria-label="$options.i18n.refreshButtonLabel" - @click="onRefresh" - /> - <dashboard-panel :graph-data="panelPreviewGraphData" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue deleted file mode 100644 index 7fae684315c..00000000000 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ /dev/null @@ -1,127 +0,0 @@ -<script> -import { - GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlSearchBoxByType, - GlModalDirective, -} from '@gitlab/ui'; -import { mapState, mapGetters } from 'vuex'; - -const events = { - selectDashboard: 'selectDashboard', -}; - -export default { - components: { - GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlSearchBoxByType, - }, - directives: { - GlModal: GlModalDirective, - }, - props: { - defaultBranch: { - type: String, - required: true, - }, - }, - data() { - return { - searchTerm: '', - }; - }, - computed: { - ...mapState('monitoringDashboard', ['allDashboards']), - ...mapGetters('monitoringDashboard', ['selectedDashboard']), - selectedDashboardText() { - return this.selectedDashboard?.display_name; - }, - selectedDashboardPath() { - return this.selectedDashboard?.path; - }, - - filteredDashboards() { - return this.allDashboards.filter(({ display_name: displayName = '' }) => - displayName.toLowerCase().includes(this.searchTerm.toLowerCase()), - ); - }, - shouldShowNoMsgContainer() { - return this.filteredDashboards.length === 0; - }, - starredDashboards() { - return this.filteredDashboards.filter(({ starred }) => starred); - }, - nonStarredDashboards() { - return this.filteredDashboards.filter(({ starred }) => !starred); - }, - }, - methods: { - dashboardDisplayName(dashboard) { - return dashboard.display_name || dashboard.path || ''; - }, - selectDashboard(dashboard) { - this.$emit(events.selectDashboard, dashboard); - }, - }, -}; -</script> -<template> - <gl-dropdown - toggle-class="dropdown-menu-toggle" - menu-class="monitor-dashboard-dropdown-menu" - :text="selectedDashboardText" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-section-header>{{ __('Dashboard') }}</gl-dropdown-section-header> - <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" /> - - <div class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="dashboard in starredDashboards" - :key="dashboard.path" - is-check-item - :is-checked="dashboard.path === selectedDashboardPath" - @click="selectDashboard(dashboard)" - > - <div class="gl-display-flex"> - <span class="gl-flex-grow-1 gl-min-w-0 gl-overflow-hidden gl-overflow-wrap-break"> - {{ dashboardDisplayName(dashboard) }} - </span> - <gl-icon class="text-muted gl-flex-shrink-0 gl-ml-3 gl-align-self-center" name="star" /> - </div> - </gl-dropdown-item> - <gl-dropdown-divider - v-if="starredDashboards.length && nonStarredDashboards.length" - ref="starredListDivider" - /> - - <gl-dropdown-item - v-for="dashboard in nonStarredDashboards" - :key="dashboard.path" - is-check-item - :is-checked="dashboard.path === selectedDashboardPath" - @click="selectDashboard(dashboard)" - > - <span class="gl-overflow-hidden gl-overflow-wrap-break"> - {{ dashboardDisplayName(dashboard) }} - </span> - </gl-dropdown-item> - </div> - - <div - v-show="shouldShowNoMsgContainer" - ref="monitorDashboardsDropdownMsg" - class="text-secondary no-matches-message" - > - {{ __('No matching results') }} - </div> - </div> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue deleted file mode 100644 index 9ad14b3d52e..00000000000 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; -import { __, s__, sprintf } from '~/locale'; - -const defaultFileName = (dashboard) => dashboard.path.split('/').reverse()[0]; - -export default { - components: { - GlFormGroup, - GlFormInput, - GlFormRadioGroup, - GlFormTextarea, - }, - props: { - dashboard: { - type: Object, - required: true, - }, - defaultBranch: { - type: String, - required: true, - }, - }, - radioVals: { - /* Use the default branch (e.g. main) */ - DEFAULT: 'DEFAULT', - /* Create a new branch */ - NEW: 'NEW', - }, - data() { - return { - form: { - dashboard: this.dashboard.path, - fileName: defaultFileName(this.dashboard), - commitMessage: '', - }, - branchName: '', - branchOption: this.$options.radioVals.NEW, - branchOptions: [ - { - value: this.$options.radioVals.DEFAULT, - html: sprintf( - __('Commit to %{branchName} branch'), - { - branchName: `<strong>${esc(this.defaultBranch)}</strong>`, - }, - false, - ), - }, - { value: this.$options.radioVals.NEW, text: __('Create new branch') }, - ], - }; - }, - computed: { - defaultCommitMsg() { - return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), { - fileName: this.form.fileName, - }); - }, - fileNameState() { - // valid if empty or *.yml - return !(this.form.fileName && !this.form.fileName.endsWith('.yml')); - }, - fileNameFeedback() { - return !this.fileNameState ? __('The file name should have a .yml extension') : ''; - }, - }, - mounted() { - this.change(); - }, - methods: { - change() { - this.$emit('change', { - ...this.form, - commitMessage: this.form.commitMessage || this.defaultCommitMsg, - branch: - this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch, - }); - }, - focus(option) { - if (option === this.$options.radioVals.NEW) { - this.$nextTick(() => { - this.$refs.branchName.$el.focus(); - }); - } - }, - }, -}; -</script> -<template> - <form @change="change"> - <p class="text-muted"> - {{ - s__(`Metrics|You can save a copy of this dashboard to your repository - so it can be customized. Select a file name and branch to save it.`) - }} - </p> - <gl-form-group - ref="fileNameFormGroup" - :label="__('File name')" - :state="fileNameState" - :invalid-feedback="fileNameFeedback" - label-size="sm" - label-for="fileName" - > - <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> - </gl-form-group> - <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> - <gl-form-radio-group - ref="branchOption" - v-model="branchOption" - :checked="$options.radioVals.NEW" - :stacked="true" - :options="branchOptions" - @change="focus" - /> - <gl-form-input - v-show="branchOption === $options.radioVals.NEW" - id="branchName" - ref="branchName" - v-model="branchName" - /> - </gl-form-group> - <gl-form-group - :label="__('Commit message (optional)')" - label-size="sm" - label-for="commitMessage" - > - <gl-form-textarea - id="commitMessage" - ref="commitMessage" - v-model="form.commitMessage" - :placeholder="defaultCommitMsg" - /> - </gl-form-group> - </form> -</template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue deleted file mode 100644 index d1ce7bad39a..00000000000 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { GlAlert, GlModal } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; -import { __, s__ } from '~/locale'; -import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; - -const events = { - dashboardDuplicated: 'dashboardDuplicated', -}; - -export default { - components: { GlAlert, GlModal, DuplicateDashboardForm }, - props: { - defaultBranch: { - type: String, - required: true, - }, - modalId: { - type: String, - required: true, - }, - }, - data() { - return { - alert: null, - loading: false, - form: {}, - }; - }, - computed: { - ...mapGetters('monitoringDashboard', ['selectedDashboard']), - okButtonText() { - return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); - }, - actionPrimaryProps() { - return { - text: this.okButtonText, - attributes: { - loading: this.loading, - variant: 'confirm', - }, - }; - }, - actionCancelProps() { - return { - text: __('Cancel'), - }; - }, - }, - methods: { - ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), - ok(bvModalEvt) { - // Prevent modal from hiding in case submit fails - bvModalEvt.preventDefault(); - - this.loading = true; - this.alert = null; - this.duplicateSystemDashboard(this.form) - .then((createdDashboard) => { - this.loading = false; - this.alert = null; - - // Trigger hide modal as submit is successful - this.$refs.duplicateDashboardModal.hide(); - - // Dashboards in the default branch become available immediately. - // Not so in other branches, so we refresh the current dashboard - const dashboard = - this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; - this.$emit(events.dashboardDuplicated, dashboard); - }) - .catch((error) => { - this.loading = false; - this.alert = error; - }); - }, - hide() { - this.alert = null; - }, - formChange(form) { - this.form = form; - }, - }, -}; -</script> - -<template> - <gl-modal - ref="duplicateDashboardModal" - :modal-id="modalId" - :title="s__('Metrics|Duplicate dashboard')" - :action-primary="actionPrimaryProps" - :action-cancel="actionCancelProps" - @ok="ok" - @hide="hide" - > - <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> - {{ alert }} - </gl-alert> - <duplicate-dashboard-form - :dashboard="selectedDashboard" - :default-branch="defaultBranch" - @change="formChange" - /> - </gl-modal> -</template> diff --git a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue deleted file mode 100644 index 8eef3d69a4f..00000000000 --- a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue +++ /dev/null @@ -1,102 +0,0 @@ -<script> -import { GlButton, GlCard, GlIcon } from '@gitlab/ui'; -import sum from 'lodash/sum'; -import { mapState, mapActions, mapGetters } from 'vuex'; -import { n__ } from '~/locale'; -import { monitoringDashboard } from '~/monitoring/stores'; -import MetricEmbed from './metric_embed.vue'; - -export default { - components: { - GlButton, - GlCard, - GlIcon, - MetricEmbed, - }, - props: { - urls: { - type: Array, - required: true, - validator: (urls) => urls.length > 0, - }, - }, - data() { - return { - isCollapsed: false, - }; - }, - computed: { - ...mapState('embedGroup', ['module']), - ...mapGetters('embedGroup', ['metricsWithData']), - arrowIconName() { - return this.isCollapsed ? 'chevron-right' : 'chevron-down'; - }, - bodyClass() { - return ['border-top', 'pl-3', 'pt-3', { 'd-none': this.isCollapsed }]; - }, - buttonLabel() { - return this.isCollapsed - ? n__('View chart', 'View charts', this.numCharts) - : n__('Hide chart', 'Hide charts', this.numCharts); - }, - containerClass() { - return this.isSingleChart ? 'col-lg-12' : 'col-lg-6'; - }, - numCharts() { - if (this.metricsWithData === null) { - return 0; - } - return sum(this.metricsWithData); - }, - isSingleChart() { - return this.numCharts === 1; - }, - }, - created() { - this.urls.forEach((url, index) => { - const name = this.getNamespace(index); - this.$store.registerModule(name, monitoringDashboard); - this.addModule(name); - }); - }, - methods: { - ...mapActions('embedGroup', ['addModule']), - getNamespace(id) { - return `monitoringDashboard/${id}`; - }, - toggleCollapsed() { - this.isCollapsed = !this.isCollapsed; - }, - }, -}; -</script> -<template> - <gl-card - v-show="numCharts > 0" - class="collapsible-card border p-0 gl-mb-5" - header-class="d-flex align-items-center border-bottom-0 py-2" - :body-class="bodyClass" - > - <template #header> - <gl-button - class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!" - :aria-label="buttonLabel" - variant="link" - category="tertiary" - @click="toggleCollapsed" - > - <gl-icon class="mr-1" :name="arrowIconName" /> - {{ buttonLabel }} - </gl-button> - </template> - <div class="d-flex flex-wrap"> - <metric-embed - v-for="(url, index) in urls" - :key="`${index}/${url}`" - :dashboard-url="url" - :namespace="getNamespace(index)" - :container-class="containerClass" - /> - </div> - </gl-card> -</template> diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue deleted file mode 100644 index 25500747573..00000000000 --- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue +++ /dev/null @@ -1,125 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { sidebarAnimationDuration } from '../../constants'; -import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils'; - -let sidebarMutationObserver; - -export default { - components: { - DashboardPanel, - }, - props: { - containerClass: { - type: String, - required: false, - default: 'col-lg-12', - }, - dashboardUrl: { - type: String, - required: true, - }, - namespace: { - type: String, - required: false, - default: 'monitoringDashboard', - }, - }, - data() { - const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange; - return { - timeRange: convertToFixedRange(timeRange), - elWidth: 0, - }; - }, - computed: { - ...mapState({ - dashboard(state) { - return state[this.namespace].dashboard; - }, - metricsWithData(state, getters) { - return getters[`${this.namespace}/metricsWithData`](); - }, - }), - charts() { - if (!this.dashboard || !this.dashboard.panelGroups) { - return []; - } - return this.dashboard.panelGroups.reduce( - (acc, currentGroup) => acc.concat(currentGroup.panels.filter(this.chartHasData)), - [], - ); - }, - isSingleChart() { - return this.charts.length === 1; - }, - embedClass() { - return this.isSingleChart ? this.containerClass : 'col-lg-12'; - }, - panelClass() { - return this.isSingleChart ? 'col-lg-12' : 'col-lg-6'; - }, - }, - mounted() { - this.setInitialState({ - dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl), - }); - this.setShowErrorBanner(false); - this.setTimeRange(this.timeRange); - this.fetchDashboard(); - - sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); - sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); - }, - beforeDestroy() { - if (sidebarMutationObserver) { - sidebarMutationObserver.disconnect(); - } - }, - methods: { - // Use function args to support dynamic namespaces in mapXXX helpers. Pattern described - // in https://github.com/vuejs/vuex/issues/863#issuecomment-329510765 - ...mapActions({ - setTimeRange(dispatch, payload) { - return dispatch(`${this.namespace}/setTimeRange`, payload); - }, - fetchDashboard(dispatch, payload) { - return dispatch(`${this.namespace}/fetchDashboard`, payload); - }, - setInitialState(dispatch, payload) { - return dispatch(`${this.namespace}/setInitialState`, payload); - }, - setShowErrorBanner(dispatch, payload) { - return dispatch(`${this.namespace}/setShowErrorBanner`, payload); - }, - }), - chartHasData(chart) { - return chart.metrics.some((metric) => this.metricsWithData.includes(metric.metricId)); - }, - onSidebarMutation() { - setTimeout(() => { - this.elWidth = this.$el.clientWidth; - }, sidebarAnimationDuration); - }, - }, -}; -</script> -<template> - <div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass"> - <dashboard-panel - v-for="(graphData, graphIndex) in charts" - :key="`dashboard-panel-${graphIndex}`" - :class="panelClass" - :graph-data="graphData" - :group-id="dashboardUrl" - :namespace="namespace" - /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue deleted file mode 100644 index 867f7139d71..00000000000 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ /dev/null @@ -1,114 +0,0 @@ -<script> -import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { dashboardEmptyStates } from '../constants'; - -export default { - components: { - GlLoadingIcon, - GlEmptyState, - }, - props: { - selectedState: { - type: String, - required: true, - validator: (state) => Object.values(dashboardEmptyStates).includes(state), - }, - documentationPath: { - type: String, - required: true, - }, - settingsPath: { - type: String, - required: false, - default: '', - }, - clustersPath: { - type: String, - required: false, - default: '', - }, - emptyGettingStartedSvgPath: { - type: String, - required: true, - }, - emptyLoadingSvgPath: { - type: String, - required: true, - }, - emptyNoDataSvgPath: { - type: String, - required: true, - }, - emptyNoDataSmallSvgPath: { - type: String, - required: true, - }, - emptyUnableToConnectSvgPath: { - type: String, - required: true, - }, - compact: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - /** - * Possible empty states. - * Keys in each state must match GlEmptyState props - */ - states: { - [dashboardEmptyStates.GETTING_STARTED]: { - svgPath: this.emptyGettingStartedSvgPath, - title: __('Get started with performance monitoring'), - description: __(`Stay updated about the performance and health - of your environment by configuring Prometheus to monitor your deployments.`), - primaryButtonText: __('Install on clusters'), - primaryButtonLink: this.clustersPath, - secondaryButtonText: __('Configure existing installation'), - secondaryButtonLink: this.settingsPath, - }, - [dashboardEmptyStates.NO_DATA]: { - svgPath: this.emptyNoDataSvgPath, - title: __('No data found'), - description: __(`You are connected to the Prometheus server, but there is currently - no data to display.`), - primaryButtonText: __('Configure Prometheus'), - primaryButtonLink: this.settingsPath, - secondaryButtonText: '', - secondaryButtonLink: '', - }, - [dashboardEmptyStates.UNABLE_TO_CONNECT]: { - svgPath: this.emptyUnableToConnectSvgPath, - title: __('Unable to connect to Prometheus server'), - description: __( - 'Ensure connectivity is available from the GitLab server to the Prometheus server', - ), - primaryButtonText: __('View documentation'), - primaryButtonLink: this.documentationPath, - secondaryButtonText: __('Configure Prometheus'), - secondaryButtonLink: this.settingsPath, - }, - }, - }; - }, - computed: { - isLoading() { - return this.selectedState === dashboardEmptyStates.LOADING; - }, - currentState() { - return this.states[this.selectedState]; - }, - }, -}; -</script> - -<template> - <div> - <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" /> - <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue deleted file mode 100644 index 74a806c50a9..00000000000 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlLoadingIcon, - GlIcon, - }, - props: { - name: { - type: String, - required: true, - }, - showPanels: { - type: Boolean, - required: false, - default: true, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - /** - * Initial value of collapse on mount. - */ - collapseGroup: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isCollapsed: this.collapseGroup, - }; - }, - computed: { - caretIcon() { - return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down'; - }, - }, - watch: { - collapseGroup(val) { - // Respond to changes in collapseGroup but do not - // collapse it once was opened by the user. - if (this.showPanels && !val) { - this.isCollapsed = false; - } - }, - }, - methods: { - collapse() { - this.isCollapsed = !this.isCollapsed; - }, - }, -}; -</script> - -<template> - <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> - <div class="card-header d-flex align-items-center"> - <h4 class="flex-grow-1">{{ name }}</h4> - <gl-loading-icon v-if="isLoading" size="sm" name="loading" /> - <a - data-testid="group-toggle-button" - :aria-label="__('Toggle collapse')" - :icon="caretIcon" - role="button" - class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900" - tabindex="0" - @click="collapse" - @keyup.enter="collapse" - > - <gl-icon :name="caretIcon" /> - </a> - </div> - <div - v-show="!isCollapsed" - ref="graph-group-content" - class="card-body prometheus-graph-group p-0" - > - <slot></slot> - </div> - </div> - <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div> -</template> diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue deleted file mode 100644 index a67770b93be..00000000000 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { __, sprintf } from '~/locale'; -import { metricStates } from '../constants'; - -export default { - components: { - GlEmptyState, - }, - directives: { - SafeHtml, - }, - props: { - documentationPath: { - type: String, - required: true, - }, - settingsPath: { - type: String, - required: true, - }, - selectedState: { - type: String, - required: true, - }, - svgPath: { - type: String, - required: true, - }, - }, - data() { - const documentationLink = `<a href="${this.documentationPath}">${__('More information')}</a>`; - return { - states: { - [metricStates.NO_DATA]: { - title: __('No data to display'), - slottedDescription: sprintf( - __( - 'The data source is connected, but there is no data to display. %{documentationLink}', - ), - { documentationLink }, - false, - ), - }, - [metricStates.TIMEOUT]: { - title: __('Connection timed out'), - slottedDescription: sprintf( - __( - "Charts can't be displayed as the request for data has timed out. %{documentationLink}", - ), - { documentationLink }, - false, - ), - }, - [metricStates.CONNECTION_FAILED]: { - title: __('Connection failed'), - description: __(`We couldn't reach the Prometheus server. - Either the server no longer exists or the configuration details need updating.`), - buttonText: __('Verify configuration'), - buttonPath: this.settingsPath, - }, - [metricStates.BAD_QUERY]: { - title: __('Query cannot be processed'), - slottedDescription: sprintf( - __( - `The Prometheus server responded with "bad request". - Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}`, - ), - { documentationLink }, - false, - ), - buttonText: __('Verify configuration'), - buttonPath: this.settingsPath, - }, - [metricStates.LOADING]: { - title: __('Waiting for performance data'), - description: __(`Creating graphs uses the data from the Prometheus server. - If this takes a long time, ensure that data is available.`), - }, - [metricStates.UNKNOWN_ERROR]: { - title: __('An error has occurred'), - description: __('An error occurred while loading the data. Please try again.'), - }, - }, - }; - }, - computed: { - currentState() { - return this.states[this.selectedState] || this.states[metricStates.UNKNOWN_ERROR]; - }, - }, -}; -</script> - -<template> - <gl-empty-state - :title="currentState.title" - :primary-button-text="currentState.buttonText" - :primary-button-link="currentState.buttonPath" - :description="currentState.description" - :svg-path="svgPath" - :compact="true" - > - <template v-if="currentState.slottedDescription" #description> - <div v-safe-html="currentState.slottedDescription"></div> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue deleted file mode 100644 index fb5ab12916e..00000000000 --- a/app/assets/javascripts/monitoring/components/links_section.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlIcon, GlLink } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; - -export default { - components: { - GlIcon, - GlLink, - }, - computed: { - ...mapGetters('monitoringDashboard', { links: 'linksWithMetadata' }), - }, -}; -</script> -<template> - <div - ref="linksSection" - class="gl-sm-display-flex gl-sm-flex-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section" - > - <div - v-for="(link, key) in links" - :key="key" - class="gl-mb-1 gl-mr-5 gl-display-flex gl-sm-display-block gl-hover-text-blue-600-children gl-word-break-all" - > - <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!" - ><gl-icon name="link" class="gl-text-gray-500 gl-vertical-align-text-bottom gl-mr-2" />{{ - link.title - }} - </gl-link> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue deleted file mode 100644 index 55c602db33d..00000000000 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ /dev/null @@ -1,168 +0,0 @@ -<script> -import { - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlTooltipDirective, -} from '@gitlab/ui'; -import Visibility from 'visibilityjs'; -import { mapActions } from 'vuex'; -import { n__, __, s__ } from '~/locale'; - -const makeInterval = (length = 0, unit = 's') => { - const shortLabel = `${length}${unit}`; - switch (unit) { - case 'd': - return { - interval: length * 24 * 60 * 60 * 1000, - shortLabel, - label: n__('%d day', '%d days', length), - }; - case 'h': - return { - interval: length * 60 * 60 * 1000, - shortLabel, - label: n__('%d hour', '%d hours', length), - }; - case 'm': - return { - interval: length * 60 * 1000, - shortLabel, - label: n__('%d minute', '%d minutes', length), - }; - case 's': - default: - return { - interval: length * 1000, - shortLabel, - label: n__('%d second', '%d seconds', length), - }; - } -}; - -export default { - i18n: { - refreshDashboard: s__('Metrics|Refresh dashboard'), - }, - components: { - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - data() { - return { - refreshInterval: null, - timeoutId: null, - }; - }, - computed: { - dropdownText() { - return this.refreshInterval?.shortLabel ?? __('Off'); - }, - }, - watch: { - refreshInterval() { - if (this.refreshInterval !== null) { - this.startAutoRefresh(); - } else { - this.stopAutoRefresh(); - } - }, - }, - destroyed() { - this.stopAutoRefresh(); - }, - methods: { - ...mapActions('monitoringDashboard', ['fetchDashboardData']), - - refresh() { - this.fetchDashboardData(); - }, - startAutoRefresh() { - const schedule = () => { - if (this.refreshInterval) { - this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval); - } - }; - - this.stopAutoRefresh(); - - if (Visibility.hidden()) { - // Inactive tab? Skip fetch and schedule again - schedule(); - } else { - // Active tab! Fetch data and then schedule when settled - // eslint-disable-next-line promise/catch-or-return - this.fetchDashboardData().finally(schedule); - } - }, - stopAutoRefresh() { - clearTimeout(this.timeoutId); - this.timeoutId = null; - }, - - setRefreshInterval(option) { - this.refreshInterval = option; - }, - removeRefreshInterval() { - this.refreshInterval = null; - }, - isChecked(option) { - if (this.refreshInterval) { - return option.interval === this.refreshInterval.interval; - } - return false; - }, - }, - - refreshIntervals: [ - makeInterval(5), - makeInterval(10), - makeInterval(30), - makeInterval(5, 'm'), - makeInterval(30, 'm'), - makeInterval(1, 'h'), - makeInterval(2, 'h'), - makeInterval(12, 'h'), - makeInterval(1, 'd'), - ], -}; -</script> - -<template> - <gl-button-group> - <gl-button - v-gl-tooltip - class="gl-flex-grow-1" - variant="default" - :title="$options.i18n.refreshDashboard" - :aria-label="$options.i18n.refreshDashboard" - icon="retry" - @click="refresh" - /> - <gl-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> - <gl-dropdown-item - is-check-item - :is-checked="refreshInterval === null" - @click="removeRefreshInterval()" - >{{ __('Off') }}</gl-dropdown-item - > - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="(option, i) in $options.refreshIntervals" - :key="i" - is-check-item - :is-checked="isChecked(option)" - @click="setRefreshInterval(option)" - >{{ option.label }}</gl-dropdown-item - > - </gl-dropdown> - </gl-button-group> -</template> diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue deleted file mode 100644 index ff0327f5f99..00000000000 --- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; - -export default { - components: { - GlFormGroup, - GlDropdown, - GlDropdownItem, - }, - props: { - name: { - type: String, - required: true, - }, - label: { - type: String, - required: true, - }, - value: { - type: String, - required: false, - default: '', - }, - options: { - type: Object, - required: true, - }, - }, - computed: { - text() { - const selectedOpt = this.options.values?.find((opt) => opt.value === this.value); - return selectedOpt?.text || this.value; - }, - }, - methods: { - onUpdate(value) { - this.$emit('input', value); - }, - }, -}; -</script> -<template> - <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> - <gl-dropdown-item - v-for="val in options.values" - :key="val.value" - @click="onUpdate(val.value)" - >{{ val.text }}</gl-dropdown-item - > - </gl-dropdown> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_field.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue deleted file mode 100644 index a0418806e5f..00000000000 --- a/app/assets/javascripts/monitoring/components/variables/text_field.vue +++ /dev/null @@ -1,39 +0,0 @@ -<script> -import { GlFormGroup, GlFormInput } from '@gitlab/ui'; - -export default { - components: { - GlFormGroup, - GlFormInput, - }, - props: { - name: { - type: String, - required: true, - }, - label: { - type: String, - required: true, - }, - value: { - type: String, - required: true, - }, - }, - methods: { - onUpdate(event) { - this.$emit('input', event.target.value); - }, - }, -}; -</script> -<template> - <gl-form-group :label="label"> - <gl-form-input - :value="value" - :name="name" - @keyup.native.enter="onUpdate" - @blur.native="onUpdate" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue deleted file mode 100644 index 971f188e9f3..00000000000 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { VARIABLE_TYPES } from '../constants'; -import { setCustomVariablesFromUrl } from '../utils'; -import DropdownField from './variables/dropdown_field.vue'; -import TextField from './variables/text_field.vue'; - -export default { - components: { - DropdownField, - TextField, - }, - computed: { - ...mapState('monitoringDashboard', ['variables']), - }, - methods: { - ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']), - refreshDashboard(variable, value) { - if (variable.value !== value) { - this.updateVariablesAndFetchData({ name: variable.name, value }); - // update the Vuex store - // the below calls can ideally be moved out of the - // component and into the actions and let the - // mutation respond directly. - // This can be further investigate in - // https://gitlab.com/gitlab-org/gitlab/-/issues/217713 - setCustomVariablesFromUrl(this.variables); - } - }, - variableField(type) { - if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) { - return DropdownField; - } - return TextField; - }, - }, -}; -</script> -<template> - <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> - <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block"> - <component - :is="variableField(variable.type)" - class="mb-0 flex-grow-1" - :label="variable.label" - :value="variable.value" - :name="variable.name" - :options="variable.options" - @input="refreshDashboard(variable, $event)" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js deleted file mode 100644 index e35dcc350f2..00000000000 --- a/app/assets/javascripts/monitoring/constants.js +++ /dev/null @@ -1,262 +0,0 @@ -export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES - -export const dashboardEmptyStates = { - GETTING_STARTED: 'gettingStarted', - LOADING: 'loading', - NO_DATA: 'noData', - UNABLE_TO_CONNECT: 'unableToConnect', -}; - -/** - * States and error states in Prometheus Queries (PromQL) for metrics - */ -export const metricStates = { - /** - * Metric data is available - */ - OK: 'OK', - - /** - * Metric data is being fetched for the first time. - * - * Not used during data refresh, if data is available in - * the metric, the recommneded state is OK. - */ - LOADING: 'LOADING', - - /** - * Connection timed out to prometheus server - * the timeout is set to PROMETHEUS_TIMEOUT - * - */ - TIMEOUT: 'TIMEOUT', - - /** - * The prometheus server replies with an empty data set - */ - NO_DATA: 'NO_DATA', - - /** - * The prometheus server cannot be reached - */ - CONNECTION_FAILED: 'CONNECTION_FAILED', - - /** - * The prometheus server was reached but it cannot process - * the query. This can happen for several reasons: - * - PromQL syntax is incorrect - * - An operator is not supported - */ - BAD_QUERY: 'BAD_QUERY', - - /** - * No specific reason found for error - */ - UNKNOWN_ERROR: 'UNKNOWN_ERROR', -}; - -/** - * Supported panel types in dashboards, values of `panel.type`. - * - * Values should not be changed as they correspond to - * values in users the `.yml` dashboard definition. - */ -export const panelTypes = { - /** - * Area Chart - * - * Time Series chart with an area - */ - AREA_CHART: 'area-chart', - /** - * Line Chart - * - * Time Series chart with a line - */ - LINE_CHART: 'line-chart', - /** - * Anomaly Chart - * - * Time Series chart with 3 metrics - */ - ANOMALY_CHART: 'anomaly-chart', - /** - * Single Stat - * - * Single data point visualization - */ - SINGLE_STAT: 'single-stat', - /** - * Gauge - */ - GAUGE_CHART: 'gauge', - /** - * Heatmap - */ - HEATMAP: 'heatmap', - /** - * Bar chart - */ - BAR: 'bar', - /** - * Column chart - */ - COLUMN: 'column', - /** - * Stacked column chart - */ - STACKED_COLUMN: 'stacked-column', -}; - -export const sidebarAnimationDuration = 300; // milliseconds. -export const chartHeight = 300; - -export const graphTypes = { - annotationsData: 'scatter', -}; - -export const symbolSizes = { - anomaly: 8, - default: 14, -}; - -export const areaOpacityValues = { - default: 0.2, -}; - -export const colorValues = { - primaryColor: '#1f78d1', // $blue-500 (see variables.scss) - anomalySymbol: '#db3b21', - anomalyAreaColor: '#1f78d1', -}; - -export const lineTypes = { - default: 'solid', -}; - -export const lineWidths = { - default: 2, -}; - -/** - * User-defined links can be passed in dashboard yml file. - * These are the supported type of links. - */ -export const linkTypes = { - GRAFANA: 'grafana', -}; - -/** - * These are the supported values for the GitLab-UI - * chart legend layout. - * - * Currently defined in - * https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/src/utils/charts/constants.js - * - */ -export const legendLayoutTypes = { - inline: 'inline', - table: 'table', -}; - -/** - * These Vuex store properties are allowed to be - * replaced dynamically after component has been created - * and initial state has been set. - * - * Currently used in `receiveMetricsDashboardSuccess` action. - */ -export const endpointKeys = [ - 'deploymentsEndpoint', - 'dashboardEndpoint', - 'dashboardsEndpoint', - 'currentDashboard', - 'projectPath', -]; - -/** - * These Vuex store properties are set as soon as the - * dashboard component has been created. The values are - * passed as data-* attributes and received by dashboard - * as Vue props. - */ -export const initialStateKeys = [...endpointKeys, 'currentEnvironmentName']; - -/** - * Constant to indicate if a metric exists in the database - */ -export const NOT_IN_DB_PREFIX = 'NO_DB'; - -/** - * graphQL environments API value for active environments. - * Used as a value for the 'states' query filter - */ -export const ENVIRONMENT_AVAILABLE_STATE = 'available'; - -/** - * As of %12.10, the svg icon library does not have an annotation - * arrow icon yet. In order to deliver annotations feature, the icon - * is hard coded until the icon is added. The below issue is - * to track the icon. - * - * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118 - * - * Once the icon is merged this can be removed. - * https://gitlab.com/gitlab-org/gitlab/-/issues/214540 - */ -export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; - -/** - * As of %12.10, dashboard path is required to create annotation. - * The FE gets the dashboard name from the URL params. It is not - * ideal to store the path this way but there is no other way to - * get this path unless annotations fetch is delayed. This could - * potentially be removed and have the backend send this to the FE. - * - * This technical debt is being tracked here - * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 - */ -export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; - -/** - * GitLab provide metrics dashboards that are available to a user once - * the Prometheus managed app has been installed, without any extra setup - * required. These "out of the box" dashboards are defined under the - * `config/prometheus` path. - */ -export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/'; - -/** - * Dashboard yml files support custom user-defined variables that - * are rendered as input elements in the monitoring dashboard. - * These values can be edited by the user and are passed on to the - * the backend and eventually to Prometheus API proxy. - * - * As of 13.0, the supported types are: - * simple custom -> dropdown elements - * advanced custom -> dropdown elements - * text -> text input elements - * - * Custom variables have a simple and a advanced variant. - */ -export const VARIABLE_TYPES = { - custom: 'custom', - text: 'text', - metric_label_values: 'metric_label_values', -}; - -/** - * The names of templating variables defined in the dashboard yml - * file are prefixed with a constant so that it doesn't collide with - * other URL params that the monitoring dashboard relies on for - * features like panel fullscreen etc. - * - * The prefix is added before it is appended to the URL and removed - * before passing the data to the backend. - */ -export const VARIABLE_PREFIX = 'var-'; - -export const thresholdModeTypes = { - ABSOLUTE: 'absolute', - PERCENTAGE: 'percentage', -}; diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js deleted file mode 100644 index 7e15b659767..00000000000 --- a/app/assets/javascripts/monitoring/csv_export.js +++ /dev/null @@ -1,146 +0,0 @@ -import { getSeriesLabel } from '~/helpers/monitor_helper'; - -/** - * Returns a label for a header of the csv. - * - * Includes double quotes ("") in case the header includes commas or other separator. - * - * @param {String} axisLabel - * @param {String} metricLabel - * @param {Object} metricAttributes - */ -const csvHeader = (axisLabel, metricLabel, metricAttributes = {}) => - `${axisLabel} > ${getSeriesLabel(metricLabel, metricAttributes)}`; - -/** - * Returns an array with the header labels given a list of metrics - * - * ``` - * metrics = [ - * { - * label: "..." // user-defined label - * result: [ - * { - * metric: { ... } // metricAttributes - * }, - * ... - * ] - * }, - * ... - * ] - * ``` - * - * When metrics have a `label` or `metricAttributes`, they are - * used to generate the column name. - * - * @param {String} axisLabel - Main label - * @param {Array} metrics - Metrics with results - */ -const csvMetricHeaders = (axisLabel, metrics) => - metrics.flatMap(({ label, result }) => - // The `metric` in a `result` is a map of `metricAttributes` - // contains key-values to identify the series, rename it - // here for clarity. - result.map(({ metric: metricAttributes }) => { - return csvHeader(axisLabel, label, metricAttributes); - }), - ); - -/** - * Returns a (flat) array with all the values arrays in each - * metric and series - * - * ``` - * metrics = [ - * { - * result: [ - * { - * values: [ ... ] // `values` - * }, - * ... - * ] - * }, - * ... - * ] - * ``` - * - * @param {Array} metrics - Metrics with results - */ -const csvMetricValues = (metrics) => - metrics.flatMap(({ result }) => result.map((res) => res.values || [])); - -/** - * Returns headers and rows for csv, sorted by their timestamp. - * - * { - * headers: ["timestamp", "<col_1_name>", "col_2_name"], - * rows: [ - * [ <timestamp>, <col_1_value>, <col_2_value> ], - * [ <timestamp>, <col_1_value>, <col_2_value> ] - * ... - * ] - * } - * - * @param {Array} metricHeaders - * @param {Array} metricValues - */ -const csvData = (metricHeaders, metricValues) => { - const rowsByTimestamp = {}; - - metricValues.forEach((values, colIndex) => { - values.forEach(([timestamp, value]) => { - if (!rowsByTimestamp[timestamp]) { - rowsByTimestamp[timestamp] = []; - } - // `value` should be in the right column - rowsByTimestamp[timestamp][colIndex] = value; - }); - }); - - const rows = Object.keys(rowsByTimestamp) - .sort() - .map((timestamp) => { - // force each row to have the same number of entries - rowsByTimestamp[timestamp].length = metricHeaders.length; - // add timestamp as the first entry - return [timestamp, ...rowsByTimestamp[timestamp]]; - }); - - // Escape double quotes and enclose headers: - // "If double-quotes are used to enclose fields, then a double-quote - // appearing inside a field must be escaped by preceding it with - // another double quote." - // https://www.rfc-editor.org/rfc/rfc4180#page-2 - const headers = metricHeaders.map((header) => `"${header.replace(/"/g, '""')}"`); - - return { - headers: ['timestamp', ...headers], - rows, - }; -}; - -/** - * Returns dashboard panel's data in a string in CSV format - * - * @param {Object} graphData - Panel contents - * @returns {String} - */ -export const graphDataToCsv = (graphData) => { - const delimiter = ','; - const br = '\r\n'; - const { metrics = [], y_label: axisLabel } = graphData; - - const metricsWithResults = metrics.filter((metric) => metric.result); - const metricHeaders = csvMetricHeaders(axisLabel, metricsWithResults); - const metricValues = csvMetricValues(metricsWithResults); - const { headers, rows } = csvData(metricHeaders, metricValues); - - if (rows.length === 0) { - return ''; - } - - const headerLine = headers.join(delimiter) + br; - const lines = rows.map((row) => row.join(delimiter)); - - return headerLine + lines.join(br) + br; -}; diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js deleted file mode 100644 index f20fea48084..00000000000 --- a/app/assets/javascripts/monitoring/format_date.js +++ /dev/null @@ -1,40 +0,0 @@ -import dateFormat from '~/lib/dateformat'; - -export const timezones = { - /** - * Renders a date with a local timezone - */ - LOCAL: 'LOCAL', - - /** - * Renders at date with UTC - */ - UTC: 'UTC', -}; - -export const formats = { - shortTime: 'h:MM TT', - shortDateTime: 'm/d h:MM TT', - default: 'dd mmm yyyy, h:MMTT (Z)', -}; - -/** - * Formats a date for a metric dashboard or chart. - * - * Convenience wrapper of dateFormat with default formats - * and settings. - * - * dateFormat has some limitations and we could use `toLocaleString` instead - * See: https://gitlab.com/gitlab-org/gitlab/-/issues/219246 - * - * @param {Date|String|Number} date - * @param {Object} options - Formatting options - * @param {string} options.format - Format or mask from `formats`. - * @param {string} options.timezone - Timezone abbreviation. - * Accepts "LOCAL" for the client local timezone. - */ -export const formatDate = (date, options = {}) => { - const { format = formats.default, timezone = timezones.LOCAL } = options; - const useUTC = timezone === timezones.UTC; - return dateFormat(date, format, useUTC); -}; diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js deleted file mode 100644 index ee67e5dd827..00000000000 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ /dev/null @@ -1,49 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import createRouter from './router'; -import { createStore } from './stores'; -import { stateAndPropsFromDataset } from './utils'; - -Vue.use(GlToast); - -export default (props = {}) => { - const el = document.getElementById('prometheus-graphs'); - - if (el && el.dataset) { - const { metricsDashboardBasePath, ...dataset } = el.dataset; - - const { initState, dataProps } = stateAndPropsFromDataset(dataset); - const store = createStore(initState); - const router = createRouter(metricsDashboardBasePath); - - // eslint-disable-next-line no-new - new Vue({ - el, - store, - router, - data() { - return { - dashboardProps: { ...dataProps, ...props }, - }; - }, - render(h) { - return h('RouterView', { - // This is attrs rather than props because: - // 1. RouterView only actually defines one prop: `name`. - // 2. The RouterView [throws away other props][1] given to it, in - // favour of those configured in the route config/params. - // 3. The Vue template compiler itself in general compiles anything - // like <some-component :foo="bar" /> into roughly - // h('some-component', { attrs: { foo: bar } }). Then later, Vue - // [extract props from attrs and merges them with props][2], - // matching them up according to the component's definition. - // [1]: https://github.com/vuejs/vue-router/blob/v3.4.9/src/components/view.js#L124 - // [2]: https://github.com/vuejs/vue/blob/v2.6.12/src/core/vdom/helpers/extract-props.js#L12-L50 - attrs: { - dashboardProps: this.dashboardProps, - }, - }); - }, - }); - } -}; diff --git a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js deleted file mode 100644 index 5ae1eca10de..00000000000 --- a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js +++ /dev/null @@ -1,10 +0,0 @@ -import Tracking from '~/tracking'; - -const trackDashboardLoad = ({ label, value }) => - Tracking.event(document.body.dataset.page, 'dashboard_fetch', { - label, - property: 'count', - value, - }); - -export default trackDashboardLoad; diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue deleted file mode 100644 index df0e2d7f8f6..00000000000 --- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import Dashboard from '../components/dashboard.vue'; - -export default { - components: { - Dashboard, - }, - props: { - dashboardProps: { - type: Object, - required: true, - }, - }, - created() { - // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path - // and the new format <project>/-/metrics/:dashboardPath - const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard; - const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null; - this.setCurrentDashboard({ currentDashboard }); - }, - methods: { - ...mapActions('monitoringDashboard', ['setCurrentDashboard']), - }, -}; -</script> -<template> - <dashboard v-bind="{ ...dashboardProps }" /> -</template> diff --git a/app/assets/javascripts/monitoring/pages/panel_new_page.vue b/app/assets/javascripts/monitoring/pages/panel_new_page.vue deleted file mode 100644 index dbda6e80dac..00000000000 --- a/app/assets/javascripts/monitoring/pages/panel_new_page.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { s__ } from '~/locale'; -import DashboardPanelBuilder from '../components/dashboard_panel_builder.vue'; -import { DASHBOARD_PAGE } from '../router/constants'; - -export default { - components: { - GlButton, - DashboardPanelBuilder, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - computed: { - ...mapState('monitoringDashboard', ['panelPreviewYml']), - dashboardPageLocation() { - return { - ...this.$route, - name: DASHBOARD_PAGE, - }; - }, - }, - i18n: { - backToDashboard: s__('Metrics|Back to dashboard'), - }, -}; -</script> -<template> - <div class="gl-mt-5"> - <div class="gl-display-flex gl-align-items-baseline gl-mb-5"> - <gl-button - v-gl-tooltip - icon="go-back" - :to="dashboardPageLocation" - :aria-label="$options.i18n.backToDashboard" - :title="$options.i18n.backToDashboard" - class="gl-mr-5" - /> - <h1 class="gl-font-size-h1 gl-my-0">{{ s__('Metrics|Add panel') }}</h1> - </div> - <dashboard-panel-builder /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql b/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql deleted file mode 100644 index 32b982ff195..00000000000 --- a/app/assets/javascripts/monitoring/queries/get_annotations.query.graphql +++ /dev/null @@ -1,27 +0,0 @@ -query getAnnotations( - $projectPath: ID! - $environmentName: String - $dashboardPath: String! - $startingFrom: Time! -) { - project(fullPath: $projectPath) { - id - environments(name: $environmentName) { - nodes { - id - name - metricsDashboard(path: $dashboardPath) { - annotations(from: $startingFrom) { - nodes { - id - description - startingAt - endingAt - panelId - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql b/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql deleted file mode 100644 index a61d601cd34..00000000000 --- a/app/assets/javascripts/monitoring/queries/get_dashboard_validation_warnings.query.graphql +++ /dev/null @@ -1,19 +0,0 @@ -query getDashboardValidationWarnings( - $projectPath: ID! - $environmentName: String - $dashboardPath: String! -) { - project(fullPath: $projectPath) { - id - environments(name: $environmentName) { - nodes { - id - name - metricsDashboard(path: $dashboardPath) { - path - schemaValidationWarnings - } - } - } - } -} diff --git a/app/assets/javascripts/monitoring/queries/get_environments.query.graphql b/app/assets/javascripts/monitoring/queries/get_environments.query.graphql deleted file mode 100644 index 48d0a780fc7..00000000000 --- a/app/assets/javascripts/monitoring/queries/get_environments.query.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query getEnvironments($projectPath: ID!, $search: String, $states: [String!]) { - project(fullPath: $projectPath) { - id - data: environments(search: $search, states: $states) { - environments: nodes { - name - id - } - } - } -} diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js deleted file mode 100644 index 29786a79c56..00000000000 --- a/app/assets/javascripts/monitoring/requests/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_NO_CONTENT, - HTTP_STATUS_SERVICE_UNAVAILABLE, - HTTP_STATUS_UNPROCESSABLE_ENTITY, -} from '~/lib/utils/http_status'; -import { PROMETHEUS_TIMEOUT } from '../constants'; - -const cancellableBackOffRequest = (makeRequestCallback) => - backOff((next, stop) => { - makeRequestCallback() - .then((resp) => { - if (resp.status === HTTP_STATUS_NO_CONTENT) { - next(); - } else { - stop(resp); - } - }) - // If the request is cancelled by axios - // then consider it as noop so that its not - // caught by subsequent catches - .catch((thrown) => (axios.isCancel(thrown) ? undefined : stop(thrown))); - }, PROMETHEUS_TIMEOUT); - -export const getDashboard = (dashboardEndpoint, params) => - cancellableBackOffRequest(() => axios.get(dashboardEndpoint, { params })).then( - (axiosResponse) => axiosResponse.data, - ); - -export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => - cancellableBackOffRequest(() => axios.get(prometheusEndpoint, { params, ...opts })) - .then((axiosResponse) => axiosResponse.data) - .then((prometheusResponse) => prometheusResponse.data) - .catch((error) => { - // Prometheus returns errors in specific cases - // https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview - const { response = {} } = error; - if ( - response.status === HTTP_STATUS_BAD_REQUEST || - response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY || - response.status === HTTP_STATUS_SERVICE_UNAVAILABLE - ) { - const { data } = response; - if (data?.status === 'error' && data?.error) { - throw new Error(data.error); - } - } - throw error; - }); diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js deleted file mode 100644 index 7834c14a65d..00000000000 --- a/app/assets/javascripts/monitoring/router/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -export const DASHBOARD_PAGE = 'dashboard'; -export const PANEL_NEW_PAGE = 'panel_new'; - -export default { - DASHBOARD_PAGE, - PANEL_NEW_PAGE, -}; diff --git a/app/assets/javascripts/monitoring/router/index.js b/app/assets/javascripts/monitoring/router/index.js deleted file mode 100644 index 12692612bbc..00000000000 --- a/app/assets/javascripts/monitoring/router/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import Vue from 'vue'; -import VueRouter from 'vue-router'; -import routes from './routes'; - -Vue.use(VueRouter); - -export default function createRouter(base) { - const router = new VueRouter({ - base, - mode: 'history', - routes, - }); - - return router; -} diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js deleted file mode 100644 index cc43fd8622a..00000000000 --- a/app/assets/javascripts/monitoring/router/routes.js +++ /dev/null @@ -1,24 +0,0 @@ -import DashboardPage from '../pages/dashboard_page.vue'; -import PanelNewPage from '../pages/panel_new_page.vue'; - -import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from './constants'; - -/** - * Because the cluster health page uses the dashboard - * app instead the of the dashboard component, hitting - * `/` route is not possible. Hence using `*` until the - * health page is refactored. - * https://gitlab.com/gitlab-org/gitlab/-/issues/221096 - */ -export default [ - { - name: PANEL_NEW_PAGE, - path: '/:dashboard(.+)?/panel/new', - component: PanelNewPage, - }, - { - name: DASHBOARD_PAGE, - path: '/:dashboard(.+)?', - component: DashboardPage, - }, -]; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js deleted file mode 100644 index 32e85262882..00000000000 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ /dev/null @@ -1,576 +0,0 @@ -import * as Sentry from '@sentry/browser'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { s__, sprintf } from '~/locale'; -import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; -import trackDashboardLoad from '../monitoring_tracking_helper'; -import getAnnotations from '../queries/get_annotations.query.graphql'; -import getDashboardValidationWarnings from '../queries/get_dashboard_validation_warnings.query.graphql'; -import getEnvironments from '../queries/get_environments.query.graphql'; -import { getDashboard, getPrometheusQueryData } from '../requests'; - -import * as types from './mutation_types'; -import { - gqClient, - parseEnvironmentsResponse, - parseAnnotationsResponse, - removeLeadingSlash, -} from './utils'; - -const axiosCancelToken = axios.CancelToken; -let cancelTokenSource; - -function prometheusMetricQueryParams(timeRange) { - const { start, end } = convertToFixedRange(timeRange); - - const timeDiff = (new Date(end) - new Date(start)) / 1000; - const minStep = 60; - const queryDataPoints = 600; - - return { - start_time: start, - end_time: end, - step: Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)), - }; -} - -/** - * Extract error messages from API or HTTP request errors. - * - * - API errors are in `error.response.data.message` - * - HTTP (axios) errors are in `error.message` - * - * @param {Object} error - * @returns {String} User friendly error message - */ -function extractErrorMessage(error) { - const message = error?.response?.data?.message; - return message ?? error.message; -} - -// Setup - -export const setGettingStartedEmptyState = ({ commit }) => { - commit(types.SET_GETTING_STARTED_EMPTY_STATE); -}; - -export const setInitialState = ({ commit }, initialState) => { - commit(types.SET_INITIAL_STATE, initialState); -}; - -export const setTimeRange = ({ commit }, timeRange) => { - commit(types.SET_TIME_RANGE, timeRange); -}; - -export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { - commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); - dispatch('fetchEnvironmentsData'); -}; - -export const setShowErrorBanner = ({ commit }, enabled) => { - commit(types.SET_SHOW_ERROR_BANNER, enabled); -}; - -export const setExpandedPanel = ({ commit }, { group, panel }) => { - commit(types.SET_EXPANDED_PANEL, { group, panel }); -}; - -export const clearExpandedPanel = ({ commit }) => { - commit(types.SET_EXPANDED_PANEL, { - group: null, - panel: null, - }); -}; - -export const setCurrentDashboard = ({ commit }, { currentDashboard }) => { - commit(types.SET_CURRENT_DASHBOARD, currentDashboard); -}; - -// All Data - -/** - * Fetch all dashboard data. - * - * @param {Object} store - * @returns A promise that resolves when the dashboard - * skeleton has been loaded. - */ -export const fetchData = ({ dispatch }) => { - dispatch('fetchEnvironmentsData'); - dispatch('fetchDashboard'); - dispatch('fetchAnnotations'); -}; - -// Metrics dashboard - -export const fetchDashboard = ({ state, commit, dispatch, getters }) => { - dispatch('requestMetricsDashboard'); - - const params = {}; - if (getters.fullDashboardPath) { - params.dashboard = getters.fullDashboardPath; - } - - return getDashboard(state.dashboardEndpoint, params) - .then((response) => { - dispatch('receiveMetricsDashboardSuccess', { response }); - /** - * After the dashboard is fetched, there can be non-blocking invalid syntax - * in the dashboard file. This call will fetch such syntax warnings - * and surface a warning on the UI. If the invalid syntax is blocking, - * the `fetchDashboard` returns a 404 with error messages that are displayed - * on the UI. - */ - dispatch('fetchDashboardValidationWarnings'); - }) - .catch((error) => { - Sentry.captureException(error); - - commit(types.SET_ALL_DASHBOARDS, error.response?.data?.all_dashboards ?? []); - dispatch('receiveMetricsDashboardFailure', error); - - if (state.showErrorBanner) { - if (error.response.data && error.response.data.message) { - const { message } = error.response.data; - createAlert({ - message: sprintf( - s__('Metrics|There was an error while retrieving metrics. %{message}'), - { message }, - false, - ), - }); - } else { - createAlert({ - message: s__('Metrics|There was an error while retrieving metrics'), - }); - } - } - }); -}; - -export const requestMetricsDashboard = ({ commit }) => { - commit(types.REQUEST_METRICS_DASHBOARD); -}; -export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response }) => { - const { all_dashboards, dashboard, metrics_data } = response; - - commit(types.SET_ALL_DASHBOARDS, all_dashboards); - commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard); - commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); - - return dispatch('fetchDashboardData'); -}; -export const receiveMetricsDashboardFailure = ({ commit }, error) => { - commit(types.RECEIVE_METRICS_DASHBOARD_FAILURE, error); -}; - -// Metrics - -/** - * Loads timeseries data: Prometheus data points and deployment data from the project - * @param {Object} Vuex store - */ -export const fetchDashboardData = ({ state, dispatch, getters }) => { - dispatch('fetchDeploymentsData'); - - if (!state.timeRange) { - createAlert({ - message: s__(`Metrics|Invalid time range, please verify.`), - type: 'warning', - }); - return Promise.reject(); - } - - // Time range params must be pre-calculated once for all metrics and options - // A subsequent call, may calculate a different time range - const defaultQueryParams = prometheusMetricQueryParams(state.timeRange); - - dispatch('fetchVariableMetricLabelValues', { defaultQueryParams }); - - const promises = []; - state.dashboard.panelGroups.forEach((group) => { - group.panels.forEach((panel) => { - panel.metrics.forEach((metric) => { - promises.push(dispatch('fetchPrometheusMetric', { metric, defaultQueryParams })); - }); - }); - }); - - return Promise.all(promises) - .then(() => { - const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom'; - trackDashboardLoad({ - label: `${dashboardType}_metrics_dashboard`, - value: getters.metricsWithData().length, - }); - }) - .catch(() => { - createAlert({ - message: s__(`Metrics|There was an error while retrieving metrics`), - type: 'warning', - }); - }); -}; - -/** - * Returns list of metrics in data.result - * {"status":"success", "data":{"resultType":"matrix","result":[]}} - * - * @param {metric} metric - */ -export const fetchPrometheusMetric = ( - { commit, state, getters }, - { metric, defaultQueryParams }, -) => { - let queryParams = { ...defaultQueryParams }; - if (metric.step) { - queryParams.step = metric.step; - } - - if (state.variables.length > 0) { - queryParams = { - ...queryParams, - ...getters.getCustomVariablesParams, - }; - } - - commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); - - return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams) - .then((data) => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data }); - }) - .catch((error) => { - Sentry.captureException(error); - - commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error }); - // Continue to throw error so the dashboard can notify using createAlert - throw error; - }); -}; - -// Deployments - -export const fetchDeploymentsData = ({ state, dispatch }) => { - if (!state.deploymentsEndpoint) { - return Promise.resolve([]); - } - return axios - .get(state.deploymentsEndpoint) - .then((resp) => resp.data) - .then((response) => { - if (!response || !response.deployments) { - createAlert({ - message: s__('Metrics|Unexpected deployment data response from prometheus endpoint'), - }); - } - - dispatch('receiveDeploymentsDataSuccess', response.deployments); - }) - .catch((error) => { - Sentry.captureException(error); - dispatch('receiveDeploymentsDataFailure'); - createAlert({ - message: s__('Metrics|There was an error getting deployment information.'), - }); - }); -}; -export const receiveDeploymentsDataSuccess = ({ commit }, data) => { - commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); -}; -export const receiveDeploymentsDataFailure = ({ commit }) => { - commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); -}; - -// Environments - -export const fetchEnvironmentsData = ({ state, dispatch }) => { - dispatch('requestEnvironmentsData'); - return gqClient - .mutate({ - mutation: getEnvironments, - variables: { - projectPath: removeLeadingSlash(state.projectPath), - search: state.environmentsSearchTerm, - states: [ENVIRONMENT_AVAILABLE_STATE], - }, - }) - .then((resp) => - parseEnvironmentsResponse(resp.data?.project?.data?.environments, state.projectPath), - ) - .then((environments) => { - if (!environments) { - createAlert({ - message: s__( - 'Metrics|There was an error fetching the environments data, please try again', - ), - }); - } - - dispatch('receiveEnvironmentsDataSuccess', environments); - }) - .catch((err) => { - Sentry.captureException(err); - dispatch('receiveEnvironmentsDataFailure'); - createAlert({ - message: s__('Metrics|There was an error getting environments information.'), - }); - }); -}; -export const requestEnvironmentsData = ({ commit }) => { - commit(types.REQUEST_ENVIRONMENTS_DATA); -}; -export const receiveEnvironmentsDataSuccess = ({ commit }, data) => { - commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); -}; -export const receiveEnvironmentsDataFailure = ({ commit }) => { - commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); -}; - -export const fetchAnnotations = ({ state, dispatch, getters }) => { - const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; - return gqClient - .mutate({ - mutation: getAnnotations, - variables: { - projectPath: removeLeadingSlash(state.projectPath), - environmentName: state.currentEnvironmentName, - dashboardPath, - startingFrom: start, - }, - }) - .then( - (resp) => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes, - ) - .then(parseAnnotationsResponse) - .then((annotations) => { - if (!annotations) { - createAlert({ - message: s__('Metrics|There was an error fetching annotations. Please try again.'), - }); - } - - dispatch('receiveAnnotationsSuccess', annotations); - }) - .catch((err) => { - Sentry.captureException(err); - dispatch('receiveAnnotationsFailure'); - createAlert({ - message: s__('Metrics|There was an error getting annotations information.'), - }); - }); -}; - -export const receiveAnnotationsSuccess = ({ commit }, data) => - commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); -export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); - -export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { - /** - * Normally, the overview dashboard won't throw any validation warnings. - * - * However, if a bug sneaks into the overview dashboard making it invalid, - * this might come handy for our clients - */ - const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; - return gqClient - .mutate({ - mutation: getDashboardValidationWarnings, - variables: { - projectPath: removeLeadingSlash(state.projectPath), - environmentName: state.currentEnvironmentName, - dashboardPath, - }, - }) - .then((resp) => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard || undefined) - .then(({ schemaValidationWarnings } = {}) => { - const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0; - /** - * The payload of the dispatch is a boolean, because at the moment a standard - * warning message is shown instead of the warnings the BE returns - */ - dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false); - }) - .catch((err) => { - Sentry.captureException(err); - dispatch('receiveDashboardValidationWarningsFailure'); - createAlert({ - message: s__( - 'Metrics|There was an error getting dashboard validation warnings information.', - ), - }); - }); -}; - -export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) => - commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings); -export const receiveDashboardValidationWarningsFailure = ({ commit }) => - commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE); - -// Dashboard manipulation - -export const toggleStarredValue = ({ commit, state, getters }) => { - const { selectedDashboard } = getters; - - if (state.isUpdatingStarredValue) { - // Prevent repeating requests for the same change - return; - } - if (!selectedDashboard) { - return; - } - - const method = selectedDashboard.starred ? 'DELETE' : 'POST'; - const url = selectedDashboard.user_starred_path; - const newStarredValue = !selectedDashboard.starred; - - commit(types.REQUEST_DASHBOARD_STARRING); - - axios({ - url, - method, - }) - .then(() => { - commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, { selectedDashboard, newStarredValue }); - }) - .catch(() => { - commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE); - }); -}; - -/** - * Set a new array of metrics to a panel group - * @param {*} data An object containing - * - `key` with a unique panel key - * - `metrics` with the metrics array - */ -export const setPanelGroupMetrics = ({ commit }, data) => { - commit(types.SET_PANEL_GROUP_METRICS, data); -}; - -export const duplicateSystemDashboard = ({ state }, payload) => { - const params = { - dashboard: payload.dashboard, - file_name: payload.fileName, - branch: payload.branch, - commit_message: payload.commitMessage, - }; - - return axios - .post(state.dashboardsEndpoint, params) - .then((response) => response.data) - .then((data) => data.dashboard) - .catch((error) => { - Sentry.captureException(error); - - const { response } = error; - - if (response && response.data && response.data.error) { - throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), { - error: response.data.error, - }); - } else { - throw s__('Metrics|There was an error creating the dashboard.'); - } - }); -}; - -// Variables manipulation - -export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { - commit(types.UPDATE_VARIABLE_VALUE, updatedVariable); - - return dispatch('fetchDashboardData'); -}; - -export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => { - const { start_time, end_time } = defaultQueryParams; - const optionsRequests = []; - - state.variables.forEach((variable) => { - if (variable.type === VARIABLE_TYPES.metric_label_values) { - const { prometheusEndpointPath, label } = variable.options; - - const optionsRequest = getPrometheusQueryData(prometheusEndpointPath, { - start_time, - end_time, - }) - .then((data) => { - commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); - }) - .catch(() => { - createAlert({ - message: sprintf( - s__('Metrics|There was an error getting options for variable "%{name}".'), - { - name: variable.name, - }, - ), - }); - }); - optionsRequests.push(optionsRequest); - } - }); - - return Promise.all(optionsRequests); -}; - -// Panel Builder - -export const setPanelPreviewTimeRange = ({ commit }, timeRange) => { - commit(types.SET_PANEL_PREVIEW_TIME_RANGE, timeRange); -}; - -export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) => { - if (!panelPreviewYml) { - return null; - } - - commit(types.SET_PANEL_PREVIEW_IS_SHOWN, true); - commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml); - - return axios - .post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml }) - .then(({ data }) => { - commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data); - - dispatch('fetchPanelPreviewMetrics'); - }) - .catch((error) => { - commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error)); - }); -}; - -export const fetchPanelPreviewMetrics = ({ state, commit }) => { - if (cancelTokenSource) { - cancelTokenSource.cancel(); - } - cancelTokenSource = axiosCancelToken.source(); - - const defaultQueryParams = prometheusMetricQueryParams(state.panelPreviewTimeRange); - - state.panelPreviewGraphData.metrics.forEach((metric, index) => { - commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index }); - - const params = { ...defaultQueryParams }; - if (metric.step) { - params.step = metric.step; - } - return getPrometheusQueryData(metric.prometheusEndpointPath, params, { - cancelToken: cancelTokenSource.token, - }) - .then((data) => { - commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS, { index, data }); - }) - .catch((error) => { - Sentry.captureException(error); - - commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error }); - // Continue to throw error so the panel builder can notify using createAlert - throw error; - }); - }); -}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/actions.js b/app/assets/javascripts/monitoring/stores/embed_group/actions.js deleted file mode 100644 index ca0d2e5ba35..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/actions.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as types from './mutation_types'; - -export const addModule = ({ commit }, data) => commit(types.ADD_MODULE, data); diff --git a/app/assets/javascripts/monitoring/stores/embed_group/getters.js b/app/assets/javascripts/monitoring/stores/embed_group/getters.js deleted file mode 100644 index 8eddd830c58..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/getters.js +++ /dev/null @@ -1,2 +0,0 @@ -export const metricsWithData = (state, getters, rootState, rootGetters) => - state.modules.map((module) => rootGetters[`${module}/metricsWithData`]().length); diff --git a/app/assets/javascripts/monitoring/stores/embed_group/index.js b/app/assets/javascripts/monitoring/stores/embed_group/index.js deleted file mode 100644 index 66c65adc413..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -// In practice this store will have a number of `monitoringDashboard` modules added dynamically -export const createStore = () => - new Vuex.Store({ - modules: { - embedGroup: { - namespaced: true, - actions, - getters, - mutations, - state, - }, - }, - }); diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js deleted file mode 100644 index 288e6db4151..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js +++ /dev/null @@ -1 +0,0 @@ -export const ADD_MODULE = 'ADD_MODULE'; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutations.js b/app/assets/javascripts/monitoring/stores/embed_group/mutations.js deleted file mode 100644 index 3c66129f239..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/mutations.js +++ /dev/null @@ -1,7 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.ADD_MODULE](state, module) { - state.modules.push(module); - }, -}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/state.js b/app/assets/javascripts/monitoring/stores/embed_group/state.js deleted file mode 100644 index 016c7e5dac7..00000000000 --- a/app/assets/javascripts/monitoring/stores/embed_group/state.js +++ /dev/null @@ -1,3 +0,0 @@ -export default () => ({ - modules: [], -}); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js deleted file mode 100644 index d6a04006264..00000000000 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ /dev/null @@ -1,174 +0,0 @@ -import { NOT_IN_DB_PREFIX } from '../constants'; -import { - addPrefixToCustomVariableParams, - addDashboardMetaDataToLink, - normalizeCustomDashboardPath, -} from './utils'; - -const metricsIdsInPanel = (panel) => - panel.metrics - .filter((metric) => metric.metricId && metric.result) - .map((metric) => metric.metricId); - -/** - * Returns a reference to the currently selected dashboard - * from the list of dashboards. - * - * @param {Object} state - */ -export const selectedDashboard = (state, getters) => { - const { allDashboards } = state; - return ( - allDashboards.find((d) => d.path === getters.fullDashboardPath) || - allDashboards.find((d) => d.default) || - null - ); -}; - -/** - * Get all state for metric in the dashboard or a group. The - * states are not repeated so the dashboard or group can show - * a global state. - * - * @param {Object} state - * @returns {Function} A function that returns an array of - * states in all the metric in the dashboard or group. - */ -export const getMetricStates = (state) => (groupKey) => { - let groups = state.dashboard.panelGroups; - if (groupKey) { - groups = groups.filter((group) => group.key === groupKey); - } - - const metricStates = groups.reduce((acc, group) => { - group.panels.forEach((panel) => { - panel.metrics.forEach((metric) => { - if (metric.state) { - acc.push(metric.state); - } - }); - }); - return acc; - }, []); - - // Deduplicate and sort array - return Array.from(new Set(metricStates)).sort(); -}; - -/** - * Getter to obtain the list of metric ids that have data - * - * Useful to understand which parts of the dashboard should - * be displayed. It is a Vuex Method-Style Access getter. - * - * @param {Object} state - * @returns {Function} A function that returns an array of - * metrics in the dashboard that contain results, optionally - * filtered by group key. - */ -export const metricsWithData = (state) => (groupKey) => { - let groups = state.dashboard.panelGroups; - if (groupKey) { - groups = groups.filter((group) => group.key === groupKey); - } - - const res = []; - groups.forEach((group) => { - group.panels.forEach((panel) => { - res.push(...metricsIdsInPanel(panel)); - }); - }); - - return res; -}; - -/** - * Metrics loaded from project-defined dashboards do not have a metric_id. - * This getter checks which metrics are stored in the db (have a metric id) - * This is hopefully a temporary solution until BE processes metrics before passing to FE - * - * Related: - * https://gitlab.com/gitlab-org/gitlab/-/issues/28241 - * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 - */ -export const metricsSavedToDb = (state) => { - const metricIds = []; - state.dashboard.panelGroups.forEach(({ panels }) => { - panels.forEach(({ metrics }) => { - const metricIdsInDb = metrics - .filter(({ metricId }) => !metricId.startsWith(NOT_IN_DB_PREFIX)) - .map(({ metricId }) => metricId); - - metricIds.push(...metricIdsInDb); - }); - }); - return metricIds; -}; - -/** - * Filter environments by names. - * - * This is used in the environments dropdown with searchable input. - * - * @param {Object} state - * @returns {Array} List of environments - */ -export const filteredEnvironments = (state) => - state.environments.filter((env) => - env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()), - ); - -/** - * User-defined links from the yml file can have other - * dashboard-related metadata baked into it. This method - * returns modified links which will get rendered in the - * metrics dashboard - * - * @param {Object} state - * @returns {Array} modified array of links - */ -export const linksWithMetadata = (state) => { - const metadata = { - timeRange: state.timeRange, - }; - return state.links?.map(addDashboardMetaDataToLink(metadata)); -}; - -/** - * Maps a variables array to an object for replacement in - * prometheus queries. - * - * This method outputs an object in the below format - * - * { - * variables[key1]=value1, - * variables[key2]=value2, - * } - * - * This is done so that the backend can identify the custom - * user-defined variables coming through the URL and differentiate - * from other variables used for Prometheus API endpoint. - * - * @param {Object} state - State containing variables provided by the user - * @returns {Array} The custom variables object to be send to the API - * in the format of {variables[key1]=value1, variables[key2]=value2} - */ - -export const getCustomVariablesParams = (state) => - state.variables.reduce((acc, variable) => { - const { name, value } = variable; - if (value !== null) { - acc[addPrefixToCustomVariableParams(name)] = value; - } - return acc; - }, {}); - -/** - * For a given custom dashboard file name, this method - * returns the full file path. - * - * @param {Object} state - * @returns {String} full dashboard path - */ -export const fullDashboardPath = (state) => - normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js deleted file mode 100644 index 213a8508aa2..00000000000 --- a/app/assets/javascripts/monitoring/stores/index.js +++ /dev/null @@ -1,29 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const monitoringDashboard = { - namespaced: true, - actions, - getters, - mutations, - state, -}; - -export const createStore = (initState = {}) => - new Vuex.Store({ - modules: { - monitoringDashboard: { - ...monitoringDashboard, - state: { - ...state(), - ...initState, - }, - }, - }, - }); diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js deleted file mode 100644 index 1d7279912cc..00000000000 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ /dev/null @@ -1,62 +0,0 @@ -// Dashboard "skeleton", groups, panels, metrics, query variables -export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; -export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; -export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; -export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; -export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES'; - -export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; -export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; -export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; - -export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD'; - -// Annotations -export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; -export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; - -// Dashboard validation warnings -export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS = - 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS'; -export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE = - 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE'; - -// Git project deployments -export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; -export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; -export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE'; - -// Environments -export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; -export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; -export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; - -// Metric data points -export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT'; -export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS'; -export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE'; - -// Parameters and other information -export const SET_TIME_RANGE = 'SET_TIME_RANGE'; -export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; -export const SET_ENDPOINTS = 'SET_ENDPOINTS'; -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; -export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; -export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; -export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; -export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL'; - -// Panel preview -export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW'; -export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS'; -export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE'; - -export const REQUEST_PANEL_PREVIEW_METRIC_RESULT = 'REQUEST_PANEL_PREVIEW_METRIC_RESULT'; -export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS = - 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS'; -export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE = - 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE'; - -export const SET_PANEL_PREVIEW_TIME_RANGE = 'SET_PANEL_PREVIEW_TIME_RANGE'; -export const SET_PANEL_PREVIEW_IS_SHOWN = 'SET_PANEL_PREVIEW_IS_SHOWN'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js deleted file mode 100644 index 5fab292b6df..00000000000 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ /dev/null @@ -1,273 +0,0 @@ -import { pick } from 'lodash'; -import Vue from 'vue'; -import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils'; -import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; -import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; -import * as types from './mutation_types'; -import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; -import { optionsFromSeriesData } from './variable_mapping'; - -/** - * Locate and return a metric in the dashboard by its id - * as generated by `uniqMetricsId()`. - * @param {String} metricId Unique id in the dashboard - * @param {Object} dashboard Full dashboard object - */ -const findMetricInDashboard = (metricId, dashboard) => { - let res = null; - dashboard.panelGroups.forEach((group) => { - group.panels.forEach((panel) => { - panel.metrics.forEach((metric) => { - if (metric.metricId === metricId) { - res = metric; - } - }); - }); - }); - return res; -}; - -/** - * Maps a backened error state to a `metricStates` constant - * @param {Object} error - Error from backend response - */ -const emptyStateFromError = (error) => { - if (!error) { - return metricStates.UNKNOWN_ERROR; - } - - // Special error responses - if (error.message === BACKOFF_TIMEOUT) { - return metricStates.TIMEOUT; - } - - // Axios error responses - const { response } = error; - if (response && response.status === HTTP_STATUS_SERVICE_UNAVAILABLE) { - return metricStates.CONNECTION_FAILED; - } else if (response && response.status === HTTP_STATUS_BAD_REQUEST) { - // Note: "error.response.data.error" may contain Prometheus error information - return metricStates.BAD_QUERY; - } - - return metricStates.UNKNOWN_ERROR; -}; - -export const metricStateFromData = (data) => { - if (data?.result?.length) { - const result = normalizeQueryResponseData(data); - return { state: metricStates.OK, result: Object.freeze(result) }; - } - return { state: metricStates.NO_DATA, result: null }; -}; - -export default { - /** - * Dashboard panels structure and global state - */ - [types.REQUEST_METRICS_DASHBOARD](state) { - state.emptyState = dashboardEmptyStates.LOADING; - }, - [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) { - const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML); - state.dashboard = { - dashboard, - panelGroups, - }; - state.variables = variables; - state.links = links; - - if (!state.dashboard.panelGroups.length) { - state.emptyState = dashboardEmptyStates.NO_DATA; - } else { - state.emptyState = null; - } - }, - [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) { - state.emptyState = error - ? dashboardEmptyStates.UNABLE_TO_CONNECT - : dashboardEmptyStates.NO_DATA; - }, - - [types.REQUEST_DASHBOARD_STARRING](state) { - state.isUpdatingStarredValue = true; - }, - [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, { selectedDashboard, newStarredValue }) { - const index = state.allDashboards.findIndex((d) => d === selectedDashboard); - - state.isUpdatingStarredValue = false; - - // Trigger state updates in the reactivity system for this change - // https://vuejs.org/v2/guide/reactivity.html#For-Arrays - Vue.set(state.allDashboards, index, { ...selectedDashboard, starred: newStarredValue }); - }, - [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) { - state.isUpdatingStarredValue = false; - }, - - [types.SET_CURRENT_DASHBOARD](state, currentDashboard) { - state.currentDashboard = currentDashboard; - }, - - /** - * Deployments and environments - */ - [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) { - state.deploymentData = deployments; - }, - [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) { - state.deploymentData = []; - }, - [types.REQUEST_ENVIRONMENTS_DATA](state) { - state.environmentsLoading = true; - }, - [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) { - state.environmentsLoading = false; - state.environments = environments; - }, - [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { - state.environmentsLoading = false; - state.environments = []; - }, - - /** - * Annotations - */ - [types.RECEIVE_ANNOTATIONS_SUCCESS](state, annotations) { - state.annotations = annotations; - }, - [types.RECEIVE_ANNOTATIONS_FAILURE](state) { - state.annotations = []; - }, - - /** - * Dashboard Validation Warnings - */ - [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) { - state.hasDashboardValidationWarnings = hasDashboardValidationWarnings; - }, - [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) { - state.hasDashboardValidationWarnings = false; - }, - - /** - * Individual panel/metric results - */ - [types.REQUEST_METRIC_RESULT](state, { metricId }) { - const metric = findMetricInDashboard(metricId, state.dashboard); - metric.loading = true; - if (!metric.result) { - metric.state = metricStates.LOADING; - } - }, - [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { - const metric = findMetricInDashboard(metricId, state.dashboard); - const metricState = metricStateFromData(data); - - metric.loading = false; - metric.state = metricState.state; - metric.result = metricState.result; - }, - [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { - const metric = findMetricInDashboard(metricId, state.dashboard); - - metric.state = emptyStateFromError(error); - metric.loading = false; - metric.result = null; - }, - - // Parameters and other information - [types.SET_INITIAL_STATE](state, initialState = {}) { - Object.assign(state, pick(initialState, initialStateKeys)); - }, - [types.SET_ENDPOINTS](state, endpoints = {}) { - Object.assign(state, pick(endpoints, endpointKeys)); - }, - [types.SET_TIME_RANGE](state, timeRange) { - state.timeRange = timeRange; - }, - [types.SET_GETTING_STARTED_EMPTY_STATE](state) { - state.emptyState = dashboardEmptyStates.GETTING_STARTED; - }, - [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards || []; - }, - [types.SET_SHOW_ERROR_BANNER](state, enabled) { - state.showErrorBanner = enabled; - }, - [types.SET_PANEL_GROUP_METRICS](state, payload) { - const panelGroup = state.dashboard.panelGroups.find((pg) => payload.key === pg.key); - panelGroup.panels = payload.panels; - }, - [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) { - state.environmentsSearchTerm = searchTerm; - }, - [types.SET_EXPANDED_PANEL](state, { group, panel }) { - state.expandedPanel.group = group; - state.expandedPanel.panel = panel; - }, - [types.UPDATE_VARIABLE_VALUE](state, { name, value }) { - const variable = state.variables.find((v) => v.name === name); - if (variable) { - Object.assign(variable, { - value, - }); - } - }, - [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) { - const values = optionsFromSeriesData({ label, data }); - - // Add new options with assign to ensure Vue reactivity - Object.assign(variable.options, { values }); - }, - - [types.REQUEST_PANEL_PREVIEW](state, panelPreviewYml) { - state.panelPreviewIsLoading = true; - - state.panelPreviewYml = panelPreviewYml; - state.panelPreviewGraphData = null; - state.panelPreviewError = null; - }, - [types.RECEIVE_PANEL_PREVIEW_SUCCESS](state, payload) { - state.panelPreviewIsLoading = false; - - state.panelPreviewGraphData = mapPanelToViewModel(payload); - state.panelPreviewError = null; - }, - [types.RECEIVE_PANEL_PREVIEW_FAILURE](state, error) { - state.panelPreviewIsLoading = false; - - state.panelPreviewGraphData = null; - state.panelPreviewError = error; - }, - - [types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](state, { index }) { - const metric = state.panelPreviewGraphData.metrics[index]; - - metric.loading = true; - if (!metric.result) { - metric.state = metricStates.LOADING; - } - }, - [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](state, { index, data }) { - const metric = state.panelPreviewGraphData.metrics[index]; - const metricState = metricStateFromData(data); - - metric.loading = false; - metric.state = metricState.state; - metric.result = metricState.result; - }, - [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](state, { index, error }) { - const metric = state.panelPreviewGraphData.metrics[index]; - - metric.loading = false; - metric.state = emptyStateFromError(error); - metric.result = null; - }, - [types.SET_PANEL_PREVIEW_TIME_RANGE](state, timeRange) { - state.panelPreviewTimeRange = timeRange; - }, - [types.SET_PANEL_PREVIEW_IS_SHOWN](state, isPreviewShown) { - state.panelPreviewIsShown = isPreviewShown; - }, -}; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js deleted file mode 100644 index e513b575475..00000000000 --- a/app/assets/javascripts/monitoring/stores/state.js +++ /dev/null @@ -1,97 +0,0 @@ -import invalidUrl from '~/lib/utils/invalid_url'; -import { defaultTimeRange } from '~/vue_shared/constants'; -import { dashboardEmptyStates } from '../constants'; -import { timezones } from '../format_date'; - -export default () => ({ - // API endpoints - deploymentsEndpoint: null, - dashboardEndpoint: invalidUrl, - dashboardsEndpoint: invalidUrl, - panelPreviewEndpoint: invalidUrl, - - // Dashboard request parameters - timeRange: null, - /** - * Currently selected dashboard. For custom dashboards, - * this could be the filename or the file path. - * - * If this is the filename and full path is required, - * getters.fullDashboardPath should be used. - */ - currentDashboard: null, - - // Dashboard data - hasDashboardValidationWarnings: false, - - /** - * {?String} If set, dashboard should display a global - * empty state, there is no way to interact (yet) - * with the dashboard. - */ - emptyState: dashboardEmptyStates.GETTING_STARTED, - showErrorBanner: true, - isUpdatingStarredValue: false, - dashboard: { - panelGroups: [], - }, - /** - * Panel that is currently "zoomed" in as - * a single panel in view. - */ - expandedPanel: { - /** - * {?String} Panel's group name. - */ - group: null, - /** - * {?Object} Panel content from `dashboard` - * null when no panel is expanded. - */ - panel: null, - }, - allDashboards: [], - /** - * User-defined custom variables are passed - * via the dashboard yml file. - */ - variables: [], - /** - * User-defined custom links are passed - * via the dashboard yml file. - */ - links: [], - - // Panel editor / builder - panelPreviewYml: '', - panelPreviewIsLoading: false, - panelPreviewGraphData: null, - panelPreviewError: null, - panelPreviewTimeRange: defaultTimeRange, - panelPreviewIsShown: false, - - // Other project data - dashboardTimezone: timezones.LOCAL, - annotations: [], - deploymentData: [], - environments: [], - environmentsSearchTerm: '', - environmentsLoading: false, - currentEnvironmentName: null, - - // GitLab paths to other pages - externalDashboardUrl: '', - projectPath: null, - operationsSettingsPath: '', - addDashboardDocumentationPath: '', - - // static paths - customDashboardBasePath: '', - - // current user data - /** - * Flag that denotes if the currently logged user can access - * the project Settings -> Operations - */ - canAccessOperationsSettings: false, -}); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js deleted file mode 100644 index 02a2435d575..00000000000 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ /dev/null @@ -1,505 +0,0 @@ -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import createGqClient, { fetchPolicies } from '~/lib/graphql'; -import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; -import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; -import { slugify } from '~/lib/utils/text_utility'; -import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; -import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants'; -import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; - -export const gqClient = createGqClient( - {}, - { - fetchPolicy: fetchPolicies.NO_CACHE, - }, -); - -/** - * Metrics loaded from project-defined dashboards do not have a metricId. - * This method creates a unique ID combining metricId and id, if either is present. - * This is hopefully a temporary solution until BE processes metrics before passing to FE - * - * Related: - * https://gitlab.com/gitlab-org/gitlab/-/issues/28241 - * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 - * - * @param {Object} metric - metric - * @param {Number} metric.metricId - Database metric id - * @param {String} metric.id - User-defined identifier - * @returns {Object} - normalized metric with a uniqueID - */ -export const uniqMetricsId = ({ metricId, id }) => `${metricId || NOT_IN_DB_PREFIX}_${id}`; - -/** - * Project path has a leading slash that doesn't work well - * with project full path resolver here - * https://gitlab.com/gitlab-org/gitlab/blob/5cad4bd721ab91305af4505b2abc92b36a56ad6b/app/graphql/resolvers/full_path_resolver.rb#L10 - * - * @param {String} str String with leading slash - * @returns {String} - */ -export const removeLeadingSlash = (str) => (str || '').replace(/^\/+/, ''); - -/** - * GraphQL environments API returns only id and name. - * For the environments dropdown we need metrics_path. - * This method parses the results and add necessary attrs - * - * @param {Array} response Environments API result - * @param {String} projectPath Current project path - * @returns {Array} - */ -export const parseEnvironmentsResponse = (response = [], projectPath) => - (response || []).map((env) => { - const id = getIdFromGraphQLId(env.id); - return { - ...env, - id, - metrics_path: `${projectPath}/-/metrics?environment=${id}`, - }; - }); - -/** - * Annotation API returns time in UTC. This method - * converts time to local time. - * - * startingAt always exists but endingAt does not. - * If endingAt does not exist, a threshold line is - * drawn. - * - * If endingAt exists, a threshold range is drawn. - * But this is not supported as of %12.10 - * - * @param {Array} response annotations response - * @returns {Array} parsed responses - */ -export const parseAnnotationsResponse = (response) => { - if (!response) { - return []; - } - return response.map((annotation) => ({ - ...annotation, - startingAt: new Date(annotation.startingAt), - endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, - })); -}; - -/** - * Maps metrics to its view model - * - * This function difers from other in that is maps all - * non-define properties as-is to the object. This is not - * advisable as it could lead to unexpected side-effects. - * - * Related issue: - * https://gitlab.com/gitlab-org/gitlab/issues/207198 - * - * @param {Array} metrics - Array of prometheus metrics - * @returns {Object} - */ -const mapToMetricsViewModel = (metrics) => - metrics.map( - ({ - label, - id, - metric_id: metricId, - query_range: queryRange, - prometheus_endpoint_path: prometheusEndpointPath, - ...metric - }) => ({ - label, - queryRange, - prometheusEndpointPath, - metricId: uniqMetricsId({ metricId, id }), - - // metric data - loading: false, - result: null, - state: null, - - ...metric, - }), - ); - -/** - * Maps X-axis view model - * - * @param {Object} axis - */ -const mapXAxisToViewModel = ({ name = '' }) => ({ name }); - -/** - * Maps Y-axis view model - * - * Defaults to a 2 digit precision and `engineering` format. It only allows - * formats in the SUPPORTED_FORMATS array. - * - * @param {Object} axis - */ -const mapYAxisToViewModel = ({ - name = '', - format = SUPPORTED_FORMATS.engineering, - precision = 2, -}) => { - return { - name, - format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, - precision, - }; -}; - -/** - * Maps a link to its view model, expects an url and - * (optionally) a title. - * - * Unsafe URLs are ignored. - * - * @param {Object} Link - * @returns {Object} Link object with a `title`, `url` and `type` - * - */ -const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => { - return { - title: title || String(url), - type, - url: url && isSafeURL(url) ? String(url) : '#', - }; -}; - -/** - * Maps a metrics panel to its view model - * - * @param {Object} panel - Metrics panel - * @returns {Object} - */ -export const mapPanelToViewModel = ({ - id = null, - title = '', - type, - x_axis: xAxisBase = {}, - x_label: xLabel, - y_label: yLabel, - y_axis: yAxisBase = {}, - field, - metrics = [], - links = [], - min_value: minValue, - max_value: maxValue, - split, - thresholds, - format, -}) => { - // Both `x_axis.name` and `x_label` are supported for now - // https://gitlab.com/gitlab-org/gitlab/issues/210521 - const xAxis = mapXAxisToViewModel({ name: xLabel, ...xAxisBase }); - - // Both `y_axis.name` and `y_label` are supported for now - // https://gitlab.com/gitlab-org/gitlab/issues/208385 - const yAxis = mapYAxisToViewModel({ name: yLabel, ...yAxisBase }); - - return { - id, - title, - type, - xLabel: xAxis.name, - y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198 - yAxis, - xAxis, - field, - minValue, - maxValue, - split, - thresholds, - format, - links: links.map(mapLinksToViewModel), - metrics: mapToMetricsViewModel(metrics), - }; -}; - -/** - * Maps a metrics panel group to its view model - * - * @param {Object} panelGroup - Panel Group - * @returns {Object} - */ -const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => { - return { - key: `${slugify(group || 'default')}-${i}`, - group, - panels: panels.map(mapPanelToViewModel), - }; -}; - -/** - * Convert dashboard time range to Grafana - * dashboards time range. - * - * @param {Object} timeRange - * @returns {Object} - */ -export const convertToGrafanaTimeRange = (timeRange) => { - const timeRangeType = getRangeType(timeRange); - if (timeRangeType === DATETIME_RANGE_TYPES.fixed) { - return { - from: new Date(timeRange.start).getTime(), - to: new Date(timeRange.end).getTime(), - }; - } else if (timeRangeType === DATETIME_RANGE_TYPES.rolling) { - const { seconds } = timeRange.duration; - return { - from: `now-${seconds}s`, - to: 'now', - }; - } - // fallback to returning the time range as is - return timeRange; -}; - -/** - * Convert dashboard time ranges to other supported - * link formats. - * - * @param {Object} timeRange metrics dashboard time range - * @param {String} type type of link - * @returns {String} - */ -export const convertTimeRanges = (timeRange, type) => { - if (type === linkTypes.GRAFANA) { - return convertToGrafanaTimeRange(timeRange); - } - return timeRangeToParams(timeRange); -}; - -/** - * Adds dashboard-related metadata to the user-defined links. - * - * As of %13.1, metadata only includes timeRange but in the - * future more info will be added to the links. - * - * @param {Object} metadata - * @returns {Function} - */ -export const addDashboardMetaDataToLink = (metadata) => (link) => { - let modifiedLink = { ...link }; - if (metadata.timeRange) { - modifiedLink = { - ...modifiedLink, - url: mergeUrlParams(convertTimeRanges(metadata.timeRange, link.type), link.url), - }; - } - return modifiedLink; -}; - -/** - * Maps a dashboard json object to its view model - * - * @param {Object} dashboard - Dashboard object - * @param {String} dashboard.dashboard - Dashboard name object - * @param {Array} dashboard.panel_groups - Panel groups array - * @returns {Object} - */ -export const mapToDashboardViewModel = ({ - dashboard = '', - templating = {}, - links = [], - panel_groups: panelGroups = [], -}) => { - return { - dashboard, - variables: mergeURLVariables(parseTemplatingVariables(templating.variables)), - links: links.map(mapLinksToViewModel), - panelGroups: panelGroups.map(mapToPanelGroupViewModel), - }; -}; - -// Prometheus Results Parsing - -const dateTimeFromUnixTime = (unixTime) => new Date(unixTime * 1000).toISOString(); - -const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)]; - -// Note: `string` value type is unused as of prometheus 2.19. -const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value]; - -/** - * Processes a scalar result. - * - * The corresponding result property has the following format: - * - * [ <unix_time>, "<scalar_value>" ] - * - * @param {array} result - * @returns {array} - */ -const normalizeScalarResult = (result) => [ - { - metric: {}, - value: mapScalarValue(result), - values: [mapScalarValue(result)], - }, -]; - -/** - * Processes a string result. - * - * The corresponding result property has the following format: - * - * [ <unix_time>, "<string_value>" ] - * - * Note: This value type is unused as of prometheus 2.19. - * - * @param {array} result - * @returns {array} - */ -const normalizeStringResult = (result) => [ - { - metric: {}, - value: mapStringValue(result), - values: [mapStringValue(result)], - }, -]; - -/** - * Proccesses an instant vector. - * - * Instant vectors are returned as result type `vector`. - * - * The corresponding result property has the following format: - * - * [ - * { - * "metric": { "<label_name>": "<label_value>", ... }, - * "value": [ <unix_time>, "<sample_value>" ], - * "values": [ [ <unix_time>, "<sample_value>" ] ] - * }, - * ... - * ] - * - * `metric` - Key-value pairs object representing metric measured - * `value` - The vector result - * `values` - An array with a single value representing the result - * - * This method also adds the matrix version of the vector - * by introducing a `values` array with a single element. This - * allows charts to default to `values` if needed. - * - * @param {array} result - * @returns {array} - */ -const normalizeVectorResult = (result) => - result.map(({ metric, value }) => { - const scalar = mapScalarValue(value); - // Add a single element to `values`, to support matrix - // style charts. - return { metric, value: scalar, values: [scalar] }; - }); - -/** - * Range vectors are returned as result type matrix. - * - * The corresponding result property has the following format: - * - * { - * "metric": { "<label_name>": "<label_value>", ... }, - * "value": [ <unix_time>, "<sample_value>" ], - * "values": [ [ <unix_time>, "<sample_value>" ], ... ] - * }, - * - * `metric` - Key-value pairs object representing metric measured - * `value` - The last (more recent) result - * `values` - A range of results for the metric - * - * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors - * - * @param {array} result - * @returns {object} Normalized result. - */ -const normalizeResultMatrix = (result) => - result.map(({ metric, values }) => { - const mappedValues = values.map(mapScalarValue); - return { - metric, - value: mappedValues[mappedValues.length - 1], - values: mappedValues, - }; - }); - -/** - * Parse response data from a Prometheus Query that comes - * in the format: - * - * { - * "resultType": "matrix" | "vector" | "scalar" | "string", - * "result": <value> - * } - * - * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats - * - * @param {object} data - Data containing results and result type. - * @returns {object} - A result array of metric results: - * [ - * { - * metric: { ... }, - * value: ['2015-07-01T20:10:51.781Z', '1'], - * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ], - * }, - * ... - * ] - * - */ -export const normalizeQueryResponseData = (data) => { - const { resultType, result } = data; - if (resultType === 'vector') { - return normalizeVectorResult(result); - } else if (resultType === 'scalar') { - return normalizeScalarResult(result); - } else if (resultType === 'string') { - return normalizeStringResult(result); - } - return normalizeResultMatrix(result); -}; - -/** - * Custom variables defined in the dashboard yml file are - * eventually passed over the wire to the backend Prometheus - * API proxy. - * - * This method adds a prefix to the URL param keys so that - * the backend can differential these variables from the other - * variables. - * - * This is currently only used by getters/getCustomVariablesParams - * - * @param {String} name Variable key that needs to be prefixed - * @returns {String} - */ -export const addPrefixToCustomVariableParams = (name) => `variables[${name}]`; - -/** - * Normalize custom dashboard paths. This method helps support - * metrics dashboard to work with custom dashboard file names instead - * of the entire path. - * - * If dashboard is empty, it is the overview dashboard. - * If dashboard is set, it usually is a custom dashboard unless - * explicitly it is set to overview dashboard path. - * - * @param {String} dashboard dashboard path - * @param {String} dashboardPrefix custom dashboard directory prefix - * @returns {String} normalized dashboard path - */ -export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => { - const currDashboard = dashboard || ''; - let dashboardPath = `${dashboardPrefix}/${currDashboard}`; - - if (!currDashboard) { - dashboardPath = ''; - } else if ( - currDashboard.startsWith(dashboardPrefix) || - currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX) - ) { - dashboardPath = currDashboard; - } - return dashboardPath; -}; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js deleted file mode 100644 index 4ca7a0b51d6..00000000000 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ /dev/null @@ -1,273 +0,0 @@ -import { isString } from 'lodash'; -import { VARIABLE_TYPES } from '../constants'; -import { templatingVariablesFromUrl } from '../utils'; - -/** - * This file exclusively deals with parsing user-defined variables - * in dashboard yml file. - * - * As of 13.0, simple text, advanced text, simple custom and - * advanced custom variables are supported. - * - * In the future iterations, text and query variables will be - * supported - * - */ - -/** - * Simple text variable is a string value only. - * This method parses such variables to a standard format. - * - * @param {String|Object} simpleTextVar - * @returns {Object} - */ -const textSimpleVariableParser = (simpleTextVar) => ({ - type: VARIABLE_TYPES.text, - label: null, - value: simpleTextVar, -}); - -/** - * Advanced text variable is an object. - * This method parses such variables to a standard format. - * - * @param {Object} advTextVar - * @returns {Object} - */ -const textAdvancedVariableParser = (advTextVar) => ({ - type: VARIABLE_TYPES.text, - label: advTextVar.label, - value: advTextVar.options.default_value, -}); - -/** - * Normalize simple and advanced custom variable options to a standard - * format - * @param {Object} custom variable option - * @returns {Object} normalized custom variable options - */ -const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({ - default: defaultOpt, - text: text || value, - value, -}); - -/** - * Custom advanced variables are rendered as dropdown elements in the dashboard - * header. This method parses advanced custom variables. - * - * The default value is the option with default set to true or the first option - * if none of the options have default prop true. - * - * @param {Object} advVariable advanced custom variable - * @returns {Object} - */ -const customAdvancedVariableParser = (advVariable) => { - const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues); - const defaultValue = values.find((opt) => opt.default === true) || values[0]; - return { - type: VARIABLE_TYPES.custom, - label: advVariable.label, - options: { - values, - }, - value: defaultValue?.value || null, - }; -}; - -/** - * Simple custom variables have an array of values. - * This method parses such variables options to a standard format. - * - * @param {String} opt option from simple custom variable - * @returns {Object} - */ -export const parseSimpleCustomValues = (opt) => ({ text: opt, value: opt }); - -/** - * Custom simple variables are rendered as dropdown elements in the dashboard - * header. This method parses simple custom variables. - * - * Simple custom variables do not have labels so its set to null here. - * - * The default value is set to the first option as the user cannot - * set a default value for this format - * - * @param {Array} customVariable array of options - * @returns {Object} - */ -const customSimpleVariableParser = (simpleVar) => { - const values = (simpleVar || []).map(parseSimpleCustomValues); - return { - type: VARIABLE_TYPES.custom, - label: null, - value: values[0].value || null, - options: { - values: values.map(normalizeVariableValues), - }, - }; -}; - -const metricLabelValuesVariableParser = ({ label, options = {} }) => ({ - type: VARIABLE_TYPES.metric_label_values, - label, - value: null, - options: { - prometheusEndpointPath: options.prometheus_endpoint_path || '', - label: options.label || null, - values: [], // values are initially empty - }, -}); - -/** - * Utility method to determine if a custom variable is - * simple or not. If its not simple, it is advanced. - * - * @param {Array|Object} customVar Array if simple, object if advanced - * @returns {Boolean} true if simple, false if advanced - */ -const isSimpleCustomVariable = (customVar) => Array.isArray(customVar); - -/** - * This method returns a parser based on the type of the variable. - * Currently, the supported variables are simple custom and - * advanced custom only. In the future, this method will support - * text and query variables. - * - * @param {Array|Object} variable - * @return {Function} parser method - */ -const getVariableParser = (variable) => { - if (isString(variable)) { - return textSimpleVariableParser; - } else if (isSimpleCustomVariable(variable)) { - return customSimpleVariableParser; - } else if (variable.type === VARIABLE_TYPES.text) { - return textAdvancedVariableParser; - } else if (variable.type === VARIABLE_TYPES.custom) { - return customAdvancedVariableParser; - } else if (variable.type === VARIABLE_TYPES.metric_label_values) { - return metricLabelValuesVariableParser; - } - return () => null; -}; - -/** - * This method parses the templating property in the dashboard yml file. - * The templating property has variables that are rendered as input elements - * for the user to edit. The values from input elements are relayed to - * backend and eventually Prometheus API. - * - * @param {Object} templating variables from the dashboard yml file - * @returns {array} An array of variables to display as inputs - */ -export const parseTemplatingVariables = (ymlVariables = {}) => - Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => { - // get the parser - const parser = getVariableParser(ymlVariable); - // parse the variable - const variable = parser(ymlVariable); - // for simple custom variable label is null and it should be - // replace with key instead - if (variable) { - acc.push({ - ...variable, - name, - label: variable.label || name, - }); - } - return acc; - }, []); - -/** - * Custom variables are defined in the dashboard yml file - * and their values can be passed through the URL. - * - * On component load, this method merges variables data - * from the yml file with URL data to store in the Vuex store. - * Not all params coming from the URL need to be stored. Only - * the ones that have a corresponding variable defined in the - * yml file. - * - * This ensures that there is always a single source of truth - * for variables - * - * This method can be improved further. See the below issue - * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 - * - * @param {array} parsedYmlVariables - template variables from yml file - * @returns {Object} - */ -export const mergeURLVariables = (parsedYmlVariables = []) => { - const varsFromURL = templatingVariablesFromUrl(); - parsedYmlVariables.forEach((variable) => { - const { name } = variable; - if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) { - Object.assign(variable, { value: varsFromURL[name] }); - } - }); - return parsedYmlVariables; -}; - -/** - * Converts series data to options that can be added to a - * variable. Series data is returned from the Prometheus API - * `/api/v1/series`. - * - * Finds a `label` in the series data, so it can be used as - * a filter. - * - * For example, for the arguments: - * - * { - * "label": "job" - * "data" : [ - * { - * "__name__" : "up", - * "job" : "prometheus", - * "instance" : "localhost:9090" - * }, - * { - * "__name__" : "up", - * "job" : "node", - * "instance" : "localhost:9091" - * }, - * { - * "__name__" : "process_start_time_seconds", - * "job" : "prometheus", - * "instance" : "localhost:9090" - * } - * ] - * } - * - * It returns all the different "job" values: - * - * [ - * { - * "label": "node", - * "value": "node" - * }, - * { - * "label": "prometheus", - * "value": "prometheus" - * } - * ] - * - * @param {options} options object - * @param {options.seriesLabel} name of the searched series label - * @param {options.data} series data from the series API - * @return {array} Options objects with the shape `{ label, value }` - * - * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers - */ -export const optionsFromSeriesData = ({ label, data = [] }) => { - const optionsSet = data.reduce((set, seriesObject) => { - // Use `new Set` to deduplicate options - if (seriesObject[label]) { - set.add(seriesObject[label]); - } - return set; - }, new Set()); - - return [...optionsSet].map(parseSimpleCustomValues); -}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js deleted file mode 100644 index 5f4d2703d21..00000000000 --- a/app/assets/javascripts/monitoring/utils.js +++ /dev/null @@ -1,402 +0,0 @@ -import { pickBy, mapKeys } from 'lodash'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { - timeRangeParamNames, - timeRangeFromParams, - timeRangeToParams, -} from '~/lib/utils/datetime_range'; -import { - queryToObject, - mergeUrlParams, - removeParams, - updateHistory, -} from '~/lib/utils/url_utility'; -import { VARIABLE_PREFIX } from './constants'; - -/** - * Extracts the initial state and props from HTML dataset - * and places them in separate objects to setup bundle. - * @param {*} dataset - */ -export const stateAndPropsFromDataset = (dataset = {}) => { - const { - currentDashboard, - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - panelPreviewEndpoint, - dashboardTimezone, - canAccessOperationsSettings, - operationsSettingsPath, - projectPath, - externalDashboardUrl, - currentEnvironmentName, - customDashboardBasePath, - addDashboardDocumentationPath, - ...dataProps - } = dataset; - - // HTML attributes are always strings, parse other types. - dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); - dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); - - return { - initState: { - currentDashboard, - deploymentsEndpoint, - dashboardEndpoint, - dashboardsEndpoint, - panelPreviewEndpoint, - dashboardTimezone, - canAccessOperationsSettings, - operationsSettingsPath, - projectPath, - externalDashboardUrl, - currentEnvironmentName, - customDashboardBasePath, - addDashboardDocumentationPath, - }, - dataProps, - }; -}; - -/** - * List of non time range url parameters - * This will be removed once we add support for free text variables - * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689 - */ -export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded']; - -/** - * This method is used to validate if the graph data format for a chart component - * that needs a time series as a response from a prometheus query (queryRange) is - * of a valid format or not. - * @param {Object} graphData the graph data response from a prometheus request - * @returns {boolean} whether the graphData format is correct - */ -export const graphDataValidatorForValues = (isValues, graphData) => { - const responseValueKeyName = isValues ? 'value' : 'values'; - return ( - Array.isArray(graphData.metrics) && - graphData.metrics.filter((query) => { - if (Array.isArray(query.result)) { - return ( - query.result.filter((res) => Array.isArray(res[responseValueKeyName])).length === - query.result.length - ); - } - return false; - }).length === graphData.metrics.filter((query) => query.result).length - ); -}; - -/** - * Checks that element that triggered event is located on cluster health check dashboard - * @param {HTMLElement} element to check against - * @returns {boolean} - */ -const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); - -/** - * Tracks snowplow event when user generates link to metric chart - * @param {String} chart link that will be sent as a property for the event - * @return {Object} config object for event tracking - */ -export const generateLinkToChartOptions = (chartLink) => { - const isCLusterHealthBoard = isClusterHealthBoard(); - - const category = isCLusterHealthBoard - ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings - : 'Incident Management::Embedded metrics'; - const action = isCLusterHealthBoard - ? 'generate_link_to_cluster_metric_chart' - : 'generate_link_to_metrics_chart'; - - return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings -}; - -/** - * Tracks snowplow event when user downloads CSV of cluster metric - * @param {String} chart title that will be sent as a property for the event - * @return {Object} config object for event tracking - */ -export const downloadCSVOptions = (title) => { - const isCLusterHealthBoard = isClusterHealthBoard(); - - const category = isCLusterHealthBoard - ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings - : 'Incident Management::Embedded metrics'; - const action = isCLusterHealthBoard - ? 'download_csv_of_cluster_metric_chart' - : 'download_csv_of_metrics_dashboard_chart'; - - return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings -}; -/* eslint-enable @gitlab/require-i18n-strings */ - -/** - * Generate options for snowplow to track adding a new metric via the dashboard - * custom metric modal - * @return {Object} config object for event tracking - */ -export const getAddMetricTrackingOptions = () => ({ - category: document.body.dataset.page, - action: 'click_button', - label: 'add_new_metric', - property: 'modal', -}); - -/** - * This function validates the graph data contains exactly 3 metrics plus - * value validations from graphDataValidatorForValues. - * @param {Object} isValues - * @param {Object} graphData the graph data response from a prometheus request - * @returns {boolean} true if the data is valid - */ -export const graphDataValidatorForAnomalyValues = (graphData) => { - const anomalySeriesCount = 3; // metric, upper, lower - return ( - graphData.metrics && - graphData.metrics.length === anomalySeriesCount && - graphDataValidatorForValues(false, graphData) - ); -}; - -/** - * Returns a time range from the current URL params - * - * @returns {Object|null} The time range defined by the - * current URL, reading from search query or `window.location.search`. - * Returns `null` if no parameters form a time range. - */ -export const timeRangeFromUrl = (search = window.location.search) => { - const params = queryToObject(search, { legacySpacesDecode: true }); - return timeRangeFromParams(params); -}; - -/** - * Variable labels are used as names for the dropdowns and also - * as URL params. Prefixing the name reduces the risk of - * collision with other URL params - * - * @param {String} label label for the template variable - * @returns {String} - */ -export const addPrefixToLabel = (label) => `${VARIABLE_PREFIX}${label}`; - -/** - * Before the templating variables are passed to the backend the - * prefix needs to be removed. - * - * This method removes the prefix at the beginning of the string. - * - * @param {String} label label to remove prefix from - * @returns {String} - */ -export const removePrefixFromLabel = (label) => - (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), ''); - -/** - * Convert parsed template variables to an object - * with just keys and values. Prepare the variables - * to be added to the URL. Keys of the object will - * have a prefix so that these params can be - * differentiated from other URL params. - * - * @param {Object} variables - * @returns {Object} - */ -export const convertVariablesForURL = (variables) => - variables.reduce((acc, { name, value }) => { - if (value !== null) { - acc[addPrefixToLabel(name)] = value; - } - return acc; - }, {}); - -/** - * User-defined variables from the URL are extracted. The variables - * begin with a constant prefix so that it doesn't collide with - * other URL params. - * - * @param {String} search URL - * @returns {Object} The custom variables defined by the user in the URL - */ -export const templatingVariablesFromUrl = (search = window.location.search) => { - const params = queryToObject(search, { legacySpacesDecode: true }); - // pick the params with variable prefix - const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX)); - // remove the prefix before storing in the Vuex store - return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key)); -}; - -/** - * Update the URL with variables. This usually get triggered when - * the user interacts with the dynamic input elements in the monitoring - * dashboard header. - * - * @param {Object} variables user defined variables - */ -export const setCustomVariablesFromUrl = (variables) => { - // prep the variables to append to URL - const parsedVariables = convertVariablesForURL(variables); - // update the URL - updateHistory({ - url: mergeUrlParams(parsedVariables, window.location.href), - title: document.title, - }); -}; - -/** - * Returns a URL with no time range based on the current URL. - * - * @param {String} New URL - */ -export const removeTimeRangeParams = (url = window.location.href) => - removeParams(timeRangeParamNames, url); - -/** - * Returns a URL for the a different time range based on the - * current URL and a time range. - * - * @param {String} New URL - */ -export const timeRangeToUrl = (timeRange, url = window.location.href) => { - const toUrl = removeTimeRangeParams(url); - const params = timeRangeToParams(timeRange); - return mergeUrlParams(params, toUrl); -}; - -/** - * Locates a panel (and its corresponding group) given a (URL) search query. Returns - * it as payload for the store to set the right expandaded panel. - * - * Params used to locate a panel are: - * - group: Group identifier - * - title: Panel title - * - y_label: Panel y_label - * - * @param {Object} dashboard - Dashboard reference from the Vuex store - * @param {String} search - URL location search query - * @returns {Object} payload - Payload for expanded panel to be displayed - * @returns {String} payload.group - Group where panel is located - * @returns {Object} payload.panel - Dashboard panel (graphData) reference - * @throws Will throw an error if Panel cannot be located. - */ -export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => { - const params = queryToObject(search, { legacySpacesDecode: true }); - - // Search for the panel if any of the search params is identified - if (params.group || params.title || params.y_label) { - const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group); - const panel = panelGroup.panels.find( - // eslint-disable-next-line camelcase - ({ y_label, title }) => y_label === params.y_label && title === params.title, - ); - - if (!panel) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Panel could no found by URL parameters.'); - } - return { group: panelGroup.group, panel }; - } - return null; -}; - -/** - * Convert panel information to a URL for the user to - * bookmark or share highlighting a specific panel. - * - * If no group/panel is set, the dashboard URL is returned. - * - * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard - * @param {?Object} variables - Custom variables that came from the URL - * @param {?String} group - Group Identifier - * @param {?Object} panel - Panel object from the dashboard - * @param {?String} url - Base URL including current search params - * @returns Dashboard URL which expands a panel (chart) - */ -export const panelToUrl = ( - dashboard = null, - variables, - group, - panel, - url = window.location.href, -) => { - const params = { - dashboard, - ...variables, - }; - - if (group && panel) { - params.group = group; - params.title = panel.title; - params.y_label = panel.y_label; - } else { - // Remove existing parameters if any - params.group = null; - params.title = null; - params.y_label = null; - } - - return mergeUrlParams(params, url); -}; - -/** - * Get the metric value from first data point. - * Currently only used for bar charts - * - * @param {Array} values data points - * @returns {Number} - */ -const metricValueMapper = (values) => values[0]?.[1]; - -/** - * Get the metric name from metric object - * Currently only used for bar charts - * e.g. { handler: '/query' } - * { method: 'get' } - * - * @param {Object} metric metric object - * @returns {String} - */ -const metricNameMapper = (metric) => Object.values(metric)?.[0]; - -/** - * Parse metric object to extract metric value and name in - * [<metric-value>, <metric-name>] format. - * Currently only used for bar charts - * - * @param {Object} param0 metric object - * @returns {Array} - */ -const resultMapper = ({ metric, values = [] }) => [ - metricValueMapper(values), - metricNameMapper(metric), -]; - -/** - * Bar charts graph data parser to massage data from - * backend to a format acceptable by bar charts component - * in GitLab UI - * - * e.g. - * { - * SLO: [ - * [98, 'api'], - * [99, 'web'], - * [99, 'database'] - * ] - * } - * - * @param {Array} data series information - * @returns {Object} - */ -export const barChartsDataParser = (data = []) => - data?.reduce( - (acc, { result = [], label }) => ({ - ...acc, - [label]: result.map(resultMapper), - }), - {}, - ); diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js deleted file mode 100644 index 05a9d8b9db5..00000000000 --- a/app/assets/javascripts/monitoring/validators.js +++ /dev/null @@ -1,55 +0,0 @@ -import { isSafeURL } from '~/lib/utils/url_utility'; - -const isRunbookUrlValid = (runbookUrl) => { - if (!runbookUrl) { - return true; - } - return isSafeURL(runbookUrl); -}; - -// Prop validator for alert information, expecting an object like the example below. -// -// { -// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': { -// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37", -// metricId: '1', -// operator: ">", -// query: "rate(http_requests_total[5m])[30m:1m]", -// threshold: 0.002, -// title: "Core Usage (Total)", -// runbookUrl: "https://www.gitlab.com/my-project/-/wikis/runbook" -// } -// } -export function alertsValidator(value) { - return Object.keys(value).every((key) => { - const alert = value[key]; - return ( - alert.alert_path && - key === alert.alert_path && - alert.metricId && - typeof alert.metricId === 'string' && - alert.operator && - typeof alert.threshold === 'number' && - isRunbookUrlValid(alert.runbookUrl) - ); - }); -} - -// Prop validator for query information, expecting an array like the example below. -// -// [ -// { -// metricId: '16', -// label: 'Total Cores' -// }, -// { -// metricId: '17', -// label: 'Sub-total Cores' -// } -// ] -export function queriesValidator(value) { - return value.every( - (query) => - query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string', - ); -} diff --git a/app/assets/javascripts/mr_more_dropdown.js b/app/assets/javascripts/mr_more_dropdown.js index 720619b72ae..4a9e10be5ad 100644 --- a/app/assets/javascripts/mr_more_dropdown.js +++ b/app/assets/javascripts/mr_more_dropdown.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { initReportAbuse } from '~/projects/report_abuse'; import MrMoreDropdown from '~/vue_shared/components/mr_more_dropdown.vue'; export const initMrMoreDropdown = () => { @@ -11,6 +12,7 @@ export const initMrMoreDropdown = () => { const { mergeRequest, projectPath, + url, editUrl, isCurrentUser, isLoggedIn, @@ -20,7 +22,6 @@ export const initMrMoreDropdown = () => { sourceProjectMissing, clipboardText, reportedUserId, - reportedFromUrl, } = el.dataset; let mr; @@ -35,12 +36,17 @@ export const initMrMoreDropdown = () => { el, provide: { reportAbusePath: el.dataset.reportAbusePath, + showSummaryNotesToggle: Boolean(document.querySelector('#js-summary-notes')), + }, + beforeCreate() { + initReportAbuse(); }, render: (createElement) => createElement(MrMoreDropdown, { props: { mr, projectPath, + url, editUrl, isCurrentUser, isLoggedIn: Boolean(isLoggedIn), @@ -50,7 +56,6 @@ export const initMrMoreDropdown = () => { sourceProjectMissing, clipboardText, reportedUserId: Number(reportedUserId), - reportedFromUrl, }, }), }); diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js index e8e3376cee2..28f294589ae 100644 --- a/app/assets/javascripts/mr_notes/init.js +++ b/app/assets/javascripts/mr_notes/init.js @@ -1,12 +1,13 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, getCookie } from '~/lib/utils/common_utils'; import mrNotes from '~/mr_notes/stores'; -import { getLocationHash } from '~/lib/utils/url_utility'; +import { getLocationHash, getParameterValues } from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; import { initReviewBar } from '~/batch_comments'; import { initDiscussionCounter } from '~/mr_notes/discussion_counter'; import { initOverviewTabCounter } from '~/mr_notes/init_count'; import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; import { getReviewsForMergeRequest } from '~/diffs/utils/file_reviews'; +import { DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; function setupMrNotesState(store, notesDataset, diffsDataset) { const noteableData = JSON.parse(notesDataset.noteableData); @@ -37,6 +38,8 @@ function setupMrNotesState(store, notesDataset, diffsDataset) { viewDiffsFileByFile: parseBoolean(diffsDataset.fileByFileDefault), defaultSuggestionCommitMessage: diffsDataset.defaultSuggestionCommitMessage, mrReviews: getReviewsForMergeRequest(mrPath), + diffViewType: + getParameterValues('view')[0] || getCookie(DIFF_VIEW_COOKIE_NAME) || INLINE_DIFF_VIEW_TYPE, }); } diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index 9db123da405..c36c56d7e43 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -71,7 +71,12 @@ export default { class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!" > {{ $options.i18n.toggleMenuItemLabel }} - <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" /> + <gl-toggle + :value="isEnabled" + :label="$options.i18n.toggleLabel" + label-position="hidden" + data-testid="new-navigation-toggle" + /> </div> </div> </gl-disclosure-dropdown-item> @@ -92,7 +97,7 @@ export default { :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" - data-qa-selector="new_navigation_toggle" + data-testid="new_navigation_toggle" /> </div> </li> diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue index a80fda96363..371b252a6ba 100644 --- a/app/assets/javascripts/nav/components/responsive_home.vue +++ b/app/assets/javascripts/nav/components/responsive_home.vue @@ -55,7 +55,7 @@ export default { v-gl-tooltip="{ title: newDropdownViewModel.title }" :view-model="newDropdownViewModel" class="gl-ml-3" - data-qa-selector="mobile_new_dropdown" + data-testid="mobile_new_dropdown" /> </header> <top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" /> diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index ab9313f7041..22c77e9ae32 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -35,7 +35,7 @@ export default { <gl-nav class="navbar-sub-nav"> <gl-nav-item-dropdown v-gl-tooltip.bottom="navData.menuTooltip" - data-qa-selector="navbar_dropdown" + data-testid="navbar_dropdown" data-qa-title="Menu" menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu" toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" 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 0f069670d09..fa202a0574d 100644 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -87,7 +87,6 @@ export default { :slot-key="activeView" class="gl-w-grid-size-40 gl-overflow-hidden gl-p-3" data-testid="menu-subview" - data-qa-selector="menu_subview_container" > <template #projects> <top-nav-container-view diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index bde7d219e9f..cefcc1b0c98 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -66,9 +66,7 @@ export default { }; </script> <template> - <div - class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden" - > + <div class="comment-warning-wrapper"> <div v-if="withAlertContainer" class="error-alert" @@ -76,7 +74,7 @@ export default { ></div> <noteable-warning v-if="hasWarning" - class="gl-py-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none" + class="gl-pt-4 gl-pb-5 gl-mb-n3 gl-rounded-lg gl-rounded-bottom-left-none gl-rounded-bottom-right-none" :is-locked="isLocked" :is-confidential="isConfidential" :noteable-type="noteableType" @@ -84,10 +82,20 @@ export default { :confidential-noteable-docs-path="noteableData.confidential_issues_docs_path" /> <slot></slot> - <attachments-warning v-if="showAttachmentWarning" /> + <attachments-warning + v-if="showAttachmentWarning" + :class="{ + 'gl-py-3': !showEmailParticipantsWarning, + 'gl-pt-4 gl-pb-3 gl-mt-n3': showEmailParticipantsWarning, + }" + /> <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!" + class="gl-border-t-1 gl-rounded-lg gl-rounded-top-left-none! gl-rounded-top-right-none!" + :class="{ + 'gl-pt-4 gl-pb-3 gl-mt-n3': !showAttachmentWarning, + 'gl-py-3 gl-mt-1': showAttachmentWarning, + }" :emails="emailParticipants" /> </div> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index cba0f960c00..c6d94a3b7b7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -16,11 +16,12 @@ import { sprintf } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import * as constants from '../constants'; import eventHub from '../event_hub'; import { COMMENT_FORM } from '../i18n'; -import { getErrorMessages } from '../utils'; +import { createNoteErrorMessages } from '../utils'; import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; @@ -146,9 +147,6 @@ export default { markdownDocsPath() { return this.getNotesData.markdownDocsPath; }, - quickActionsDocsPath() { - return this.getNotesData.quickActionsDocsPath; - }, markdownPreviewPath() { return this.getNoteableData.preview_note_path; }, @@ -219,7 +217,7 @@ export default { 'toggleIssueLocalState', ]), handleSaveError({ data, status }) { - this.errors = getErrorMessages(data, status); + this.errors = createNoteErrorMessages(data, status); }, handleSaveDraft() { this.handleSave({ isDraft: true }); @@ -258,6 +256,11 @@ export default { this.isSubmitting = true; + trackSavedUsingEditor( + this.$refs.markdownEditor.isContentEditorActive, + `${this.noteableType}_${this.noteType}`, + ); + this.saveNote(noteData) .then(() => { this.restartPolling(); @@ -366,7 +369,6 @@ export default { :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" - :quick-actions-docs-path="quickActionsDocsPath" :form-field-props="formFieldProps" :autosave-key="autosaveKey" :disabled="isSubmitting" diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue index 543be838920..2e4f925194f 100644 --- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue +++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue @@ -1,16 +1,20 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui'; -import { sprintf } from '~/locale'; +import { sprintf, __ } from '~/locale'; import { COMMENT_FORM } from '~/notes/i18n'; import * as constants from '../constants'; export default { - i18n: COMMENT_FORM, + name: 'CommentTypeDropdown', + i18n: { + ...COMMENT_FORM, + toggleSrText: __('Comment type'), + }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlButtonGroup, + GlButton, + GlCollapsibleListbox, }, model: { prop: 'noteType', @@ -93,56 +97,63 @@ export default { noteableDisplayName: this.noteableDisplayName, }); }, + dropdownItems() { + return [ + { + text: this.dropdownCommentButtonTitle, + description: this.commentDescription, + value: constants.COMMENT, + }, + { + text: this.dropdownStartThreadButtonTitle, + description: this.startDiscussionDescription, + value: constants.DISCUSSION, + qaSelector: 'discussion_menu_item', + }, + ]; + }, }, methods: { handleClick() { this.$emit('click'); }, - setNoteTypeToComment() { - if (this.noteType !== constants.COMMENT) { - this.$emit('change', constants.COMMENT); - } - }, - setNoteTypeToDiscussion() { - if (this.noteType !== constants.DISCUSSION) { - this.$emit('change', constants.DISCUSSION); - } + setNoteType(value) { + this.$emit('change', value); }, }, }; </script> <template> - <gl-dropdown - split - :text="commentButtonTitle" - class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown" - category="primary" - variant="confirm" - :disabled="disabled" - data-testid="comment-button" - data-qa-selector="comment_button" + <!--TODO: Replace button-group workaround once `split` option for new dropdowns is implemented.--> + <!-- See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263--> + <gl-button-group + class="js-comment-button js-comment-submit-button comment-type-dropdown gl-w-full gl-mb-3 gl-md-w-auto gl-md-mb-0" :data-track-label="trackingLabel" data-track-action="click_button" - @click="$emit('click')" + data-testid="comment-button" + data-qa-selector="comment_button" > - <gl-dropdown-item - is-check-item - :is-checked="isNoteTypeComment" - @click.stop.prevent="setNoteTypeToComment" - > - <strong>{{ dropdownCommentButtonTitle }}</strong> - <p class="gl-m-0">{{ commentDescription }}</p> - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item - is-check-item - :is-checked="isNoteTypeDiscussion" - data-qa-selector="discussion_menu_item" - @click.stop.prevent="setNoteTypeToDiscussion" + <gl-button variant="confirm" :disabled="disabled" @click="handleClick"> + {{ commentButtonTitle }} + </gl-button> + <gl-collapsible-listbox + class="split" + toggle-class="gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-pl-1!" + variant="confirm" + text-sr-only + :toggle-text="$options.i18n.toggleSrText" + :disabled="disabled" + :items="dropdownItems" + :selected="noteType" + @select="setNoteType" > - <strong>{{ dropdownStartThreadButtonTitle }}</strong> - <p class="gl-m-0">{{ startDiscussionDescription }}</p> - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <div :data-qa-selector="item.qaSelector"> + <strong>{{ item.text }}</strong> + <p class="gl-m-0">{{ item.description }}</p> + </div> + </template> + </gl-collapsible-listbox> + </gl-button-group> </template> diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index c53d3203327..e7b7ba7743e 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -107,7 +107,13 @@ export default { <template> <div class="discussion-header gl-display-flex gl-align-items-center"> <div v-once class="timeline-avatar gl-align-self-start gl-flex-shrink-0 gl-flex-shrink"> - <gl-avatar-link v-if="author" :href="author.path"> + <gl-avatar-link + v-if="author" + :href="author.path" + :data-user-id="author.id" + :data-username="author.username" + class="js-user-link" + > <gl-avatar :src="author.avatar_url" :alt="author.name" :size="32" /> </gl-avatar-link> </div> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index ba5ffc60917..cff1043c258 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,13 +1,6 @@ <script> -import { - GlTooltipDirective, - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlIcon, -} from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; +import { GlButton, GlButtonGroup, GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; import { throttle } from 'lodash'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -18,11 +11,9 @@ export default { GlTooltip: GlTooltipDirective, }, components: { + GlDisclosureDropdown, GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlIcon, }, mixins: [glFeatureFlagsMixin(), discussionNavigation], props: { @@ -56,6 +47,29 @@ export default { resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, + threadOptions() { + const options = [ + { + text: this.toggleThreadsLabel, + action: this.handleExpandDiscussions, + extraAttrs: { + 'data-testid': 'toggle-all-discussions-btn', + }, + }, + ]; + + if (this.resolveAllDiscussionsIssuePath && !this.allResolved) { + options.push({ + text: __('Resolve all with new issue'), + href: this.resolveAllDiscussionsIssuePath, + extraAttrs: { + 'data-testid': 'resolve-all-with-issue-link', + }, + }); + } + + return options; + }, }, methods: { ...mapActions(['setExpandDiscussions']), @@ -86,32 +100,25 @@ export default { > <template v-if="allResolved"> {{ __('All threads resolved!') }} - <gl-dropdown - v-gl-tooltip:discussionCounter.hover.bottom + <gl-disclosure-dropdown + v-gl-tooltip:discussionCounter.hover.top + icon="ellipsis_v" size="small" category="tertiary" - right + placement="right" + no-caret :title="__('Thread options')" :aria-label="__('Thread options')" toggle-class="btn-icon" class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2" - > - <template #button-content> - <gl-icon name="ellipsis_v" class="mr-0" /> - </template> - <gl-dropdown-item - data-testid="toggle-all-discussions-btn" - @click="handleExpandDiscussions" - > - {{ toggleThreadsLabel }} - </gl-dropdown-item> - </gl-dropdown> + :items="threadOptions" + /> </template> <template v-else> {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} <gl-button-group class="gl-ml-3"> <gl-button - v-gl-tooltip:discussionCounter.hover.bottom + v-gl-tooltip:discussionCounter.hover.top :title="__('Go to previous unresolved thread')" :aria-label="__('Go to previous unresolved thread')" class="discussion-previous-btn gl-rounded-base! gl-px-2!" @@ -123,7 +130,7 @@ export default { @click="jumpPrevious" /> <gl-button - v-gl-tooltip:discussionCounter.hover.bottom + v-gl-tooltip:discussionCounter.hover.top :title="__('Go to next unresolved thread')" :aria-label="__('Go to next unresolved thread')" class="discussion-next-btn gl-rounded-base! gl-px-2!" @@ -134,32 +141,19 @@ export default { category="tertiary" @click="jumpNext" /> - <gl-dropdown - v-gl-tooltip:discussionCounter.hover.bottom + <gl-disclosure-dropdown + v-gl-tooltip:discussionCounter.hover.top + icon="ellipsis_v" size="small" category="tertiary" - right + placement="right" + no-caret :title="__('Thread options')" :aria-label="__('Thread options')" toggle-class="btn-icon" class="gl-pt-0! gl-px-2" - > - <template #button-content> - <gl-icon name="ellipsis_v" class="mr-0" /> - </template> - <gl-dropdown-item - data-testid="toggle-all-discussions-btn" - @click="handleExpandDiscussions" - > - {{ toggleThreadsLabel }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="resolveAllDiscussionsIssuePath && !allResolved" - :href="resolveAllDiscussionsIssuePath" - > - {{ __('Resolve all with new issue') }} - </gl-dropdown-item> - </gl-dropdown> + :items="threadOptions" + /> </gl-button-group> </template> </div> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 9fb027fb955..080787884c8 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -169,7 +169,6 @@ export default { v-if="hasReplies" :collapsed="!isExpanded" :replies="replies" - :class="{ 'discussion-toggle-replies': discussion.diff_discussion }" @toggle="toggleDiscussion({ discussionId: discussion.id })" /> <template v-if="isExpanded"> diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue index 1dd07fe90d2..571928b972b 100644 --- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue +++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue @@ -21,7 +21,7 @@ export default { 'li', { class: - 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base clearfix', + 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-border-top-0', }, [h('ul', { class: 'notes' }, children)], ); diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue index 2338c9eef67..7ca0c4730a9 100644 --- a/app/assets/javascripts/notes/components/mr_discussion_filter.vue +++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue @@ -62,6 +62,12 @@ export default { this.updateMergeRequestFilters(filters); this.selectedFilters = filters; }, + deselectAll() { + this.selectedFilters = []; + }, + selectAll() { + this.selectedFilters = MR_FILTER_OPTIONS.map((f) => f.value); + }, }, MR_FILTER_OPTIONS, }; @@ -84,9 +90,14 @@ export default { <gl-collapsible-listbox v-model="selectedFilters" :items="$options.MR_FILTER_OPTIONS" + :header-text="__('Filter activity')" + :show-select-all-button-label="__('Select all')" + :reset-button-label="__('Deselect all')" multiple placement="right" @hidden="applyFilters" + @reset="deselectAll" + @select-all="selectAll" > <template #toggle> <gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 47e0ace1ea7..8d2d8095a44 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective, - GlIcon, GlButton, GlDisclosureDropdown, GlDisclosureDropdownItem, @@ -30,15 +29,14 @@ export default { }, name: 'NoteActions', components: { - GlIcon, - ReplyButton, - TimelineEventButton, + AbuseCategorySelector, + EmojiPicker: () => import('~/emoji/components/picker.vue'), GlButton, GlDisclosureDropdown, GlDisclosureDropdownItem, + ReplyButton, + TimelineEventButton, UserAccessRoleBadge, - EmojiPicker: () => import('~/emoji/components/picker.vue'), - AbuseCategorySelector, }, directives: { GlTooltip: GlTooltipDirective, @@ -318,22 +316,12 @@ export default { /> <emoji-picker v-if="canAwardEmoji" + v-gl-tooltip + :title="$options.i18n.addReactionLabel" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" data-testid="note-emoji-button" @click="setAwardEmoji" - > - <template #button-content> - <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" /> - <gl-icon - class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!" - name="smiley" - /> - <gl-icon - class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!" - name="smile" - /> - </template> - </emoji-picker> + /> <reply-button v-if="showReply" ref="replyButton" @@ -365,7 +353,8 @@ export default { <gl-disclosure-dropdown v-gl-tooltip :title="$options.i18n.moreActionsLabel" - :aria-label="$options.i18n.moreActionsLabel" + :toggle-text="$options.i18n.moreActionsLabel" + text-sr-only icon="ellipsis_v" category="tertiary" placement="right" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index b4e5129ca0e..1c6be0cfd77 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -174,6 +174,7 @@ export default { :note-id="note.id" :line="line" :note="note" + :diff-file="file" :save-button-title="saveButtonTitle" :help-page-path="helpPagePath" :discussion="discussion" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index fe7967f1ed0..4e816038539 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -5,6 +5,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -192,9 +193,6 @@ export default { markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); }, - quickActionsDocsPath() { - return this.getNotesDataByProp('quickActionsDocsPath'); - }, currentUserId() { return this.getUserDataByProp('id'); }, @@ -223,6 +221,15 @@ export default { enableContentEditor() { return Boolean(this.glFeatures.contentEditorOnIssues); }, + codeSuggestionsConfig() { + return { + canSuggest: this.canSuggest, + line: this.line, + lines: this.lines, + showPopover: this.showSuggestPopover, + diffFile: this.diffFile, + }; + }, }, watch: { noteBody() { @@ -290,6 +297,11 @@ export default { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; + trackSavedUsingEditor( + this.$refs.markdownEditor.isContentEditorActive, + `${this.getNoteableData.noteableType}_note`, + ); + this.$emit( 'handleFormUpdate', this.updatedNoteBody, @@ -321,7 +333,15 @@ export default { (!this.discussionResolved && this.isResolving); this.isSubmitting = true; - this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve); + this.$emit( + 'handleFormUpdateAddToReview', + this.updatedNoteBody, + shouldResolve, + this.$refs.editNoteForm, + () => { + this.isSubmitting = false; + }, + ); }, hasEmailParticipants() { return this.getNoteableData.issue_email_participants?.length; @@ -351,15 +371,11 @@ export default { :value="updatedNoteBody" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :line="line" - :lines="lines" - :can-suggest="canSuggest" + :code-suggestions-config="codeSuggestionsConfig" :add-spacing-classes="false" :help-page-path="helpPagePath" :note="discussionNote" :form-field-props="formFieldProps" - :show-suggest-popover="showSuggestPopover" - :quick-actions-docs-path="quickActionsDocsPath" :autosave-key="autosaveKey" :autocomplete-data-sources="autocompleteDataSources" :disabled="isSubmitting" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 499581653ba..a5939e1023c 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -15,7 +15,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; -import { getErrorMessages } from '../utils'; +import { createNoteErrorMessages } from '../utils'; import DiffDiscussionHeader from './diff_discussion_header.vue'; import DiffWithNote from './diff_with_note.vue'; import DiscussionActions from './discussion_actions.vue'; @@ -96,6 +96,18 @@ export default { 'showJumpToNextDiscussion', 'getUserData', ]), + diffFile() { + const diffFile = this.discussion.diff_file; + if (!diffFile) return null; + + return { + ...diffFile, + view_path: window.location.href.replace( + /\/-\/merge_requests.*/, + `/-/blob/${diffFile.content_sha}/${diffFile.new_path}`, + ), + }; + }, currentUser() { return this.getUserData; }, @@ -270,7 +282,7 @@ export default { }); }, handleSaveError({ response }) { - const errorMessage = getErrorMessages(response.data, response.status)[0]; + const errorMessage = createNoteErrorMessages(response.data, response.status)[0]; createAlert({ message: errorMessage, @@ -331,7 +343,7 @@ export default { <li v-else-if="canShowReplyActions && showReplies" data-testid="reply-wrapper" - class="discussion-reply-holder gl-border-t-0! clearfix" + class="discussion-reply-holder gl-border-t-0! gl-pb-5! clearfix" :class="discussionHolderClass" > <discussion-actions @@ -348,6 +360,7 @@ export default { v-if="isReplying" ref="noteForm" :discussion="discussion" + :diff-file="diffFile" :line="diffLine" :save-button-title="saveButtonTitle" :autosave-key="autosaveKey" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index dd135eaee3b..69c41af97ab 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -17,8 +17,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; -import { renderMarkdown } from '../utils'; -import { UPDATE_COMMENT_FORM } from '../i18n'; +import { renderMarkdown, updateNoteErrorMessage } from '../utils'; import { getStartLineNumber, getEndLineNumber, @@ -114,7 +113,6 @@ export default { isResolving: false, commentLineStart: {}, resolveAsThread: true, - oldContent: this.note.note_html, }; }, computed: { @@ -212,7 +210,8 @@ export default { return fileResolvedFromAvailableSource || null; }, isMRDiffView() { - return this.line && !this.isOverviewTab; + const isFileComment = this.note.position?.position_type === 'file'; + return !this.isOverviewTab && (this.line || isFileComment); }, }, created() { @@ -295,7 +294,7 @@ export default { updateSuccess() { this.isEditing = false; this.isRequesting = false; - this.oldContent = this.note.note_html; + this.oldContent = null; renderGFM(this.$refs.noteBody.$el); this.$emit('updateSuccess'); }, @@ -317,7 +316,9 @@ export default { noteText, resolveDiscussion, position, + flashContainer: this.$el, callback: () => this.updateSuccess(), + errorCallback: () => callback(), }); if (this.isDraft) return; @@ -343,6 +344,7 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/298827 if (!isEmpty(position)) data.note.note.position = JSON.stringify(position); this.isRequesting = true; + this.oldContent = this.note.note_html; // eslint-disable-next-line vue/no-mutating-props this.note.note_html = renderMarkdown(noteText); @@ -369,14 +371,8 @@ export default { }); }, handleUpdateError(e) { - const serverErrorMessage = e?.response?.data?.errors; - - const alertMessage = serverErrorMessage - ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false) - : UPDATE_COMMENT_FORM.defaultError; - createAlert({ - message: alertMessage, + message: updateNoteErrorMessage(e), parent: this.$el, }); }, @@ -442,7 +438,12 @@ export default { </div> <div v-if="isMRDiffView" class="timeline-avatar gl-float-left gl-pt-2"> - <gl-avatar-link :href="author.path"> + <gl-avatar-link + :href="author.path" + :data-user-id="author.id" + :data-username="author.username" + class="js-user-link" + > <gl-avatar :src="author.avatar_url" :entity-name="author.username" @@ -455,7 +456,12 @@ export default { </div> <div v-else class="timeline-avatar gl-float-left"> - <gl-avatar-link :href="author.path"> + <gl-avatar-link + :href="author.path" + :data-user-id="author.id" + :data-username="author.username" + class="js-user-link" + > <gl-avatar :src="author.avatar_url" :entity-name="author.username" diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index b0f7a4a4732..a012b4411bc 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -39,7 +39,7 @@ export default { }, liClasses() { return this.collapsed - ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base! replies-widget-collapsed' + ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base!' : 'gl-border-b'; }, buttonIcon() { diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 55a63212dc5..cb6f72538b9 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -7,8 +7,9 @@ import { } from '~/diffs/constants'; import { createAlert } from '~/alert'; import { clearDraft } from '~/lib/utils/autosave'; -import { s__ } from '~/locale'; +import { sprintf } from '~/locale'; import { formatLineRange } from '~/notes/components/multiline_comment_utils'; +import { SAVING_THE_COMMENT_FAILED, SOMETHING_WENT_WRONG } from '~/diffs/i18n'; export default { computed: { @@ -24,7 +25,7 @@ export default { methods: { ...mapActions('diffs', ['cancelCommentForm', 'toggleFileCommentForm']), ...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']), - addReplyToReview(noteText, isResolving) { + addReplyToReview(noteText, isResolving, parentElement, errorCallback) { const postData = getDraftReplyFormData({ in_reply_to_discussion_id: this.discussion.reply_id, target_type: this.getNoteableData.targetType, @@ -39,19 +40,26 @@ export default { postData.note_project_id = this.discussion.project_id; } - this.isReplying = false; - this.saveDraft(postData) .then(() => { + this.isReplying = false; this.handleClearForm(this.discussion.line_code); }) - .catch(() => { + .catch((response) => { + const reason = response?.data?.errors; + const errorMessage = reason + ? sprintf(SAVING_THE_COMMENT_FAILED, { reason }) + : SOMETHING_WENT_WRONG; + createAlert({ - message: s__('MergeRequests|An error occurred while saving the draft comment.'), + message: errorMessage, + parent: parentElement, }); + + errorCallback(); }); }, - addToReview(note, positionType = null) { + addToReview(note, positionType = null, parentElement, errorCallback) { const lineRange = (this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) || {}; @@ -88,10 +96,18 @@ export default { this.toggleFileCommentForm(diffFile.file_path); } }) - .catch(() => { + .catch((response) => { + const reason = response?.data?.errors; + const errorMessage = reason + ? sprintf(SAVING_THE_COMMENT_FAILED, { reason }) + : SOMETHING_WENT_WRONG; + createAlert({ - message: s__('MergeRequests|An error occurred while saving the draft comment.'), + message: errorMessage, + parent: parentElement, }); + + errorCallback(); }); }, handleClearForm(lineCode) { diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js index c5859a89182..a561d26ad56 100644 --- a/app/assets/javascripts/notes/utils.js +++ b/app/assets/javascripts/notes/utils.js @@ -4,7 +4,7 @@ import { sanitize } from '~/lib/dompurify'; import { markdownConfig } from '~/lib/utils/text_utility'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { sprintf } from '~/locale'; -import { COMMENT_FORM } from './i18n'; +import { UPDATE_COMMENT_FORM, COMMENT_FORM } from './i18n'; /** * Tracks snowplow event when User toggles timeline view @@ -23,7 +23,7 @@ export const renderMarkdown = (rawMarkdown) => { return sanitize(marked(rawMarkdown), markdownConfig); }; -export const getErrorMessages = (data, status) => { +export const createNoteErrorMessages = (data, status) => { const errors = data?.errors; if (errors && status === HTTP_STATUS_UNPROCESSABLE_ENTITY) { @@ -36,3 +36,13 @@ export const getErrorMessages = (data, status) => { return [COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]; }; + +export const updateNoteErrorMessage = (e) => { + const errors = e?.response?.data?.errors; + + if (errors) { + return sprintf(UPDATE_COMMENT_FORM.error, { reason: errors.toLowerCase() }); + } + + return UPDATE_COMMENT_FORM.defaultError; +}; diff --git a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue index 5d5524deb0d..26b8e06a1a7 100644 --- a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue +++ b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue @@ -5,7 +5,7 @@ export default { components: { ListboxInput, }, - inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled'], + inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled', 'placement'], data() { return { selected: this.value, @@ -41,6 +41,8 @@ export default { :name="name" :items="options" :disabled="disabled" + :placement="placement" + fluid-width @select="onSelect" /> </template> diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js index 1395084f68c..d41b1d95854 100644 --- a/app/assets/javascripts/notifications/index.js +++ b/app/assets/javascripts/notifications/index.js @@ -7,10 +7,11 @@ import NotificationEmailListboxInput from './components/notification_email_listb Vue.use(GlToast); const initNotificationEmailListboxInputs = () => { - const els = [...document.querySelectorAll('.js-notification-email-listbox-input')]; + const CLASS_NAME = 'js-notification-email-listbox-input'; + const els = [...document.querySelectorAll(`.${CLASS_NAME}`)]; els.forEach((el, index) => { - const { label, name, emptyValueText, value = '' } = el.dataset; + const { label, name, emptyValueText, value = '', placement } = el.dataset; return new Vue({ el, @@ -22,9 +23,12 @@ const initNotificationEmailListboxInputs = () => { emptyValueText, value, disabled: parseBoolean(el.dataset.disabled), + placement, }, render(h) { - return h(NotificationEmailListboxInput); + return h(NotificationEmailListboxInput, { + class: el.className.replace(CLASS_NAME, '').trim(), + }); }, }); }); diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js new file mode 100644 index 00000000000..251c165e7dd --- /dev/null +++ b/app/assets/javascripts/observability/client.js @@ -0,0 +1,43 @@ +import axios from '~/lib/utils/axios_utils'; + +function enableTraces() { + // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271 + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); +} + +function isTracingEnabled() { + // TODO remove mocks https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2271 + return new Promise((resolve) => { + setTimeout(() => { + // Currently relying on manual provisioning, hence assuming tracing is enabled + resolve(true); + }, 1000); + }); +} + +async function fetchTraces(tracingUrl) { + const { data } = await axios.get(tracingUrl, { withCredentials: true }); + if (!Array.isArray(data.traces)) { + throw new Error('traces are missing/invalid in the response.'); // eslint-disable-line @gitlab/require-i18n-strings + } + return data.traces.map((t) => { + // aggregating duration on the client for now, but expecting to be coming from the backend + const duration = t.spans.reduce((acc, cur) => acc + cur.duration_nano, 0); + return { + ...t, + duration: duration / 1000, + }; + }); +} + +export function buildClient({ provisioningUrl, tracingUrl }) { + return { + enableTraces: () => enableTraces(provisioningUrl), + isTracingEnabled: () => isTracingEnabled(provisioningUrl), + fetchTraces: () => fetchTraces(tracingUrl), + }; +} diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue new file mode 100644 index 00000000000..4306f531ab5 --- /dev/null +++ b/app/assets/javascripts/observability/components/observability_container.vue @@ -0,0 +1,92 @@ +<script> +import { buildClient } from '../client'; +import { SKELETON_SPINNER_VARIANT } from '../constants'; +import ObservabilitySkeleton from './skeleton/index.vue'; + +export default { + SKELETON_SPINNER_VARIANT, + components: { + ObservabilitySkeleton, + }, + props: { + oauthUrl: { + type: String, + required: true, + }, + tracingUrl: { + type: String, + required: true, + }, + provisioningUrl: { + type: String, + required: true, + }, + }, + data() { + return { + observabilityClient: null, + authCompleted: false, + }; + }, + mounted() { + window.addEventListener('message', this.messageHandler); + + // TODO Remove once backend work done - just for testing + // https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2270 + // setTimeout(() => { + // this.messageHandler({ + // data: { type: 'AUTH_COMPLETION', status: 'success' }, + // origin: new URL(this.oauthUrl).origin, + // }); + // }, 2000); + }, + destroyed() { + window.removeEventListener('message', this.messageHandler); + }, + methods: { + messageHandler(e) { + const isExpectedOrigin = e.origin === new URL(this.oauthUrl).origin; + if (!isExpectedOrigin) return; + + const { data } = e; + + if (data.type === 'AUTH_COMPLETION') { + if (this.authCompleted) return; + + const { status, message, statusCode } = data; + if (status === 'success') { + this.observabilityClient = buildClient({ + provisioningUrl: this.provisioningUrl, + tracingUrl: this.tracingUrl, + }); + this.$refs.observabilitySkeleton?.onContentLoaded(); + } else if (status === 'error') { + // eslint-disable-next-line @gitlab/require-i18n-strings,no-console + console.error('GOB auth failed with error:', message, statusCode); + this.$refs.observabilitySkeleton?.onError(); + } + this.authCompleted = true; + } + }, + }, +}; +</script> + +<template> + <div> + <iframe + v-if="!authCompleted" + sandbox="allow-same-origin allow-forms allow-scripts" + hidden + :src="oauthUrl" + data-testid="observability-oauth-iframe" + ></iframe> + + <observability-skeleton + ref="observabilitySkeleton" + :variant="$options.SKELETON_SPINNER_VARIANT" + > + <slot v-if="observabilityClient" :observability-client="observabilityClient"></slot> + </observability-skeleton> + </div> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue index d91f2874943..4df0f86be1f 100644 --- a/app/assets/javascripts/observability/components/skeleton/index.vue +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { SKELETON_VARIANTS_BY_ROUTE, @@ -9,6 +9,7 @@ import { TIMEOUT_ERROR_LABEL, TIMEOUT_ERROR_MESSAGE, SKELETON_VARIANT_EMBED, + SKELETON_SPINNER_VARIANT, } from '../../constants'; import DashboardsSkeleton from './dashboards.vue'; import ExploreSkeleton from './explore.vue'; @@ -23,6 +24,7 @@ export default { ManageSkeleton, EmbedSkeleton, GlAlert, + GlLoadingIcon, }, SKELETON_VARIANTS_BY_ROUTE, SKELETON_STATE, @@ -46,6 +48,23 @@ export default { errorTimeout: null, }; }, + computed: { + skeletonVisible() { + return this.state === SKELETON_STATE.VISIBLE; + }, + skeletonHidden() { + return this.state === SKELETON_STATE.HIDDEN; + }, + errorVisible() { + return this.state === SKELETON_STATE.ERROR; + }, + spinnerVariant() { + return this.variant === SKELETON_SPINNER_VARIANT; + }, + embedVariant() { + return this.variant === SKELETON_VARIANT_EMBED; + }, + }, mounted() { this.setLoadingTimeout(); this.setErrorTimeout(); @@ -61,6 +80,12 @@ export default { this.hideSkeleton(); }, + onError() { + clearTimeout(this.errorTimeout); + clearTimeout(this.loadingTimeout); + + this.showError(); + }, setLoadingTimeout() { this.loadingTimeout = setTimeout(() => { /** @@ -92,8 +117,7 @@ export default { showError() { this.state = SKELETON_STATE.ERROR; }, - - isSkeletonShown(route) { + isVariantByRoute(route) { return this.variant === SKELETON_VARIANTS_BY_ROUTE[route]; }, }, @@ -102,11 +126,12 @@ export default { <template> <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> <transition name="fade"> - <div v-if="state === $options.SKELETON_STATE.VISIBLE" class="gl-px-5"> - <dashboards-skeleton v-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.DASHBOARDS)" /> - <explore-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.EXPLORE)" /> - <manage-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.MANAGE)" /> - <embed-skeleton v-else-if="variant === $options.SKELETON_VARIANT_EMBED" /> + <div v-if="skeletonVisible" class="gl-px-5 gl-my-5"> + <dashboards-skeleton v-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.DASHBOARDS)" /> + <explore-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.EXPLORE)" /> + <manage-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.MANAGE)" /> + <embed-skeleton v-else-if="embedVariant" /> + <gl-loading-icon v-else-if="spinnerVariant" size="lg" /> <gl-skeleton-loader v-else> <rect y="2" width="10" height="8" /> @@ -115,10 +140,19 @@ export default { <rect y="15" width="400" height="30" /> </gl-skeleton-loader> </div> + + <!-- The double condition is only here temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 --> + <div + v-else-if="spinnerVariant && skeletonHidden" + data-testid="content-wrapper" + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" + > + <slot></slot> + </div> </transition> <gl-alert - v-if="state === $options.SKELETON_STATE.ERROR" + v-if="errorVisible" :title="$options.i18n.TIMEOUT_ERROR_LABEL" variant="danger" :dismissible="false" @@ -127,10 +161,11 @@ export default { {{ $options.i18n.TIMEOUT_ERROR_MESSAGE }} </gl-alert> - <transition> + <!-- This is only kept temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 --> + <transition v-if="!spinnerVariant"> <div - v-show="state === $options.SKELETON_STATE.HIDDEN" - data-testid="observability-wrapper" + v-show="skeletonHidden" + data-testid="content-wrapper" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" > <slot></slot> diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index 6b97c51e997..b0a0941779d 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -18,6 +18,7 @@ export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({ }); export const SKELETON_VARIANT_EMBED = 'embed'; +export const SKELETON_SPINNER_VARIANT = 'spinner'; export const SKELETON_STATE = Object.freeze({ ERROR: 'error', diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json new file mode 100644 index 00000000000..6f83f718d96 --- /dev/null +++ b/app/assets/javascripts/observability/mock_traces.json @@ -0,0 +1,2807 @@ +{ + "project_id": "1", + "message": "", + "traces": [ + { + "timestamp": "2023-07-10T15:02:30.677538Z", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677538Z", + "span_id": "E2CB9B54BB6FCAC1", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 147000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677561Z", + "span_id": "4B29015A902EF378", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677538Z", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677538Z", + "span_id": "E2CB9B54BB6FCAC1", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 147000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677561Z", + "span_id": "4B29015A902EF378", + "trace_id": "97e6b7b3-9579-b6e9-eb86-25f1ded2cff0", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67758Z", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67758Z", + "span_id": "F0788D69026E13A1", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677581Z", + "span_id": "14987F8F6FDD27AE", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67758Z", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67758Z", + "span_id": "F0788D69026E13A1", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677581Z", + "span_id": "14987F8F6FDD27AE", + "trace_id": "36f65703-d085-0674-a589-b35db23f77d5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677583Z", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677583Z", + "span_id": "F5AB66F29F53ECAF", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677584Z", + "span_id": "17D79F52E57E9C6A", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677583Z", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677583Z", + "span_id": "F5AB66F29F53ECAF", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677584Z", + "span_id": "17D79F52E57E9C6A", + "trace_id": "219da434-0271-581c-0bfa-1c0e31f7bc03", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677585Z", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677585Z", + "span_id": "468B2959252EDA28", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677586Z", + "span_id": "7AC8860F5CB85E0A", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677585Z", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677585Z", + "span_id": "468B2959252EDA28", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677586Z", + "span_id": "7AC8860F5CB85E0A", + "trace_id": "e899d9a8-4d54-b131-9580-e540697604b4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677588Z", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677588Z", + "span_id": "3411BDD296DB9370", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677589Z", + "span_id": "1774F16A178B8FCB", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677588Z", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677588Z", + "span_id": "3411BDD296DB9370", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677589Z", + "span_id": "1774F16A178B8FCB", + "trace_id": "057228bd-3c6d-2551-b779-641d71f111c7", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677613Z", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677613Z", + "span_id": "CDACF24BB78C3534", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677614Z", + "span_id": "7B69777569B9EA84", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677613Z", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677613Z", + "span_id": "CDACF24BB78C3534", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677614Z", + "span_id": "7B69777569B9EA84", + "trace_id": "a6d8c574-d1ce-e46d-8634-e12d1251a009", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677616Z", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677616Z", + "span_id": "1265DF31CD5EC4E2", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677617Z", + "span_id": "3E0260222F729537", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677616Z", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677616Z", + "span_id": "1265DF31CD5EC4E2", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677617Z", + "span_id": "3E0260222F729537", + "trace_id": "d29efaa9-f0dd-8ce8-901c-9bb076292144", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "span_id": "3A06AC7ABCA9D043", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "span_id": "9C99F917736586E1", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "span_id": "3A06AC7ABCA9D043", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677619Z", + "span_id": "9C99F917736586E1", + "trace_id": "175fe57e-ce90-3e58-6e75-ffb37e6ed787", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677621Z", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677621Z", + "span_id": "B2417463C771A704", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677622Z", + "span_id": "897DD866880697F0", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677621Z", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677621Z", + "span_id": "B2417463C771A704", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677622Z", + "span_id": "897DD866880697F0", + "trace_id": "5bda0cda-5c5d-ccc6-7266-5e438572bccf", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677637Z", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677637Z", + "span_id": "AB982E41826E4CB4", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677638Z", + "span_id": "8577639018E3ACE2", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677637Z", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677637Z", + "span_id": "AB982E41826E4CB4", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677638Z", + "span_id": "8577639018E3ACE2", + "trace_id": "1b00f009-80fb-4389-5629-0e6326838a87", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677651Z", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677651Z", + "span_id": "E4D0C62B763FC25C", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677653Z", + "span_id": "C059EDEE59610CB1", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677651Z", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677651Z", + "span_id": "E4D0C62B763FC25C", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677653Z", + "span_id": "C059EDEE59610CB1", + "trace_id": "effe5f7a-b902-eb19-cccf-9ac4a0aea683", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677654Z", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677654Z", + "span_id": "EF63FD474F6898CE", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677655Z", + "span_id": "694494E5AA2A2763", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677654Z", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677654Z", + "span_id": "EF63FD474F6898CE", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677655Z", + "span_id": "694494E5AA2A2763", + "trace_id": "9532c21e-1e43-14b3-d5e9-239256361eb4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "span_id": "A7C41B19AC60C808", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "span_id": "57A5EDD6AF5CEB5B", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "span_id": "A7C41B19AC60C808", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677657Z", + "span_id": "57A5EDD6AF5CEB5B", + "trace_id": "311ab221-6e09-0ec6-e8f4-5bd1a50110f1", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677659Z", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677659Z", + "span_id": "EA174F950C4D04D8", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67766Z", + "span_id": "BA21BB5236E2EF8E", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677659Z", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677659Z", + "span_id": "EA174F950C4D04D8", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67766Z", + "span_id": "BA21BB5236E2EF8E", + "trace_id": "d32d61e6-a3c7-ac62-3a54-37b4f82255e4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677661Z", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677661Z", + "span_id": "BF7E718C91691CBE", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677666Z", + "span_id": "50F782517AF36EA8", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677661Z", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677661Z", + "span_id": "BF7E718C91691CBE", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677666Z", + "span_id": "50F782517AF36EA8", + "trace_id": "43690e3b-acbd-512d-5a4a-a5b2db28e84c", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677668Z", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677668Z", + "span_id": "3B5B0841228A565D", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 131000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677669Z", + "span_id": "62ADBDBA30734B48", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677668Z", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677668Z", + "span_id": "3B5B0841228A565D", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 131000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677669Z", + "span_id": "62ADBDBA30734B48", + "trace_id": "36764123-3cff-bc0e-70a5-8b781294556f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677685Z", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677685Z", + "span_id": "8A9D888CF37A3F57", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677686Z", + "span_id": "388636C7D201F9FB", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677685Z", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677685Z", + "span_id": "8A9D888CF37A3F57", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677686Z", + "span_id": "388636C7D201F9FB", + "trace_id": "c891efc0-b266-cb02-4b8e-1085df4ac0c1", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677688Z", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677688Z", + "span_id": "9E84A870338BBACA", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 128000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677693Z", + "span_id": "FC1665CC8A7536B6", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677688Z", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677688Z", + "span_id": "9E84A870338BBACA", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 128000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677693Z", + "span_id": "FC1665CC8A7536B6", + "trace_id": "e78889ea-f202-5c87-e64b-f6db159d9632", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "span_id": "1B5140A527AC99F8", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "span_id": "6C328C8E60FBB3A8", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "span_id": "1B5140A527AC99F8", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677695Z", + "span_id": "6C328C8E60FBB3A8", + "trace_id": "5154401c-5126-856b-9474-29efb69b8588", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677697Z", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677697Z", + "span_id": "BC05CC86EF04A641", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677703Z", + "span_id": "3DAF5282D4311F57", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677697Z", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677697Z", + "span_id": "BC05CC86EF04A641", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677703Z", + "span_id": "3DAF5282D4311F57", + "trace_id": "fe2fab22-75c3-afa2-6046-2c90b1cd6224", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677707Z", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677707Z", + "span_id": "AEAF8AC47E800113", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677708Z", + "span_id": "D6314BCF73DC741F", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677707Z", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677707Z", + "span_id": "AEAF8AC47E800113", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677708Z", + "span_id": "D6314BCF73DC741F", + "trace_id": "1899b1d8-7f12-e38e-7e7f-e39189ae7c7e", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677718Z", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677718Z", + "span_id": "D0BEDA55261815BE", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677719Z", + "span_id": "E1FA20547B7056ED", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677718Z", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677718Z", + "span_id": "D0BEDA55261815BE", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677719Z", + "span_id": "E1FA20547B7056ED", + "trace_id": "e5655518-6f37-8226-0161-f8cb85eb4ba4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "span_id": "B171E7315A8B7FD1", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "span_id": "CD8D690AC2924C06", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "span_id": "B171E7315A8B7FD1", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677721Z", + "span_id": "CD8D690AC2924C06", + "trace_id": "b0717d3c-d555-e6fa-1e4d-1aca7c889c25", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677723Z", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677723Z", + "span_id": "224B0CB973E7D237", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677724Z", + "span_id": "452D473BC85DDBA5", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677723Z", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677723Z", + "span_id": "224B0CB973E7D237", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677724Z", + "span_id": "452D473BC85DDBA5", + "trace_id": "0dd663d5-d314-1736-cd5a-90d2811232fc", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677726Z", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677726Z", + "span_id": "6491B39AA9F2CB97", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677727Z", + "span_id": "96C2E96EA8D3AF1D", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677726Z", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677726Z", + "span_id": "6491B39AA9F2CB97", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677727Z", + "span_id": "96C2E96EA8D3AF1D", + "trace_id": "b057b9c7-96dd-337a-ff46-c62254e4b0f8", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677728Z", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677728Z", + "span_id": "3A7F5394923A5AF0", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677729Z", + "span_id": "24CC4AB1032650CF", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677728Z", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677728Z", + "span_id": "3A7F5394923A5AF0", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677729Z", + "span_id": "24CC4AB1032650CF", + "trace_id": "c2b52b78-3a3a-0a44-bc73-04288175f112", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677743Z", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677743Z", + "span_id": "F783055E10DA9E2D", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677744Z", + "span_id": "E581CC7A539137BF", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677743Z", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677743Z", + "span_id": "F783055E10DA9E2D", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677744Z", + "span_id": "E581CC7A539137BF", + "trace_id": "e25bf442-7823-44e0-4400-0570b4bf525c", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677746Z", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677746Z", + "span_id": "89BD651A0E16281F", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677747Z", + "span_id": "1FADCC9FB8DDE886", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677746Z", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677746Z", + "span_id": "89BD651A0E16281F", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677747Z", + "span_id": "1FADCC9FB8DDE886", + "trace_id": "7737691c-16f6-8aa4-9326-2114e3a1048e", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677749Z", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677749Z", + "span_id": "09E59AECCEDE7725", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67775Z", + "span_id": "9885AAE43420A45B", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677749Z", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677749Z", + "span_id": "09E59AECCEDE7725", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67775Z", + "span_id": "9885AAE43420A45B", + "trace_id": "79cc7e3b-10dc-7f53-03a6-2a5c4417bdc5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677751Z", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677751Z", + "span_id": "CC204570C29BDBF4", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677752Z", + "span_id": "D17C651E1245C0F9", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677751Z", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677751Z", + "span_id": "CC204570C29BDBF4", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677752Z", + "span_id": "D17C651E1245C0F9", + "trace_id": "5e7ba616-863c-a3d9-531d-def5659bfb5f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "span_id": "B9C3F1DAF9940B7F", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "span_id": "06A614DD43EC1E9A", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "span_id": "B9C3F1DAF9940B7F", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677755Z", + "span_id": "06A614DD43EC1E9A", + "trace_id": "9be40513-f8ae-1f93-0a2b-043323294f75", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677757Z", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677757Z", + "span_id": "46A99707D5225859", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 131000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677763Z", + "span_id": "F489DDD88539BDA4", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677757Z", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677757Z", + "span_id": "46A99707D5225859", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 131000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677763Z", + "span_id": "F489DDD88539BDA4", + "trace_id": "89f98a5b-09d4-0e0b-3f92-3572152ddc3d", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677773Z", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677773Z", + "span_id": "3223B8A1131D2A70", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677774Z", + "span_id": "82904DC8C7ED5487", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677773Z", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677773Z", + "span_id": "3223B8A1131D2A70", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677774Z", + "span_id": "82904DC8C7ED5487", + "trace_id": "b8ea3732-f870-f54c-1b3e-dff49376a1ec", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677776Z", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677776Z", + "span_id": "D9EC63C08230FB02", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677777Z", + "span_id": "F504530C5C200E2E", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677776Z", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677776Z", + "span_id": "D9EC63C08230FB02", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677777Z", + "span_id": "F504530C5C200E2E", + "trace_id": "a8ed5cde-b713-64ac-e11a-1222134e2857", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677778Z", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677778Z", + "span_id": "6F0F8D30DF04BA3E", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677779Z", + "span_id": "2FF73BB3675EBE65", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677778Z", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677778Z", + "span_id": "6F0F8D30DF04BA3E", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677779Z", + "span_id": "2FF73BB3675EBE65", + "trace_id": "bcbb0a0a-35a4-ff10-65b6-848271d6101a", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67778Z", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67778Z", + "span_id": "3D79FEC1831E8F1A", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677781Z", + "span_id": "35D99AA84BCD627A", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67778Z", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67778Z", + "span_id": "3D79FEC1831E8F1A", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677781Z", + "span_id": "35D99AA84BCD627A", + "trace_id": "636bfe8f-c6f3-54a7-f828-b1b85740c33f", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "span_id": "0077A5FDB210BAB9", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "span_id": "BD4D0517234DC84A", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "span_id": "0077A5FDB210BAB9", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677783Z", + "span_id": "BD4D0517234DC84A", + "trace_id": "685185ef-cf46-c7c7-9a00-6553b0171911", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 130000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677793Z", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677793Z", + "span_id": "C148B50CA183BF05", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677794Z", + "span_id": "91C5D79D971A4A6E", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677793Z", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677793Z", + "span_id": "C148B50CA183BF05", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677794Z", + "span_id": "91C5D79D971A4A6E", + "trace_id": "fb17567a-a567-d0ac-c5f0-bbd5abb66cb8", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677804Z", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677804Z", + "span_id": "948B20672FD5954F", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677805Z", + "span_id": "529CA18AF8EF5017", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677804Z", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677804Z", + "span_id": "948B20672FD5954F", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677805Z", + "span_id": "529CA18AF8EF5017", + "trace_id": "d35cd179-4e71-8be7-ba39-b7b5ae2fdaa4", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677806Z", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677806Z", + "span_id": "ADF60B9EFA97AD89", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677807Z", + "span_id": "03DE70E55CBF857C", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677806Z", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677806Z", + "span_id": "ADF60B9EFA97AD89", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677807Z", + "span_id": "03DE70E55CBF857C", + "trace_id": "e156a5f9-c203-2979-8fbc-5f50b8a67f32", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677809Z", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677809Z", + "span_id": "4350413FDF6C0DCD", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 128000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677814Z", + "span_id": "BDC8BD58C638FC78", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677809Z", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677809Z", + "span_id": "4350413FDF6C0DCD", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 128000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677814Z", + "span_id": "BDC8BD58C638FC78", + "trace_id": "7637dadf-0405-3e70-e78f-f0fa26d42511", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677815Z", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677815Z", + "span_id": "1A9A97C656E06304", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677816Z", + "span_id": "5C6D85FF7954A628", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677815Z", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677815Z", + "span_id": "1A9A97C656E06304", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677816Z", + "span_id": "5C6D85FF7954A628", + "trace_id": "9fdab630-253f-d030-6f7f-ce1a359fa330", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677819Z", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677819Z", + "span_id": "6ED8D4E93C42E03E", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67782Z", + "span_id": "E3A02E872E9E95EA", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677819Z", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677819Z", + "span_id": "6ED8D4E93C42E03E", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.67782Z", + "span_id": "E3A02E872E9E95EA", + "trace_id": "f3a4220e-79e8-dc0a-44b0-33642b2561ad", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677824Z", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677824Z", + "span_id": "2AF1B764C7572560", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677825Z", + "span_id": "321BA81B83ABAB3C", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677824Z", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677824Z", + "span_id": "2AF1B764C7572560", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677825Z", + "span_id": "321BA81B83ABAB3C", + "trace_id": "ac93cf21-6d07-94a2-7e87-f64a637a4a05", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677835Z", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677835Z", + "span_id": "3A2C3D8803D79AC4", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677836Z", + "span_id": "B44FBDD3FD165A7D", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677835Z", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677835Z", + "span_id": "3A2C3D8803D79AC4", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677836Z", + "span_id": "B44FBDD3FD165A7D", + "trace_id": "d0695ec7-66dd-bbdc-9577-86aaeffe9ec5", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "span_id": "B3541547AC06BBBE", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "span_id": "D9A66F89D75DF7A9", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "span_id": "B3541547AC06BBBE", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677838Z", + "span_id": "D9A66F89D75DF7A9", + "trace_id": "ac155a1f-ab02-d5dc-b77e-65d87e53ab8d", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67784Z", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67784Z", + "span_id": "6319FEA0EAA4E4C2", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677841Z", + "span_id": "ACF1D215C677342D", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67784Z", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67784Z", + "span_id": "6319FEA0EAA4E4C2", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677841Z", + "span_id": "ACF1D215C677342D", + "trace_id": "51b58b66-f449-6183-7844-7d1c6fb834fd", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 123000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677847Z", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677847Z", + "span_id": "BFC1E9D5F3C3DC01", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 126000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677848Z", + "span_id": "021083418A0CC7D6", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677847Z", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677847Z", + "span_id": "BFC1E9D5F3C3DC01", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 126000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677848Z", + "span_id": "021083418A0CC7D6", + "trace_id": "fcdba2a3-e163-1676-09fd-7e8a579b2d16", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 125000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "span_id": "D732E63B1D99C410", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "span_id": "2115BD5B480ED78A", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "okey-dokey", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "span_id": "D732E63B1D99C410", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677877Z", + "span_id": "2115BD5B480ED78A", + "trace_id": "fb03a9e2-cfb5-cf88-5765-9373285acb88", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67788Z", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67788Z", + "span_id": "479F386B26842545", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677885Z", + "span_id": "AC44E5C2E91E801A", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + }, + { + "timestamp": "2023-07-10T15:02:30.67788Z", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "lets-go", + "statusCode": "STATUS_CODE_UNSET", + "spans": [ + { + "timestamp": "2023-07-10T15:02:30.67788Z", + "span_id": "479F386B26842545", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "lets-go", + "duration_nano": 129000, + "statusCode": "STATUS_CODE_UNSET" + }, + { + "timestamp": "2023-07-10T15:02:30.677885Z", + "span_id": "AC44E5C2E91E801A", + "trace_id": "a1b1e40c-63d1-cd61-c962-e6672540ad11", + "service_name": "tracegen", + "operation": "okey-dokey", + "duration_nano": 124000, + "statusCode": "STATUS_CODE_UNSET" + } + ], + "totalSpans": 2 + } + ], + "totalTraces": 200 +} diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue new file mode 100644 index 00000000000..2b42c821cd5 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -0,0 +1,62 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createAlert } from '~/alert'; +import projectsQuery from '../graphql/queries/projects.query.graphql'; + +export default { + i18n: { + pageTitle: __('Groups and projects'), + errorMessage: s__( + 'Organization|An error occurred loading the projects. Please refresh the page to try again.', + ), + }, + components: { + ProjectsList, + GlLoadingIcon, + }, + data() { + return { + projects: [], + }; + }, + apollo: { + projects: { + query: projectsQuery, + update(data) { + return data.organization.projects.nodes; + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + formattedProjects() { + return this.projects.map(({ id, nameWithNamespace, accessLevel, ...project }) => ({ + ...project, + id: getIdFromGraphQLId(id), + name: nameWithNamespace, + permissions: { + projectAccess: { + accessLevel: accessLevel.integerValue, + }, + }, + })); + }, + isLoading() { + return this.$apollo.queries.projects?.loading; + }, + }, +}; +</script> + +<template> + <div> + <h1 class="gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> + <projects-list v-else :projects="formattedProjects" show-project-icon /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql new file mode 100644 index 00000000000..b4cb8c607d4 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql @@ -0,0 +1,24 @@ +query getOrganizationProjects { + organization @client { + id + projects { + nodes { + id + nameWithNamespace + webUrl + topics + forksCount + avatarUrl + starCount + visibility + openIssuesCount + descriptionHtml + issuesAccessLevel + forkingAccessLevel + accessLevel { + integerValue + } + } + } + } +} diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js new file mode 100644 index 00000000000..794410c2a78 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js @@ -0,0 +1,14 @@ +import { organizationProjects } from 'jest/organizations/groups_and_projects/components/mock_data'; + +export default { + Query: { + organization: async () => { + // Simulate API loading + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + return organizationProjects; + }, + }, +}; diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js new file mode 100644 index 00000000000..d0790bcc040 --- /dev/null +++ b/app/assets/javascripts/organizations/groups_and_projects/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import resolvers from './graphql/resolvers'; +import App from './components/app.vue'; + +export const initOrganizationsGroupsAndProjects = () => { + const el = document.getElementById('js-organizations-groups-and-projects'); + + if (!el) return false; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'OrganizationsGroupsAndProjects', + apolloProvider, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue index 8e89128a382..a3f58cc3323 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue @@ -4,8 +4,7 @@ import { GlTooltipDirective, GlSprintf, GlIcon, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -33,8 +32,7 @@ export default { GlSprintf, GlFormCheckbox, GlIcon, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, ListItem, ClipboardButton, TimeAgoTooltip, @@ -76,6 +74,22 @@ export default { COPY_IMAGE_PATH_TITLE, }, computed: { + items() { + return [ + { + text: this.$options.i18n.REMOVE_TAG_BUTTON_TITLE, + extraAttrs: { + class: 'gl-text-red-500!', + 'data-testid': 'single-delete-button', + 'data-qa-selector': 'tag_delete_button', + }, + action: () => { + this.$emit('delete'); + }, + }, + ]; + }, + formattedSize() { return this.tag.totalSize ? numberToHumanSize(Number(this.tag.totalSize)) @@ -177,31 +191,23 @@ export default { </span> </template> <template v-if="tag.canDelete" #right-action> - <gl-dropdown + <gl-disclosure-dropdown :disabled="disabled" icon="ellipsis_v" - :text="$options.i18n.MORE_ACTIONS_TEXT" + :toggle-text="$options.i18n.MORE_ACTIONS_TEXT" :text-sr-only="true" category="tertiary" no-caret - right + placement="right" :class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }" data-testid="additional-actions" data-qa-selector="more_actions_menu" - > - <gl-dropdown-item - variant="danger" - data-testid="single-delete-button" - data-qa-selector="tag_delete_button" - @click="$emit('delete')" - > - {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }} - </gl-dropdown-item> - </gl-dropdown> + :items="items" + /> </template> <template v-if="!isInvalidTag" #details-published> - <details-row icon="clock" data-testid="published-date-detail"> + <details-row icon="clock" padding="gl-py-3" data-testid="published-date-detail"> <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> <template #repositoryPath> <i>{{ tagLocation }}</i> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 732d544816b..87a2eb362d5 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -4,9 +4,9 @@ import { GlButton, GlDropdown, GlDropdownItem, - GlEmptyState, GlFormGroup, GlFormInputGroup, + GlSkeletonLoader, GlModal, GlModalDirective, GlSprintf, @@ -17,7 +17,6 @@ import Api from '~/api'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; -import { DEPENDENCY_PROXY_DOCS_PATH } from '~/packages_and_registries/settings/group/constants'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; @@ -28,7 +27,7 @@ export default { GlButton, GlDropdown, GlDropdownItem, - GlEmptyState, + GlSkeletonLoader, GlFormGroup, GlFormInputGroup, GlModal, @@ -41,13 +40,12 @@ export default { GlModalDirective, GlTooltip: GlTooltipDirective, }, - inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache', 'settingsPath'], + inject: ['groupPath', 'groupId', 'canClearCache', 'settingsPath'], i18n: { proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), pageTitle: s__('DependencyProxy|Dependency Proxy'), - noManifestTitle: s__('DependencyProxy|There are no images in the cache'), deleteCacheAlertMessageSuccess: s__( 'DependencyProxy|All items in the cache are scheduled for removal.', ), @@ -64,9 +62,6 @@ export default { text: __('Cancel'), }, }, - links: { - DEPENDENCY_PROXY_DOCS_PATH, - }, data() { return { group: {}, @@ -90,7 +85,7 @@ export default { return this.group.dependencyProxyManifests?.pageInfo; }, manifests() { - return this.group.dependencyProxyManifests?.nodes; + return this.group.dependencyProxyManifests?.nodes ?? []; }, modalTitleWithCount() { return sprintf( @@ -199,10 +194,16 @@ export default { </template> </title-area> - <gl-form-group v-if="showDependencyProxyImagePrefix" :label="$options.i18n.proxyImagePrefix"> + <gl-form-group + v-if="showDependencyProxyImagePrefix" + :label="$options.i18n.proxyImagePrefix" + label-for="proxy-url" + > <gl-form-input-group + id="proxy-url" readonly :value="group.dependencyProxyImagePrefix" + select-on-click class="gl-layout-w-limited" data-testid="proxy-url" > @@ -222,9 +223,9 @@ export default { </span> </template> </gl-form-group> + <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> <manifests-list - v-if="manifests && manifests.length" :dependency-proxy-image-prefix="dependencyProxyImagePrefix" :loading="$apollo.queries.group.loading" :manifests="manifests" @@ -233,12 +234,6 @@ export default { @next-page="fetchNextPage" /> - <gl-empty-state - v-else - :svg-path="noManifestsIllustration" - :title="$options.i18n.noManifestTitle" - /> - <gl-modal :modal-id="$options.confirmClearCacheModal" :title="modalTitleWithCount" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue new file mode 100644 index 00000000000..b0d03a7cebe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue @@ -0,0 +1,80 @@ +<script> +import { GlEmptyState, GlFormGroup, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { DEPENDENCY_PROXY_HELP_PAGE_PATH } from '~/packages_and_registries/dependency_proxy/constants'; + +export default { + name: 'ManifestsEmptyState', + components: { + ClipboardButton, + GlEmptyState, + GlFormGroup, + GlFormInputGroup, + GlLink, + GlSprintf, + }, + inject: ['noManifestsIllustration'], + i18n: { + codeExampleLabel: s__('DependencyProxy|Pull image by digest example'), + noManifestTitle: s__('DependencyProxy|There are no images in the cache'), + emptyText: s__( + 'DependencyProxy|To store docker images in Dependency Proxy cache, pull an image by tag in your %{codeStart}.gitlab-ci.yml%{codeEnd} file. In this example, the image is %{codeStart}alpine:latest%{codeEnd}', + ), + documentationText: s__( + 'DependencyProxy|%{docLinkStart}See the documentation%{docLinkEnd} for other ways to store Docker images in Dependency Proxy cache.', + ), + copyExample: s__('DependencyProxy|Copy example'), + }, + // eslint-disable-next-line no-template-curly-in-string + codeExample: 'image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/alpine:latest', + links: { + DEPENDENCY_PROXY_HELP_PAGE_PATH, + }, +}; +</script> + +<template> + <gl-empty-state :svg-path="noManifestsIllustration" :title="$options.i18n.noManifestTitle"> + <template #description> + <p class="gl-mb-5"> + <gl-sprintf :message="$options.i18n.emptyText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <gl-form-group + class="gl-mb-5" + :label="$options.i18n.codeExampleLabel" + label-for="code-example" + label-sr-only + > + <gl-form-input-group + id="code-example" + readonly + :value="$options.codeExample" + class="gl-w-70p gl-mx-auto" + select-on-click + > + <template #append> + <clipboard-button + :text="$options.codeExample" + :title="$options.i18n.copyExample" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </gl-form-group> + + <p> + <gl-sprintf :message="$options.i18n.documentationText"> + <template #docLink="{ content }"> + <gl-link :href="$options.links.DEPENDENCY_PROXY_HELP_PAGE_PATH">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue index 9870841f1ff..94c958308dd 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue @@ -2,11 +2,13 @@ import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui'; import { s__ } from '~/locale'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; +import ManifestsEmptyState from '~/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue'; export default { name: 'ManifestsLists', components: { ManifestRow, + ManifestsEmptyState, GlKeysetPagination, GlSkeletonLoader, }, @@ -18,7 +20,8 @@ export default { }, pagination: { type: Object, - required: true, + required: false, + default: () => ({}), }, loading: { type: Boolean, @@ -44,12 +47,18 @@ export default { <template> <div class="gl-mt-6"> - <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3> - <gl-skeleton-loader v-if="loading" /> + <h3 class="gl-font-base gl-pb-3 gl-mb-0 gl-border-b-1 gl-border-gray-100 gl-border-b-solid"> + {{ $options.i18n.listTitle }} + </h3> + + <div v-if="loading" class="gl-py-3"> + <gl-skeleton-loader /> + </div> + + <manifests-empty-state v-else-if="manifests.length === 0" /> + <div v-else data-testid="main-area"> - <div - class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column" - > + <div class="gl-display-flex gl-flex-direction-column"> <manifest-row v-for="(manifest, index) in manifests" :key="index" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js index fdad69204ba..8e88df92155 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js @@ -1,2 +1,11 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; + export const GRAPHQL_PAGE_SIZE = 20; export const MANIFEST_PENDING_DESTRUCTION_STATUS = 'PENDING_DESTRUCTION'; + +export const DEPENDENCY_PROXY_HELP_PAGE_PATH = helpPagePath( + 'user/packages/dependency_proxy/index', + { + anchor: 'store-a-docker-image-in-dependency-proxy-cache', + }, +); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index 3157653648b..96d097eff38 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -3,16 +3,20 @@ import { GlAlert, GlLink, GlTable, - GlDropdownItem, - GlDropdown, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlButton, GlFormCheckbox, GlLoadingIcon, GlModal, GlSprintf, + GlKeysetPagination, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; +import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { scrollToElement } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; import Tracking from '~/tracking'; @@ -47,12 +51,13 @@ export default { GlAlert, GlLink, GlTable, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormCheckbox, GlButton, GlLoadingIcon, GlModal, + GlKeysetPagination, GlSprintf, FileIcon, TimeAgoTooltip, @@ -94,10 +99,17 @@ export default { return this.queryVariables; }, update(data) { - return data.package?.packageFiles ?? {}; + return data.package?.packageFiles?.nodes ?? []; }, - error() { + result({ data }) { + const { packageFiles } = data?.package ?? {}; + if (packageFiles?.pageInfo) { + this.pageInfo = packageFiles.pageInfo; + } + }, + error(error) { this.fetchPackageFilesError = true; + Sentry.captureException(error); }, }, }, @@ -105,23 +117,21 @@ export default { return { fetchPackageFilesError: false, filesToDelete: [], - packageFiles: {}, + packageFiles: [], mutationLoading: false, selectedReferences: [], + pageInfo: {}, }; }, computed: { - files() { - return this.packageFiles?.nodes ?? []; - }, areFilesSelected() { return this.selectedReferences.length > 0; }, areAllFilesSelected() { - return this.files.length > 0 && this.files.every(this.isSelected); + return this.packageFiles.length > 0 && this.packageFiles.every(this.isSelected); }, filesTableRows() { - return this.files.map((pf) => ({ + return this.packageFiles.map((pf) => ({ ...pf, size: this.formatSize(pf.size), })); @@ -168,6 +178,10 @@ export default { first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, }; }, + showPagination() { + const { hasPreviousPage, hasNextPage } = this.pageInfo; + return hasPreviousPage || hasNextPage; + }, tracking() { return { category: packageTypeToTrackCategory(this.packageType), @@ -258,7 +272,7 @@ export default { }, handleFileDelete(files) { this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION); - if (files.length === this.files.length && !this.packageFiles?.pageInfo?.hasNextPage) { + if (files.length === this.packageFiles.length && !this.pageInfo.hasNextPage) { this.$emit( 'delete-all-files', this.hasOneItem(files) @@ -281,6 +295,41 @@ export default { } this.deletePackageFiles(this.filesToDelete.map((file) => file.id)); }, + fetchPreviousFilesPage() { + return this.$apollo.queries.packageFiles + .fetchMore({ + variables: { + first: null, + last: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, + before: this.pageInfo.startCursor, + }, + }) + .then(() => { + this.scrollAndFocus(); + }); + }, + fetchNextFilesPage() { + return this.$apollo.queries.packageFiles + .fetchMore({ + variables: { + first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE, + last: null, + after: this.pageInfo.endCursor, + }, + }) + .then(() => { + this.scrollAndFocus(); + }); + }, + scrollAndFocus() { + scrollToElement(this.$el); + + // get first focusable row + const focusable = this.$el.querySelector('tbody tr'); + if (focusable) { + focusable.focus(); + } + }, }, i18n: { deleteFile: s__('PackageRegistry|Delete asset'), @@ -295,6 +344,8 @@ export default { deleteSelected: s__('PackageRegistry|Delete selected'), moreActionsText: __('More actions'), fetchPackageFilesErrorMessage: FETCH_PACKAGE_FILES_ERROR_MESSAGE, + prev: PREV, + next: NEXT, }, modal: { fileDeletePrimaryAction: { @@ -334,102 +385,122 @@ export default { > {{ $options.i18n.fetchPackageFilesErrorMessage }} </gl-alert> - <gl-table - v-else - :busy="isLoading" - :fields="filesTableHeaderFields" - :items="filesTableRows" - show-empty - selectable - select-mode="multi" - selected-variant="primary" - :tbody-tr-attr="{ 'data-testid': 'file-row' }" - @row-selected="updateSelectedReferences" - > - <template #table-busy> - <gl-loading-icon size="lg" class="gl-my-5" /> - </template> - <template #head(checkbox)="{ selectAllRows, clearSelected }"> - <gl-form-checkbox - v-if="canDelete" - data-testid="package-files-checkbox-all" - :checked="areAllFilesSelected" - :indeterminate="hasSelectedSomeFiles" - @change="areAllFilesSelected ? clearSelected() : selectAllRows()" - /> - </template> + <template v-else> + <gl-table + ref="table" + :busy="isLoading" + :fields="filesTableHeaderFields" + :items="filesTableRows" + show-empty + selectable + select-mode="multi" + selected-variant="primary" + :tbody-tr-attr="{ 'data-testid': 'file-row' }" + @row-selected="updateSelectedReferences" + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> + <template #head(checkbox)="{ selectAllRows, clearSelected }"> + <gl-form-checkbox + v-if="canDelete" + data-testid="package-files-checkbox-all" + :checked="areAllFilesSelected" + :indeterminate="hasSelectedSomeFiles" + @change="areAllFilesSelected ? clearSelected() : selectAllRows()" + /> + </template> - <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }"> - <gl-form-checkbox - v-if="canDelete" - class="gl-mt-1" - :checked="rowSelected" - data-testid="package-files-checkbox" - @change="rowSelected ? unselectRow() : selectRow()" - /> - </template> + <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }"> + <gl-form-checkbox + v-if="canDelete" + class="gl-mt-1" + :checked="rowSelected" + data-testid="package-files-checkbox" + @change="rowSelected ? unselectRow() : selectRow()" + /> + </template> - <template #cell(name)="{ item, toggleDetails, detailsShowing }"> - <gl-button - v-if="hasDetails(item)" - :icon="detailsShowing ? 'chevron-up' : 'chevron-down'" - :aria-label="detailsShowing ? __('Collapse') : __('Expand')" - category="tertiary" - size="small" - @click=" - toggleDetails(); - trackToggleDetails(detailsShowing); - " - /> - <gl-link - :href="item.downloadPath" - class="gl-text-gray-500" - data-testid="download-link" - @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" - > - <file-icon - :file-name="item.fileName" - css-classes="gl-relative file-icon" - class="gl-mr-1 gl-relative" + <template #cell(name)="{ item, toggleDetails, detailsShowing }"> + <gl-button + v-if="hasDetails(item)" + :icon="detailsShowing ? 'chevron-up' : 'chevron-down'" + :aria-label="detailsShowing ? __('Collapse') : __('Expand')" + data-testid="toggle-details-button" + category="tertiary" + size="small" + @click=" + toggleDetails(); + trackToggleDetails(detailsShowing); + " /> - <span>{{ item.fileName }}</span> - </gl-link> - </template> + <gl-link + :href="item.downloadPath" + class="gl-text-gray-500" + data-testid="download-link" + @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)" + > + <file-icon + :file-name="item.fileName" + css-classes="gl-relative file-icon" + class="gl-mr-1 gl-relative" + /> + <span>{{ item.fileName }}</span> + </gl-link> + </template> - <template #cell(created)="{ item }"> - <time-ago-tooltip :time="item.createdAt" /> - </template> + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> - <template #cell(actions)="{ item }"> - <gl-dropdown - category="tertiary" - icon="ellipsis_v" - :text-sr-only="true" - :text="$options.i18n.moreActionsText" - no-caret - right - > - <gl-dropdown-item data-testid="delete-file" @click="handleFileDelete([item])"> - {{ $options.i18n.deleteFile }} - </gl-dropdown-item> - </gl-dropdown> - </template> + <template #cell(actions)="{ item }"> + <gl-disclosure-dropdown + category="tertiary" + icon="ellipsis_v" + placement="right" + :toggle-text="$options.i18n.moreActionsText" + text-sr-only + no-caret + > + <gl-disclosure-dropdown-item + data-testid="delete-file" + @action="handleFileDelete([item])" + > + <template #list-item> + {{ $options.i18n.deleteFile }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> + </template> - <template #row-details="{ item }"> - <div - 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" - > - <file-sha - v-if="item.fileSha256" - data-testid="sha-256" - title="SHA-256" - :sha="item.fileSha256" - /> - <file-sha v-if="item.fileMd5" data-testid="md5" title="MD5" :sha="item.fileMd5" /> - <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" /> - </div> - </template> - </gl-table> + <template #row-details="{ item }"> + <div + 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" + > + <file-sha + v-if="item.fileSha256" + data-testid="sha-256" + title="SHA-256" + :sha="item.fileSha256" + /> + <file-sha v-if="item.fileMd5" data-testid="md5" title="MD5" :sha="item.fileMd5" /> + <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" /> + </div> + </template> + </gl-table> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :disabled="isLoading" + v-bind="pageInfo" + :prev-text="$options.i18n.prev" + :next-text="$options.i18n.next" + class="gl-mt-3" + @prev="fetchPreviousFilesPage" + @next="fetchNextFilesPage" + /> + </div> + </template> <gl-modal ref="deleteFilesModal" 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 37a6fe75f15..ca2516810cf 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,7 +1,6 @@ <script> import { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlFormCheckbox, GlIcon, GlLink, @@ -25,8 +24,7 @@ import { export default { name: 'PackageVersionRow', components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, GlFormCheckbox, GlIcon, GlLink, @@ -61,6 +59,18 @@ export default { errorStatusRow() { return this.packageEntity?.status === PACKAGE_ERROR_STATUS; }, + dropdownItems() { + return [ + { + text: this.$options.i18n.deletePackage, + action: () => this.$emit('delete'), + extraAttrs: { + class: 'gl-text-red-500!', + 'data-testid': 'action-delete', + }, + }, + ]; + }, }, i18n: { deletePackage: DELETE_PACKAGE_TEXT, @@ -129,18 +139,15 @@ export default { </template> <template v-if="packageEntity.canDestroy" #right-action> - <gl-dropdown + <gl-disclosure-dropdown data-testid="delete-dropdown" icon="ellipsis_v" - :text="$options.i18n.moreActions" - :text-sr-only="true" + :items="dropdownItems" + :toggle-text="$options.i18n.moreActions" category="tertiary" + text-sr-only no-caret - > - <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{ - $options.i18n.deletePackage - }}</gl-dropdown-item> - </gl-dropdown> + /> </template> </list-item> </template> 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 4ec83a869b3..c690e8fac43 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 @@ -7,8 +7,9 @@ import { GlSprintf, GlTooltipDirective, GlTruncate, + GlLink, } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { DELETE_PACKAGE_TEXT, @@ -19,7 +20,6 @@ import { 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'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -34,8 +34,8 @@ export default { GlIcon, GlSprintf, GlTruncate, + GlLink, PackageTags, - PackagePath, PublishMethod, ListItem, TimeagoTooltip, @@ -43,7 +43,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['isGroupPage'], + inject: ['isGroupPage', 'canDeletePackages'], props: { packageEntity: { type: Object, @@ -68,9 +68,29 @@ export default { pipeline() { return this.packageEntity?.pipelines?.nodes[0]; }, + projectName() { + return this.packageEntity.project.name; + }, + projectLink() { + return this.packageEntity.project.webUrl; + }, pipelineUser() { return this.pipeline?.user?.name; }, + publishedMessage() { + if (this.isGroupPage) { + if (this.pipelineUser) { + return s__(`PackageRegistry|Published to %{projectName} by %{author}, %{date}`); + } + return s__(`PackageRegistry|Published to %{projectName}, %{date}`); + } + + if (this.pipelineUser) { + return s__(`PackageRegistry|Published by %{author}, %{date}`); + } + + return s__(`PackageRegistry|Published %{date}`); + }, errorStatusRow() { return this.packageEntity.status === PACKAGE_ERROR_STATUS; }, @@ -102,7 +122,7 @@ export default { <list-item data-testid="package-row" :selected="selected" v-bind="$attrs"> <template #left-action> <gl-form-checkbox - v-if="packageEntity.canDestroy" + v-if="canDeletePackages" class="gl-m-0" :checked="selected" @change="$emit('select')" @@ -142,20 +162,7 @@ export default { :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}')"> - <template #author>{{ pipelineUser }}</template> - </gl-sprintf> - </div> - <span class="gl-ml-2" data-testid="package-type">· {{ packageType }}</span> - - <package-path - v-if="isGroupPage" - :path="packageEntity.project.fullPath" - :disabled="nonDefaultRow" - /> </div> <div v-else> <gl-icon @@ -174,11 +181,15 @@ export default { </template> <template #right-secondary> - <span data-testid="created-date"> - <gl-sprintf :message="$options.i18n.createdAt"> - <template #timestamp> + <span data-testid="right-secondary"> + <gl-sprintf :message="publishedMessage"> + <template v-if="isGroupPage" #projectName> + <gl-link data-testid="root-link" :href="projectLink">{{ projectName }}</gl-link> + </template> + <template #date> <timeago-tooltip :time="packageEntity.createdAt" /> </template> + <template v-if="pipelineUser" #author>{{ pipelineUser }}</template> </gl-sprintf> </span> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index effed4891d8..a7831ef2588 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 @@ -36,6 +36,7 @@ export default { RegistryList, }, mixins: [Tracking.mixin()], + inject: ['canDeletePackages'], props: { list: { type: Array, @@ -175,6 +176,7 @@ export default { > <registry-list data-testid="packages-table" + :hidden-delete="!canDeletePackages" :is-loading="isLoading" :items="list" :pagination="pageInfo" 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 80712c2991c..364bd430f07 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -235,4 +235,4 @@ export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath( ); export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10; -export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 100; +export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 20; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index bcd90b7bee5..0c8af248c43 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -26,6 +26,7 @@ fragment PackageData on Package { } project { id + name fullPath webUrl } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js index d05ff5daad4..d630e040d52 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -22,6 +22,7 @@ export const apolloProvider = new VueApollo({ merge: mergeVariables, }, packageFiles: { + keyArgs: ['id'], merge: mergeVariables, }, }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql index e6f292ec1d3..7a389b2aa5c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql @@ -1,9 +1,17 @@ -query getPackageFiles($id: PackagesPackageID!, $first: Int) { +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getPackageFiles( + $id: PackagesPackageID! + $first: Int + $last: Int + $after: String + $before: String +) { package(id: $id) { id - packageFiles(first: $first) { + packageFiles(after: $after, before: $before, first: $first, last: $last) { pageInfo { - hasNextPage + ...PageInfo } 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 e2f8d239bae..ae0f6d18d99 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue'; import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; @@ -20,6 +21,7 @@ export default () => { projectListUrl, groupListUrl, settingsPath, + canDeletePackages, } = el.dataset; const isGroupPage = pageType === 'groups'; @@ -50,6 +52,7 @@ export default () => { groupListUrl, breadCrumbState, settingsPath, + canDeletePackages: parseBoolean(canDeletePackages), }, render(createElement) { return createElement(PackageRegistry); 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 14d617a7a3c..486c3ef31c5 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 @@ -2,6 +2,7 @@ import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_INFO } from '~/alert'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import { fetchPolicies } from '~/lib/graphql'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; @@ -38,11 +39,13 @@ export default { sort: '', filters: {}, mutationLoading: false, + pageParams: {}, }; }, apollo: { packagesResource: { query: getPackagesQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, variables() { return this.queryVariables; }, @@ -72,6 +75,7 @@ export default { packageName: this.filters?.packageName, packageType: this.filters?.packageType, first: GRAPHQL_PAGE_SIZE, + ...this.pageParams, }; }, graphqlResource() { @@ -120,37 +124,22 @@ export default { } }, handleSearchUpdate({ sort, filters }) { + this.pageParams = {}; this.sort = sort; this.filters = { ...filters }; }, - updateQuery(_, { fetchMoreResult }) { - return fetchMoreResult; - }, fetchNextPage() { - const variables = { - ...this.queryVariables, + this.pageParams = { first: GRAPHQL_PAGE_SIZE, - last: null, after: this.pageInfo?.endCursor, }; - - this.$apollo.queries.packagesResource.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); }, fetchPreviousPage() { - const variables = { - ...this.queryVariables, + this.pageParams = { first: null, last: GRAPHQL_PAGE_SIZE, before: this.pageInfo?.startCursor, }; - - this.$apollo.queries.packagesResource.fetchMore({ - variables, - updateQuery: this.updateQuery, - }); }, }, i18n: { diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 4c25c0f97de..6ff7d58fd9e 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -78,6 +78,7 @@ export default { </gl-alert> <packages-settings + class="settings-section-no-bottom" :package-settings="packageSettings" :is-loading="isLoading" @success="handleSuccess(2)" 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 80df8ef81e6..e9b72651c67 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 @@ -147,7 +147,7 @@ export default { <p v-if="value.nextRunAt" data-testid="next-run-at"> {{ nextCleanupMessage }} </p> - <div class="gl-mt-7 gl-display-flex gl-align-items-center"> + <div class="gl-mt-6 gl-display-flex gl-align-items-center"> <gl-button data-testid="save-button" type="submit" diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue index 7740924b058..1a63252a850 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue @@ -1,17 +1,15 @@ <template> - <section class="settings gl-py-7"> - <div class="row"> - <div class="col-lg-4"> - <h4> + <section class="settings-section"> + <div class="settings-sticky-header"> + <div class="settings-sticky-header-inner"> + <h4 class="gl-my-0"> <slot name="title"></slot> </h4> - <p> - <slot name="description"></slot> - </p> - </div> - <div class="col-lg-8 gl-pt-3"> - <slot></slot> </div> </div> + <p class="gl-text-secondary"> + <slot name="description"></slot> + </p> + <slot></slot> </section> </template> diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue index 3ee15077d00..876e85e4a47 100644 --- a/app/assets/javascripts/pages/groups/new/components/app.vue +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -1,6 +1,6 @@ <script> -import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg?raw'; -import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg?raw'; +import GROUP_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/group-import.svg?url'; +import GROUP_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/group-new.svg?url'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; @@ -69,12 +69,12 @@ export default { description: s__( 'GroupsNew|Assemble related projects together and grant members access to several projects at once.', ), - illustration: newGroupIllustration, details: createGroupDescriptionDetails, detailProps: { parentGroupName: this.parentGroupName, importExistingGroupPath: this.importExistingGroupPath, }, + imageSrc: GROUP_NEW_SVG_URL, }, { name: 'import-group-pane', @@ -83,8 +83,8 @@ export default { description: s__( 'GroupsNew|Import a group and related data from another GitLab instance.', ), - illustration: importGroupIllustration, details: 'Migrate your existing groups from another instance of GitLab.', + imageSrc: GROUP_IMPORT_SVG_URL, }, ]; }, diff --git a/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js b/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js new file mode 100644 index 00000000000..50afa5a75ae --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/groups_and_projects/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsGroupsAndProjects } from '~/organizations/groups_and_projects'; + +initOrganizationsGroupsAndProjects(); diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index ea6bca644ed..8fe822e4639 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -7,7 +7,9 @@ const twoFactorNode = document.querySelector('.js-two-factor-auth'); const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false; if (skippable) { - const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; + const button = `<div class="gl-alert-actions"> + <a class="btn gl-button btn-md btn-confirm gl-alert-action" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a> + </div>`; const flashAlert = document.querySelector('.flash-alert'); if (flashAlert) { // eslint-disable-next-line no-unsanitized/method @@ -17,7 +19,5 @@ if (skippable) { mount2faRegistration(); initWebAuthnRegistration(); - initRecoveryCodes(); - initManageTwoFactorForm(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index f8dcf1a5c9c..f5e09d972a9 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; +import { provideWebIdeLink } from 'ee_else_ce/pages/projects/shared/web_ide_link/provide_web_ide_link'; import TableOfContents from '~/blob/components/table_contents.vue'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index'; @@ -10,7 +11,7 @@ import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; import ForkInfo from '~/repository/components/fork_info.vue'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; -import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; import createStore from '~/code_navigation/store'; @@ -18,6 +19,7 @@ import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_util import RefSelector from '~/ref/components/ref_selector.vue'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; +import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; Vue.use(Vuex); Vue.use(VueApollo); @@ -69,6 +71,7 @@ if (viewBlobEl) { resourceId, userId, explainCodeAvailable, + ...dataset } = viewBlobEl.dataset; // eslint-disable-next-line no-new @@ -78,11 +81,13 @@ if (viewBlobEl) { router, apolloProvider, provide: { + highlightWorker: gon.features.highlightJsWorker ? new HighlightWorker() : null, targetBranch, originalBranch, resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable), + ...provideWebIdeLink(dataset), }, render(createElement) { return createElement(BlobContentViewer, { diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js deleted file mode 100644 index 606439866ea..00000000000 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import monitoringApp from '~/monitoring/monitoring_app'; - -monitoringApp(); 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 9f7a7b436df..9659c927fbf 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 @@ -262,7 +262,6 @@ export default { try { const { data } = await axios.post(url, postParams); redirectTo(data.web_url); // eslint-disable-line import/no-deprecated - return; } catch (error) { createAlert({ message: s__( diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index c5b63b74c35..2911069a967 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,5 +1,5 @@ import { initForm } from 'ee_else_ce/issues'; -import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 7dd128fedb9..0844e322de2 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,3 +1,6 @@ import { initFilteredSearchServiceDesk } from '~/issues'; +import { mountServiceDeskListApp } from '~/service_desk'; initFilteredSearchServiceDesk(); + +mountServiceDeskListApp(); 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 3d81e77f879..f71a1041068 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,9 +1,11 @@ import Vue from 'vue'; + +import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; + import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; import { __ } from '~/locale'; -import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); diff --git a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js index 77294c0fb9e..b15e9a14b6a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js @@ -1,5 +1,5 @@ import initDiffsApp from '~/diffs'; -import { initMrPage } from '../page'; +import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page'; initMrPage(); initDiffsApp(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index 6127adc3584..79d771ab993 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,7 +1,8 @@ +import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; + import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js index 552e75da9b8..75e308e706f 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/page.js +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -9,6 +9,7 @@ import store from '~/mr_notes/stores'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { initMrMoreDropdown } from '~/mr_more_dropdown'; import initShow from './init_merge_request_show'; import getStateQuery from './queries/get_state.query.graphql'; @@ -17,6 +18,7 @@ Vue.use(VueApollo); export function initMrPage() { initMrNotes(); initShow(); + initMrMoreDropdown(); startCodeReviewMessaging({ signalBus: diffsEventHub }); } 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 67dc3782a24..9eaf490abb2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,9 +1,5 @@ import mountNotesApp from 'ee_else_ce/mr_notes/mount_app'; -import { initReportAbuse } from '~/projects/report_abuse'; -import { initMrMoreDropdown } from '~/mr_more_dropdown'; -import { initMrPage } from '../page'; +import { initMrPage } from 'ee_else_ce/pages/projects/merge_requests/page'; initMrPage(); mountNotesApp(); -initReportAbuse(); -initMrMoreDropdown(); diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js deleted file mode 100644 index 606439866ea..00000000000 --- a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import monitoringApp from '~/monitoring/monitoring_app'; - -monitoringApp(); diff --git a/app/assets/javascripts/pages/projects/ml/models/index/index.js b/app/assets/javascripts/pages/projects/ml/models/index/index.js new file mode 100644 index 00000000000..62d326f43a5 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/models/index/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import MlModelsIndex from '~/ml/model_registry/routes/models/index'; + +initSimpleApp('#js-index-ml-models', MlModelsIndex); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js index e2a782bc5d8..a51c2e9c47b 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -2,7 +2,7 @@ import initPipelineSchedulesFormApp from '~/ci/pipeline_schedules/mount_pipeline import initForm from '../shared/init_form'; if (gon.features?.pipelineSchedulesVue) { - initPipelineSchedulesFormApp('#pipeline-schedules-form-edit'); + initPipelineSchedulesFormApp('#pipeline-schedules-form-edit', true); } else { initForm(); } diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index eab4be4dcf1..a79f20d596c 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -10,6 +10,7 @@ import { import { getWeekdayNames } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; const KEY_EVERY_DAY = 'everyDay'; const KEY_EVERY_WEEK = 'everyWeek'; @@ -54,7 +55,7 @@ export default { inputNameAttribute: 'schedule[cron]', radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, - cronSyntaxUrl: 'https://docs.gitlab.com/ee/topics/cron/', + cronSyntaxUrl: `${DOCS_URL_IN_EE_DIR}/topics/cron/`, }; }, computed: { @@ -116,7 +117,7 @@ export default { }, }, watch: { - cronInterval() { + cronInterval(val) { // updates field validation state when model changes, as // glFieldError only updates on input. if (this.sendNativeErrors) { @@ -124,6 +125,8 @@ export default { gl.pipelineScheduleFieldErrors.updateFormValidityState(); }); } + + this.$emit('cronValue', val); }, radioValue: { immediate: true, diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js index 44a384f03c6..d3f46b7e025 100644 --- a/app/assets/javascripts/pages/projects/pipelines/show/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js @@ -2,4 +2,4 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; initPipelines(); -initPipelineDetails(gon.features.pipelineDetailsHeaderVue); +initPipelineDetails(); diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js index ce36ff6a230..8ceea37b701 100644 --- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { provideWebIdeLink } from 'ee_else_ce/pages/projects/shared/web_ide_link/provide_web_ide_link'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; -import WebIdeButton from '~/vue_shared/components/web_ide_link.vue'; +import WebIdeButton from 'ee_else_ce/vue_shared/components/web_ide_link.vue'; import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); @@ -26,6 +27,7 @@ export default ({ el, router }) => { apolloProvider, provide: { projectPath, + ...provideWebIdeLink(options), }, render(h) { return h(WebIdeButton, { @@ -37,6 +39,7 @@ export default ({ el, router }) => { : webIDEUrl( joinPaths('/', projectPath, 'edit', ref, '-', this.$route?.params.path || '', '/'), ), + projectPath, ...options, }, }); diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js new file mode 100644 index 00000000000..7c64bb6572e --- /dev/null +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/provide_web_ide_link.js @@ -0,0 +1,9 @@ +/** + * Inspects an object and extracts properties + * that are relevant to the web_ide_link.vue + * component. + * + * @returns An object with properties that are + * relevant to the web_ide_link.vue component. See EE version. + */ +export const provideWebIdeLink = () => ({}); diff --git a/app/assets/javascripts/pages/projects/tracing/index/index.js b/app/assets/javascripts/pages/projects/tracing/index/index.js new file mode 100644 index 00000000000..64ca303f8ba --- /dev/null +++ b/app/assets/javascripts/pages/projects/tracing/index/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import ListIndex from '~/tracing/list_index.vue'; + +initSimpleApp('#js-tracing', ListIndex); 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 4f68c7984e8..5bc630c61cb 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -15,8 +15,8 @@ import { setUrlFragment } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import { - SAVED_USING_CONTENT_EDITOR_ACTION, WIKI_CONTENT_EDITOR_TRACKING_LABEL, WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION, @@ -257,9 +257,8 @@ export default { }, trackFormSubmit() { - if (this.isContentEditorActive) { - this.track(SAVED_USING_CONTENT_EDITOR_ACTION); - } + // eslint-disable-next-line @gitlab/require-i18n-strings + trackSavedUsingEditor(this.isContentEditorActive, 'Wiki'); }, trackWikiFormat() { diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js index 94d086158f1..3e685292971 100644 --- a/app/assets/javascripts/pages/shared/wikis/constants.js +++ b/app/assets/javascripts/pages/shared/wikis/constants.js @@ -1,5 +1,4 @@ export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded'; -export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor'; export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor'; export const WIKI_FORMAT_LABEL = 'wiki_format'; export const WIKI_FORMAT_UPDATED_ACTION = 'wiki_format_updated'; diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js index 0044575de62..a0a7cc0b07a 100644 --- a/app/assets/javascripts/pages/shared/wikis/edit.js +++ b/app/assets/javascripts/pages/shared/wikis/edit.js @@ -1,5 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createApolloClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import Translate from '~/vue_shared/translate'; @@ -64,9 +66,14 @@ const createWikiFormApp = () => { if (el) { const { pageInfo, formatOptions } = el.dataset; + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ defaultClient: createApolloClient() }); + // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, provide: { formatOptions: JSON.parse(formatOptions), pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)), diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js index 47424ec1dd3..7d612d6cc4e 100644 --- a/app/assets/javascripts/pages/users/show/index.js +++ b/app/assets/javascripts/pages/users/show/index.js @@ -1,3 +1,5 @@ import { initUserAchievements } from '~/profile'; +import { initUserActionsApp } from '~/users/profile/actions'; initUserAchievements(); +initUserActionsApp(); 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 83cd64c17ed..b2cef7c37b9 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -1,8 +1,8 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; @@ -34,6 +34,7 @@ export default { components: { GlAlert, GlLoadingIcon, + GlSprintf, GraphViewSelector, LocalStorageSync, PipelineGraph, @@ -62,6 +63,7 @@ export default { pipeline: null, skipRetryModal: false, showAlert: false, + showJobCountWarning: false, showLinks: false, }; }, @@ -104,7 +106,7 @@ export default { }, headerPipeline: { query: getPipelineQuery, - // this query is already being called in header_component.vue, which shares the same cache as this component + // this query is already being called in pipeline_details_header.vue, which shares the same cache as this component // the skip here is to prevent sending double network requests on page load skip() { return !this.canRefetchHeaderPipeline; @@ -166,7 +168,12 @@ export default { }, ); }, - result({ error }) { + result({ data, error }) { + const stages = data?.project?.pipeline?.stages?.nodes || []; + + this.showJobCountWarning = stages.some((stage) => { + return stage.groups.nodes.length >= 100; + }); /* If there is a successful load after a failure, clear the failure notification to avoid confusion. @@ -273,14 +280,38 @@ export default { this.currentViewType = type; }, }, + i18n: { + jobLimitWarning: { + title: s__('Pipeline|Only the first 100 jobs per stage are displayed'), + desc: s__('Pipeline|To see the remaining jobs, go to the %{boldStart}Jobs%{boldEnd} tab.'), + }, + }, viewTypeKey: VIEW_TYPE_KEY, }; </script> <template> <div> - <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> + <gl-alert + v-if="showAlert" + :variant="alert.variant" + data-testid="error-alert" + @dismiss="hideAlert" + > {{ alert.text }} </gl-alert> + <gl-alert + v-if="showJobCountWarning" + variant="warning" + :dismissible="false" + :title="$options.i18n.jobLimitWarning.title" + data-testid="job-count-warning" + > + <gl-sprintf :message="$options.i18n.jobLimitWarning.desc"> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </gl-alert> <local-storage-sync :storage-key="$options.viewTypeKey" :value="currentViewType" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 9b4e5d471d6..d8b843bdfb0 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -13,7 +13,7 @@ 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 CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '../../utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; @@ -22,7 +22,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - CiStatus, + CiIcon, GlBadge, GlButton, GlLink, @@ -240,7 +240,7 @@ export default { </gl-tooltip> <div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses"> <div class="gl-display-flex gl-gap-x-3"> - <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" css-classes="" /> + <ci-icon v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" /> <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> <div class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue deleted file mode 100644 index 27119419060..00000000000 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ /dev/null @@ -1,320 +0,0 @@ -<script> -import { - GlAlert, - GlButton, - GlLoadingIcon, - GlModal, - GlModalDirective, - GlTooltipDirective, -} from '@gitlab/ui'; -import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { __ } from '~/locale'; -import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import { - LOAD_FAILURE, - POST_FAILURE, - DELETE_FAILURE, - DEFAULT, - BUTTON_TOOLTIP_RETRY, - BUTTON_TOOLTIP_CANCEL, -} from '../constants'; -import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; -import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; -import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; -import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; -import { getQueryHeaders } from './graph/utils'; - -const DELETE_MODAL_ID = 'pipeline-delete-modal'; -const POLL_INTERVAL = 10000; - -export default { - name: 'PipelineHeaderSection', - BUTTON_TOOLTIP_RETRY, - BUTTON_TOOLTIP_CANCEL, - pipelineCancel: 'pipelineCancel', - pipelineRetry: 'pipelineRetry', - finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], - components: { - CiHeader, - GlAlert, - GlButton, - GlLoadingIcon, - GlModal, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - errorTexts: { - [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), - [POST_FAILURE]: __('An error occurred while making the request.'), - [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'), - [DEFAULT]: __('An unknown error occurred.'), - }, - inject: { - graphqlResourceEtag: { - default: '', - }, - paths: { - default: {}, - }, - pipelineId: { - default: '', - }, - pipelineIid: { - default: '', - }, - }, - modal: { - id: DELETE_MODAL_ID, - actionPrimary: { - text: __('Delete pipeline'), - attributes: { - variant: 'danger', - }, - }, - actionCancel: { - text: __('Cancel'), - }, - }, - apollo: { - pipeline: { - context() { - return getQueryHeaders(this.graphqlResourceEtag); - }, - query: getPipelineQuery, - variables() { - return { - fullPath: this.paths.fullProject, - iid: this.pipelineIid, - }; - }, - update: (data) => data.project.pipeline, - error() { - this.reportFailure(LOAD_FAILURE); - }, - pollInterval: POLL_INTERVAL, - watchLoading(isLoading) { - if (!isLoading) { - // To ensure apollo has updated the cache, - // we only remove the loading state in sync with GraphQL - this.isCanceling = false; - this.isRetrying = false; - } - }, - }, - }, - data() { - return { - pipeline: null, - failureMessages: [], - failureType: null, - isCanceling: false, - isRetrying: false, - isDeleting: false, - }; - }, - computed: { - deleteModalConfirmationText() { - return __( - 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', - ); - }, - hasError() { - return this.failureType; - }, - hasPipelineData() { - return Boolean(this.pipeline); - }, - isLoadingInitialQuery() { - return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; - }, - status() { - return this.pipeline?.status; - }, - isFinished() { - return this.$options.finishedStatuses.includes(this.status); - }, - shouldRenderContent() { - return !this.isLoadingInitialQuery && this.hasPipelineData; - }, - failure() { - switch (this.failureType) { - case LOAD_FAILURE: - return { - text: this.$options.errorTexts[LOAD_FAILURE], - variant: 'danger', - }; - case POST_FAILURE: - return { - text: this.$options.errorTexts[POST_FAILURE], - variant: 'danger', - }; - case DELETE_FAILURE: - return { - text: this.$options.errorTexts[DELETE_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT], - variant: 'danger', - }; - } - }, - canRetryPipeline() { - const { retryable, userPermissions } = this.pipeline; - - return retryable && userPermissions.updatePipeline; - }, - canCancelPipeline() { - const { cancelable, userPermissions } = this.pipeline; - - return cancelable && userPermissions.updatePipeline; - }, - }, - methods: { - reportFailure(errorType, errorMessages = []) { - this.failureType = errorType; - this.failureMessages = errorMessages; - }, - async postPipelineAction(name, mutation) { - try { - const { - data: { - [name]: { errors }, - }, - } = await this.$apollo.mutate({ - mutation, - variables: { id: this.pipeline.id }, - }); - - if (errors.length > 0) { - this.isRetrying = false; - - this.reportFailure(POST_FAILURE, errors); - } else { - await this.$apollo.queries.pipeline.refetch(); - if (!this.isFinished) { - this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); - } - } - } catch { - this.isRetrying = false; - - this.reportFailure(POST_FAILURE); - } - }, - cancelPipeline() { - this.isCanceling = true; - this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation); - }, - retryPipeline() { - this.isRetrying = true; - this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation); - }, - async deletePipeline() { - this.isDeleting = true; - this.$apollo.queries.pipeline.stopPolling(); - - try { - const { - data: { - pipelineDestroy: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: deletePipelineMutation, - variables: { - id: this.pipeline.id, - }, - }); - - if (errors.length > 0) { - this.reportFailure(DELETE_FAILURE, errors); - this.isDeleting = false; - } else { - redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated - } - } catch { - this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); - this.reportFailure(DELETE_FAILURE); - this.isDeleting = false; - } - }, - }, - DELETE_MODAL_ID, -}; -</script> -<template> - <div class="js-pipeline-header-container"> - <gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false"> - <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`"> - {{ failureMessage }} - </div> - </gl-alert> - <ci-header - v-if="shouldRenderContent" - :status="pipeline.detailedStatus" - :time="pipeline.createdAt" - :user="pipeline.user" - :item-id="pipelineId" - item-name="Pipeline" - > - <gl-button - v-if="canRetryPipeline" - v-gl-tooltip - :aria-label="$options.BUTTON_TOOLTIP_RETRY" - :title="$options.BUTTON_TOOLTIP_RETRY" - :loading="isRetrying" - :disabled="isRetrying" - variant="confirm" - data-testid="retryPipeline" - class="js-retry-button" - @click="retryPipeline()" - > - {{ __('Retry') }} - </gl-button> - - <gl-button - v-if="canCancelPipeline" - v-gl-tooltip - :aria-label="$options.BUTTON_TOOLTIP_CANCEL" - :title="$options.BUTTON_TOOLTIP_CANCEL" - :loading="isCanceling" - :disabled="isCanceling" - class="gl-ml-3" - variant="danger" - data-testid="cancelPipeline" - @click="cancelPipeline()" - > - {{ __('Cancel pipeline') }} - </gl-button> - - <gl-button - v-if="pipeline.userPermissions.destroyPipeline" - v-gl-modal="$options.modal.id" - :loading="isDeleting" - :disabled="isDeleting" - class="gl-ml-3" - variant="danger" - category="secondary" - data-testid="deletePipeline" - > - {{ __('Delete') }} - </gl-button> - </ci-header> - <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" /> - - <gl-modal - :modal-id="$options.modal.id" - :title="__('Delete pipeline')" - :action-primary="$options.modal.actionPrimary" - :action-cancel="$options.modal.actionCancel" - @primary="deletePipeline()" - > - <p> - {{ deleteModalConfirmationText }} - </p> - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue index 8fe6707028a..c53321f82bd 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue @@ -17,6 +17,7 @@ import { __, s__, sprintf, formatNumber } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { LOAD_FAILURE, @@ -30,7 +31,6 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; -import TimeAgo from './pipelines_list/time_ago.vue'; import { getQueryHeaders } from './graph/utils'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; @@ -54,7 +54,7 @@ export default { GlLoadingIcon, GlModal, GlSprintf, - TimeAgo, + TimeAgoTooltip, }, directives: { GlModal: GlModalDirective, @@ -84,12 +84,14 @@ export default { ), stuckBadgeText: s__('Pipelines|stuck'), stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'), - computeCreditsTooltip: s__('Pipelines|Total amount of compute credits used for the pipeline'), + computeMinutesTooltip: s__('Pipelines|Total amount of compute minutes used for the pipeline'), totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'), retryPipelineText: __('Retry'), cancelPipelineText: __('Cancel pipeline'), deletePipelineText: __('Delete'), clipboardTooltip: __('Copy commit SHA'), + createdText: s__('Pipelines|created'), + finishedText: s__('Pipelines|finished'), }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), @@ -135,7 +137,7 @@ export default { required: false, default: '', }, - computeCredits: { + computeMinutes: { type: String, required: false, default: '', @@ -310,8 +312,8 @@ export default { return cancelable && userPermissions.updatePipeline; }, - showComputeCredits() { - return this.isFinished && this.computeCredits !== '0.0'; + showComputeMinutes() { + return this.isFinished && this.computeMinutes !== '0.0'; }, }, methods: { @@ -387,7 +389,7 @@ export default { </script> <template> - <div class="gl-my-4"> + <div class="gl-my-4" data-testid="pipeline-details-header"> <gl-alert v-if="hasError" class="gl-mb-4" @@ -402,17 +404,17 @@ export default { <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> <div v-else - class="gl-display-flex gl-justify-content-space-between" + class="gl-display-flex gl-justify-content-space-between gl-flex-wrap" data-qa-selector="pipeline_details_header" > <div> - <h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3> - <h3 v-else class="gl-mt-0 gl-mb-2" data-testid="pipeline-commit-title"> + <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3> + <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title"> {{ commitTitle }} </h3> <div> <ci-badge-link :status="detailedStatus" /> - <div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6"> + <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6"> <gl-link v-if="user" :href="user.webUrl" @@ -441,16 +443,17 @@ export default { :title="$options.i18n.clipboardTooltip" size="small" /> - <time-ago - v-if="isFinished" - :pipeline="pipeline" - class="gl-display-inline gl-mb-0" - :display-calendar-icon="false" - font-size="gl-font-md" - /> + <span v-if="inProgress" data-testid="pipeline-created-time-ago"> + {{ $options.i18n.createdText }} + <time-ago-tooltip :time="pipeline.createdAt" /> + </span> + <span v-if="isFinished" data-testid="pipeline-finished-time-ago"> + {{ $options.i18n.finishedText }} + <time-ago-tooltip :time="pipeline.finishedAt" /> + </span> </div> </div> - <div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div> + <div v-safe-html="refText" class="gl-mb-3" data-testid="pipeline-ref-text"></div> <div> <gl-badge v-if="badges.schedule" @@ -527,7 +530,6 @@ export default { :title="$options.i18n.detachedBadgeTooltip" variant="info" size="sm" - data-qa-selector="merge_request_badge_tag" > {{ $options.i18n.detachedBadgeText }} </gl-badge> @@ -550,14 +552,14 @@ export default { {{ totalJobsText }} </span> <span - v-if="showComputeCredits" + v-if="showComputeMinutes" v-gl-tooltip - :title="$options.i18n.computeCreditsTooltip" + :title="$options.i18n.computeMinutesTooltip" class="gl-ml-2" - data-testid="compute-credits" + data-testid="compute-minutes" > <gl-icon name="quota" /> - {{ computeCredits }} + {{ computeMinutes }} </span> <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> <gl-icon name="timer" /> @@ -569,7 +571,7 @@ export default { </span> </div> </div> - <div> + <div class="gl-mt-5 gl-lg-mt-0"> <gl-button v-if="canRetryPipeline" v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue index 8ff311e90e7..5208f9a3ce7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue @@ -2,7 +2,7 @@ import { GlButton, GlCard, GlSprintf, GlLink, GlPopover, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams, DOCS_URL } from '~/lib/utils/url_utility'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import apolloProvider from '~/pipelines/graphql/provider'; import CiTemplates from './ci_templates.vue'; @@ -31,7 +31,7 @@ export default { apolloProvider, iOSTemplateName: 'iOS-Fastlane', modalId: 'runner-instructions-modal', - runnerDocsLink: 'https://docs.gitlab.com/runner/install/osx', + runnerDocsLink: `${DOCS_URL}/runner/install/osx`, whatElseLink: helpPagePath('ci/index.md'), i18n: { title: s__('Pipelines|Get started with GitLab CI/CD'), diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue index e40e30f2b8d..6b5e3d77b92 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue @@ -1,16 +1,20 @@ <script> -import { GlCollapse, GlIcon, GlLink } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { GlButton, GlCollapse, GlIcon, GlLink, GlTooltip } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import RetryMrFailedJobMutation from '../../../graphql/mutations/retry_mr_failed_job.mutation.graphql'; export default { components: { CiIcon, + GlButton, GlCollapse, GlIcon, GlLink, + GlTooltip, }, directives: { SafeHtml, @@ -23,14 +27,21 @@ export default { }, data() { return { - isJobLogVisible: false, isHovered: false, + isJobLogVisible: false, + isLoadingAction: false, }; }, computed: { activeClass() { return this.isHovered ? 'gl-bg-gray-50' : ''; }, + canReadBuild() { + return this.job.userPermissions.readBuild; + }, + canRetryJob() { + return this.job.retryable && this.job.userPermissions.updateBuild; + }, isVisibleId() { return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`; }, @@ -38,7 +49,11 @@ export default { return this.isJobLogVisible ? 'chevron-down' : 'chevron-right'; }, jobTrace() { - return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + if (this.canReadBuild) { + return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + } + + return this.$options.i18n.cannotReadBuild; }, parsedJobId() { return getIdFromGraphQLId(this.job.id); @@ -54,18 +69,45 @@ export default { resetActiveRow() { this.isHovered = false; }, - toggleJobLog(e) { + async retryJob() { + try { + this.isLoadingAction = true; + + const { + data: { + jobRetry: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: RetryMrFailedJobMutation, + variables: { id: this.job.id }, + }); + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + this.$emit('job-retried', this.job.name); + } catch (error) { + createAlert({ message: error?.message || this.$options.i18n.retryError }); + } finally { + this.isLoadingAction = false; + } + }, + toggleJobLog(event) { // Do not toggle the log visibility when clicking on a link - if (e.target.tagName === 'A') { + if (event.target.tagName === 'A') { return; } - this.isJobLogVisible = !this.isJobLogVisible; }, }, i18n: { + cannotReadBuild: s__("Job|You do not have permission to read this job's log"), + cannotRetry: s__('Job|You do not have permission to retry this job'), jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'), noTraceText: s__('Job|No job log'), + retry: __('Retry'), + retryError: __('There was an error while retrying this job'), }, }; </script> @@ -93,6 +135,21 @@ export default { <div class="col-2 gl-text-left"> <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link> </div> + <gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top"> + {{ $options.i18n.cannotRetry }} + </gl-tooltip> + <div class="col-2 gl-text-left"> + <span ref="retryBtn"> + <gl-button + :disabled="!canRetryJob" + icon="retry" + :loading="isLoadingAction" + :title="$options.i18n.retry" + :aria-label="$options.i18n.retry" + @click.stop="retryJob" + /> + </span> + </div> </div> <div class="row"> <gl-collapse :visible="isJobLogVisible" class="gl-w-full"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue new file mode 100644 index 00000000000..36687129cdd --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue @@ -0,0 +1,166 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; +import { getQueryHeaders } from '~/pipelines/components/graph/utils'; +import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { graphqlEtagPipelinePath, sortJobsByStatus } from './utils'; +import FailedJobDetails from './failed_job_details.vue'; + +const POLL_INTERVAL = 10000; + +const JOB_ACTION_HEADER = __('Actions'); +const JOB_ID_HEADER = __('Job ID'); +const JOB_NAME_HEADER = __('Job name'); +const STAGE_HEADER = __('Stage'); + +export default { + components: { + GlLoadingIcon, + FailedJobDetails, + }, + inject: ['fullPath', 'graphqlPath'], + props: { + isPipelineActive: { + required: true, + type: Boolean, + }, + pipelineIid: { + type: Number, + required: true, + }, + }, + data() { + return { + failedJobs: [], + isActive: false, + isLoadingMore: false, + }; + }, + apollo: { + failedJobs: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineFailedJobs, + pollInterval: POLL_INTERVAL, + variables() { + return { + fullPath: this.fullPath, + pipelineIid: this.pipelineIid, + }; + }, + update(data) { + const jobs = data?.project?.pipeline?.jobs?.nodes || []; + return sortJobsByStatus(jobs); + }, + result({ data }) { + const pipeline = data?.project?.pipeline; + + if (pipeline?.jobs?.count) { + this.$emit('failed-jobs-count', pipeline.jobs.count); + this.isActive = pipeline.active; + } + }, + error(e) { + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + }, + }, + }, + computed: { + graphqlResourceEtag() { + return graphqlEtagPipelinePath(this.graphqlPath, this.pipelineIid); + }, + hasFailedJobs() { + return this.failedJobs.length > 0; + }, + isInitialLoading() { + return this.isLoading && !this.isLoadingMore; + }, + isLoading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, + watch: { + isPipelineActive(flag) { + // Turn polling on and off based on REST actions + // By refetching jobs, we will get the graphql `active` + // field to update properly and cascade the polling changes + this.refetchJobs(); + this.handlePolling(flag); + }, + isActive(flag) { + this.handlePolling(flag); + }, + }, + mounted() { + if (!this.isActive && !this.isPipelineActive) { + this.handlePolling(false); + } + }, + methods: { + handlePolling(isActive) { + // If the pipeline status has changed and the widget is not expanded, + // We start polling. + if (isActive) { + this.$apollo.queries.failedJobs.startPolling(POLL_INTERVAL); + } else { + this.$apollo.queries.failedJobs.stopPolling(); + } + }, + async retryJob(jobName) { + await this.refetchJobs(); + + this.$toast.show(sprintf(this.$options.i18n.retriedJobsSuccess, { jobName })); + }, + async refetchJobs() { + this.isLoadingMore = true; + + try { + await this.$apollo.queries.failedJobs.refetch(); + } catch { + createAlert(this.$options.i18n.fetchError); + } finally { + this.isLoadingMore = false; + } + }, + }, + columns: [ + { text: JOB_NAME_HEADER, class: 'col-6' }, + { text: STAGE_HEADER, class: 'col-2' }, + { text: JOB_ID_HEADER, class: 'col-2' }, + { text: JOB_ACTION_HEADER, class: 'col-2' }, + ], + i18n: { + fetchError: __('There was a problem fetching failed jobs'), + noFailedJobs: s__('Pipeline|No failed jobs in this pipeline 🎉'), + retriedJobsSuccess: __('%{jobName} job is being retried'), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isInitialLoading" /> + <div v-else-if="!hasFailedJobs">{{ $options.i18n.noFailedJobs }}</div> + <div v-else class="container-fluid gl-grid-tpl-rows-auto"> + <div class="row gl-mb-6 gl-text-gray-900"> + <div + v-for="col in $options.columns" + :key="col.text" + class="gl-font-weight-bold gl-text-left" + :class="col.class" + data-testid="header" + > + {{ col.text }} + </div> + </div> + </div> + <failed-job-details + v-for="job in failedJobs" + :key="job.id" + :job="job" + @job-retried="retryJob" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue index fce0b5f525e..5e49c05f47d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue @@ -1,22 +1,7 @@ <script> -import { - GlButton, - GlCollapse, - GlIcon, - GlLink, - GlLoadingIcon, - GlPopover, - GlSprintf, -} from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { __, s__ } from '~/locale'; -import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; -import WidgetFailedJobRow from './widget_failed_job_row.vue'; -import { sortJobsByStatus } from './utils'; - -const JOB_ID_HEADER = __('Job ID'); -const JOB_NAME_HEADER = __('Job name'); -const STAGE_HEADER = __('Stage'); +import { GlButton, GlCollapse, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import FailedJobsList from './failed_jobs_list.vue'; export default { components: { @@ -24,13 +9,20 @@ export default { GlCollapse, GlIcon, GlLink, - GlLoadingIcon, GlPopover, GlSprintf, - WidgetFailedJobRow, + FailedJobsList, }, inject: ['fullPath'], props: { + failedJobsCount: { + required: true, + type: Number, + }, + isPipelineActive: { + required: true, + type: Boolean, + }, pipelineIid: { required: true, type: Number, @@ -42,62 +34,44 @@ export default { }, data() { return { - failedJobs: [], + currentFailedJobsCount: this.failedJobsCount, + isActive: false, isExpanded: false, }; }, - apollo: { - failedJobs: { - query: getPipelineFailedJobs, - skip() { - return !this.isExpanded; - }, - variables() { - return { - fullPath: this.fullPath, - pipelineIid: this.pipelineIid, - }; - }, - update(data) { - const jobs = data?.project?.pipeline?.jobs?.nodes || []; - return sortJobsByStatus(jobs); - }, - error(e) { - createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); - }, - }, - }, computed: { bodyClasses() { return this.isExpanded ? '' : 'gl-display-none'; }, - failedJobsCount() { - return this.failedJobs.length; + failedJobsCountText() { + return sprintf(this.$options.i18n.showFailedJobs, { count: this.currentFailedJobsCount }); }, iconName() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, - isLoading() { - return this.$apollo.queries.failedJobs.loading; + popoverId() { + return `popover-${this.pipelineIid}`; + }, + }, + watch: { + failedJobsCount(val) { + this.currentFailedJobsCount = val; }, }, methods: { + setFailedJobsCount(count) { + this.currentFailedJobsCount = count; + }, toggleWidget() { this.isExpanded = !this.isExpanded; }, }, - columns: [ - { text: JOB_NAME_HEADER, class: 'col-6' }, - { text: STAGE_HEADER, class: 'col-2' }, - { text: JOB_ID_HEADER, class: 'col-2' }, - ], i18n: { additionalInfoPopover: s__( 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.', ), additionalInfoTitle: __('Limitation on this view'), - fetchError: __('There was a problem fetching failed jobs'), - showFailedJobs: __('Show failed jobs'), + showFailedJobs: __('Show failed jobs (%{count})'), }, }; </script> @@ -105,9 +79,9 @@ export default { <div class="gl-border-none!"> <gl-button variant="link" @click="toggleWidget"> <gl-icon :name="iconName" /> - {{ $options.i18n.showFailedJobs }} - <gl-icon id="target" name="information-o" /> - <gl-popover target="target" placement="top"> + {{ failedJobsCountText }} + <gl-icon :id="popoverId" name="information-o" /> + <gl-popover :target="popoverId" placement="top"> <template #title> {{ $options.i18n.additionalInfoTitle }} </template> <slot> <gl-sprintf :message="$options.i18n.additionalInfoPopover"> @@ -118,26 +92,16 @@ export default { </slot> </gl-popover> </gl-button> - <gl-loading-icon v-if="isLoading" /> <gl-collapse - v-else v-model="isExpanded" class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3" > - <div class="container-fluid gl-grid-tpl-rows-auto"> - <div class="row gl-mb-6 gl-text-gray-900"> - <div - v-for="col in $options.columns" - :key="col.text" - class="gl-font-weight-bold gl-text-left" - :class="col.class" - data-testid="header" - > - {{ col.text }} - </div> - </div> - </div> - <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" /> + <failed-jobs-list + v-if="isExpanded" + :is-pipeline-active="isPipelineActive" + :pipeline-iid="pipelineIid" + @failed-jobs-count="setFailedJobsCount" + /> </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js index 3f395fff7e0..2d0c467c54f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js @@ -13,3 +13,7 @@ export const sortJobsByStatus = (jobs = []) => { return 1; }); }; + +export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => { + return `${graphqlPath}pipelines/id/${pipelineId}`; +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 7d0cea67099..4452db64b0a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -1,10 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export const i18n = { @@ -18,9 +13,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, + GlDisclosureDropdown, }, inject: { artifactsEndpoint: { @@ -42,6 +35,21 @@ export default { }, }, computed: { + items() { + return [ + { + name: this.$options.i18n.artifactSectionHeader, + items: this.artifacts.map(({ name, path }) => ({ + text: name, + href: path, + extraAttrs: { + download: '', + rel: 'nofollow', + }, + })), + }, + ]; + }, shouldShowDropdown() { return this.artifacts?.length; }, @@ -49,31 +57,16 @@ export default { }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown v-if="shouldShowDropdown" v-gl-tooltip class="build-artifacts js-pipeline-dropdown-download" :title="$options.i18n.artifacts" - :text="$options.i18n.artifacts" + :toggle-text="$options.i18n.artifacts" :aria-label="$options.i18n.artifacts" icon="download" - right - lazy + placement="right" text-sr-only - > - <gl-dropdown-section-header>{{ - $options.i18n.artifactSectionHeader - }}</gl-dropdown-section-header> - - <gl-dropdown-item - v-for="(artifact, i) in artifacts" - :key="i" - :href="artifact.path" - rel="nofollow" - download - class="gl-word-break-word" - > - {{ artifact.name }} - </gl-dropdown-item> - </gl-dropdown> + :items="items" + /> </template> 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 d884935d95b..dbb0b443235 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -71,6 +71,9 @@ export default { }; }, computed: { + showFailedJobsWidget() { + return this.glFeatures.ciJobFailuresInMr; + }, tableFields() { return [ { @@ -143,17 +146,14 @@ export default { const downstream = pipeline.triggered; return keepLatestDownstreamPipelines(downstream); }, - hasFailedJobs(pipeline) { - return pipeline?.failed_builds?.length > 0 || false; + failedJobsCount(pipeline) { + return pipeline?.failed_builds?.length || 0; }, setModalData(data) { this.pipelineId = data.pipeline.id; this.pipeline = data.pipeline; this.endpoint = data.endpoint; }, - showFailedJobsWidget(item) { - return this.glFeatures.ciJobFailuresInMr && this.hasFailedJobs(item); - }, onSubmit() { eventHub.$emit('postAction', this.endpoint); this.cancelingPipeline = this.pipelineId; @@ -220,7 +220,9 @@ export default { <template #row-details="{ item }"> <pipeline-failed-jobs-widget - v-if="showFailedJobsWidget(item)" + v-if="showFailedJobsWidget" + :failed-jobs-count="failedJobsCount(item)" + :is-pipeline-active="item.active" :pipeline-iid="item.iid" :pipeline-path="item.path" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index bdecbb88a58..70343544638 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -14,11 +14,6 @@ export default { type: Object, required: true, }, - displayCalendarIcon: { - type: Boolean, - required: false, - default: true, - }, fontSize: { type: String, required: false, @@ -50,13 +45,7 @@ export default { </p> <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center"> - <gl-icon - v-if="displayCalendarIcon" - name="calendar" - class="gl-mr-2" - :size="12" - data-testid="calendar-icon" - /> + <gl-icon name="calendar" class="gl-mr-2" :size="12" data-testid="calendar-icon" /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue index e9f7874d3e4..3e7827dc416 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue @@ -43,7 +43,7 @@ export default { }; }, testReportDocPath() { - return helpPagePath('ci/unit_test_reports'); + return helpPagePath('ci/testing/unit_test_reports'); }, }, }; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 2974bd2dd37..19318cb0c8b 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -66,7 +66,7 @@ export default { }, wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], i18n, - learnMorePath: helpPagePath('ci/unit_test_reports', { + learnMorePath: helpPagePath('ci/testing/unit_test_reports', { anchor: 'viewing-unit-test-reports-on-gitlab', }), }; diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql new file mode 100644 index 00000000000..022d461dbec --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql @@ -0,0 +1,5 @@ +mutation retryMrFailedJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql index 2c842f1ac77..3d69c5e451b 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql @@ -3,7 +3,9 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { id pipeline(iid: $pipelineIid) { id + active jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + count nodes { id allowFailure @@ -19,6 +21,7 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { } name retried + retryable stage { id name @@ -26,6 +29,10 @@ query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { trace { htmlSummary } + userPermissions { + readBuild + updateBuild + } webPath } } diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql new file mode 100644 index 00000000000..b70e95deab6 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs_count.query.graphql @@ -0,0 +1,12 @@ +query getPipelineFailedJobsCount($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + active + jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + count + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 5b9bfd53b13..f9c027539f2 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,20 +2,18 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { pipelineTabName } from './constants'; -import { createPipelineHeaderApp, createPipelineDetailsHeaderApp } from './pipeline_details_header'; +import { createPipelineDetailsHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; const SELECTORS = { - PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_DETAILS_HEADER: '#js-pipeline-details-header-vue', PIPELINE_TABS: '#js-pipeline-tabs', }; -export default async function initPipelineDetailsBundle(flagEnabled) { - const headerSelector = flagEnabled - ? SELECTORS.PIPELINE_DETAILS_HEADER - : SELECTORS.PIPELINE_HEADER; - const headerApp = flagEnabled ? createPipelineDetailsHeaderApp : createPipelineHeaderApp; +export default async function initPipelineDetailsBundle() { + const headerSelector = SELECTORS.PIPELINE_DETAILS_HEADER; + + const headerApp = createPipelineDetailsHeaderApp; const headerEl = document.querySelector(headerSelector); diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index 807ef225edd..c79aaef23e8 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -1,41 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; -import PipelineHeader from './components/header_component.vue'; import PipelineDetailsHeader from './components/pipeline_details_header.vue'; Vue.use(VueApollo); -export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { - const el = document.querySelector(elSelector); - - if (!el) { - return; - } - - const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el.dataset; - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - PipelineHeader, - }, - apolloProvider, - provide: { - paths: { - fullProject: fullPath, - graphqlResourceEtag, - pipelinesPath, - }, - pipelineId, - pipelineIid, - }, - render(createElement) { - return createElement('pipeline-header', {}); - }, - }); -}; - export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { const el = document.querySelector(elSelector); @@ -49,7 +18,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph pipelinesPath, name, totalJobs, - computeCredits, + computeMinutes, yamlErrors, failureReason, triggeredByPath, @@ -84,7 +53,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph props: { name, totalJobs, - computeCredits, + computeMinutes, yamlErrors, failureReason, refText, diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index c64fbc91d12..915f6578ac3 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import csrf from '~/lib/utils/csrf'; import { __, s__ } from '~/locale'; @@ -8,6 +9,7 @@ export default { GlModal, GlSprintf, }, + mixins: [glFeatureFlagMixin()], props: { actionUrl: { type: String, @@ -67,6 +69,9 @@ export default { }, }, i18n: { + textdelay: s__(`Profiles| +You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. +Once you confirm %{deleteAccount}, it cannot be undone or recovered. You might have to wait seven days before creating a new account with the same username or email.`), text: s__(`Profiles| You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), @@ -85,7 +90,16 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), @primary="onSubmit" > <p> - <gl-sprintf :message="$options.i18n.text"> + <gl-sprintf v-if="glFeatures.delayDeleteOwnUser" :message="$options.i18n.textdelay"> + <template #yourAccount> + <strong>{{ s__('Profiles|your account') }}</strong> + </template> + + <template #deleteAccount> + <strong>{{ s__('Profiles|Delete account') }}</strong> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="$options.i18n.text"> <template #yourAccount> <strong>{{ s__('Profiles|your account') }}</strong> </template> diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index d96b5748abc..ae017d2a299 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -115,7 +115,7 @@ Please update your Git repository remotes as soon as possible.`), v-model="newUsername" data-testid="new-username-input" :disabled="isRequestPending" - class="form-control" + class="form-control gl-md-form-input-lg" required="required" /> </div> diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue index 7bab8a1c30d..2673ab6fbf4 100644 --- a/app/assets/javascripts/profile/components/follow.vue +++ b/app/assets/javascripts/profile/components/follow.vue @@ -1,7 +1,14 @@ <script> -import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlAvatarLink, + GlLoadingIcon, + GlPagination, + GlEmptyState, +} from '@gitlab/ui'; import { DEFAULT_PER_PAGE } from '~/api'; import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; +import { isCurrentUser } from '~/lib/utils/common_utils'; export default { i18n: { @@ -13,7 +20,9 @@ export default { GlAvatarLink, GlLoadingIcon, GlPagination, + GlEmptyState, }, + inject: ['followEmptyState', 'userId'], props: { /** * Expected format: @@ -48,12 +57,34 @@ export default { required: false, default: DEFAULT_PER_PAGE, }, + currentUserEmptyStateTitle: { + type: String, + required: true, + }, + visitorEmptyStateTitle: { + type: String, + required: true, + }, + }, + computed: { + emptyStateTitle() { + return isCurrentUser(this.userId) + ? this.currentUserEmptyStateTitle + : this.visitorEmptyStateTitle; + }, }, }; </script> <template> <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <gl-empty-state + v-else-if="!users.length" + class="gl-mt-5" + :svg-path="followEmptyState" + :svg-height="144" + :title="emptyStateTitle" + /> <div v-else> <div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap"> <div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p"> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue index 1fa579bc611..927424d6c3f 100644 --- a/app/assets/javascripts/profile/components/followers_tab.vue +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -12,6 +12,8 @@ export default { errorMessage: s__( 'UserProfile|An error occurred loading the followers. Please refresh the page to try again.', ), + currentUserEmptyStateTitle: s__('UserProfile|You do not have any followers'), + visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any followers"), }, components: { GlBadge, @@ -68,6 +70,8 @@ export default { :loading="loading" :page="page" :total-items="totalItems" + :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle" + :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle" @pagination-input="onPaginationInput" /> </gl-tab> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue index 8ee878e3dcc..66c7ee42a3f 100644 --- a/app/assets/javascripts/profile/components/following_tab.vue +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -1,16 +1,62 @@ <script> import { GlBadge, GlTab } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getUserFollowing } from '~/rest_api'; +import { createAlert } from '~/alert'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import Follow from './follow.vue'; export default { i18n: { title: s__('UserProfile|Following'), + errorMessage: s__( + 'UserProfile|An error occurred loading the following. Please refresh the page to try again.', + ), + currentUserEmptyStateTitle: s__('UserProfile|You are not following other users'), + visitorEmptyStateTitle: s__("UserProfile|This user isn't following other users"), }, components: { GlBadge, GlTab, + Follow, + }, + inject: ['followeesCount', 'userId'], + data() { + return { + following: [], + loading: true, + totalItems: 0, + page: 1, + }; + }, + watch: { + page: { + async handler() { + this.loading = true; + + try { + const { data: following, headers } = await getUserFollowing(this.userId, { + page: this.page, + }); + + const { total } = parseIntPagination(normalizeHeaders(headers)); + + this.following = following; + this.totalItems = total; + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + immediate: true, + }, + }, + methods: { + onPaginationInput(page) { + this.page = page; + }, }, - inject: ['followeesCount'], }; </script> @@ -20,5 +66,14 @@ export default { <span>{{ $options.i18n.title }}</span> <gl-badge size="sm" class="gl-ml-2">{{ followeesCount }}</gl-badge> </template> + <follow + :users="following" + :loading="loading" + :page="page" + :total-items="totalItems" + :current-user-empty-state-title="$options.i18n.currentUserEmptyStateTitle" + :visitor-empty-state-title="$options.i18n.visitorEmptyStateTitle" + @pagination-input="onPaginationInput" + /> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue index 3a30c3bdc9b..e24167eb4fa 100644 --- a/app/assets/javascripts/profile/components/profile_tabs.vue +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -5,6 +5,7 @@ import { getUserProjects } from '~/rest_api'; import { s__ } from '~/locale'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; +import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; import OverviewTab from './overview_tab.vue'; import ActivityTab from './activity_tab.vue'; import GroupsTab from './groups_tab.vue'; @@ -81,7 +82,21 @@ export default { async mounted() { try { const response = await getUserProjects(this.userId, { per_page: 10 }); - this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }); + this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }).map( + (project) => { + // This API does not return the `visibility` key if user is signed out. + // Because this API only returns public projects when signed out, in this case, we can assume + // the `visibility` attribute is `public` if it is missing. + if (!project.visibility) { + return { + ...project, + visibility: VISIBILITY_LEVEL_PUBLIC_STRING, + }; + } + + return project; + }, + ); this.personalProjectsLoading = false; } catch (error) { createAlert({ message: this.$options.i18n.personalProjectsErrorMessage }); diff --git a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue index fce5e2f5e78..95649f9645b 100644 --- a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue +++ b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue @@ -1,9 +1,11 @@ <script> import { GlTab, GlKeysetPagination, GlEmptyState } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants'; +import { isCurrentUser } from '~/lib/utils/common_utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; import getUserSnippets from '../graphql/get_user_snippets.query.graphql'; import SnippetRow from './snippet_row.vue'; @@ -11,7 +13,11 @@ export default { name: 'SnippetsTab', i18n: { title: s__('UserProfile|Snippets'), - noSnippets: s__('UserProfiles|No snippets found.'), + currentUserEmptyStateTitle: s__('UserProfile|Get started with snippets'), + visitorEmptyStateTitle: s__("UserProfile|This user doesn't have any snippets"), + emptyStateDescription: s__('UserProfile|Store, share, and embed bits of code and text.'), + newSnippet: __('New snippet'), + learnMore: __('Learn more'), }, components: { GlTab, @@ -19,7 +25,7 @@ export default { GlEmptyState, SnippetRow, }, - inject: ['userId', 'snippetsEmptyState'], + inject: ['userId', 'snippetsEmptyState', 'newSnippetPath'], data() { return { userInfo: {}, @@ -57,6 +63,14 @@ export default { hasSnippets() { return this.userSnippets?.length; }, + emptyStateTitle() { + return isCurrentUser(this.userId) + ? this.$options.i18n.currentUserEmptyStateTitle + : this.$options.i18n.visitorEmptyStateTitle; + }, + emptyStateDescription() { + return isCurrentUser(this.userId) ? this.$options.i18n.emptyStateDescription : null; + }, }, methods: { isLastSnippet(index) { @@ -76,6 +90,7 @@ export default { beforeToken: this.pageInfo.startCursor, }; }, + helpPagePath, }, }; </script> @@ -100,11 +115,17 @@ export default { </div> </template> <template v-if="!hasSnippets"> - <gl-empty-state class="gl-mt-5" :svg-height="75" :svg-path="snippetsEmptyState"> - <template #title> - <p class="gl-font-weight-bold gl-mt-n5">{{ $options.i18n.noSnippets }}</p> - </template> - </gl-empty-state> + <gl-empty-state + class="gl-mt-5" + :svg-path="snippetsEmptyState" + :svg-height="144" + :title="emptyStateTitle" + :description="emptyStateDescription" + :primary-button-link="newSnippetPath" + :primary-button-text="$options.i18n.newSnippet" + :secondary-button-text="$options.i18n.learnMore" + :secondary-button-link="helpPagePath('user/snippets')" + /> </template> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue index 13a1b797a83..7ce6b61c4ac 100644 --- a/app/assets/javascripts/profile/components/user_achievements.vue +++ b/app/assets/javascripts/profile/components/user_achievements.vue @@ -85,7 +85,7 @@ export default { :size="32" tabindex="0" shape="rect" - class="gl-mx-2" + class="gl-mx-2 gl-p-1 gl-border-none" /> <br /> <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{ diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index 198ffdb434b..76430d7b34d 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -21,6 +21,8 @@ export const initProfileTabs = () => { utcOffset, userId, snippetsEmptyState, + newSnippetPath, + followEmptyState, } = el.dataset; const apolloProvider = new VueApollo({ @@ -39,6 +41,8 @@ export const initProfileTabs = () => { utcOffset, userId, snippetsEmptyState, + newSnippetPath, + followEmptyState, }, render(createElement) { return createElement(ProfileTabs); diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 164ec46cdb9..aa30192b74b 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -110,34 +110,33 @@ export default { </script> <template> - <div class="row gl-mt-3 js-preferences-form js-search-settings-section"> - <div v-if="integrationViews.length" class="col-sm-12"> - <hr data-testid="profile-preferences-integrations-rule" /> - </div> - <div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar"> - <h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading"> - {{ $options.i18n.integrations }} - </h4> - <p> + <div class="gl-display-contents js-preferences-form"> + <div + v-if="integrationViews.length" + class="settings-section gl-border-t gl-pt-6! js-search-settings-section" + > + <div class="settings-sticky-header"> + <div class="settings-sticky-header-inner"> + <h4 class="gl-my-0" data-testid="profile-preferences-integrations-heading"> + {{ $options.i18n.integrations }} + </h4> + </div> + </div> + <p class="gl-text-secondary"> {{ $options.i18n.integrationsDescription }} </p> + <div> + <integration-view + v-for="view in integrationViews" + :key="view.name" + :help-link="view.help_link" + :message="view.message" + :message-url="view.message_url" + :config="$options.integrationViewConfigs[view.name]" + /> + </div> </div> - <div v-if="integrationViews.length" class="col-lg-8"> - <integration-view - v-for="view in integrationViews" - :key="view.name" - :help-link="view.help_link" - :message="view.message" - :message-url="view.message_url" - :config="$options.integrationViewConfigs[view.name]" - /> - </div> - - <div class="col-lg-4"></div> - <div class="col-lg-8"> - <hr /> - </div> - <div class="col-sm-12 js-hide-when-nothing-matches-search"> + <div class="settings-sticky-footer js-hide-when-nothing-matches-search"> <gl-button category="primary" variant="confirm" diff --git a/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue index e026b3e1060..750eb5836e1 100644 --- a/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue +++ b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue @@ -10,6 +10,7 @@ export default { }, inject: ['message'], docsLink: helpPagePath('user/infrastructure/clusters/migrate_to_gitlab_agent.md'), + deprecationEpic: 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8', }; </script> <template> @@ -18,6 +19,9 @@ export default { <template #link="{ content }"> <gl-link :href="$options.docsLink">{{ content }}</gl-link> </template> + <template #deprecationLink="{ content }"> + <gl-link :href="$options.deprecationEpic">{{ content }}</gl-link> + </template> </gl-sprintf> </gl-alert> </template> diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 2966214e051..cf251bc7465 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -1,27 +1,18 @@ <script> -import { - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, - GlDropdownDivider, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox, GlTooltipDirective } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { mapState, mapActions } from 'vuex'; -import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { __ } from '~/locale'; +import { mapActions, mapState } from 'vuex'; +import { queryToObject, visitUrl } from '~/lib/utils/url_utility'; +import { n__, __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; const tooltipMessage = __('Searching by both author and message is currently not supported.'); export default { name: 'AuthorSelect', components: { - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, - GlDropdownDivider, + GlAvatar, + GlCollapsibleListbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,9 +26,9 @@ export default { data() { return { hasSearchParam: false, - searchTerm: '', - authorInput: '', currentAuthor: '', + searchTerm: '', + searching: false, }; }, computed: { @@ -45,9 +36,33 @@ export default { dropdownText() { return this.currentAuthor || __('Author'); }, + dropdownItems() { + const commitAuthorOptions = this.commitsAuthors.map((author) => ({ + value: author.name, + text: author.name, + secondaryText: author.username, + avatarUrl: author.avatar_url, + })); + if (this.searchTerm) return commitAuthorOptions; + + const defaultOptions = { + text: '', + options: [{ text: __('Any Author'), value: '' }], + textSrOnly: true, + }; + const authorOptionsGroup = { + text: 'authors', + options: commitAuthorOptions, + textSrOnly: true, + }; + return [defaultOptions, authorOptionsGroup]; + }, tooltipTitle() { return this.hasSearchParam && tooltipMessage; }, + searchSummarySrText() { + return n__('%d author', '%d authors', this.commitsAuthors.length); + }, }, mounted() { this.fetchAuthors(); @@ -73,9 +88,7 @@ export default { }, methods: { ...mapActions(['fetchAuthors']), - selectAuthor(author) { - const { name: user } = author || {}; - + selectAuthor(user) { // Follow up issue "Remove usage of $.fadeIn from the codebase" // > https://gitlab.com/gitlab-org/gitlab/-/issues/214395 @@ -89,13 +102,19 @@ export default { commitListElement.style.transition = 'opacity 200ms'; if (!user) { - return redirectTo(this.commitsPath); // eslint-disable-line import/no-deprecated + return visitUrl(this.commitsPath); } - return redirectTo(`${this.commitsPath}?author=${user}`); // eslint-disable-line import/no-deprecated + return visitUrl(`${this.commitsPath}?author=${user}`); }, - searchAuthors() { - this.fetchAuthors(this.authorInput); + searchAuthors: debounce(async function debouncedSearch() { + this.searching = true; + await this.fetchAuthors(this.searchTerm); + this.searching = false; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + handleSearch(input) { + this.searchTerm = input; + this.searchAuthors(); }, setSearchParam(value) { this.hasSearchParam = Boolean(value); @@ -105,36 +124,45 @@ export default { </script> <template> - <div ref="dropdownContainer" v-gl-tooltip :title="tooltipTitle" :disabled="!hasSearchParam"> - <gl-dropdown - :text="dropdownText" + <div ref="listboxContainer" v-gl-tooltip :title="tooltipTitle" :disabled="!hasSearchParam"> + <gl-collapsible-listbox + v-model="currentAuthor" + block + is-check-centered + searchable + class="gl-mt-3 gl-sm-mt-0" + :items="dropdownItems" + :header-text="__('Search by author')" + :toggle-text="dropdownText" + :search-placeholder="__('Search')" + :searching="searching" :disabled="hasSearchParam" - toggle-class="gl-py-3 gl-border-0" - class="w-100 gl-mt-3 mt-sm-0" + @search="handleSearch" + @select="selectAuthor" > - <gl-dropdown-section-header> - {{ __('Search by author') }} - </gl-dropdown-section-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - v-model.trim="authorInput" - :placeholder="__('Search')" - @input="searchAuthors" - /> - <gl-dropdown-item :is-checked="!currentAuthor" @click="selectAuthor(null)"> - {{ __('Any Author') }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="author in commitsAuthors" - :key="author.id" - :is-checked="author.name === currentAuthor" - :avatar-url="author.avatar_url" - :secondary-text="author.username" - @click="selectAuthor(author)" - > - {{ author.name }} - </gl-dropdown-item> - </gl-dropdown> + <template #search-summary-sr-only> + {{ searchSummarySrText }} + </template> + <template #list-item="{ item }"> + <span class="gl-display-flex gl-align-items-center"> + <gl-avatar + v-if="item.avatarUrl" + class="gl-mr-3" + :size="32" + :entity-name="item.text" + :src="item.avatarUrl" + :alt="item.text" + /> + <span + class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-overflow-break-word" + > + {{ item.text }} + <span v-if="item.secondaryText" class="gl-text-secondary"> + {{ item.secondaryText }} + </span> + </span> + </span> + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index e4d5e5bd233..b40b28adab9 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -1,7 +1,21 @@ <script> -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { + GlButton, + GlFormGroup, + GlFormRadioGroup, + GlIcon, + GlTooltipDirective, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { joinPaths } from '~/lib/utils/url_utility'; +import { + I18N, + COMPARE_OPTIONS, + COMPARE_REVISIONS_DOCS_URL, + COMPARE_OPTIONS_INPUT_NAME, +} from '../constants'; import RevisionCard from './revision_card.vue'; export default { @@ -9,8 +23,14 @@ export default { components: { RevisionCard, GlButton, - GlDropdown, - GlDropdownItem, + GlFormRadioGroup, + GlFormGroup, + GlIcon, + GlLink, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { projectCompareIndexPath: { @@ -76,24 +96,6 @@ export default { isStraight: this.straight, }; }, - computed: { - straightModeDropdownItems() { - return [ - { - modeType: 'off', - isEnabled: false, - content: '..', - testId: 'disableStraightModeButton', - }, - { - modeType: 'on', - isEnabled: true, - content: '...', - testId: 'enableStraightModeButton', - }, - ]; - }, - }, methods: { onSubmit() { this.$refs.form.submit(); @@ -110,10 +112,11 @@ export default { onSwapRevision() { [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to' }, - setStraightMode(isStraight) { - this.isStraight = isStraight; - }, }, + i18n: I18N, + compareOptions: COMPARE_OPTIONS, + docsLink: COMPARE_REVISIONS_DOCS_URL, + inputName: COMPARE_OPTIONS_INPUT_NAME, }; </script> @@ -125,13 +128,26 @@ export default { :action="projectCompareIndexPath" > <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <h1 class="gl-font-size-h1 gl-mt-4">{{ $options.i18n.title }}</h1> + <p> + <gl-sprintf :message="$options.i18n.subtitle"> + <template #bold="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link target="_blank" :href="$options.docsLink" data-testid="help-link">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> <div class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards" > <revision-card data-testid="sourceRevisionCard" :refs-project-path="to.refsProjectPath" - :revision-text="__('Source')" + :revision-text="$options.i18n.source" params-name="to" :params-branch="to.revision" :projects="to.projects" @@ -139,28 +155,26 @@ export default { @selectProject="onSelectProject" @selectRevision="onSelectRevision" /> - <div - class="gl-display-flex gl-justify-content-center gl-align-items-center gl-align-self-end gl-my-3 gl-md-my-0 gl-pl-3 gl-pr-3" - data-testid="ellipsis" + <gl-button + v-gl-tooltip="$options.i18n.swapRevisions" + class="gl-display-flex gl-mx-3 gl-align-self-end swap-button" + data-testid="swapRevisionsButton" + category="tertiary" + @click="onSwapRevision" > - <input :value="isStraight ? 'true' : 'false'" type="hidden" name="straight" /> - <gl-dropdown data-testid="modeDropdown" :text="isStraight ? '...' : '..'" size="small"> - <gl-dropdown-item - v-for="mode in straightModeDropdownItems" - :key="mode.modeType" - :is-check-item="true" - :is-checked="isStraight == mode.isEnabled" - :data-testid="mode.testId" - @click="setStraightMode(mode.isEnabled)" - > - <span class="dropdown-menu-inner-content"> {{ mode.content }} </span> - </gl-dropdown-item> - </gl-dropdown> - </div> + <gl-icon name="substitute" /> + </gl-button> + <gl-button + v-gl-tooltip="$options.i18n.swapRevisions" + class="gl-display-none gl-align-self-end gl-my-5 swap-button-mobile" + @click="onSwapRevision" + > + {{ $options.i18n.swap }} + </gl-button> <revision-card data-testid="targetRevisionCard" :refs-project-path="from.refsProjectPath" - :revision-text="__('Target')" + :revision-text="$options.i18n.target" params-name="from" :params-branch="from.revision" :projects="from.projects" @@ -169,22 +183,32 @@ export default { @selectRevision="onSelectRevision" /> </div> - <div class="gl-display-flex gl-mt-6 gl-gap-3"> - <gl-button category="primary" variant="confirm" @click="onSubmit"> - {{ s__('CompareRevisions|Compare') }} - </gl-button> - <gl-button data-testid="swapRevisionsButton" @click="onSwapRevision"> - {{ s__('CompareRevisions|Swap revisions') }} + <gl-form-group :label="$options.i18n.optionsLabel" class="gl-mt-4"> + <gl-form-radio-group + v-model="isStraight" + :options="$options.compareOptions" + :name="$options.inputName" + required + /> + </gl-form-group> + <div class="gl-display-flex gl-gap-3 gl-pb-4"> + <gl-button + category="primary" + variant="confirm" + data-testid="compare-button" + @click="onSubmit" + > + {{ $options.i18n.compare }} </gl-button> <gl-button v-if="projectMergeRequestPath" :href="projectMergeRequestPath" data-testid="projectMrButton" > - {{ s__('CompareRevisions|View open merge request') }} + {{ $options.i18n.viewMr }} </gl-button> <gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton"> - {{ s__('CompareRevisions|Create merge request') }} + {{ $options.i18n.openMr }} </gl-button> </div> </form> diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue index 162aca44f9d..212937c87c6 100644 --- a/app/assets/javascripts/projects/compare/components/revision_card.vue +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -40,7 +40,7 @@ export default { <template> <div class="revision-card gl-flex-basis-half"> - <h2 class="gl-font-size-h2"> + <h2 class="gl-font-base gl-mt-0"> {{ s__(`CompareRevisions|${revisionText}`) }} </h2> <div class="gl-sm-display-flex gl-align-items-center gl-gap-3"> diff --git a/app/assets/javascripts/projects/compare/constants.js b/app/assets/javascripts/projects/compare/constants.js new file mode 100644 index 00000000000..2f07cf57521 --- /dev/null +++ b/app/assets/javascripts/projects/compare/constants.js @@ -0,0 +1,25 @@ +import { __, s__ } from '~/locale'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; + +export const COMPARE_OPTIONS_INPUT_NAME = 'straight'; +export const COMPARE_OPTIONS = [ + { value: false, text: s__('CompareRevisions|Only incoming changes from source') }, + { value: true, text: s__('CompareRevisions|Include changes to target since source was created') }, +]; + +export const I18N = { + title: s__('CompareRevisions|Compare revisions'), + subtitle: s__( + 'CompareRevisions|Changes are shown as if the %{boldStart}source%{boldEnd} revision was being merged into the %{boldStart}target%{boldEnd} revision. %{linkStart}Learn more about comparing revisions.%{linkEnd}', + ), + source: __('Source'), + swap: s__('CompareRevisions|Swap'), + target: __('Target'), + swapRevisions: s__('CompareRevisions|Swap revisions'), + compare: s__('CompareRevisions|Compare'), + optionsLabel: s__('CompareRevisions|Show changes'), + viewMr: s__('CompareRevisions|View open merge request'), + openMr: s__('CompareRevisions|Create merge request'), +}; + +export const COMPARE_REVISIONS_DOCS_URL = `${DOCS_URL_IN_EE_DIR}/user/project/repository/branches/#compare-branches`; diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 6ca83b0b500..a841766a93c 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -1,8 +1,8 @@ <script> -import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?raw'; -import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?raw'; -import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?raw'; -import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?raw'; +import PROJECT_CREATE_FROM_TEMPLATE_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?url'; +import PROJECT_CREATE_NEW_SVG_URL from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?url'; +import PROJECT_IMPORT_SVG_URL from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?url'; +import PROJECT_RUN_CICD_PIPELINES_SVG_URL from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?url'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; @@ -19,7 +19,7 @@ const PANELS = [ description: s__( 'ProjectsNew|Create a blank project to store your files, plan your work, and collaborate on code, among other things.', ), - illustration: blankProjectIllustration, + imageSrc: PROJECT_CREATE_NEW_SVG_URL, }, { key: 'template', @@ -29,7 +29,7 @@ const PANELS = [ description: s__( 'ProjectsNew|Create a project pre-populated with the necessary files to get you started quickly.', ), - illustration: createFromTemplateIllustration, + imageSrc: PROJECT_CREATE_FROM_TEMPLATE_SVG_URL, }, { key: 'import', @@ -39,7 +39,7 @@ const PANELS = [ description: s__( 'ProjectsNew|Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.', ), - illustration: importProjectIllustration, + imageSrc: PROJECT_IMPORT_SVG_URL, }, { key: 'ci', @@ -47,7 +47,7 @@ const PANELS = [ selector: '#ci-cd-project-pane', title: s__('ProjectsNew|Run CI/CD for external repository'), description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'), - illustration: ciCdProjectIllustration, + imageSrc: PROJECT_RUN_CICD_PIPELINES_SVG_URL, }, ]; diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index d8675a851ea..75d72f719e5 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -3,6 +3,7 @@ import { escape, find, countBy } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { createAlert } from '~/alert'; import { n__, s__, __, sprintf } from '~/locale'; +import { renderAvatar } from '~/helpers/avatar_helper'; import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api'; import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants'; @@ -534,13 +535,22 @@ export default class AccessDropdown { userRowHtml(user, isActive) { const isActiveClass = isActive || ''; + const avatarEl = renderAvatar(user, { + sizeClass: 's32', + }); return ` <li> <a href="#" class="${isActiveClass}"> - <img src="${user.avatar_url}" class="avatar avatar-inline" width="30"> - <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong> - <span class="dropdown-menu-user-username">${user.username}</span> + <div class="gl-avatar-labeled"> + ${avatarEl} + <div> + <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong> + <span class="gl-avatar-labeled-sublabel dropdown-menu-user-username">@${ + user.username + }</span> + </div> + </div> </a> </li> `; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 650b60cba4f..ae28694f5d2 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -7,8 +7,8 @@ import { __, sprintf } from '~/locale'; import ServiceDeskSetting from './service_desk_setting.vue'; export default { - customEmailHelpPath: helpPagePath('/user/project/service_desk.html', { - anchor: 'use-a-custom-email-address', + serviceDeskEmailHelpPath: helpPagePath('/user/project/service_desk.html', { + anchor: 'use-an-additional-service-desk-alias-email', }), components: { GlAlert, @@ -32,10 +32,10 @@ export default { initialIncomingEmail: { default: '', }, - customEmail: { + serviceDeskEmail: { default: '', }, - customEmailEnabled: { + serviceDeskEmailEnabled: { default: false, }, selectedTemplate: { @@ -65,7 +65,7 @@ export default { alertVariant: 'danger', alertMessage: '', incomingEmail: this.initialIncomingEmail, - updatedCustomEmail: this.customEmail, + updatedServiceDeskEmail: this.serviceDeskEmail, }; }, methods: { @@ -110,7 +110,7 @@ export default { return axios .put(this.endpoint, body) .then(({ data }) => { - this.updatedCustomEmail = data?.service_desk_address; + this.updatedServiceDeskEmail = data?.service_desk_address; this.showAlert(__('Changes saved.'), 'success'); }) .catch((err) => { @@ -155,7 +155,7 @@ export default { " > <template #link="{ content }"> - <gl-link :href="$options.customEmailHelpPath" target="_blank"> + <gl-link :href="$options.serviceDeskEmailHelpPath" target="_blank"> {{ content }} </gl-link> </template> @@ -168,8 +168,8 @@ export default { :is-enabled="isEnabled" :is-issue-tracker-enabled="isIssueTrackerEnabled" :incoming-email="incomingEmail" - :custom-email="updatedCustomEmail" - :custom-email-enabled="customEmailEnabled" + :service-desk-email="updatedServiceDeskEmail" + :service-desk-email-enabled="serviceDeskEmailEnabled" :initial-selected-template="selectedTemplate" :initial-selected-file-template-project-id="selectedFileTemplateProjectId" :initial-outgoing-name="outgoingName" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 38a2c12d137..5078cbbdf59 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -49,12 +49,12 @@ export default { required: false, default: '', }, - customEmail: { + serviceDeskEmail: { type: String, required: false, default: '', }, - customEmailEnabled: { + serviceDeskEmailEnabled: { type: Boolean, required: false, }, @@ -101,22 +101,22 @@ export default { }, computed: { hasProjectKeySupport() { - return Boolean(this.customEmailEnabled); + return Boolean(this.serviceDeskEmailEnabled); }, email() { - return this.customEmail || this.incomingEmail; + return this.serviceDeskEmail || this.incomingEmail; }, - hasCustomEmail() { - return this.customEmail && this.customEmail !== this.incomingEmail; + hasServiceDeskEmail() { + return this.serviceDeskEmail && this.serviceDeskEmail !== this.incomingEmail; }, emailSuffixHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'configure-a-custom-email-address-suffix', + anchor: 'configure-a-suffix-for-service-desk-alias-email', }); }, - customEmailAddressHelpUrl() { + serviceDeskEmailAddressHelpUrl() { return helpPagePath('user/project/service_desk.html', { - anchor: 'use-a-custom-email-address', + anchor: 'use-an-additional-service-desk-alias-email', }); }, issuesHelpPagePath() { @@ -204,7 +204,7 @@ export default { <clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" /> </template> </gl-form-input-group> - <template v-if="email && hasCustomEmail" #description> + <template v-if="email && hasServiceDeskEmail" #description> <span class="gl-mt-2 gl-display-inline-block"> <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> <template #email> @@ -260,7 +260,7 @@ export default { > <template #link="{ content }"> <gl-link - :href="customEmailAddressHelpUrl" + :href="serviceDeskEmailAddressHelpUrl" target="_blank" class="gl-text-blue-600 font-size-inherit" >{{ content }} diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 84229175c0b..0f4c747a7b6 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -10,8 +10,8 @@ export default () => { } const { - customEmail, - customEmailEnabled, + serviceDeskEmail, + serviceDeskEmailEnabled, enabled, issueTrackerEnabled, endpoint, @@ -27,8 +27,8 @@ export default () => { return new Vue({ el, provide: { - customEmail, - customEmailEnabled: parseBoolean(customEmailEnabled), + serviceDeskEmail, + serviceDeskEmailEnabled: parseBoolean(serviceDeskEmailEnabled), endpoint, initialIncomingEmail: incomingEmail, initialIsEnabled: parseBoolean(enabled), diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue index 5b620aa2300..5b620aa2300 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index f672acda062..1044d25c1a3 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -184,21 +184,14 @@ export default { <template> <div id="related-issues" class="related-issues-block"> <gl-card - class="gl-overflow-hidden gl-mt-5 gl-mb-0" - header-class="gl-p-0 gl-border-0" - body-class="gl-p-0 gl-bg-gray-10" + class="gl-new-card gl-overflow-hidden" + header-class="gl-new-card-header" + body-class="gl-new-card-body" + :aria-expanded="isOpen.toString()" > <template #header> - <div - :class="{ - 'gl-border-b-1': isOpen, - 'gl-border-b-0': !isOpen, - }" - class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100" - > - <h3 - class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24" - > + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title" data-testid="card-title"> <gl-link id="user-content-related-issues" class="anchor position-absolute gl-text-decoration-none" @@ -206,48 +199,44 @@ export default { aria-hidden="true" /> <slot name="header-text">{{ headerText }}</slot> - - <div - class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3 gl-text-gray-500" - > - <span class="gl-display-inline-flex gl-align-items-center"> - <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" /> - {{ badgeLabel }} - </span> - </div> </h3> - <slot name="header-actions"></slot> + <div class="gl-new-card-count js-related-issues-header-issue-count"> + <gl-icon :name="issuableTypeIcon" class="gl-mr-2" /> + {{ badgeLabel }} + </div> + </div> + <slot name="header-actions"></slot> + <gl-button + v-if="canAdmin" + size="small" + data-testid="related-issues-plus-button" + :aria-label="addIssuableButtonText" + class="gl-ml-3" + @click="addButtonClick" + > + <slot name="add-button-text">{{ __('Add') }}</slot> + </gl-button> + <div class="gl-new-card-toggle"> <gl-button - v-if="canAdmin" + category="tertiary" size="small" - data-testid="related-issues-plus-button" - :aria-label="addIssuableButtonText" - class="gl-ml-3" - @click="addButtonClick" - > - <slot name="add-button-text">{{ __('Add') }}</slot> - </gl-button> - <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100"> - <gl-button - category="tertiary" - size="small" - :icon="toggleIcon" - :aria-label="toggleLabel" - data-testid="toggle-links" - @click="handleToggle" - /> - </div> + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-links" + @click="handleToggle" + /> </div> </template> <div v-if="isOpen" - class="linked-issues-card-body gl-py-3 gl-px-4 gl-bg-gray-10" + class="linked-issues-card-body gl-new-card-content" data-testid="related-issues-body" > <div v-if="isFormVisible" - class="js-add-related-issues-form-area card-body bg-white gl-mt-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base" + class="js-add-related-issues-form-area gl-new-card-add-form" :class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }" + data-testid="add-item-form" > <add-issuable-form :show-categorized-issues="showCategorizedIssues" @@ -289,8 +278,8 @@ export default { @saveReorder="$emit('saveReorder', $event)" /> </template> - <div v-if="!shouldShowTokenBody && !isFormVisible" data-testid="related-items-empty"> - <p class="gl-p-2 gl-mb-0 gl-text-gray-500"> + <div v-if="!shouldShowTokenBody && !isFormVisible"> + <p class="gl-new-card-empty"> {{ emptyStateMessage }} <gl-link v-if="hasHelpPath" diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index ff92cdd42c6..516162b57b5 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -215,14 +215,13 @@ export default { </gl-form-group> <gl-form-group data-testid="release-notes"> <label for="release-notes">{{ __('Release notes') }}</label> - <div class="bordered-box pr-3 pl-3"> + <div class="common-note-form"> <markdown-field :can-attach-file="true" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" :textarea-value="formattedReleaseNotes" - class="gl-mt-3 gl-mb-3" > <template #textarea> <textarea diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index e056a822c8b..969036f84b7 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -7,14 +7,16 @@ import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constant import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; import LineHighlighter from '~/blob/line_highlighter'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import { addBlameLink } from '~/blob/blob_blame_link'; +import highlightMixin from '~/repository/mixins/highlight_mixin'; import projectInfoQuery from '../queries/project_info.query.graphql'; import getRefMixin from '../mixins/get_ref'; import userInfoQuery from '../queries/user_info.query.graphql'; @@ -36,7 +38,7 @@ export default { CodeIntelligence, AiGenie: () => import('ee_component/ai/components/ai_genie.vue'), }, - mixins: [getRefMixin, glFeatureFlagMixin()], + mixins: [getRefMixin, glFeatureFlagMixin(), highlightMixin], inject: { originalBranch: { default: '', @@ -81,7 +83,10 @@ export default { shouldFetchRawText: Boolean(this.glFeatures.highlightJs), }; }, - result() { + result({ data }) { + const blob = data.project?.repository?.blobs?.nodes[0] || {}; + this.initHighlightWorker(blob); + const urlHash = getLocationHash(); const plain = this.$route?.query?.plain; @@ -170,7 +175,14 @@ export default { }, blobViewer() { const { fileType } = this.viewer; - return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs); + return this.shouldLoadLegacyViewer + ? null + : loadViewer( + fileType, + this.isUsingLfs, + this.glFeatures.highlightJsWorker, + this.blobInfo.language, + ); }, shouldLoadLegacyViewer() { const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs; @@ -215,6 +227,9 @@ export default { isUsingLfs() { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; }, + projectIdAsNumber() { + return getIdFromGraphQLId(this.project?.id); + }, }, watch: { // Watch the URL 'plain' query value to know if the viewer needs changing. @@ -345,6 +360,8 @@ export default { :gitpod-url="blobInfo.gitpodBlobUrl" :show-gitpod-button="gitpodEnabled" :gitpod-enabled="currentUser && currentUser.gitpodEnabled" + :project-path="projectPath" + :project-id="projectIdAsNumber" :user-preferences-gitpod-path="currentUser && currentUser.preferencesGitpodPath" :user-profile-enable-gitpod-path="currentUser && currentUser.profileEnableGitpodPath" is-blob @@ -386,7 +403,14 @@ export default { :loading="isLoadingLegacyViewer" :data-loading="isRenderingLegacyTextViewer" /> - <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" @error="onError" /> + <component + :is="blobViewer" + v-else + :blob="blobInfo" + :chunks="chunks" + class="blob-viewer" + @error="onError" + /> <code-intelligence v-if="blobViewer || legacyViewerLoaded" :code-navigation-path="blobInfo.codeNavigationPath" diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js index fd4d111b4b0..2c95e63720e 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js +++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js @@ -7,7 +7,7 @@ export const RENDER_ERROR_MSG = __( 'The map can not be displayed because there was an error loading the GeoJSON file.', ); -export const OPEN_STREET_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; +export const OPEN_STREET_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; export const ICON_CONFIG = { iconUrl, iconRetinaUrl, shadowUrl }; export const MAP_ATTRIBUTION = __('Map data from'); export const OPEN_STREET_COPYRIGHT_LINK = diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index b749702972f..368f42e0064 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -15,9 +15,15 @@ const viewers = { geo_json: () => import('./geo_json/geo_json_viewer.vue'), }; -export const loadViewer = (type, isUsingLfs) => { +export const loadViewer = (type, isUsingLfs, hljsWorkerEnabled, language) => { let viewer = viewers[type]; + if (hljsWorkerEnabled && language === 'json') { + // The New Source Viewer currently only supports JSON files. + // More language support will be added in: https://gitlab.com/gitlab-org/gitlab/-/issues/415753 + viewer = () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue'); + } + if (!viewer && isUsingLfs) { viewer = viewers.lfs; } diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 46dee9db69a..d498be0b2bb 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -1,14 +1,8 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlIcon, - GlModalDirective, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; @@ -16,21 +10,12 @@ import projectShortPathQuery from '../queries/project_short_path.query.graphql'; import UploadBlobModal from './upload_blob_modal.vue'; import NewDirectoryModal from './new_directory_modal.vue'; -const ROW_TYPES = { - header: 'header', - divider: 'divider', -}; - const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory'; export default { components: { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlIcon, + GlDisclosureDropdown, UploadBlobModal, NewDirectoryModal, }, @@ -171,103 +156,99 @@ export default { canCreateMrFromFork() { return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn; }, + hasPushCodePermission() { + return this.userPermissions?.pushCode; + }, showUploadModal() { return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, showNewDirectoryModal() { return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, - dropdownItems() { - const items = []; - + dropdownDirectoryItems() { if (this.canEditTree) { - items.push( + return [ { - type: ROW_TYPES.header, - text: __('This directory'), - }, - { - attrs: { - href: `${this.newBlobPath}/${ - this.currentPath ? encodeURIComponent(this.currentPath) : '' - }`, + text: __('New file'), + href: joinPaths( + this.newBlobPath, + this.currentPath ? encodeURIComponent(this.currentPath) : '', + ), + extraAttrs: { 'data-qa-selector': 'new_file_menu_item', }, - text: __('New file'), }, { - attrs: { - href: '#modal-upload-blob', - }, text: __('Upload file'), - modalId: UPLOAD_BLOB_MODAL_ID, - }, - ); - - items.push({ - attrs: { - href: '#modal-create-new-dir', - }, - text: __('New directory'), - modalId: NEW_DIRECTORY_MODAL_ID, - }); - } else if (this.canCreateMrFromFork) { - items.push( - { - attrs: { - href: this.forkNewBlobPath, - 'data-method': 'post', - }, - text: __('New file'), + action: () => this.$root.$emit(BV_SHOW_MODAL, UPLOAD_BLOB_MODAL_ID), }, { - attrs: { - href: this.forkUploadBlobPath, - 'data-method': 'post', - }, - text: __('Upload file'), - }, - { - attrs: { - href: this.forkNewDirectoryPath, - 'data-method': 'post', - }, text: __('New directory'), + action: () => this.$root.$emit(BV_SHOW_MODAL, NEW_DIRECTORY_MODAL_ID), }, - ); + ]; } - if (this.userPermissions?.pushCode) { - items.push( + if (this.canCreateMrFromFork) { + return [ { - type: ROW_TYPES.divider, - }, - { - type: ROW_TYPES.header, - text: __('This repository'), + text: __('New file'), + href: this.forkNewBlobPath, + extraAttrs: { + 'data-method': 'post', + }, }, { - attrs: { - href: this.newBranchPath, + text: __('Upload file'), + href: this.forkUploadBlobPath, + extraAttrs: { + 'data-method': 'post', }, - text: __('New branch'), }, { - attrs: { - href: this.newTagPath, + text: __('New directory'), + href: this.forkNewDirectoryPath, + extraAttrs: { + 'data-method': 'post', }, - text: __('New tag'), }, - ); + ]; } - return items; + return []; + }, + dropdownRepositoryItems() { + if (!this.hasPushCodePermission) return []; + return [ + { + text: __('New branch'), + href: this.newBranchPath, + }, + { + text: __('New tag'), + href: this.newTagPath, + }, + ]; + }, + dropdownItems() { + if (this.isBlobPath) return []; + if (!this.canCollaborate && !this.canCreateMrFromFork) return []; + return [ + this.dropdownDirectoryItems?.length && { + name: __('This directory'), + items: this.dropdownDirectoryItems, + }, + this.dropdownRepositoryItems?.length && { + name: __('This repository'), + items: this.dropdownRepositoryItems, + }, + ].filter(Boolean); }, isBlobPath() { return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded'; }, renderAddToTreeDropdown() { - return !this.isBlobPath && (this.canCollaborate || this.canCreateMrFromFork); + return this.dropdownItems.length; }, newDirectoryPath() { return joinPaths(this.newDirPath, this.currentPath); @@ -277,16 +258,6 @@ export default { isLast(i) { return i === this.pathLinks.length - 1; }, - getComponent(type) { - switch (type) { - case ROW_TYPES.divider: - return 'gl-dropdown-divider'; - case ROW_TYPES.header: - return 'gl-dropdown-section-header'; - default: - return 'gl-dropdown-item'; - } - }, }, }; </script> @@ -300,27 +271,15 @@ export default { </router-link> </li> <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> - <gl-dropdown + <gl-disclosure-dropdown + :toggle-text="__('Add to tree')" toggle-class="add-to-tree gl-ml-2" data-testid="add-to-tree" data-qa-selector="add_to_tree_dropdown" - > - <template #button-content> - <span class="sr-only">{{ __('Add to tree') }}</span> - <gl-icon name="plus" :size="16" class="float-left" /> - <gl-icon name="chevron-down" :size="16" class="float-left" /> - </template> - <template v-for="(item, i) in dropdownItems"> - <component - :is="getComponent(item.type)" - :key="i" - v-bind="item.attrs" - v-gl-modal="item.modalId || null" - > - {{ item.text }} - </component> - </template> - </gl-dropdown> + text-sr-only + icon="plus" + :items="dropdownItems" + /> </li> </ol> <upload-blob-modal diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 82dd1fda2a0..bdc9ed210ed 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -138,7 +138,7 @@ export default { :size="32" /> <div - class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" > <div class="commit-content" data-qa-selector="commit_content"> <gl-link diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index befd731a61b..c1e0104c6ac 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -8,6 +8,7 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import createStore from '~/code_navigation/store'; import RefSelector from '~/ref/components/ref_selector.vue'; +import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -290,7 +291,12 @@ export default function setupVueRepositoryList() { store: createStore(), router, apolloProvider, - provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) }, + provide: { + resourceId, + userId, + explainCodeAvailable: parseBoolean(explainCodeAvailable), + highlightWorker: gon.features.highlightJsWorker ? new HighlightWorker() : null, + }, render(h) { return h(App); }, diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js index 95d0c55bb04..822a8b4ee38 100644 --- a/app/assets/javascripts/repository/mixins/highlight_mixin.js +++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js @@ -1,4 +1,3 @@ -import { nextTick } from 'vue'; import { LEGACY_FALLBACKS, EVENT_ACTION, @@ -38,8 +37,8 @@ export default { this.trackEvent(EVENT_LABEL_FALLBACK, language); this?.onError(); }, - initHighlightWorker({ rawTextBlob, language, simpleViewer }) { - if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return; + initHighlightWorker({ rawTextBlob, language, simpleViewer, fileType }) { + if (simpleViewer?.fileType !== TEXT_FILE_TYPE || !this.glFeatures.highlightJsWorker) return; if (this.isUnsupportedLanguage(language)) { this.handleUnsupportedLanguage(language); @@ -72,14 +71,14 @@ export default { this.instructWorker(firstSeventyLines, language); // Instruct the worker to start highlighting all lines in the background. - this.instructWorker(rawTextBlob, language); + this.instructWorker(rawTextBlob, language, fileType); }, 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 }); + instructWorker(content, language, fileType) { + this.highlightWorker.postMessage({ content, language, fileType }); }, async highlightHash() { const { hash } = this.$route; @@ -97,7 +96,7 @@ export default { } // Line numbers in the DOM needs to update first based on changes made to `chunks`. - await nextTick(); + await this.$nextTick(); const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); lineHighlighter.highlightHash(hash); diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue index 74855482b5d..eb3556ac2cf 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -14,17 +14,11 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import { uniq } from 'lodash'; import { rgbFromHex } from '@gitlab/ui/dist/utils/utils'; import { slugify } from '~/lib/utils/text_utility'; -import { s__, sprintf } from '~/locale'; +import { sprintf } from '~/locale'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; -import { - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, - SEARCH_DESCRIBED_BY_DEFAULT, - SEARCH_DESCRIBED_BY_UPDATED, - SEARCH_RESULTS_LOADING, -} from '~/vue_shared/global_search/constants'; +import { I18N } from '~/vue_shared/global_search/constants'; import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants'; import LabelDropdownItems from './label_dropdown_items.vue'; @@ -60,16 +54,7 @@ export default { isFocused: false, }; }, - i18n: { - SEARCH_LABELS: s__('GlobalSearch|Search labels'), - DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'), - AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'), - SEARCH_DESCRIBED_BY_DEFAULT, - SEARCH_RESULTS_LOADING, - SEARCH_DESCRIBED_BY_UPDATED, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - }, + i18n: I18N, computed: { ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']), ...mapGetters([ @@ -260,7 +245,7 @@ export default { :default-index="defaultIndex" :enable-cycle="true" /> - <div v-if="!aggregations.error"> + <div v-if="!aggregations.error && filteredLabels.length > 0"> <gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{ $options.i18n.DROPDOWN_HEADER }}</gl-dropdown-section-header> @@ -280,7 +265,13 @@ export default { </gl-form-checkbox-group> </gl-dropdown-form> </div> - <gl-alert v-else :dismissible="false" variant="danger"> + <span + v-if="!aggregations.error && filteredLabels.length === 0" + class="gl-px-3" + data-testid="no-labels-found-message" + >{{ $options.i18n.NO_LABELS_FOUND }}</span + > + <gl-alert v-if="aggregations.error" :dismissible="false" variant="danger"> {{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }} </gl-alert> <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" /> diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue index 7a9e6a2e4fc..0b468a60cf0 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue @@ -26,15 +26,15 @@ export default { class="gl-px-5 gl-py-3 label-filter-menu-item" > <gl-form-checkbox - class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5" + class="label-with-color-checkbox gl-display-inline-flex gl-min-h-5" :value="label.key" > <span data-testid="label-color-indicator" - class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3" + class="gl-rounded-base gl-min-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3" :style="{ 'background-color': label.color }" ></span> - <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{ + <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title gl-word-break-all">{{ label.title }}</span></gl-form-checkbox > diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index 91c16616f02..bb112c122ae 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -23,11 +23,13 @@ export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: ' export const ICON_MAP = { blobs: 'code', issues: 'issues', + epics: 'epic', merge_requests: 'merge-request', commits: 'commit', notes: 'comments', - milestones: 'tag', + milestones: 'clock', users: 'users', projects: 'project', - wiki_blobs: 'overview', + wiki_blobs: 'book', + snippet_titles: 'snippet', }; diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index e5edb21792a..4798f1127eb 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -19,7 +19,7 @@ export default { }, computed: { ...mapState(['query', 'groups', 'fetchingGroups']), - ...mapGetters(['frequentGroups']), + ...mapGetters(['frequentGroups', 'currentScope']), selectedGroup() { return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; }, @@ -43,6 +43,7 @@ export default { [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null, nav_source: null, + scope: this.currentScope, }), ); }, diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index 85cf2ddbbff..1cce3e3db8b 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -18,7 +18,7 @@ export default { }, computed: { ...mapState(['query', 'projects', 'fetchingProjects']), - ...mapGetters(['frequentProjects']), + ...mapGetters(['frequentProjects', 'currentScope']), selectedProject() { return this.initialData ? this.initialData : ANY_OPTION; }, @@ -42,6 +42,7 @@ export default { ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), [PROJECT_DATA.queryParam]: project.id, nav_source: null, + scope: this.currentScope, }; visitUrl(setUrlParams(queryParams)); diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 578d7c8a18c..f5f88e12163 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -247,7 +247,7 @@ export default { :label="__('Training mode')" label-position="hidden" :disabled="!securityTrainingEnabled" - data-qa-selector="security_training_toggle" + data-testid="security-training-toggle" :data-qa-training-provider="provider.name" @change="toggleProvider(provider)" /> diff --git a/app/assets/javascripts/service_desk/components/info_banner.vue b/app/assets/javascripts/service_desk/components/info_banner.vue new file mode 100644 index 00000000000..8aaced839a5 --- /dev/null +++ b/app/assets/javascripts/service_desk/components/info_banner.vue @@ -0,0 +1,64 @@ +<script> +import { GlLink, GlButton } from '@gitlab/ui'; +import { + infoBannerTitle, + infoBannerAdminNote, + infoBannerUserNote, + enableServiceDesk, + learnMore, +} from '../constants'; + +export default { + name: 'InfoBanner', + components: { + GlLink, + GlButton, + }, + inject: [ + 'serviceDeskCalloutSvgPath', + 'serviceDeskEmailAddress', + 'canAdminIssues', + 'canEditProjectSettings', + 'serviceDeskSettingsPath', + 'isServiceDeskEnabled', + 'serviceDeskHelpPath', + ], + i18n: { infoBannerTitle, infoBannerAdminNote, infoBannerUserNote, enableServiceDesk, learnMore }, + computed: { + canSeeEmailAddress() { + return this.canAdminIssues && this.isServiceDeskEnabled; + }, + canEnableServiceDesk() { + return this.canEditProjectSettings && !this.isServiceDeskEnabled; + }, + }, +}; +</script> + +<template> + <div class="gl-border-b gl-pb-3 gl-display-flex gl-align-items-flex-start"> + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <img + :src="serviceDeskCalloutSvgPath" + alt="" + class="gl-display-none gl-sm-display-block gl-p-5" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + <div class="gl-mt-3 gl-ml-3"> + <h5>{{ $options.i18n.infoBannerTitle }}</h5> + <p v-if="canSeeEmailAddress"> + {{ $options.i18n.infoBannerAdminNote }} <code>{{ serviceDeskEmailAddress }}</code> + </p> + <p> + {{ $options.i18n.infoBannerUserNote }} + <gl-link :href="serviceDeskHelpPath" target="_blank">{{ $options.i18n.learnMore }}</gl-link + >. + </p> + <p v-if="canEnableServiceDesk" class="gl-mt-3"> + <gl-button :href="serviceDeskSettingsPath" variant="confirm">{{ + $options.i18n.enableServiceDesk + }}</gl-button> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue new file mode 100644 index 00000000000..e8b05642e7d --- /dev/null +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -0,0 +1,151 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { fetchPolicies } from '~/lib/graphql'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { STATUS_OPEN, STATUS_CLOSED, STATUS_ALL } from '~/issues/constants'; +import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql'; +import { + errorFetchingCounts, + errorFetchingIssues, + noSearchNoFilterTitle, + searchPlaceholder, + SERVICE_DESK_BOT_USERNAME, +} from '../constants'; +import InfoBanner from './info_banner.vue'; + +export default { + i18n: { + errorFetchingCounts, + errorFetchingIssues, + noSearchNoFilterTitle, + searchPlaceholder, + }, + issuableListTabs, + components: { + GlEmptyState, + IssuableList, + InfoBanner, + }, + inject: [ + 'emptyStateSvgPath', + 'isProject', + 'isSignedIn', + 'fullPath', + 'isServiceDeskSupported', + 'hasAnyIssues', + ], + data() { + return { + serviceDeskIssues: [], + serviceDeskIssuesCounts: {}, + searchTokens: [], + sortOptions: [], + state: STATUS_OPEN, + issuesError: null, + }; + }, + apollo: { + serviceDeskIssues: { + query: getServiceDeskIssuesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.project.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 }) { + if (!data) { + return; + } + this.pageInfo = data?.project.issues.pageInfo ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + skip() { + return !this.hasAnyIssues; + }, + }, + serviceDeskIssuesCounts: { + query: getServiceDeskIssuesCounts, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.project ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); + }, + context: { + isSingleRequest: true, + }, + }, + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + isProject: this.isProject, + isSignedIn: this.isSignedIn, + authorUsername: SERVICE_DESK_BOT_USERNAME, + state: this.state, + }; + }, + tabCounts() { + const { openedIssues, closedIssues, allIssues } = this.serviceDeskIssuesCounts; + return { + [STATUS_OPEN]: openedIssues?.count, + [STATUS_CLOSED]: closedIssues?.count, + [STATUS_ALL]: allIssues?.count, + }; + }, + isInfoBannerVisible() { + return this.isServiceDeskSupported && this.hasAnyIssues; + }, + }, + methods: { + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + }, + }, +}; +</script> + +<template> + <section> + <info-banner v-if="isInfoBannerVisible" /> + <issuable-list + namespace="service-desk" + recent-searches-storage-key="issues" + :error="issuesError" + :search-input-placeholder="$options.i18n.searchPlaceholder" + :search-tokens="searchTokens" + :sort-options="sortOptions" + :issuables="serviceDeskIssues" + :tabs="$options.issuableListTabs" + :tab-counts="tabCounts" + :current-tab="state" + @click-tab="handleClickTab" + > + <template #empty-state> + <gl-empty-state + :svg-path="emptyStateSvgPath" + :title="$options.i18n.noSearchNoFilterTitle" + /> + </template> + </issuable-list> + </section> +</template> diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js new file mode 100644 index 00000000000..685ad738792 --- /dev/null +++ b/app/assets/javascripts/service_desk/constants.js @@ -0,0 +1,17 @@ +import { __, s__ } from '~/locale'; + +export const SERVICE_DESK_BOT_USERNAME = 'support-bot'; + +export const errorFetchingCounts = __('An error occurred while getting issue counts'); +export const errorFetchingIssues = __('An error occurred while loading issues'); +export const noSearchNoFilterTitle = __('Please select at least one filter to see results'); +export const searchPlaceholder = __('Search or filter results...'); +export const infoBannerTitle = s__( + 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', +); +export const infoBannerAdminNote = s__('ServiceDesk|Your users can send emails to this address:'); +export const infoBannerUserNote = s__( + 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', +); +export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk'); +export const learnMore = __('Learn more'); diff --git a/app/assets/javascripts/service_desk/graphql.js b/app/assets/javascripts/service_desk/graphql.js new file mode 100644 index 00000000000..e01973f1e8a --- /dev/null +++ b/app/assets/javascripts/service_desk/graphql.js @@ -0,0 +1,24 @@ +import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql'; + +let client; + +const typePolicies = { + Project: { + fields: { + issues: { + merge: true, + }, + }, + }, +}; + +export async function gqlClient() { + if (client) return client; + client = gon.features?.frontendCaching + ? await createApolloClientWithCaching( + {}, + { localCacheKey: 'service_desk_list', cacheConfig: { typePolicies } }, + ) + : createDefaultClient({}, { cacheConfig: { typePolicies } }); + return client; +} diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js new file mode 100644 index 00000000000..a9172f96540 --- /dev/null +++ b/app/assets/javascripts/service_desk/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { gqlClient } from './graphql'; +import ServiceDeskListApp from './components/service_desk_list_app.vue'; + +export async function mountServiceDeskListApp() { + const el = document.querySelector('.js-service-desk-list'); + + if (!el) { + return null; + } + + const { + projectDataEmptyStateSvgPath, + projectDataFullPath, + projectDataIsProject, + projectDataIsSignedIn, + projectDataHasAnyIssues, + serviceDeskEmailAddress, + canAdminIssues, + canEditProjectSettings, + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported, + isServiceDeskEnabled, + } = el.dataset; + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'ServiceDeskListRoot', + apolloProvider: new VueApollo({ + defaultClient: await gqlClient(), + }), + provide: { + emptyStateSvgPath: projectDataEmptyStateSvgPath, + fullPath: projectDataFullPath, + isProject: parseBoolean(projectDataIsProject), + isSignedIn: parseBoolean(projectDataIsSignedIn), + serviceDeskEmailAddress, + canAdminIssues: parseBoolean(canAdminIssues), + canEditProjectSettings: parseBoolean(canEditProjectSettings), + serviceDeskCalloutSvgPath, + serviceDeskSettingsPath, + serviceDeskHelpPath, + isServiceDeskSupported: parseBoolean(isServiceDeskSupported), + isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled), + hasAnyIssues: parseBoolean(projectDataHasAnyIssues), + }, + render: (createComponent) => createComponent(ServiceDeskListApp), + }); +} diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql new file mode 100644 index 00000000000..c678b8dd8ab --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues.query.graphql @@ -0,0 +1,72 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "./issue.fragment.graphql" + +query getServiceDeskIssues( + $hideUsers: Boolean = false + $isProject: Boolean = false + $isSignedIn: Boolean = false + $fullPath: ID! + $iid: String + $search: String + $sort: IssueSort + $state: IssuableState + $in: [IssuableSearchableField!] + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $crmContactId: String + $crmOrganizationId: String + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput + $beforeCursor: String + $afterCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + project(fullPath: $fullPath) @include(if: $isProject) @persist { + id + issues( + iid: $iid + search: $search + sort: $sort + state: $state + in: $in + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + before: $beforeCursor + after: $afterCursor + first: $firstPageSize + last: $lastPageSize + ) { + __persist + pageInfo { + ...PageInfo + } + nodes { + __persist + ...IssueFragment + } + } + } +} diff --git a/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql new file mode 100644 index 00000000000..c2ba397d76f --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/get_service_desk_issues_counts.query.graphql @@ -0,0 +1,91 @@ +query getServiceDeskIssuesCount( + $isProject: Boolean = false + $fullPath: ID! + $iid: String + $search: String + $assigneeId: String + $assigneeUsernames: [String!] + $authorUsername: String + $confidential: Boolean + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $releaseTag: [String!] + $releaseTagWildcardId: ReleaseTagWildcardId + $types: [IssueType!] + $crmContactId: String + $crmOrganizationId: String + $not: NegatedIssueFilterInput + $or: UnionedIssueFilterInput +) { + project(fullPath: $fullPath) @include(if: $isProject) { + id + openedIssues: issues( + state: opened + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + ) { + count + } + closedIssues: issues( + state: closed + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + ) { + count + } + allIssues: issues( + state: all + iid: $iid + search: $search + assigneeId: $assigneeId + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + confidential: $confidential + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + releaseTag: $releaseTag + releaseTagWildcardId: $releaseTagWildcardId + types: $types + crmContactId: $crmContactId + crmOrganizationId: $crmOrganizationId + not: $not + or: $or + ) { + count + } + } +} diff --git a/app/assets/javascripts/service_desk/queries/issue.fragment.graphql b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql new file mode 100644 index 00000000000..3b49c0efb14 --- /dev/null +++ b/app/assets/javascripts/service_desk/queries/issue.fragment.graphql @@ -0,0 +1,60 @@ +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 { + __persist + id + avatarUrl + name + username + webUrl + } + } + author @skip(if: $hideUsers) { + __persist + id + avatarUrl + name + username + webUrl + } + labels { + nodes { + __persist + id + color + title + description + } + } + milestone { + __persist + id + dueDate + startDate + webPath + title + } + taskCompletionStatus { + completedCount + count + } +} 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 2c6eb0e5001..820d2e94016 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -2,46 +2,8 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; -const I18N = { - BUSY: __('Busy'), - CANNOT_MERGE: __('Cannot merge'), - LC_CANNOT_MERGE: __('cannot merge'), -}; - -const paranthesize = (str) => `(${str})`; - -const generateAssigneeTooltip = ({ - name, - availability, - cannotMerge = true, - tooltipHasName = false, -}) => { - if (!tooltipHasName) { - return cannotMerge ? I18N.CANNOT_MERGE : ''; - } - - const statusInformation = []; - if (availability && isUserBusy(availability)) { - statusInformation.push(I18N.BUSY); - } - - if (cannotMerge) { - statusInformation.push(I18N.LC_CANNOT_MERGE); - } - - if (tooltipHasName && statusInformation.length) { - const status = statusInformation.map(paranthesize).join(' '); - - return `${name} ${status}`; - } - - return name; -}; - export default { components: { AssigneeAvatar, @@ -55,16 +17,6 @@ export default { type: Object, required: true, }, - tooltipPlacement: { - type: String, - default: 'bottom', - required: false, - }, - tooltipHasName: { - type: Boolean, - default: true, - required: false, - }, issuableType: { type: String, default: TYPE_ISSUE, @@ -79,34 +31,10 @@ export default { const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; return this.isMergeRequest && !canMerge; }, - tooltipTitle() { - const { name = '', availability = '' } = this.user; - return generateAssigneeTooltip({ - name, - availability, - cannotMerge: this.cannotMerge, - tooltipHasName: this.tooltipHasName, - }); - }, - tooltipOption() { - if (this.isMergeRequest) { - return null; - } - - return { - container: 'body', - placement: this.tooltipPlacement, - boundary: 'viewport', - }; - }, assigneeUrl() { return this.user.web_url || this.user.webUrl; }, assigneeId() { - if (this.isMergeRequest) { - return null; - } - return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id; }, }, @@ -116,10 +44,10 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link - v-gl-tooltip="tooltipOption" :href="assigneeUrl" - :title="tooltipTitle" :data-user-id="assigneeId" + :data-username="user.username" + :data-cannot-merge="cannotMerge" data-placement="left" class="gl-display-inline-block js-user-link" > diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 2a9100f0cb5..609a9355d20 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,15 +1,12 @@ <script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { n__, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'AssigneeTitle', components: { GlLoadingIcon, - GlIcon, }, - mixins: [glFeatureFlagMixin()], props: { loading: { type: Boolean, @@ -24,11 +21,6 @@ export default { type: Boolean, required: true, }, - showToggle: { - type: Boolean, - required: false, - default: false, - }, changing: { type: Boolean, required: false, @@ -62,15 +54,5 @@ export default { > {{ titleCopy }} </a> - <a - v-if="showToggle" - :aria-label="__('Toggle sidebar')" - class="gutter-toggle float-right js-sidebar-toggle" - :class="{ 'gl-display-block gl-md-display-none!': glFeatures.movedMrSidebar }" - href="#" - role="button" - > - <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" /> - </a> </div> </template> 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 884edc97016..577c01c50ff 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -17,7 +17,7 @@ const generateCollapsedAssigneeTooltip = ({ renderUsers, allUsers, tooltipTitleM }); if (!allUsers.length) { - return __('Assignee(s)'); + return __('Assignees'); } if (allUsers.length > names.length) { names.push(sprintf(__('+ %{amount} more'), { amount: allUsers.length - names.length })); diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 062f63175a7..0563ed8394c 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -27,11 +27,6 @@ export default { type: String, required: true, }, - signedIn: { - type: Boolean, - required: false, - default: false, - }, issuableType: { type: String, required: false, @@ -143,7 +138,6 @@ export default { :number-of-assignees="store.assignees.length" :loading="loading || store.isFetching.assignees" :editable="store.editable" - :show-toggle="!signedIn" :changing="store.changing" /> <assignees 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 b424d9074d0..930e7ff12d9 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -77,14 +77,13 @@ export default { v-for="(user, index) in uncollapsedUsers" :key="user.id" :class="{ - 'gl-mb-3': index !== users.length - 1, + 'gl-mb-3': index !== users.length - 1 || users.length > 5, }" class="assignee-grid gl-display-grid gl-align-items-center gl-w-full" > <assignee-avatar-link :user="user" :issuable-type="issuableType" - :tooltip-has-name="!isMergeRequest" class="gl-word-break-word" data-css-area="user" > 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 916ff70a5ea..398a94356e2 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -4,10 +4,12 @@ import { __, n__, sprintf } from '~/locale'; import { createAlert } from '~/alert'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_ISSUE } from '~/graphql_shared/constants'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql'; import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql'; export default { + crmDocsLink: `${DOCS_URL_IN_EE_DIR}/user/crm/`, components: { GlIcon, GlLink, @@ -104,9 +106,7 @@ export default { <span> {{ contactCount }} </span> </div> <div class="hide-collapsed help-button gl-float-right"> - <gl-link href="https://docs.gitlab.com/ee/user/crm/" target="_blank" - ><gl-icon name="question-o" - /></gl-link> + <gl-link :href="$options.crmDocsLink" target="_blank"><gl-icon name="question-o" /></gl-link> </div> <div class="title hide-collapsed gl-mb-2 gl-line-height-20 gl-font-weight-bold"> {{ contactsLabel }} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue index 89a976d45fa..1c27df2418d 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue @@ -40,7 +40,6 @@ export default { <div class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute" data-testid="labels-select-dropdown-contents" - data-qa-selector="labels_dropdown_content" :style="directionStyle" > <component :is="dropdownContentsView" /> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue index b44096c7743..53582aacabd 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue @@ -20,6 +20,11 @@ export default { GlDropdownItem, GlLink, }, + inject: { + toggleAttrs: { + default: () => ({}), + }, + }, props: { labelsCreateTitle: { type: String, @@ -204,7 +209,7 @@ export default { class="gl-w-full" block data-testid="labels-select-dropdown-contents" - data-qa-selector="labels_dropdown_content" + :toggle-attrs="toggleAttrs" @hide="handleDropdownHide" @shown="setFocus" > @@ -219,7 +224,7 @@ export default { @toggleDropdownContentsCreateView="toggleDropdownContent" @closeDropdown="hideDropdown" @input="debouncedSearchKeyUpdate" - @searchEnter="selectFirstItem" + @searchEnter.prevent="selectFirstItem" /> </template> <template #default> 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 1d9233db361..1ea8ab19012 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -19,12 +19,12 @@ export default { locked: { icon: 'lock', class: 'value', - iconClass: 'is-active', + displayText: __('Locked'), }, unlocked: { class: ['no-value hide-collapsed'], icon: 'lock-open', - iconClass: '', + displayText: __('Unlocked'), }, components: { EditForm, @@ -49,8 +49,6 @@ export default { issueCapitalized: __('Issue'), mergeRequest: __('merge request'), mergeRequestCapitalized: __('Merge request'), - locked: __('Locked'), - unlocked: __('Unlocked'), lockingMergeRequest: __('Locking %{issuableDisplayName}'), unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'), lockMergeRequest: __('Lock %{issuableDisplayName}'), @@ -84,10 +82,7 @@ export default { return this.getNoteableData.discussion_locked; }, lockStatus() { - return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; - }, - tooltipLabel() { - return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked; + return this.isLocked ? this.$options.locked : this.$options.unlocked; }, lockToggleInProgressText() { return this.isLocked ? this.unlockingMergeRequestText : this.lockingMergeRequestText; @@ -205,7 +200,7 @@ export default { </gl-disclosure-dropdown-item> <div v-else class="block issuable-sidebar-item lock"> <div - v-gl-tooltip.left.viewport="{ title: tooltipLabel }" + v-gl-tooltip.left.viewport="{ title: lockStatus.displayText }" class="sidebar-collapsed-icon" data-testid="sidebar-collapse-icon" @click="toggleForm" @@ -239,7 +234,7 @@ export default { /> <div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class"> - {{ lockStatus }} + {{ lockStatus.displayText }} </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index bbd3cda0ad3..bad73273409 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { @@ -81,6 +82,9 @@ export default { toggleMoreParticipants() { this.isShowingMoreParticipants = !this.isShowingMoreParticipants; }, + getParticipantId(participantId) { + return getIdFromGraphQLId(participantId); + }, onClickCollapsedIcon() { this.$emit('toggleSidebar'); }, @@ -118,13 +122,14 @@ export default { > <a :href="participant.web_url || participant.webUrl" - class="author-link gl-display-inline-block gl-rounded-full" + :data-user-id="getParticipantId(participant.id)" + :data-username="participant.username" + class="author-link js-user-link gl-display-inline-block gl-rounded-full" > <user-avatar-image :lazy="lazy" :img-src="participant.avatar_url || participant.avatarUrl" :size="24" - :tooltip-text="participant.name" :img-alt="participant.name" css-classes="gl-mr-0!" tooltip-placement="bottom" diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 6f82178b6fd..88a74784dd2 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -67,7 +67,7 @@ export default { const names = renderUsers.map((u) => u.name); if (!this.users.length) { - return __('Reviewer(s)'); + return __('Reviewers'); } if (this.users.length > names.length) { 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 80c051f86b5..01787c97bca 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -3,7 +3,7 @@ // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; -import { __, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ReviewerAvatar from './reviewer_avatar.vue'; export default { @@ -23,16 +23,6 @@ export default { type: String, required: true, }, - tooltipPlacement: { - type: String, - default: 'bottom', - required: false, - }, - tooltipHasName: { - type: Boolean, - default: true, - required: false, - }, issuableType: { type: String, default: TYPE_ISSUE, @@ -45,21 +35,8 @@ export default { this.issuableType === TYPE_MERGE_REQUEST && !this.user.mergeRequestInteraction?.canMerge ); }, - tooltipTitle() { - if (this.cannotMerge && this.tooltipHasName) { - return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); - } else if (this.cannotMerge) { - return __('Cannot merge'); - } - - return ''; - }, - tooltipOption() { - return { - container: 'body', - placement: this.tooltipPlacement, - boundary: 'viewport', - }; + reviewerId() { + return getIdFromGraphQLId(this.user.id); }, reviewerUrl() { return this.user.webUrl; @@ -71,9 +48,11 @@ export default { <template> <!-- must be `d-inline-block` or parent flex-basis causes width issues --> <gl-link - v-gl-tooltip="tooltipOption" :href="reviewerUrl" - :title="tooltipTitle" + :data-user-id="reviewerId" + :data-username="user.username" + :data-cannot-merge="cannotMerge" + data-placement="left" class="gl-display-inline-block js-user-link" > <!-- use d-flex so that slot can be appropriately styled --> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue index 55de0ceb388..e2a3efa096f 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlTooltip, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { TYPE_INCIDENT } from '~/issues/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; @@ -12,8 +12,7 @@ export default { components: { GlTooltip, GlSprintf, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, SeverityToken, SidebarEditableItem, }, @@ -57,6 +56,13 @@ export default { return []; } }, + dropdownItems() { + return this.severitiesList.map((severity) => ({ + text: severity.label, + value: severity.value, + severity, + })); + }, selectedItem() { return this.severitiesList.find((severity) => severity.value === this.severity); }, @@ -99,7 +105,7 @@ export default { }); }, showDropdown() { - this.$refs.dropdown.show(); + this.$refs.dropdown.open(); }, }, }; @@ -131,24 +137,20 @@ export default { </template> <template #default> - <gl-dropdown + <gl-collapsible-listbox ref="dropdown" class="gl-mt-3" block :header-text="__('Assign severity')" - :text="selectedItem.label" + :toggle-text="selectedItem.label" + :items="dropdownItems" + :selected="severity" + @select="updateSeverity" > - <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 #list-item="{ item }"> + <severity-token :severity="item.severity" /> + </template> + </gl-collapsible-listbox> </template> </sidebar-editable-item> </div> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 67e76b575e0..8f6b855ecd6 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -118,7 +118,6 @@ function mountSidebarAssigneesDeprecated(mediator) { issuableIid: String(iid), projectPath: fullPath, field: el.dataset.field, - signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'), issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() ? TYPE_ISSUE diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index 2d2eede9137..a4653b75bab 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -21,6 +21,9 @@ export const SNIPPET_VISIBILITY = { label: __('Public'), icon: 'earth', description: __('The snippet can be accessed without any authentication.'), + description_project: __( + 'The snippet can be accessed without any authentication. To embed snippets, a project must be public.', + ), }, }; diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue index c017fa8afa2..66381e4da4d 100644 --- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -27,7 +27,7 @@ export default { <template> <a v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" - class="tanuki-logo-container" + class="brand-logo" :href="rootPath" :title="$options.i18n.homepage" data-track-action="click_link" diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index 0ce856c9af8..82f4fd18e80 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -11,10 +11,11 @@ import { TOP_NAV_INVITE_MEMBERS_COMPONENT, TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, } from '~/invite_members/constants'; -import { DROPDOWN_Y_OFFSET } from '../constants'; +import { DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; // Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET = -147; +const DROPDOWN_X_OFFSET_BASE = -147; +const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { components: { @@ -27,6 +28,7 @@ export default { i18n: { createNew: __('Create new...'), }, + inject: ['isImpersonating'], props: { groups: { type: Array, @@ -38,13 +40,20 @@ export default { dropdownOpen: false, }; }, + computed: { + dropdownOffset() { + return { + mainAxis: DROPDOWN_Y_OFFSET, + crossAxis: this.isImpersonating ? DROPDOWN_X_OFFSET_IMPERSONATING : DROPDOWN_X_OFFSET_BASE, + }; + }, + }, methods: { isInvitedMembers(groupItem) { return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT; }, }, toggleId: 'create-menu-toggle', - dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, }; </script> @@ -58,7 +67,7 @@ export default { text-sr-only :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" - :dropdown-offset="$options.dropdownOffset" + :dropdown-offset="dropdownOffset" data-qa-selector="new_menu_toggle" data-testid="new-menu-toggle" @shown="dropdownOpen = true" diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue index 02adebc50af..342e1284e86 100644 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -105,7 +105,6 @@ export default { icon="dash" :aria-label="$options.i18n.removeItem" :title="$options.i18n.removeItem" - class="gl-align-self-center gl-mr-2" data-testid="item-remove" @click.stop.prevent="handleItemRemove(item)" /> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue index 96e6c9bab9e..a1d0e400b5f 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue @@ -2,21 +2,25 @@ import { debounce } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { getFormattedItem } from '../utils'; + import { COMMON_HANDLES, COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE, - GLOBAL_COMMANDS_GROUP_TITLE, + PATH_HANDLE, PAGES_GROUP_TITLE, + PATH_GROUP_TITLE, GROUP_TITLES, + MAX_ROWS, } from './constants'; import SearchItem from './search_item.vue'; -import { commandMapper, linksReducer, autocompleteQuery } from './utils'; +import { commandMapper, linksReducer, autocompleteQuery, fileMapper } from './utils'; export default { name: 'CommandPaletteItems', @@ -25,7 +29,14 @@ export default { GlLoadingIcon, SearchItem, }, - inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'], + inject: [ + 'commandPaletteCommands', + 'commandPaletteLinks', + 'autocompletePath', + 'searchContext', + 'projectFilesPath', + 'projectBlobPath', + ], props: { searchQuery: { type: String, @@ -35,21 +46,45 @@ export default { type: String, required: true, validator: (value) => { - return COMMON_HANDLES.includes(value); + return [...COMMON_HANDLES, PATH_HANDLE].includes(value); }, }, }, data: () => ({ groups: [], - error: null, loading: false, + projectFiles: [], + debouncedSearch: debounce(function debouncedSearch() { + switch (this.handle) { + case COMMAND_HANDLE: + this.getCommandsAndPages(); + break; + /* TODO: Search for recent issues initiated by #(ISSUE_HANDLE) from the command palette scope + was removed as using the # in command palette conflicted + with the existing global search functionality to search for issue by its id. + The code that performs the Recent issues search was not removed from the code base + as it would be nice to bring it back when we decide how to combine both search by id and text. + In scope of https://gitlab.com/gitlab-org/gitlab/-/issues/417434 + we either bring back the search by #issue_text or remove the related code completely */ + case USER_HANDLE: + case PROJECT_HANDLE: + case ISSUE_HANDLE: + this.getScopedItems(); + break; + case PATH_HANDLE: + this.getProjectFiles(); + break; + default: + break; + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }), computed: { isCommandMode() { return this.handle === COMMAND_HANDLE; }, - isUserMode() { - return this.handle === USER_HANDLE; + isPathMode() { + return this.handle === PATH_HANDLE; }, commands() { return this.commandPaletteCommands.map(commandMapper); @@ -62,7 +97,7 @@ export default { ? this.commands .map(({ name, items }) => { return { - name: name || GLOBAL_COMMANDS_GROUP_TITLE, + name, items: this.filterBySearchQuery(items, 'text'), }; }) @@ -73,7 +108,7 @@ export default { return this.groups?.length && this.groups.some((group) => group.items?.length); }, hasSearchQuery() { - if (this.isCommandMode) { + if (this.isCommandMode || this.isPathMode) { return this.searchQuery?.length > 0; } return this.searchQuery?.length > 2; @@ -84,44 +119,58 @@ export default { } return this.searchQuery; }, + filteredProjectFiles() { + if (!this.searchQuery) { + return this.projectFiles.slice(0, MAX_ROWS); + } + return this.filterBySearchQuery(this.projectFiles, 'text').slice(0, MAX_ROWS); + }, }, watch: { searchQuery: { handler() { - switch (this.handle) { - case COMMAND_HANDLE: - this.getCommandsAndPages(); - break; - case USER_HANDLE: - case PROJECT_HANDLE: - case ISSUE_HANDLE: - this.getScopedItems(); - break; - default: - break; - } + this.debouncedSearch(); }, immediate: true, }, }, + updated() { + this.$emit('updated'); + }, methods: { filterBySearchQuery(items, key = 'keywords') { return fuzzaldrinPlus.filter(items, this.searchQuery, { key }); }, + async getProjectFiles() { + if (!this.projectFiles.length) { + this.loading = true; + + try { + const response = await axios.get(this.projectFilesPath); + this.projectFiles = response?.data.map(fileMapper.bind(null, this.projectBlobPath)); + } catch (error) { + Sentry.captureException(error); + } finally { + this.loading = false; + } + } + + this.groups = [ + { + name: PATH_GROUP_TITLE, + items: this.filteredProjectFiles, + }, + ]; + }, getCommandsAndPages() { if (!this.searchQuery) { this.groups = [...this.commands]; return; } - const matchedLinks = this.filterBySearchQuery(this.links); - if (this.filteredCommands.length || matchedLinks.length) { - this.groups = []; - } + this.groups = [...this.filteredCommands]; - if (this.filteredCommands.length) { - this.groups = [...this.filteredCommands]; - } + const matchedLinks = this.filterBySearchQuery(this.links); if (matchedLinks.length) { this.groups.push({ @@ -130,62 +179,57 @@ export default { }); } }, - getScopedItems: debounce(function debouncedSearch() { - if (this.searchQuery && this.searchQuery.length < 3) return null; + async getScopedItems() { + if (this.searchQuery && this.searchQuery.length < 3) return; this.loading = true; - return axios - .get( + try { + const response = await axios.get( autocompleteQuery({ path: this.autocompletePath, searchTerm: this.searchTerm, handle: this.handle, projectId: this.searchContext.project?.id, }), - ) - .then(({ data }) => { - this.groups = this.getGroups(data); - }) - .catch((error) => { - this.error = error; - }) - .finally(() => { - this.loading = false; - }); - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getGroups(data) { - return [ - { - name: GROUP_TITLES[this.handle], - items: data.map(getFormattedItem), - }, - ]; + ); + + this.groups = [ + { + name: GROUP_TITLES[this.handle], + items: response.data.map(getFormattedItem), + }, + ]; + } catch (error) { + Sentry.captureException(error); + } finally { + this.loading = false; + } }, }, }; </script> <template> - <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <div> <gl-loading-icon v-if="loading" size="lg" class="gl-my-5" /> - <template v-else-if="hasResults"> + <ul v-else-if="hasResults" class="gl-p-0 gl-m-0 gl-list-style-none"> <gl-disclosure-dropdown-group v-for="(group, index) in groups" :key="index" :group="group" bordered - class="{'gl-mt-0!': index===0}" + :class="{ 'gl-mt-0!': index === 0 }" > <template #list-item="{ item }"> <search-item :item="item" :search-query="searchQuery" /> </template> </gl-disclosure-dropdown-group> - </template> + </ul> <div v-else-if="hasSearchQuery && !hasResults" class="gl-text-gray-700 gl-pl-5 gl-py-3"> {{ __('No results found') }} </div> - </ul> + </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js index 9dab16984f5..a43e621da44 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js @@ -2,19 +2,21 @@ import { s__, sprintf } from '~/locale'; export const COMMAND_HANDLE = '>'; export const USER_HANDLE = '@'; -export const PROJECT_HANDLE = '&'; +export const PROJECT_HANDLE = ':'; export const ISSUE_HANDLE = '#'; +export const PATH_HANDLE = '/'; -export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE]; +export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE]; export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf( s__( - 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...', + 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{pathHandle} for project file, or perform generic search...', ), { commandHandle: COMMAND_HANDLE, userHandle: USER_HANDLE, issueHandle: ISSUE_HANDLE, projectHandle: PROJECT_HANDLE, + pathHandle: PATH_HANDLE, }, false, ); @@ -24,6 +26,7 @@ export const SEARCH_SCOPE_PLACEHOLDER = { [USER_HANDLE]: s__('CommandPalette|user (enter at least 3 chars)'), [PROJECT_HANDLE]: s__('CommandPalette|project (enter at least 3 chars)'), [ISSUE_HANDLE]: s__('CommandPalette|issue (enter at least 3 chars)'), + [PATH_HANDLE]: s__('CommandPalette|go to project file'), }; export const SEARCH_SCOPE = { @@ -37,9 +40,13 @@ export const USERS_GROUP_TITLE = s__('GlobalSearch|Users'); export const PAGES_GROUP_TITLE = s__('CommandPalette|Pages'); export const PROJECTS_GROUP_TITLE = s__('GlobalSearch|Projects'); export const ISSUE_GROUP_TITLE = s__('GlobalSearch|Recent issues'); +export const PATH_GROUP_TITLE = s__('CommandPalette|Project files'); export const GROUP_TITLES = { [USER_HANDLE]: USERS_GROUP_TITLE, [PROJECT_HANDLE]: PROJECTS_GROUP_TITLE, [ISSUE_HANDLE]: ISSUE_GROUP_TITLE, + [PATH_HANDLE]: PATH_GROUP_TITLE, }; + +export const MAX_ROWS = 20; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue index dce2b24f551..efd93e88fa9 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -1,5 +1,5 @@ <script> -import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants'; +import { COMMON_HANDLES, PATH_HANDLE, SEARCH_SCOPE_PLACEHOLDER } from './constants'; export default { name: 'FakeSearchInput', @@ -11,7 +11,7 @@ export default { scope: { type: String, required: true, - validator: (value) => COMMON_HANDLES.includes(value), + validator: (value) => [...COMMON_HANDLES, PATH_HANDLE].includes(value), }, }, computed: { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js index 5c8c0e59eaf..347a8ffb0b4 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js @@ -1,12 +1,12 @@ import { isNil, omitBy } from 'lodash'; -import { objectToQuery } from '~/lib/utils/url_utility'; -import { SEARCH_SCOPE } from './constants'; +import { objectToQuery, joinPaths } from '~/lib/utils/url_utility'; +import { SEARCH_SCOPE, GLOBAL_COMMANDS_GROUP_TITLE } from './constants'; export const commandMapper = ({ name, items }) => { // TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here // and is out of scope for the basic command palette items. If it proves to be useful, we can add it later. return { - name, + name: name || GLOBAL_COMMANDS_GROUP_TITLE, items: items.filter(({ component }) => component !== 'invite_members'), }; }; @@ -32,6 +32,14 @@ export const linksReducer = (acc, menuItem) => { return acc; }; +export const fileMapper = (projectBlobPath, file) => { + return { + icon: 'doc-code', + text: file, + href: joinPaths(projectBlobPath, file), + }; +}; + export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => { const query = omitBy( { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index cb34f2b8c26..bec8c191b31 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -25,20 +25,24 @@ import { SEARCH_RESULTS_SCOPE, } from '~/vue_shared/global_search/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, - IS_SEARCHING, SEARCH_MODAL_ID, SEARCH_INPUT_SELECTOR, SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; import CommandPaletteItems from '../command_palette/command_palette_items.vue'; import FakeSearchInput from '../command_palette/fake_search_input.vue'; -import { COMMON_HANDLES, SEARCH_OR_COMMAND_MODE_PLACEHOLDER } from '../command_palette/constants'; +import { + COMMON_HANDLES, + PATH_HANDLE, + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, +} from '../command_palette/constants'; import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; import GlobalSearchDefaultItems from './global_search_default_items.vue'; import GlobalSearchScopedItems from './global_search_scoped_items.vue'; @@ -68,6 +72,11 @@ export default { FakeSearchInput, }, mixins: [glFeatureFlagMixin()], + data() { + return { + nextFocusedItemIndex: null, + }; + }, computed: { ...mapState(['search', 'loading', 'searchContext']), ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), @@ -108,34 +117,38 @@ export default { count: this.searchOptions.length, }); }, - searchBarClasses() { - return { - [IS_SEARCHING]: this.searchTermOverMin, - }; - }, - showScopeHelp() { + showScopeToken() { return this.searchTermOverMin && !this.isCommandMode; }, searchBarItem() { return this.searchOptions?.[0]; }, - infieldHelpContent() { + scopeTokenText() { return this.searchBarItem?.scope || this.searchBarItem?.description; }, - infieldHelpIcon() { - return this.searchBarItem?.icon; + scopeTokenIcon() { + if (!this.isCommandMode) { + return this.searchBarItem?.icon; + } + return null; }, - scopeTokenTitle() { + searchScope() { return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, { - scope: this.infieldHelpContent, + scope: this.scopeTokenText, }); }, - + truncatedSearchScope() { + return truncate(this.searchScope, SCOPE_TOKEN_MAX_LENGTH); + }, searchTextFirstChar() { return this.searchText?.trim().charAt(0); }, isCommandMode() { - return this.glFeatures?.commandPalette && COMMON_HANDLES.includes(this.searchTextFirstChar); + return ( + this.glFeatures?.commandPalette && + (COMMON_HANDLES.includes(this.searchTextFirstChar) || + (this.searchContext.project && this.searchTextFirstChar === PATH_HANDLE)) + ); }, commandPaletteQuery() { if (this.isCommandMode) { @@ -143,6 +156,14 @@ export default { } return ''; }, + commandHighlightClass() { + return darkModeEnabled() ? 'gl-bg-gray-10!' : 'gl-bg-gray-50!'; + }, + }, + watch: { + nextFocusedItemIndex() { + this.highlightFirstCommand(); + }, }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), @@ -156,9 +177,6 @@ export default { this.fetchAutocompleteOptions(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getTruncatedScope(scope) { - return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); - }, observeTokenWidth({ contentRect: { width } }) { const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input'); if (!inputField) { @@ -206,7 +224,7 @@ export default { } }, focusSearchInput() { - this.$refs.searchInputBox.$el.querySelector('input').focus(); + this.$refs.searchInput.$el.querySelector('input').focus(); }, focusNextItem(event, elements, offset) { const { target } = event; @@ -221,11 +239,34 @@ export default { elements[index]?.focus(); }, submitSearch() { + if (this.isCommandMode) { + this.runFirstCommand(); + return; + } if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { return; } visitUrl(this.searchQuery); }, + runFirstCommand() { + this.getFocusableOptions()[0]?.firstChild.click(); + }, + onSearchModalShown() { + this.$emit('shown'); + }, + onSearchModalHidden() { + this.searchText = ''; + this.$emit('hidden'); + }, + highlightFirstCommand() { + if (this.isCommandMode) { + const activeCommand = this.getFocusableOptions()[0]?.firstChild; + activeCommand?.classList.toggle( + this.commandHighlightClass, + Boolean(!this.nextFocusedItemIndex), + ); + } + }, }, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, @@ -243,24 +284,22 @@ export default { body-class="gl-p-0!" modal-class="global-search-modal" :centered="false" - @hidden="$emit('hidden')" - @shown="$emit('shown')" + @shown="onSearchModalShown" + @hide="onSearchModalHidden" > <form role="search" :aria-label="searchPlaceholder" class="gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" data-testid="global-search-form" > <div class="gl-p-1 gl-relative"> <gl-search-box-by-type id="search" - ref="searchInputBox" + ref="searchInput" v-model="searchText" role="searchbox" data-testid="global-search-input" - data-qa-selector="global_search_input" autocomplete="off" :placeholder="searchPlaceholder" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @@ -270,24 +309,20 @@ export default { @keydown="onKeydown" /> <gl-token - v-if="showScopeHelp" + v-if="showScopeToken" v-gl-resize-observer-directive="observeTokenWidth" - class="in-search-scope-help gl-sm-display-block gl-display-none" + class="search-scope-help gl-absolute gl-sm-display-block gl-display-none" view-only - :title="scopeTokenTitle" + :title="searchScope" > <gl-icon - v-if="infieldHelpIcon" + v-if="scopeTokenIcon" class="gl-mr-2" - :aria-label="infieldHelpContent" - :name="infieldHelpIcon" + :aria-label="scopeTokenText" + :name="scopeTokenIcon" :size="16" /> - {{ - getTruncatedScope( - sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), - ) - }} + {{ truncatedSearchScope }} </gl-token> <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} @@ -319,6 +354,7 @@ export default { v-if="isCommandMode" :search-query="commandPaletteQuery" :handle="searchTextFirstChar" + @updated="highlightFirstCommand" /> <template v-else> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js index cb267df6122..5a860fcd1ab 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -18,8 +18,6 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36; export const INPUT_FIELD_PADDING = 84; -export const IS_SEARCHING = 'is-searching'; - export const FETCH_TYPES = ['generic', 'search']; export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 1d4c24c6853..8ce82116194 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -38,7 +38,7 @@ export default { shortcuts: __('Keyboard shortcuts'), version: __('Your GitLab version'), whatsnew: __("What's new"), - chat: s__('TanukiBot|Ask GitLab Chat'), + chat: s__('TanukiBot|Ask GitLab Duo'), }, props: { sidebarData: { diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index 7d5af883651..764db490751 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -19,13 +19,7 @@ export default { <template> <ul class="gl-p-0 gl-list-style-none"> - <nav-item - v-for="item in items" - :key="item.id" - :item="item" - :link-classes="{ 'gl-py-2!': true }" - is-subitem - > + <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem> <template #icon> <project-avatar :project-id="item.id" @@ -33,7 +27,6 @@ export default { :project-avatar-url="item.avatar" :size="24" aria-hidden="true" - class="gl-mr-n2" /> </template> <template #actions> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index b5a8241a286..73a899eeb83 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -71,7 +71,7 @@ export default { <component :is="tag"> <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" /> <button - class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-line-height-normal gl-mb-2 gl-py-3 gl-px-0 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-2 gl-py-2 gl-px-3 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus" :class="computedLinkClasses" data-qa-selector="menu_section_button" :data-qa-section-name="item.title" @@ -84,17 +84,17 @@ export default { aria-hidden="true" style="width: 3px; border-radius: 3px; margin-right: 1px" ></span> - <span class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> + <span class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" /> </slot> </span> - <span class="gl-pr-3 gl-text-gray-900 gl-truncate-end"> + <span class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"> {{ item.title }} </span> - <span class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-text-gray-400"> + <span class="gl-text-right gl-text-gray-400"> <gl-icon :name="collapseIcon" /> </span> </button> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 0ee9db10ee2..c1e1f64dbc1 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -102,9 +102,8 @@ export default { }, computedLinkClasses() { return { - 'gl-py-2': this.isPinnable, - 'gl-py-3': !this.isPinnable, - 'gl-mx-2': this.isSubitem, + 'gl-px-2 gl-mx-2 gl-line-height-normal': this.isSubitem, + 'gl-px-3': !this.isSubitem, [this.item.link_classes]: this.item.link_classes, ...this.linkClasses, }; @@ -112,9 +111,6 @@ export default { navItemLinkComponent() { return this.item.to ? NavItemRouterLink : NavItemLink; }, - iconClasses() { - return this.isSubitem === true ? 'gl-ml-2 gl-mr-4' : 'gl-w-6 gl-mx-3'; - }, }, }; </script> @@ -125,7 +121,7 @@ export default { :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus" + class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus" :class="computedLinkClasses" data-qa-selector="nav_item_link" data-testid="nav-item-link" @@ -137,13 +133,13 @@ export default { style="width: 3px; border-radius: 3px; margin-right: 1px" data-testid="active-indicator" ></div> - <div :class="iconClasses" class="gl-flex-shrink-0"> + <div class="gl-flex-shrink-0 gl-w-6 gl-display-flex"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-m-auto item-icon" /> <gl-icon v-else-if="isInPinnedSection" name="grip" - class="gl-text-gray-400 gl-ml-2 draggable-icon" + class="gl-m-auto gl-text-gray-400 draggable-icon" /> </slot> </div> @@ -154,7 +150,7 @@ export default { </div> </div> <slot name="actions"></slot> - <span v-if="hasPill || isPinnable" class="gl-text-right gl-mr-3 gl-relative"> + <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative"> <gl-badge v-if="hasPill" size="sm" diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue index 9d2836e9dfa..6058ed3a1cd 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue @@ -1,5 +1,6 @@ <script> import { getCssClassDimensions } from '~/lib/utils/css_utils'; +import Tracking from '~/tracking'; import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants'; export const STATE_CLOSED = 'closed'; @@ -9,6 +10,7 @@ export const STATE_WILL_CLOSE = 'will-close'; export default { name: 'SidebarPeek', + mixins: [Tracking.mixin()], created() { // Nothing needs to observe these properties, so they are not reactive. this.state = null; @@ -88,6 +90,10 @@ export default { open() { if (this.changeState(STATE_OPEN)) { this.clearTimers(); + this.track('nav_peek', { + label: 'nav_hover', + property: 'nav_sidebar', + }); } }, close() { diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index 6b1efc4217c..c194401ce95 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { Mousetrap } from '~/lib/mousetrap'; import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import { sidebarState } from '../constants'; import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; import UserBar from './user_bar.vue'; @@ -26,6 +27,7 @@ export default { TrialStatusPopover: () => import('ee_component/contextual_sidebar/components/trial_status_popover.vue'), }, + mixins: [Tracking.mixin()], i18n: { skipToMainContent: __('Skip to main content'), }, @@ -68,6 +70,10 @@ export default { }, methods: { toggleSidebar() { + this.track(isCollapsed() ? 'nav_show' : 'nav_hide', { + label: 'nav_toggle_keyboard_shortcut', + property: 'nav_sidebar', + }); toggleSuperSidebarCollapsed(!isCollapsed(), true); }, collapseSidebar() { diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue index 4fff5cf832e..87762a62c0f 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants'; import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; @@ -11,6 +12,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], props: { tooltipContainer: { type: String, @@ -52,6 +54,10 @@ export default { }, methods: { toggle() { + this.track(this.isCollapsed ? 'nav_show' : 'nav_hide', { + label: 'nav_toggle', + property: 'nav_sidebar', + }); toggleSuperSidebarCollapsed(!this.isCollapsed, true); this.focusOtherToggle(); }, diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index d3b2143aaa7..a882df057fa 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -130,7 +130,6 @@ export default { v-gl-tooltip.bottom.hover.html="searchTooltip" v-gl-modal="$options.SEARCH_MODAL_ID" data-testid="super-sidebar-search-button" - data-qa-selector="global_search_button" icon="search" :aria-label="$options.i18n.search" category="tertiary" @@ -150,7 +149,6 @@ export default { category="tertiary" data-method="delete" data-testid="stop-impersonation-btn" - data-qa-selector="stop_impersonation_link" /> </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> @@ -161,6 +159,7 @@ export default { :count="userCounts.assigned_issues" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" + data-testid="issues-shortcut-button" data-track-action="click_link" data-track-label="issues_link" data-track-property="nav_core_menu" @@ -177,6 +176,7 @@ export default { icon="merge-request-open" :count="mergeRequestTotalCount" :label="$options.i18n.mergeRequests" + data-testid="merge-requests-shortcut-button" data-track-action="click_dropdown" data-track-label="merge_requests_menu" data-track-property="nav_core_menu" @@ -189,7 +189,7 @@ export default { :count="userCounts.todos" :href="sidebarData.todos_dashboard_path" :label="$options.i18n.todoList" - data-qa-selector="todos_shortcut_button" + data-testid="todos-shortcut-button" data-track-action="click_link" data-track-label="todos_link" data-track-property="nav_core_menu" diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 7d4991fbe96..869f07520a2 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -11,11 +11,12 @@ import { s__, __, sprintf } from '~/locale'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; -import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants'; +import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; import UserNameGroup from './user_name_group.vue'; // Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET = -211; +const DROPDOWN_X_OFFSET_BASE = -211; +const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005', @@ -47,7 +48,7 @@ export default { SafeHtml, }, mixins: [Tracking.mixin()], - inject: ['toggleNewNavEndpoint'], + inject: ['toggleNewNavEndpoint', 'isImpersonating'], props: { data: { required: true, @@ -89,7 +90,7 @@ export default { text: this.$options.i18n.editProfile, href: this.data.settings.profile_path, extraAttrs: { - 'data-qa-selector': 'edit_profile_link', + 'data-testid': 'edit_profile_link', ...USER_MENU_TRACKING_DEFAULTS, 'data-track-label': 'user_edit_profile', }, @@ -149,7 +150,7 @@ export default { href: this.data.sign_out_link, extraAttrs: { 'data-method': 'post', - 'data-qa-selector': 'sign_out_link', + 'data-testid': 'sign_out_link', class: 'sign-out-link', }, }, @@ -188,6 +189,12 @@ export default { showNotificationDot() { return this.data.pipeline_minutes?.show_notification_dot; }, + dropdownOffset() { + return { + mainAxis: DROPDOWN_Y_OFFSET, + crossAxis: this.isImpersonating ? DROPDOWN_X_OFFSET_IMPERSONATING : DROPDOWN_X_OFFSET_BASE, + }; + }, }, methods: { onShow() { @@ -221,7 +228,6 @@ export default { }); }, }, - dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET }, }; </script> @@ -229,9 +235,8 @@ export default { <div> <gl-disclosure-dropdown ref="userDropdown" - :dropdown-offset="$options.dropdownOffset" + :dropdown-offset="dropdownOffset" data-testid="user-dropdown" - data-qa-selector="user_menu" :auto-close="false" @shown="onShow" > @@ -243,7 +248,7 @@ export default { :entity-name="data.name" :src="data.avatar_url" aria-hidden="true" - data-qa-selector="user_avatar_content" + data-testid="user_avatar_content" /> <span v-if="showNotificationDot" diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue index dfaaaccf4a4..f3e8816cd37 100644 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -41,7 +41,7 @@ export default { item.extraAttrs = { ...USER_MENU_TRACKING_DEFAULTS, 'data-track-label': 'user_profile', - 'data-qa-selector': 'user_profile_link', + 'data-testid': 'user_profile_link', }; } @@ -74,13 +74,13 @@ export default { class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm" > <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> - <span v-safe-html="user.status.message" class="gl-text-truncate"></span> + <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> <gl-tooltip :target="() => $refs.statusTooltipTarget" boundary="viewport" placement="bottom" > - <span v-safe-html="user.status.message"></span> + <span v-safe-html="user.status.message_html"></span> </gl-tooltip> </span> </span> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index 00ceaebe2cc..757bf9c7459 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -52,3 +52,5 @@ export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10; export const DROPDOWN_Y_OFFSET = 4; export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08'; + +export const IMPERSONATING_OFFSET = 32; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index f6afde02fa5..322eca72016 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -65,13 +65,23 @@ export const initSuperSidebar = () => { if (!el) return false; - const { rootPath, sidebar, toggleNewNavEndpoint, forceDesktopExpandedSidebar } = el.dataset; + const { + rootPath, + sidebar, + toggleNewNavEndpoint, + forceDesktopExpandedSidebar, + commandPalette, + } = el.dataset; bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar); initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar)); const sidebarData = JSON.parse(sidebar); const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const commandPaletteData = JSON.parse(commandPalette); + const projectFilesPath = commandPaletteData.project_files_url; + const projectBlobPath = commandPaletteData.project_blob_url; const commandPaletteCommands = sidebarData.create_new_menu_groups || []; const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []); @@ -91,6 +101,8 @@ export const initSuperSidebar = () => { commandPaletteLinks, autocompletePath, searchContext, + projectFilesPath, + projectBlobPath, }, store: createStore({ searchPath, diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js index 2687ea5ccf8..feb7e274b07 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -1,6 +1,7 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { debounce } from 'lodash'; import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; import { sidebarState } from './constants'; export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed'; @@ -50,7 +51,15 @@ export const bindSuperSidebarCollapsedEvents = (forceDesktopExpandedSidebar = fa const widthChanged = previousWindowWidth !== newWindowWidth; if (widthChanged) { + const collapsedBeforeResize = sidebarState.isCollapsed; initSuperSidebarCollapsedState(forceDesktopExpandedSidebar); + const collapsedAfterResize = sidebarState.isCollapsed; + if (!collapsedBeforeResize && collapsedAfterResize) { + Tracking.event(undefined, 'nav_hide', { + label: 'browser_resize', + property: 'nav_sidebar', + }); + } } previousWindowWidth = newWindowWidth; }, 100); diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index 333059b5340..2dd658d62ea 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -6,6 +6,7 @@ import { s__, __ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; import Tracking from '~/tracking'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; const steps = [ { @@ -50,6 +51,7 @@ export default { thanks: s__('MrSurvey|Thank you for your feedback!'), }, gitlabLogo, + privacyLink: `${PROMO_URL}/privacy/`, data() { return { visible: false, @@ -152,7 +154,7 @@ export default { <template #link="{ content }"> <a class="gl-text-decoration-underline gl-text-gray-500" - href="https://about.gitlab.com/privacy/" + :href="$options.privacyLink" target="_blank" rel="noreferrer nofollow" v-text="content" diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index 9c9b0d37b68..f70bb77b780 100644 --- a/app/assets/javascripts/token_access/components/outbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -21,7 +21,6 @@ import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects import TokenProjectsTable from './token_projects_table.vue'; // Note: This component will be removed in 17.0, as the outbound access token is getting deprecated -// Some warnings are behind the `frozen_outbound_job_token_scopes` feature flag export default { i18n: { toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'), @@ -127,14 +126,8 @@ export default { ciJobTokenHelpPage() { return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access'); }, - disableOutboundToken() { - return ( - this.glFeatures?.frozenOutboundJobTokenScopes && - !this.glFeatures?.frozenOutboundJobTokenScopesOverride - ); - }, disableTokenToggle() { - return !this.jobTokenScopeEnabled && this.disableOutboundToken; + return !this.jobTokenScopeEnabled; }, }, methods: { @@ -226,7 +219,6 @@ export default { <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" /> <template v-else> <gl-alert - v-if="disableOutboundToken" class="gl-mb-3" variant="warning" :dismissible="false" @@ -260,7 +252,7 @@ export default { <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank"> {{ content }} </gl-link> - <strong v-if="disableOutboundToken">{{ $options.i18n.disableToggleWarning }} </strong> + <strong>{{ $options.i18n.disableToggleWarning }} </strong> </template> </gl-sprintf> </template> @@ -274,7 +266,7 @@ export default { <template #default> <gl-form-input v-model="targetProjectPath" - :disabled="disableOutboundToken" + :disabled="true" :placeholder="$options.i18n.addProjectPlaceholder" data-testid="project-path-input" /> @@ -286,16 +278,6 @@ export default { <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> </template> </gl-card> - <gl-alert - v-if="!jobTokenScopeEnabled && !disableOutboundToken" - class="gl-mb-3" - variant="warning" - :dismissible="false" - :show-icon="false" - data-testid="token-disabled-alert" - > - {{ $options.i18n.settingDisabledMessage }} - </gl-alert> <token-projects-table :projects="projects" :table-fields="$options.fields" diff --git a/app/assets/javascripts/tracing/components/tracing_empty_state.vue b/app/assets/javascripts/tracing/components/tracing_empty_state.vue new file mode 100644 index 00000000000..4cb3bd6d9f0 --- /dev/null +++ b/app/assets/javascripts/tracing/components/tracing_empty_state.vue @@ -0,0 +1,46 @@ +<script> +import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + EMPTY_TRACING_SVG, + name: 'TracingEmptyState', + i18n: { + title: __('Get started with Tracing'), + description: __('Monitor your applications with GitLab Distributed Tracing.'), + enableButtonText: __('Enable'), + }, + components: { + GlEmptyState, + GlButton, + }, + props: { + enableTracing: { + type: Function, + required: true, + }, + }, + methods: { + onEnabledClicked() { + this.enableTracing(); + }, + }, +}; +</script> + +<template> + <gl-empty-state :title="$options.i18n.title" :svg-path="$options.EMPTY_TRACING_SVG"> + <template #description> + <div> + <span>{{ $options.i18n.description }}</span> + </div> + </template> + + <template #actions> + <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="onEnabledClicked"> + {{ $options.i18n.enableButtonText }} + </gl-button> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/tracing/components/tracing_list.vue b/app/assets/javascripts/tracing/components/tracing_list.vue new file mode 100644 index 00000000000..294e520d7ac --- /dev/null +++ b/app/assets/javascripts/tracing/components/tracing_list.vue @@ -0,0 +1,93 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; +import TracingEmptyState from './tracing_empty_state.vue'; +import TracingTableList from './tracing_table_list.vue'; + +export default { + components: { + GlLoadingIcon, + TracingTableList, + TracingEmptyState, + }, + props: { + observabilityClient: { + required: true, + type: Object, + }, + }, + data() { + return { + loading: true, + /** + * tracingEnabled: boolean | null. + * null identifies a state where we don't know if tracing is enabled or not (e.g. when fetching the status from the API fails) + */ + tracingEnabled: null, + traces: [], + }; + }, + async created() { + this.checkEnabled(); + }, + methods: { + async checkEnabled() { + this.loading = true; + try { + this.tracingEnabled = await this.observabilityClient.isTracingEnabled(); + if (this.tracingEnabled) { + await this.fetchTraces(); + } + } catch (e) { + createAlert({ + message: __('Failed to load page.'), + }); + } finally { + this.loading = false; + } + }, + async enableTracing() { + this.loading = true; + try { + await this.observabilityClient.enableTraces(); + this.tracingEnabled = true; + await this.fetchTraces(); + } catch (e) { + createAlert({ + message: __('Failed to enable tracing.'), + }); + } finally { + this.loading = false; + } + }, + async fetchTraces() { + this.loading = true; + try { + const traces = await this.observabilityClient.fetchTraces(); + this.traces = traces; + } catch (e) { + createAlert({ + message: __('Failed to load traces.'), + }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="gl-py-5"> + <gl-loading-icon size="lg" /> + </div> + + <template v-else-if="tracingEnabled !== null"> + <tracing-empty-state v-if="tracingEnabled === false" :enable-tracing="enableTracing" /> + + <tracing-table-list v-else :traces="traces" @reload="fetchTraces" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/tracing/components/tracing_table_list.vue b/app/assets/javascripts/tracing/components/tracing_table_list.vue new file mode 100644 index 00000000000..7e8c296a7d4 --- /dev/null +++ b/app/assets/javascripts/tracing/components/tracing_table_list.vue @@ -0,0 +1,89 @@ +<script> +import { GlTable, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center'; +export default { + name: 'TracingTableList', + i18n: { + title: __('Traces'), + emptyText: __('No traces to display.'), + emptyLinkText: __('Check again'), + }, + fields: [ + { + key: 'date', + label: __('Date'), + tdClass: tableDataClass, + sortable: true, + }, + { + key: 'service', + label: __('Service'), + tdClass: tableDataClass, + sortable: true, + }, + { + key: 'operation', + label: __('Operation'), + tdClass: tableDataClass, + sortable: true, + }, + { + key: 'duration', + label: __('Duration'), + thClass: 'gl-w-15p', + tdClass: tableDataClass, + sortable: true, + }, + ], + components: { + GlTable, + GlLink, + }, + props: { + traces: { + required: true, + type: Array, + }, + }, +}; +</script> + +<template> + <div> + <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ $options.i18n.title }}</h4> + + <gl-table + class="gl-mt-5" + :items="traces" + :fields="$options.fields" + show-empty + fixed + stacked="md" + tbody-tr-class="table-row" + > + <template #cell(date)="data"> + {{ data.item.timestamp }} + </template> + + <template #cell(service)="data"> + {{ data.item.service_name }} + </template> + + <template #cell(operation)="data"> + {{ data.item.operation }} + </template> + + <template #cell(duration)="data"> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + {{ `${data.item.duration} ms` }} + </template> + + <template #empty> + {{ $options.i18n.emptyText }} + <gl-link @click="$emit('reload')">{{ $options.i18n.emptyLinkText }}</gl-link> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/tracing/list_index.vue b/app/assets/javascripts/tracing/list_index.vue new file mode 100644 index 00000000000..432fbb81506 --- /dev/null +++ b/app/assets/javascripts/tracing/list_index.vue @@ -0,0 +1,37 @@ +<script> +import ObservabilityContainer from '~/observability/components/observability_container.vue'; +import TracingList from './components/tracing_list.vue'; + +export default { + components: { + ObservabilityContainer, + TracingList, + }, + props: { + oauthUrl: { + type: String, + required: true, + }, + tracingUrl: { + type: String, + required: true, + }, + provisioningUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <observability-container + :oauth-url="oauthUrl" + :tracing-url="tracingUrl" + :provisioning-url="provisioningUrl" + > + <template #default="{ observabilityClient }"> + <tracing-list :observability-client="observabilityClient" /> + </template> + </observability-container> +</template> diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 968e866eedd..d0447fa167c 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -19,9 +19,14 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { export const ACTION_ATTR_SELECTOR = '[data-track-action]'; export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]'; +export const INTERNAL_EVENTS_SELECTOR = '[data-event-tracking]'; export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls'; export const REFERRER_TTL = 24 * 60 * 60 * 1000; export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga'; + +export const GITLAB_INTERNAL_EVENT_CATEGORY = 'InternalEventTracking'; + +export const SERVICE_PING_SCHEMA = 'iglu:com.gitlab/gitlab_service_ping/jsonschema/1-0-0'; diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index 472ce3c5bbf..7c2cd6fde27 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -2,8 +2,10 @@ import { getAllExperimentContexts } from '~/experimentation/utils'; import { DEFAULT_SNOWPLOW_OPTIONS } from './constants'; import getStandardContext from './get_standard_context'; import Tracking from './tracking'; +import InternalEvents from './internal_events'; export { Tracking as default }; +export { InternalEvents }; /** * Tracker initialization as defined in: @@ -67,4 +69,6 @@ export function initDefaultTrackers() { Tracking.bindDocument(); Tracking.trackLoadEvents(); + + InternalEvents.bindInternalEventDocument(); } diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js new file mode 100644 index 00000000000..16cbb3e86e1 --- /dev/null +++ b/app/assets/javascripts/tracking/internal_events.js @@ -0,0 +1,58 @@ +import API from '~/api'; + +import Tracking from './tracking'; +import { GITLAB_INTERNAL_EVENT_CATEGORY, SERVICE_PING_SCHEMA } from './constants'; +import { Tracker } from './tracker'; +import { InternalEventHandler } from './utils'; + +const InternalEvents = { + /** + * + * @param {string} event + */ + track_event(event) { + API.trackRedisHllUserEvent(event); + Tracking.event(GITLAB_INTERNAL_EVENT_CATEGORY, event, { + context: { + schema: SERVICE_PING_SCHEMA, + data: { + event_name: event, + data_source: 'redis_hll', + }, + }, + }); + }, + /** + * Returns an implementation of this class in the form of + * a Vue mixin. + */ + mixin() { + return { + methods: { + track_event(event) { + InternalEvents.track_event(event); + }, + }, + }; + }, + /** + * Attaches event handlers for data-attributes powered events. + * + * @param {HTMLElement} parent - element containing data-attributes + * @returns {Object} handler - object containing name of the event and its corresponding function + */ + bindInternalEventDocument(parent = document) { + if (!Tracker.enabled() || parent.internalEventsTrackingBound) { + return []; + } + + // eslint-disable-next-line no-param-reassign + parent.internalEventsTrackingBound = true; + + const handler = { name: 'click', func: (e) => InternalEventHandler(e, this.track_event) }; + parent.addEventListener(handler.name, handler.func); + return handler; + }, +}; + +export default InternalEvents; diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js index cc0d7e7a44a..7cbc0f1843e 100644 --- a/app/assets/javascripts/tracking/utils.js +++ b/app/assets/javascripts/tracking/utils.js @@ -6,6 +6,7 @@ import { LOAD_ACTION_ATTR_SELECTOR, URLS_CACHE_STORAGE_KEY, REFERRER_TTL, + INTERNAL_EVENTS_SELECTOR, } from './constants'; export const addExperimentContext = (opts) => { @@ -69,6 +70,23 @@ export const createEventPayload = (el, { suffix = '' } = {}) => { }; }; +export const createInternalEventPayload = (el) => { + const { eventTracking } = el?.dataset || {}; + + return eventTracking; +}; + +export const InternalEventHandler = (e, func) => { + const el = e.target.closest(INTERNAL_EVENTS_SELECTOR); + + if (!el) { + return; + } + const event = createInternalEventPayload(el); + + func(event); +}; + export const eventHandler = (e, func, opts = {}) => { const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`; const el = e.target.closest(actionSelector); diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue index cdaba2ad3f9..c1e513d3a00 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -20,7 +20,6 @@ export default { const { containerRegistrySize, buildArtifactsSize, - pipelineArtifactsSize, lfsObjectsSize, packagesSize, repositorySize, @@ -65,12 +64,6 @@ export default { size: buildArtifactsSize, }, { - id: 'pipelineArtifacts', - style: this.usageStyle(this.barRatio(pipelineArtifactsSize)), - class: 'gl-bg-data-viz-green-800', - size: pipelineArtifactsSize, - }, - { id: 'wiki', style: this.usageStyle(this.barRatio(wikiSize)), class: 'gl-bg-data-viz-magenta-500', diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js index f08e8db26b9..8926e8c1e86 100644 --- a/app/assets/javascripts/usage_quotas/storage/constants.js +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -38,11 +38,6 @@ export const PROJECT_STORAGE_TYPES = [ description: s__('UsageQuota|Job artifacts created by CI/CD.'), }, { - id: 'pipelineArtifacts', - name: __('Pipeline artifacts'), - description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'), - }, - { id: 'lfsObjects', name: __('LFS'), description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), @@ -70,19 +65,19 @@ export const PROJECT_STORAGE_TYPES = [ ]; export const projectHelpPaths = { - containerRegistry: helpPagePath( - 'user/packages/container_registry/reduce_container_registry_storage', - ), usageQuotas: helpPagePath('user/usage_quotas'), usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', { anchor: 'namespace-storage-limit', }), + lfsObjects: helpPagePath('/user/project/repository/reducing_the_repo_size_using_git', { + anchor: 'repository-cleanup', + }), + containerRegistry: helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage', + ), buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { anchor: 'when-job-artifacts-are-deleted', }), - pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', { - anchor: 'when-pipeline-artifacts-are-deleted', - }), packages: helpPagePath('user/packages/package_registry/index.md', { anchor: 'reduce-storage-usage', }), diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql index 85a181d3e01..a6de5ebae16 100644 --- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql +++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql @@ -12,7 +12,6 @@ query getProjectStorageStatistics($fullPath: ID!) { statistics { containerRegistrySize buildArtifactsSize - pipelineArtifactsSize lfsObjectsSize packagesSize repositorySize diff --git a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue new file mode 100644 index 00000000000..bf983d911ea --- /dev/null +++ b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue @@ -0,0 +1,45 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; + +export default { + components: { + GlDisclosureDropdown, + }, + props: { + userId: { + type: String, + required: true, + }, + }, + data() { + return { + // Only implement the copy function in MR for now + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122971 + // The rest will be implemented in the upcoming MR. + dropdownItems: [ + { + action: this.onUserIdCopy, + text: sprintf(this.$options.i18n.userId, { id: this.userId }), + extraAttrs: { + 'data-clipboard-text': this.userId, + }, + }, + ], + }; + }, + methods: { + onUserIdCopy() { + this.$toast.show(this.$options.i18n.userIdCopied); + }, + }, + i18n: { + userId: s__('UserProfile|Copy user ID: %{id}'), + userIdCopied: s__('UserProfile|User ID copied to clipboard'), + }, +}; +</script> + +<template> + <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" /> +</template> diff --git a/app/assets/javascripts/users/profile/actions/index.js b/app/assets/javascripts/users/profile/actions/index.js new file mode 100644 index 00000000000..37a3faf82a5 --- /dev/null +++ b/app/assets/javascripts/users/profile/actions/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import UserActionsApp from './components/user_actions_app.vue'; + +export const initUserActionsApp = () => { + const mountingEl = document.querySelector('.js-user-profile-actions'); + + if (!mountingEl) return false; + + const { userId } = mountingEl.dataset; + + Vue.use(GlToast); + + return new Vue({ + el: mountingEl, + name: 'UserActionsRoot', + render(createElement) { + return createElement(UserActionsApp, { + props: { + userId, + }, + }); + }, + }); +}; 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 028f5370028..f7c0f960c0e 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 @@ -68,7 +68,7 @@ export default { }, isCollapsible() { if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) { - if (this.shouldCollapse) { + if ('shouldCollapse' in this) { return this.shouldCollapse(this.collapsedData); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index 7e329399957..0b8f5ffa397 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { markRaw } from '~/lib/utils/vue3compat/mark_raw'; import ExtensionBase from './base.vue'; // Holds all the currently registered extensions @@ -7,45 +8,47 @@ export const registeredExtensions = Vue.observable({ extensions: [] }); export const registerExtension = (extension) => { // Pushes into the extenions array a dynamically created Vue component // that gets exteneded from `base.vue` - registeredExtensions.extensions.push({ - extends: ExtensionBase, - name: extension.name, - props: { - mr: { - type: Object, - required: true, + registeredExtensions.extensions.push( + markRaw({ + extends: ExtensionBase, + name: extension.name, + props: { + mr: { + type: Object, + required: true, + }, }, - }, - telemetry: extension.telemetry, - i18n: extension.i18n, - expandEvent: extension.expandEvent, - enablePolling: extension.enablePolling, - enableExpandedPolling: extension.enableExpandedPolling, - modalComponent: extension.modalComponent, - computed: { - ...extension.props.reduce( - (acc, propKey) => ({ - ...acc, - [propKey]() { - return this.mr[propKey]; - }, - }), - {}, - ), - ...Object.keys(extension.computed).reduce( - (acc, computedKey) => ({ - ...acc, - // Making the computed property a method allows us to pass in arguments - // this allows for each computed property to receive some data - [computedKey]() { - return extension.computed[computedKey]; - }, - }), - {}, - ), - }, - methods: { - ...extension.methods, - }, - }); + telemetry: extension.telemetry, + i18n: extension.i18n, + expandEvent: extension.expandEvent, + enablePolling: extension.enablePolling, + enableExpandedPolling: extension.enableExpandedPolling, + modalComponent: extension.modalComponent, + computed: { + ...extension.props.reduce( + (acc, propKey) => ({ + ...acc, + [propKey]() { + return this.mr[propKey]; + }, + }), + {}, + ), + ...Object.keys(extension.computed).reduce( + (acc, computedKey) => ({ + ...acc, + // Making the computed property a method allows us to pass in arguments + // this allows for each computed property to receive some data + [computedKey]() { + return extension.computed[computedKey]; + }, + }), + {}, + ), + }, + methods: { + ...extension.methods, + }, + }), + ); }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue index 5baeb309f79..8bf4d8816be 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -1,10 +1,9 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, }, props: { commits: { @@ -13,28 +12,36 @@ export default { default: () => [], }, }, + computed: { + dropdownItems() { + return this.commits.map((commit) => ({ + text: commit.title, + extraAttrs: { + text: commit.shortId || commit.short_Id, + }, + action: () => { + this.$emit('input', commit.message); + }, + })); + }, + }, }; </script> <template> <div> - <gl-dropdown - right - text="Use an existing commit message" + <gl-disclosure-dropdown + placement="right" + toggle-text="Use an existing commit message" category="tertiary" - variant="confirm" + :items="dropdownItems" size="small" class="mr-commit-dropdown" > - <gl-dropdown-item - v-for="(commit, index) in commits" - :key="index" - class="text-nowrap text-truncate" - @click="$emit('input', commit.message)" - > - <span class="monospace mr-2">{{ commit.shortId || commit.short_id }}</span> - {{ commit.title }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item="{ item }"> + <span class="gl-mr-2">{{ item.extraAttrs.text }}</span> + {{ item.text }} + </template> + </gl-disclosure-dropdown> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 52cdafd4717..7071759b8bb 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 @@ -27,7 +27,6 @@ import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { AUTO_MERGE_STRATEGIES, - WARNING, MT_MERGE_STRATEGY, PIPELINE_FAILED_STATE, STATE_MACHINE, @@ -42,7 +41,6 @@ import CommitMessageDropdown from './commit_message_dropdown.vue'; import SquashBeforeMerge from './squash_before_merge.vue'; import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue'; -const PIPELINE_RUNNING_STATE = 'running'; const PIPELINE_PENDING_STATE = 'pending'; const PIPELINE_SUCCESS_STATE = 'success'; @@ -133,8 +131,6 @@ export default { GlFormCheckbox, GlSkeletonLoader, MergeFailedPipelineConfirmationDialog, - MergeTrainHelperIcon: () => - import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'), MergeImmediatelyConfirmationDialog: () => import( 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' @@ -176,14 +172,11 @@ export default { }; }, computed: { - stateData() { - return this.state; - }, hasCI() { - return this.stateData.hasCI || this.stateData.hasCi; + return this.state.hasCI || this.state.hasCi; }, isAutoMergeAvailable() { - return !isEmpty(this.stateData.availableAutoMergeStrategies); + return !isEmpty(this.state.availableAutoMergeStrategies); }, pipeline() { return this.state.headPipeline; @@ -246,30 +239,11 @@ export default { return PIPELINE_SUCCESS_STATE; }, - iconClass() { - if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) { - return PIPELINE_RUNNING_STATE; - } - - if ( - this.status === PIPELINE_FAILED_STATE || - !this.commitMessage.length || - !this.isMergeAllowed || - this.mr.preventMerge - ) { - return WARNING; - } - - return PIPELINE_SUCCESS_STATE; - }, mergeButtonText() { if (this.isMergingImmediately) { return __('Merge in progress'); } - if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) { - return this.autoMergeTextLegacy; - } - if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) { + if (this.isAutoMergeAvailable) { return this.autoMergeText; } @@ -279,9 +253,6 @@ export default { return __('Merge'); }, - autoMergeLabelsEnabled() { - return window.gon?.features?.autoMergeLabelsMrWidget; - }, showAutoMergeHelperText() { return ( !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) && @@ -289,7 +260,7 @@ export default { ); }, hasPipelineMustSucceedConflict() { - return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds; + return !this.hasCI && this.state.onlyAllowMergeIfPipelineSucceeds; }, isNotClosed() { return this.mr.state !== STATUS_CLOSED; @@ -322,12 +293,7 @@ export default { return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed; }, shouldShowMergeControls() { - return ( - (this.isMergeAllowed || this.isAutoMergeAvailable) && - (this.stateData.userPermissions?.canMerge || this.mr.canMerge) && - !this.mr.mergeOngoing && - !this.mr.autoMergeEnabled - ); + return this.state.userPermissions?.canMerge && this.mr.state === 'readyToMerge'; }, sourceBranchDeletedText() { const isPreMerge = this.mr.state !== STATUS_MERGED; @@ -354,6 +320,11 @@ export default { }; }, }, + watch: { + 'mr.state': function mrStateWatcher() { + this.isMakingRequest = false; + }, + }, mounted() { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState); @@ -441,8 +412,6 @@ export default { } this.updateGraphqlState(); - - this.isMakingRequest = false; }) .catch(() => { this.isMakingRequest = false; @@ -511,13 +480,13 @@ export default { }, i18n: { mergeCommitTemplateHintText: s__( - 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}', + 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more%{linkEnd}.', ), squashCommitTemplateHintText: s__( - 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more.%{linkEnd}', + 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more%{linkEnd}.', ), mergeAndSquashCommitTemplatesHintText: s__( - 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}', + 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more%{linkEnd}.', ), sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch'), divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count), @@ -619,9 +588,8 @@ export default { :href="commitTemplateHelpPage" class="inline-link" target="_blank" + >{{ content }}</gl-link > - {{ content }} - </gl-link> </template> </gl-sprintf> </p> @@ -692,35 +660,21 @@ export default { > {{ __('Merge immediately') }} </gl-dropdown-item> - <merge-immediately-confirmation-dialog - ref="confirmationDialog" - :docs-url="mr.mergeImmediatelyDocsPath" - @mergeImmediately="onMergeImmediatelyConfirmation" - /> </gl-dropdown> - <merge-train-failed-pipeline-confirmation-dialog - :visible="isPipelineFailedModalVisibleMergeTrain" - @startMergeTrain="onStartMergeTrainConfirmation" - @cancel="isPipelineFailedModalVisibleMergeTrain = false" - /> - <merge-failed-pipeline-confirmation-dialog - :visible="isPipelineFailedModalVisibleNormalMerge" - @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" - @cancel="isPipelineFailedModalVisibleNormalMerge = false" - /> </gl-button-group> - <merge-train-helper-icon - v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled" - class="gl-mx-3" - /> - <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled"> + <template v-if="showAutoMergeHelperText"> <div class="gl-ml-4 gl-text-gray-500 gl-font-sm" data-qa-selector="auto_merge_helper_text" + data-testid="auto-merge-helper-text" > {{ autoMergeHelperText }} </div> - <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions"> + <help-popover + class="gl-ml-2" + :options="autoMergeHelpPopoverOptions" + data-testid="auto-merge-helper-text-icon" + > <gl-sprintf :message="autoMergePopoverSettings.bodyText"> <template #link="{ content }"> <gl-link @@ -784,6 +738,21 @@ export default { </div> </div> </div> + <merge-immediately-confirmation-dialog + ref="confirmationDialog" + :docs-url="mr.mergeImmediatelyDocsPath" + @mergeImmediately="onMergeImmediatelyConfirmation" + /> + <merge-train-failed-pipeline-confirmation-dialog + :visible="isPipelineFailedModalVisibleMergeTrain" + @startMergeTrain="onStartMergeTrainConfirmation" + @cancel="isPipelineFailedModalVisibleMergeTrain = false" + /> + <merge-failed-pipeline-confirmation-dialog + :visible="isPipelineFailedModalVisibleNormalMerge" + @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" + @cancel="isPipelineFailedModalVisibleNormalMerge = false" + /> </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue index 6655af92a55..c38c253564a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue @@ -23,7 +23,7 @@ export default { default: () => [], }, }, - data: () => { + data() { return { timeout: null, updatingTooltip: false, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 334fc01c9f7..258fa4edcda 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -5,16 +5,24 @@ export default { import( '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue' ), + + MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'), }, + props: { mr: { type: Object, required: true, }, }, + computed: { + terraformPlansWidget() { + return this.mr.terraformReportsPath && 'MrTerraformWidget'; + }, + widgets() { - return ['MrSecurityWidget']; + return [this.terraformPlansWidget, 'MrSecurityWidget'].filter((w) => w); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index cdce7c6625a..ec979861283 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -30,6 +30,11 @@ export default { required: false, default: 2, }, + rowIndex: { + type: Number, + required: false, + default: -1, + }, }, computed: { statusIcon() { @@ -44,6 +49,9 @@ export default { generatedSupportingText() { return generateText(this.data.supportingText); }, + shouldShowThirdLevel() { + return this.data.children?.length > 0 && this.level === 2; + }, }, methods: { onClickedAction(action) { @@ -60,16 +68,19 @@ export default { :widget-name="widgetName" :header="data.header" :help-popover="data.helpPopover" + :class="{ 'gl-border-top-0': rowIndex === 0 }" > <template #body> - <div class="gl-display-flex gl-flex-direction-column"> - <div> - <p v-safe-html="generatedText" class="gl-mb-0"></p> - <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> - <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> - {{ data.badge.text }} - </gl-badge> + <div class="gl-w-full gl-display-flex" :class="{ 'gl-flex-direction-column': level === 1 }"> + <div class="gl-display-flex gl-flex-grow-1"> + <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column"> + <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p> + <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> + <p v-if="data.supportingText" v-safe-html="generatedSupportingText" class="gl-mb-0"></p> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + </div> <actions :widget="widgetName" :tertiary-buttons="data.actions" @@ -78,10 +89,7 @@ export default { /> <p v-if="data.subtext" v-safe-html="generatedSubtext" class="gl-m-0 gl-font-sm"></p> </div> - <ul - v-if="data.children && data.children.length > 0 && level === 2" - class="gl-m-0 gl-p-0 gl-list-style-none" - > + <ul v-if="shouldShowThirdLevel" class="gl-m-0 gl-p-0 gl-list-style-none"> <li v-for="(childData, index) in data.children" :key="childData.id || index"> <dynamic-content :data="childData" 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 54eb15c8ac8..e327d848d8f 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 @@ -10,6 +10,7 @@ import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { EXTENSION_ICONS } from '../../constants'; import { createTelemetryHub } from '../extensions/telemetry'; +import { generateText } from '../extensions/utils'; import ContentRow from './widget_content_row.vue'; import DynamicContent from './dynamic_content.vue'; import StatusIcon from './status_icon.vue'; @@ -72,9 +73,12 @@ export default { }, // If the summary slot is not used, this value will be used as a fallback. summary: { - type: String, + type: Object, required: false, default: undefined, + validator: (s) => { + return Boolean(s.title); + }, }, // If the content slot is not used, this value will be used as a fallback. content: { @@ -154,7 +158,7 @@ export default { return { isExpandedForTheFirstTime: true, isCollapsed: true, - isLoading: false, + isLoading: true, isLoadingExpandedContent: false, summaryError: null, contentError: null, @@ -162,6 +166,12 @@ export default { }; }, computed: { + generatedSummary() { + return generateText(this.summary?.title || ''); + }, + generatedSubSummary() { + return generateText(this.summary?.subtitle || ''); + }, collapseButtonLabel() { return sprintf(this.isCollapsed ? __('Show details') : __('Hide details')); }, @@ -171,6 +181,9 @@ export default { hasActionButtons() { return this.actionButtons.length > 0 || Boolean(this.$scopedSlots['action-buttons']); }, + contentWithKeyField() { + return this.content?.map((item, index) => ({ ...item, id: item.id || index })); + }, }, watch: { hasError: { @@ -289,7 +302,7 @@ export default { <template> <section class="media-section" data-testid="widget-extension"> - <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex"> + <div class="gl-px-5 gl-pr-4 gl-py-4 gl-display-flex"> <status-icon :level="1" :name="widgetName" @@ -302,7 +315,14 @@ export default { > <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> <span v-if="summaryError">{{ summaryError }}</span> - <slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot> + <slot v-else name="summary" + ><div v-safe-html="isLoading ? loadingText : generatedSummary"></div> + <div + v-if="!isLoading && generatedSubSummary" + v-safe-html="generatedSubSummary" + class="gl-font-sm gl-text-gray-700" + ></div + ></slot> </div> <div class="gl-display-flex"> <help-popover @@ -336,7 +356,7 @@ export default { </slot> </div> <div - v-if="isCollapsible" + v-if="isCollapsible && !isLoading" class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" > <gl-button @@ -376,8 +396,8 @@ export default { <div v-else class="gl-w-full"> <slot name="content"> <dynamic-scroller - v-if="content" - :items="content" + v-if="contentWithKeyField" + :items="contentWithKeyField" :min-item-size="32" :style="{ maxHeight: '170px' }" data-testid="dynamic-content-scroller" @@ -390,6 +410,9 @@ export default { :data="item" :widget-name="widgetName" :level="2" + :row-index="index" + data-testid="extension-list-item" + @clickedAction="onActionClick" /> </dynamic-scroller-item> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index db237bc7439..a59f48fb8b2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,5 +1,6 @@ import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps'; export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4; @@ -26,7 +27,7 @@ export const SP_SHOW_TRACK_VALUE = 10; export const SP_HELP_CONTENT = s__( `mrWidget|GitLab %{linkStart}CI/CD can automatically build, test, and deploy your application.%{linkEnd} It only takes a few minutes to get started, and we can help you create a pipeline configuration file.`, ); -export const SP_HELP_URL = 'https://docs.gitlab.com/ee/ci/quick_start/'; +export const SP_HELP_URL = `${DOCS_URL_IN_EE_DIR}/ci/quick_start/`; export const SP_ICON_NAME = 'status_notfound'; export const MERGE_ACTIVE_STATUS_PHRASES = [ 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 6155a912683..e7d8de97f20 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 @@ -73,6 +73,9 @@ export default { hasSecurityReports() { return this.artifacts.length > 0; }, + summary() { + return { title: this.$options.i18n.scansHaveRun }; + }, }, methods: { handleIsLoading(value) { @@ -109,7 +112,7 @@ export default { :widget-name="$options.name" :is-collapsible="false" :help-popover="$options.widgetHelpPopover" - :summary="$options.i18n.scansHaveRun" + :summary="summary" @is-loading="handleIsLoading" > <template #action-buttons> 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.vue index c5cbed4a280..a6d12ed7aec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.vue @@ -1,12 +1,29 @@ +<script> import { __, n__, s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; import { EXTENSION_ICONS } from '../../constants'; export default { name: 'WidgetTerraform', - enablePolling: true, + components: { + MrWidget, + }, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + terraformData: { + collapsed: null, + expanded: null, + }, + }; + }, i18n: { - label: s__('Terraform|Terraform reports'), loading: s__('Terraform|Loading Terraform reports...'), error: s__('Terraform|Failed to load Terraform reports'), reportGenerated: s__('Terraform|A Terraform report was generated in your pipelines.'), @@ -23,18 +40,13 @@ export default { reportErrored: s__('Terraform|Generating the report caused an error.'), fullLog: __('Full log'), }, - props: ['terraformReportsPath'], computed: { - // Extension computed props - statusIcon() { - return EXTENSION_ICONS.warning; + terraformReportsPath() { + return this.mr.terraformReportsPath; }, - }, - methods: { - // Extension methods - summary({ valid = [], invalid = [] }) { - let title; - let subtitle = ''; + + summary() { + const { valid = [], invalid = [] } = this.terraformData.collapsed || {}; const validText = sprintf( n__( @@ -60,20 +72,13 @@ export default { false, ); - if (valid.length) { - title = validText; - if (invalid.length) { - subtitle = invalidText; - } - } else { - title = invalidText; - } - return { - subject: title, - meta: subtitle, + title: valid.length ? validText : invalidText, + subtitle: valid.length && invalid.length ? invalidText : undefined, }; }, + }, + methods: { fetchCollapsedData() { return axios .get(this.terraformReportsPath) @@ -84,6 +89,10 @@ export default { const formattedData = this.prepareReports(reports); + const { valid, invalid } = formattedData; + this.terraformData.collapsed = formattedData; + this.terraformData.expanded = [...valid, ...invalid]; + return { ...res, data: formattedData, @@ -91,14 +100,10 @@ export default { }) .catch(() => { const formattedData = this.prepareReports([{ tf_report_error: 'api_error' }]); - + this.terraformData.collapsed = formattedData; return { data: formattedData }; }); }, - fetchFullData() { - const { valid, invalid } = this.collapsedData; - return Promise.resolve([...valid, ...invalid]); - }, createReportRow(report, iconName) { const addNum = Number(report.create); const changeNum = Number(report.update); @@ -176,4 +181,20 @@ export default { return { valid, invalid }; }, }, + + WARNING_ICON: EXTENSION_ICONS.warning, }; +</script> + +<template> + <mr-widget + :error-text="$options.i18n.error" + :status-icon-name="$options.WARNING_ICON" + :loading-text="$options.i18n.loading" + :widget-name="$options.name" + :is-collapsible="Boolean(terraformData.collapsed)" + :summary="summary" + :content="terraformData.expanded" + :fetch-collapsed-data="fetchCollapsedData" + /> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 10a54c73273..2f49252a06b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -31,10 +31,6 @@ export default { pipelineMustSucceedConflictText() { return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT; }, - autoMergeTextLegacy() { - // MWPS is currently the only auto merge strategy available in CE - return __('Merge when pipeline succeeds'); - }, autoMergeText() { return __('Set to auto-merge'); }, @@ -51,7 +47,7 @@ export default { }; }, shouldShowMergeImmediatelyDropdown() { - return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds; + return this.isPipelineActive && !this.state.onlyAllowMergeIfPipelineSucceeds; }, isMergeImmediatelyDangerous() { return false; 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 af9e303594a..52a2f42f8ec 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 @@ -55,7 +55,6 @@ import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; import getStateSubscription from './queries/get_state.subscription.graphql'; -import terraformExtension from './extensions/terraform'; import accessibilityExtension from './extensions/accessibility'; import codeQualityExtension from './extensions/code_quality'; import testReportExtension from './extensions/test_report'; @@ -219,14 +218,6 @@ export default { shouldRenderCodeQuality() { return this.mr?.codequalityReportsPath; }, - shouldRenderSourceBranchRemovalStatus() { - return ( - !this.mr.canRemoveSourceBranch && - this.mr.shouldRemoveSourceBranch && - !this.mr.isNothingToMergeState && - !this.mr.isMergedState - ); - }, shouldRenderCollaborationStatus() { return this.mr.allowCollaboration && this.mr.isOpen; }, @@ -238,9 +229,6 @@ export default { this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId, ); }, - shouldRenderTerraformPlans() { - return Boolean(this.mr?.terraformReportsPath); - }, shouldRenderTestReport() { return Boolean(this.mr?.testResultsPath); }, @@ -292,11 +280,6 @@ export default { this.initPostMergeDeploymentsPolling(); } }, - shouldRenderTerraformPlans(newVal) { - if (newVal) { - this.registerTerraformPlans(); - } - }, shouldRenderCodeQuality(newVal) { if (newVal) { this.registerCodeQualityExtension(); @@ -546,11 +529,6 @@ export default { dismissSuggestPipelines() { this.mr.isDismissedSuggestPipeline = true; }, - registerTerraformPlans() { - if (this.shouldRenderTerraformPlans) { - registerExtension(terraformExtension); - } - }, registerAccessibilityExtension() { if (this.shouldShowAccessibilityReport) { registerExtension(accessibilityExtension); @@ -570,7 +548,7 @@ export default { }; </script> <template> - <div v-if="!loading" class="mr-state-widget gl-mt-3"> + <div v-if="!loading" id="widget-state" class="mr-state-widget gl-mt-5"> <header v-if="shouldRenderCollaborationStatus" class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden mr-widget-workflow gl-mt-0!" @@ -590,7 +568,11 @@ export default { :user-callout-feature-id="mr.suggestPipelineFeatureId" @dismiss="dismissSuggestPipelines" /> - <mr-widget-pipeline-container v-if="shouldRenderPipelines" :mr="mr" /> + <mr-widget-pipeline-container + v-if="shouldRenderPipelines" + :mr="mr" + data-testid="pipeline-container" + /> <mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" /> <report-widget-container> <extensions-container v-if="hasExtensions" :mr="mr" /> @@ -637,6 +619,7 @@ export default { class="js-post-merge-pipeline mr-widget-workflow" :mr="mr" :is-post-merge="true" + data-testid="merged-pipeline-container" /> </div> <loading v-else /> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 4ec301b946b..2d3815439a6 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -22,15 +22,15 @@ const DATA_REFETCH_DELAY = 250; export default { i18n: { FETCH_USERS_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.', + 'AlertManagement|There was an error while updating the assignees list. Please try again.', ), UPDATE_ALERT_ASSIGNEES_ERROR: s__( - 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.', + 'AlertManagement|There was an error while updating the assignees of the alert. Please try again.', ), UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__( 'AlertManagement|This assignee cannot be assigned to this alert.', ), - ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'), + ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignees: %{assignees}'), }, components: { GlIcon, diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index c3f3226c46e..1d6dbef799a 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -45,8 +45,12 @@ export default { :category="category" :toggle-text="toggleText" data-qa-selector="action_dropdown" + fluid-width + block + @shown="$emit('shown')" + @hidden="$emit('hidden')" > - <gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-group class="edit-dropdown-group-width"> <gl-disclosure-dropdown-item v-for="action in actions" :key="action.key" @@ -65,5 +69,6 @@ export default { </template> </gl-disclosure-dropdown-item> </gl-disclosure-dropdown-group> + <slot></slot> </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 8f1f7ba0ad8..59f03b41144 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -184,6 +184,7 @@ export default { class="gl-mr-3 gl-my-2" :class="awardList.classes" :title="awardList.title" + :data-emoji-name="awardList.name" data-testid="award-button" @click="handleAward(awardList.name)" > @@ -209,7 +210,6 @@ export default { @hidden="setIsMenuOpen(false)" > <template #button-content> - <span class="gl-sr-only">{{ __('Add reaction') }}</span> <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon name="slight-smile" /> </span> 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 3a3929fba9b..3e24a35ea39 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 @@ -12,8 +12,22 @@ export default { SafeHtml, }, mixins: [ViewerMixin], + data() { + return { + isLoading: true, + }; + }, mounted() { - handleBlobRichViewer(this.$refs.content, this.type); + window.requestIdleCallback(async () => { + /** + * Rendering Markdown usually takes long due to the amount of HTML being parsed. + * This ensures that content is loaded only when the browser goes into idle. + * More details here: https://gitlab.com/gitlab-org/gitlab/-/issues/331448 + * */ + this.isLoading = false; + await this.$nextTick(); + handleBlobRichViewer(this.$refs.content, this.type); + }); }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'], @@ -22,6 +36,7 @@ export default { </script> <template> <markdown-field-view + v-if="!isLoading" ref="content" v-safe-html:[$options.safeHtmlConfig]="richViewer || content" /> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 0d7547d88a1..6670b931416 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -36,6 +36,15 @@ export default { status: { type: Object, required: true, + validator(status) { + const { group, icon } = status; + return ( + typeof group === 'string' && + group.length && + typeof icon === 'string' && + icon.startsWith('status_') + ); + }, }, size: { type: Number, @@ -69,7 +78,7 @@ export default { computed: { wrapperStyleClasses() { const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`; + return `ci-status-icon ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`; }, icon() { return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon; @@ -84,7 +93,6 @@ export default { { interactive: isInteractive, active: isActive, borderless: isBorderless }, ]" :style="{ height: `${size}px`, width: `${size}px` }" - data-testid="ci-icon-wrapper" > <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue index 352d03befc3..d98858da95f 100644 --- a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue +++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue @@ -1,6 +1,6 @@ <script> +import { escape } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; - import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import CodeBlock from './code_block.vue'; @@ -39,7 +39,7 @@ export default { return this.hljs.highlight(this.code, { language: this.language }).value; } - return this.code; + return escape(this.code); }, }, async mounted() { diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js index 90d55d0f93f..c6b9e61b85f 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/constants.js @@ -6,7 +6,5 @@ export const CONFIRM_DANGER_MODAL_BUTTON = __('Confirm'); export const CONFIRM_DANGER_WARNING = __( 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', ); -export const CONFIRM_DANGER_PHRASE_TEXT = __( - 'Please type %{phrase_code} to proceed or close this modal to cancel.', -); +export const CONFIRM_DANGER_PHRASE_TEXT = __('Please type %{phrase_code} to proceed.'); export const CONFIRM_DANGER_MODAL_CANCEL = __('Cancel'); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 1a3220d8db9..970c24c6e87 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -75,10 +75,13 @@ export default { computed: { selected: { set(value) { - this.$emit('input', value); this.selectedValue = value; this.selectedText = value === null ? null : this.items.find((item) => item.value === value).text; + this.$emit('input', { + value: this.selectedValue, + text: this.selectedText, + }); }, get() { return this.selectedValue; @@ -161,7 +164,7 @@ export default { }, onReset() { this.selected = null; - this.$emit('input', null); + this.$emit('input', {}); }, onBottomReached() { this.fetchEntities(this.page + 1); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue index ff137d764ee..71e3bf4ff63 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -121,6 +121,7 @@ export default { :default-toggle-text="$options.i18n.toggleText" :fetch-items="fetchGroups" :fetch-initial-selection-text="fetchGroupName" + v-on="$listeners" > <template #error> <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 7af3819f2a5..13a825a68f6 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -166,6 +166,7 @@ export default { :fetch-initial-selection-text="fetchProjectName" :block="block" clearable + v-on="$listeners" > <template v-if="hasHtmlLabel" #label> <span v-safe-html="label"></span> 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 28baabbdb81..1adda905006 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -114,11 +114,7 @@ export default { </script> <template> - <header - class="page-content-header gl-md-display-flex gl-min-h-7" - data-qa-selector="pipeline_header" - data-testid="ci-header-content" - > + <header class="page-content-header gl-md-display-flex gl-min-h-7" data-testid="ci-header-content"> <section class="header-main-content gl-mr-3"> <ci-badge-link class="gl-mr-3" :status="status" /> diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js index ad89b78b521..b447822b1e0 100644 --- a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js +++ b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; export const initListboxInputs = () => { const els = [...document.querySelectorAll('.js-listbox-input')]; @@ -30,6 +31,8 @@ export const initListboxInputs = () => { name, defaultToggleText, selected: this.selected, + block: parseBoolean(el.dataset.block), + fluidWidth: parseBoolean(el.dataset.fluidWidth), items, }, attrs: { diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue index 0f8ff5291a4..a59a7494472 100644 --- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue @@ -47,6 +47,21 @@ export default { required: false, default: false, }, + fluidWidth: { + type: GlCollapsibleListbox.props.fluidWidth.type, + required: false, + default: GlCollapsibleListbox.props.fluidWidth.default, + }, + placement: { + type: GlCollapsibleListbox.props.placement.type, + required: false, + default: GlCollapsibleListbox.props.placement.default, + }, + block: { + type: GlCollapsibleListbox.props.block.type, + required: false, + default: GlCollapsibleListbox.props.block.default, + }, }, data() { return { @@ -123,6 +138,9 @@ export default { :searchable="isSearchable" :no-results-text="$options.i18n.noResultsText" :disabled="disabled" + :fluid-width="fluidWidth" + :placement="placement" + :block="block" @search="search" @select="$emit($options.model.event, $event)" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index f51ec715678..a570abae9d3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -12,7 +12,8 @@ export default { }, defaultCommitMessage: { type: String, - required: true, + required: false, + default: null, }, batchSuggestionsCount: { type: Number, diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index 186f5619b87..966a5556d24 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -1,7 +1,6 @@ <script> import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { updateText } from '~/lib/utils/text_markdown'; import savedRepliesQuery from './saved_replies.query.graphql'; export default { @@ -54,20 +53,8 @@ export default { }, onSelect(id) { const savedReply = this.savedReplies.find((r) => r.id === id); - const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); - - if (savedReply && textArea) { - updateText({ - textArea, - tag: savedReply.content, - cursorOffset: 0, - wrap: false, - }); - - // Wait for text to be added into textarea - requestAnimationFrame(() => { - textArea.focus(); - }); + if (savedReply) { + this.$emit('select', savedReply.content); } }, }, @@ -81,13 +68,14 @@ export default { :items="filteredSavedReplies" :toggle-text="__('Insert comment template')" text-sr-only + no-caret toggle-class="js-comment-template-toggle" icon="comment-lines" category="tertiary" placement="right" searchable size="small" - class="comment-template-dropdown" + class="comment-template-dropdown gl-mr-3" positioning-strategy="fixed" :searching="$apollo.queries.savedReplies.loading" @shown="fetchCommentTemplates" @@ -104,7 +92,7 @@ export default { </template> <template #footer> <div - class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3" + class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-display-flex gl-justify-content-center gl-p-2" > <gl-button :href="newCommentTemplatePath" @@ -130,4 +118,8 @@ export default { .comment-template-dropdown .gl-new-dropdown-item-check-icon { display: none; } + +.comment-template-dropdown input { + border-radius: 0; +} </style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue index 645975ca565..2426a917a53 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue @@ -1,10 +1,16 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlPopover, GlLink } from '@gitlab/ui'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import { __ } from '~/locale'; +import RICH_TEXT_EDITOR_ILLUSTRATION from '../../../../images/callouts/rich_text_editor_illustration.svg?url'; +import { counter } from './utils'; export default { components: { GlButton, + GlLink, + GlPopover, + UserCalloutDismisser, }, props: { value: { @@ -12,21 +18,102 @@ export default { required: true, }, }, + data() { + return { + counter: counter(), + }; + }, computed: { + showPromoPopover() { + return this.markdownEditorSelected && this.counter === 0; + }, markdownEditorSelected() { return this.value === 'markdown'; }, text() { - return this.markdownEditorSelected ? __('Switch to rich text') : __('Switch to Markdown'); + return this.markdownEditorSelected + ? __('Switch to rich text editing') + : __('Switch to plain text editing'); }, }, + methods: { + switchEditorType(insertTemplate = false) { + this.$emit('switch', insertTemplate); + }, + }, + richTextEditorButtonId: 'switch-to-rich-text-editor', + RICH_TEXT_EDITOR_ILLUSTRATION, }; </script> <template> - <gl-button - class="btn btn-default btn-sm gl-button btn-default-tertiary" - data-qa-selector="editing_mode_switcher" - @click="$emit('input')" - >{{ text }}</gl-button - > + <div class="content-editor-switcher gl-display-inline-flex gl-align-items-center"> + <user-callout-dismisser feature-name="rich_text_editor"> + <template #default="{ dismiss, shouldShowCallout }"> + <div> + <gl-popover + :target="$options.richTextEditorButtonId" + :show="Boolean(showPromoPopover && shouldShowCallout)" + show-close-button + :css-classes="['rich-text-promo-popover gl-p-2']" + triggers="manual" + data-testid="rich-text-promo-popover" + @close-button-clicked="dismiss" + > + <img + :src="$options.RICH_TEXT_EDITOR_ILLUSTRATION" + :alt="''" + class="rich-text-promo-popover-illustration" + width="280" + height="130" + /> + <h5 class="gl-mt-3 gl-mb-3">{{ __('Writing just got easier') }}</h5> + <p class="gl-m-0"> + {{ + __( + 'Use the new rich text editor to see your text and tables fully formatted as you type. No need to remember any formatting syntax, or switch between preview and editing modes!', + ) + }} + </p> + <gl-link + class="gl-button btn btn-confirm block gl-mb-2 gl-mt-4" + variant="confirm" + category="primary" + target="_blank" + block + @click=" + switchEditorType(showPromoPopover); + dismiss(); + " + > + {{ __('Try the rich text editor now') }} + </gl-link> + </gl-popover> + <gl-button + :id="$options.richTextEditorButtonId" + class="btn btn-default btn-sm gl-button btn-default-tertiary gl-font-sm! gl-text-secondary! gl-px-4!" + data-qa-selector="editing_mode_switcher" + @click=" + switchEditorType(); + dismiss(); + " + >{{ text }}</gl-button + > + </div> + </template> + </user-callout-dismisser> + </div> </template> +<style> +.rich-text-promo-popover { + box-shadow: 0 0 18px -1.9px rgba(119, 89, 194, 0.16), 0 0 12.9px -1.7px rgba(119, 89, 194, 0.16), + 0 0 9.2px -1.4px rgba(119, 89, 194, 0.16), 0 0 6.4px -1.1px rgba(119, 89, 194, 0.16), + 0 0 4.5px -0.8px rgba(119, 89, 194, 0.16), 0 0 3px -0.6px rgba(119, 89, 194, 0.16), + 0 0 1.8px -0.3px rgba(119, 89, 194, 0.16), 0 0 0.6px rgba(119, 89, 194, 0.16); + z-index: 999; +} + +.rich-text-promo-popover-illustration { + width: calc(100% + 32px); + margin: -32px -16px 0; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 602a83132e4..7c569763a75 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -68,10 +68,10 @@ export default { required: false, default: false, }, - quickActionsDocsPath: { - type: String, + supportsQuickActions: { + type: Boolean, required: false, - default: '', + default: false, }, canAttachFile: { type: Boolean, @@ -355,10 +355,7 @@ export default { <template> <div ref="gl-form" - :class="{ - 'gl-border-none! gl-shadow-none!': removeBorder, - }" - class="js-vue-markdown-field md-area position-relative gfm-form" + class="js-vue-markdown-field md-area position-relative gfm-form gl-overflow-hidden" :data-uploads-path="uploadsPath" > <markdown-header @@ -371,13 +368,12 @@ export default { :uploads-path="uploadsPath" :markdown-preview-path="markdownPreviewPath" :drawio-enabled="drawioEnabled" + :supports-quick-actions="supportsQuickActions" data-testid="markdownHeader" :restricted-tool-bar-items="restrictedToolBarItems" - :show-content-editor-switcher="showContentEditorSwitcher" @showPreview="showPreview" @hidePreview="hidePreview" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" - @enableContentEditor="$emit('enableContentEditor')" /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> @@ -391,36 +387,31 @@ export default { </a> <markdown-toolbar :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" :can-attach-file="canAttachFile" :show-comment-tool-bar="showCommentToolBar" + :show-content-editor-switcher="showContentEditorSwitcher" + @enableContentEditor="$emit('enableContentEditor')" /> </div> </div> - <template v-if="hasSuggestion"> - <div - v-show="previewMarkdown" - ref="markdown-preview" - class="js-vue-md-preview md-preview-holder gl-px-5" - > - <suggestions - v-if="hasSuggestion" - :note-html="markdownPreview" - :line-type="lineType" - :disabled="true" - :suggestions="suggestions" - :help-page-path="helpPagePath" - /> - </div> - </template> - <template v-else> - <div - v-show="previewMarkdown" - ref="markdown-preview" - v-safe-html:[$options.safeHtmlConfig]="markdownPreview" - class="js-vue-md-preview md md-preview-holder gl-px-5" - ></div> - </template> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="js-vue-md-preview md-preview-holder gl-px-5" + :class="{ md: !hasSuggestion }" + > + <suggestions + v-if="hasSuggestion" + :note-html="markdownPreview" + :line-type="lineType" + :disabled="true" + :suggestions="suggestions" + :help-page-path="helpPagePath" + /> + <template v-else> + <div v-safe-html:[$options.safeHtmlConfig]="markdownPreview"></div> + </template> + </div> <div v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading" v-safe-html:[$options.safeHtmlConfig]="referencedCommands" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index af0b34f1389..0907e064e01 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -13,13 +13,13 @@ import { import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getModifierKey } from '~/constants'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -import { s__, __ } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { s__, __, sprintf } from '~/locale'; import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import { updateText } from '~/lib/utils/text_markdown'; import ToolbarButton from './toolbar_button.vue'; import DrawioToolbarButton from './drawio_toolbar_button.vue'; import CommentTemplatesDropdown from './comment_templates_dropdown.vue'; -import EditorModeSwitcher from './editor_mode_switcher.vue'; export default { components: { @@ -29,7 +29,6 @@ export default { DrawioToolbarButton, CommentTemplatesDropdown, AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'), - EditorModeSwitcher, }, directives: { GlTooltip: GlTooltipDirective, @@ -40,6 +39,7 @@ export default { default: null, }, editorAiActions: { default: () => [] }, + mrGeneratedContent: { default: null }, }, props: { previewMarkdown: { @@ -91,17 +91,19 @@ export default { required: false, default: false, }, - showContentEditorSwitcher: { + supportsQuickActions: { type: Boolean, required: false, default: false, }, }, data() { + const modifierKey = getModifierKey(); return { tag: '> ', suggestPopoverVisible: false, - modifierKey: getModifierKey(), + modifierKey, + shiftKey: modifierKey === '⌘' ? '⇧' : 'Shift+', }; }, computed: { @@ -126,9 +128,6 @@ export default { const expandText = s__('MarkdownEditor|Click to expand'); return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, - showEditorModeSwitcher() { - return this.showContentEditorSwitcher && !this.previewMarkdown; - }, }, watch: { showSuggestPopover() { @@ -199,17 +198,25 @@ export default { insertIntoTextarea(text) { const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); if (textArea) { - const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated by AI')}_`; updateText({ textArea, - tag: generatedByText, + tag: text, cursorOffset: 0, wrap: false, }); } }, - handleEditorModeChanged() { - this.$emit('enableContentEditor'); + replaceTextarea(text) { + const { description, descriptionForSha } = this.$options.i18n; + const headSha = document.getElementById('merge_request_diff_head_sha').value; + const addendum = headSha + ? sprintf(descriptionForSha, { revision: truncateSha(headSha) }) + : description; + + if (this.mrGeneratedContent) { + this.mrGeneratedContent.setGeneratedContent(`${text}\n\n---\n\n_${addendum}_`); + this.mrGeneratedContent.showWarning(); + } }, switchPreview() { if (this.previewMarkdown) { @@ -218,6 +225,12 @@ export default { this.showMarkdownPreview(); } }, + insertAIAction(text) { + this.insertIntoTextarea(`${text}\n\n---\n\n_${__('This comment was generated by AI')}_`); + }, + insertSavedReply(savedReply) { + this.insertIntoTextarea(savedReply); + }, }, shortcuts: { bold: keysFor(BOLD_TEXT), @@ -228,27 +241,36 @@ export default { outdent: keysFor(OUTDENT_LINE), }, i18n: { - preview: __('Preview'), + comment: __('This comment was generated by AI'), + description: s__('MergeRequest|This description was generated using AI'), + descriptionForSha: s__( + 'MergeRequest|This description was generated for revision %{revision} using AI', + ), hidePreview: __('Continue editing'), + preview: __('Preview'), }, }; </script> <template> - <div class="md-header gl-bg-gray-50 gl-px-2 gl-rounded-base gl-mx-2 gl-mt-2"> - <div - class="gl-display-flex gl-align-items-center gl-flex-wrap" - :class="{ - 'gl-justify-content-end': previewMarkdown, - 'gl-justify-content-space-between': !previewMarkdown, - }" - > + <div class="md-header gl-border-b gl-border-gray-100 gl-px-3"> + <div class="gl-display-flex gl-align-items-center gl-flex-wrap"> <div data-testid="md-header-toolbar" - class="md-header-toolbar gl-display-flex gl-py-2 gl-flex-wrap" - :class="{ 'gl-display-none!': previewMarkdown }" + class="md-header-toolbar gl-display-flex gl-py-3 gl-flex-wrap gl-row-gap-3" > - <template v-if="canSuggest"> + <gl-button + v-if="enablePreview" + data-testid="preview-toggle" + value="preview" + :label="$options.i18n.previewTabTitle" + class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal! gl-mr-2" + size="small" + category="tertiary" + @click="switchPreview" + >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button + > + <template v-if="!previewMarkdown && canSuggest"> <toolbar-button ref="suggestButton" :tag="mdSuggestion" @@ -289,11 +311,13 @@ export default { </gl-popover> </template> <ai-actions-dropdown - v-if="editorAiActions.length" + v-if="!previewMarkdown && editorAiActions.length" :actions="editorAiActions" - @input="insertIntoTextarea" + @input="insertAIAction" + @replace="replaceTextarea" /> <toolbar-button + v-show="!previewMarkdown" tag="**" :button-title=" /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ @@ -305,6 +329,7 @@ export default { icon="bold" /> <toolbar-button + v-show="!previewMarkdown" tag="_" :button-title=" /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ @@ -317,11 +342,13 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('strikethrough')" + v-show="!previewMarkdown" tag="~~" :button-title=" /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ - sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { - modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, + sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}%{shiftKey}X)'), { + modifierKey, + shiftKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */, }) " :shortcuts="$options.shortcuts.strikethrough" @@ -329,14 +356,22 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('quote')" + v-show="!previewMarkdown" :prepend="true" :tag="tag" :button-title="__('Insert a quote')" icon="quote" @click="handleQuote" /> - <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> <toolbar-button + v-show="!previewMarkdown" + tag="`" + tag-block="```" + :button-title="__('Insert code')" + icon="code" + /> + <toolbar-button + v-show="!previewMarkdown" tag="[{text}](url)" tag-select="url" :button-title=" @@ -350,6 +385,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('bullet-list')" + v-show="!previewMarkdown" :prepend="true" tag="- " :button-title="__('Add a bullet list')" @@ -357,6 +393,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('numbered-list')" + v-show="!previewMarkdown" :prepend="true" tag="1. " :button-title="__('Add a numbered list')" @@ -364,6 +401,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('task-list')" + v-show="!previewMarkdown" :prepend="true" tag="- [ ] " :button-title="__('Add a checklist')" @@ -371,6 +409,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('indent')" + v-show="!previewMarkdown" class="gl-display-none" :button-title=" /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ @@ -384,6 +423,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('outdent')" + v-show="!previewMarkdown" class="gl-display-none" :button-title=" /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ @@ -397,6 +437,7 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('collapsible-section')" + v-show="!previewMarkdown" :tag="mdCollapsibleSection" :prepend="true" tag-select="Click to expand" @@ -405,17 +446,18 @@ export default { /> <toolbar-button v-if="!restrictedToolBarItems.includes('table')" + v-show="!previewMarkdown" :tag="mdTable" :prepend="true" :button-title="__('Add a table')" icon="table" /> <gl-button - v-if="!restrictedToolBarItems.includes('attach-file')" + v-if="!previewMarkdown && !restrictedToolBarItems.includes('attach-file')" v-gl-tooltip :aria-label="__('Attach a file or image')" :title="__('Attach a file or image')" - class="gl-mr-2" + class="gl-mr-3" data-testid="button-attach-file" category="tertiary" icon="paperclip" @@ -423,46 +465,37 @@ export default { @click="handleAttachFile" /> <drawio-toolbar-button - v-if="drawioEnabled" + v-if="!previewMarkdown && drawioEnabled" :uploads-path="uploadsPath" :markdown-preview-path="markdownPreviewPath" /> + <!-- TODO Add icon and trigger functionality from here --> + <toolbar-button + v-if="supportsQuickActions" + v-show="!previewMarkdown" + :prepend="true" + tag="/" + :button-title="__('Add a quick action')" + icon="quick-actions" + /> <comment-templates-dropdown - v-if="newCommentTemplatePath && glFeatures.savedReplies" + v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies" :new-comment-template-path="newCommentTemplatePath" + @select="insertSavedReply" /> - </div> - <div class="switch-preview gl-py-2 gl-display-flex gl-align-items-center gl-ml-auto"> - <editor-mode-switcher - v-if="showEditorModeSwitcher" - size="small" - class="gl-mr-2" - value="markdown" - @input="handleEditorModeChanged" - /> - <gl-button - v-if="enablePreview" - data-testid="preview-toggle" - value="preview" - :label="$options.i18n.previewTabTitle" - class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal!" - size="small" - category="tertiary" - @click="switchPreview" - >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button - > - <gl-button - v-if="!restrictedToolBarItems.includes('full-screen')" - v-gl-tooltip - :class="{ 'gl-display-none!': previewMarkdown }" - class="js-zen-enter gl-ml-2" - category="tertiary" - icon="maximize" - size="small" - :title="__('Go full screen')" - :prepend="true" - :aria-label="__('Go full screen')" - /> + <div v-if="!previewMarkdown" class="full-screen"> + <gl-button + v-if="!restrictedToolBarItems.includes('full-screen')" + v-gl-tooltip + class="js-zen-enter" + category="tertiary" + icon="maximize" + size="small" + :title="__('Go full screen')" + :prepend="true" + :aria-label="__('Go full screen')" + /> + </div> </div> </div> </div> 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 9fd606d775d..8b8247a5b2c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -5,6 +5,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave'; import { setUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { + EDITING_MODE_KEY, EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR, CLEAR_AUTOSAVE_ENTRY_EVENT, @@ -80,11 +81,6 @@ export default { required: false, default: '', }, - quickActionsDocsPath: { - type: String, - required: false, - default: '', - }, drawioEnabled: { type: Boolean, required: false, @@ -100,6 +96,11 @@ export default { required: false, default: false, }, + codeSuggestionsConfig: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -171,7 +172,7 @@ export default { renderMarkdown(markdown) { const url = setUrlParams( { render_quick_actions: this.supportsQuickActions }, - joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath), + joinPaths(gon.relative_url_root || window.location.origin, this.renderMarkdownPath), ); return axios.post(url, { text: markdown }).then(({ data }) => data.body); }, @@ -223,14 +224,15 @@ export default { } }, }, + EDITING_MODE_KEY, }; </script> <template> - <div class="md-area gl-px-0! gl-overflow-hidden"> + <div class="gl-px-0!"> <local-storage-sync :value="editingMode" as-string - storage-key="gl-markdown-editor-mode" + :storage-key="$options.EDITING_MODE_KEY" @input="onEditingModeRestored" /> <markdown-field @@ -240,12 +242,16 @@ export default { data-testid="markdown-field" :markdown-preview-path="renderMarkdownPath" :can-attach-file="!disableAttachments" + :can-suggest="codeSuggestionsConfig.canSuggest" + :line="codeSuggestionsConfig.line" + :lines="codeSuggestionsConfig.lines" + :show-suggest-popover="codeSuggestionsConfig.showPopover" :textarea-value="markdown" :uploads-path="uploadsPath" :enable-autocomplete="enableAutocomplete" :autocomplete-data-sources="autocompleteDataSources" :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" + :supports-quick-actions="supportsQuickActions" :show-content-editor-switcher="enableContentEditor" :drawio-enabled="drawioEnabled" :restricted-tool-bar-items="markdownFieldRestrictedToolBarItems" @@ -272,9 +278,10 @@ export default { <content-editor ref="contentEditor" :render-markdown="renderMarkdown" + :markdown-docs-path="markdownDocsPath" :uploads-path="uploadsPath" :markdown="markdown" - :quick-actions-docs-path="quickActionsDocsPath" + :supports-quick-actions="supportsQuickActions" :autofocus="contentEditorAutofocused" :placeholder="formFieldProps.placeholder" :drawio-enabled="drawioEnabled" @@ -282,6 +289,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" :disable-attachments="disableAttachments" + :code-suggestions-config="codeSuggestionsConfig" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @keydown="$emit('keydown', $event)" diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js index 8ff14220eab..0b0867ae84c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createApolloClient from '~/lib/graphql'; import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; + import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants'; import MarkdownEditor from './markdown_editor.vue'; import eventHub from './eventhub'; @@ -51,8 +54,13 @@ function mountAutosaveClearOnSubmit(autosaveKey) { } } -export function mountMarkdownEditor() { +export function mountMarkdownEditor(options = {}) { const el = document.querySelector('.js-markdown-editor'); + const componentConfiguration = { + provide: { + ...options.provide, + }, + }; if (!el) { return null; @@ -71,6 +79,7 @@ export function mountMarkdownEditor() { const supportsQuickActions = parseBoolean(el.dataset.supportsQuickActions ?? true); const enableAutocomplete = parseBoolean(el.dataset.enableAutocomplete ?? true); const disableAttachments = parseBoolean(el.dataset.disableAttachments ?? false); + const autofocus = parseBoolean(el.dataset.autofocus ?? true); const hiddenInput = el.querySelector('input[type="hidden"]'); const formFieldName = hiddenInput.getAttribute('name'); const formFieldId = hiddenInput.getAttribute('id'); @@ -86,6 +95,9 @@ export function mountMarkdownEditor() { const setFacade = (props) => Object.assign(facade, props); const autosaveKey = `autosave/${document.location.pathname}/${searchTerm}/description`; + componentConfiguration.apolloProvider = + options.apolloProvider || new VueApollo({ defaultClient: createApolloClient() }); + // eslint-disable-next-line no-new new Vue({ el, @@ -110,10 +122,11 @@ export function mountMarkdownEditor() { autocompleteDataSources: gl.GfmAutoComplete?.dataSources, supportsQuickActions, disableAttachments, - autofocus: true, + autofocus, }, }); }, + ...componentConfiguration, }); mountAutosaveClearOnSubmit(autosaveKey); diff --git a/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js new file mode 100644 index 00000000000..0ba6a44d153 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.stories.js @@ -0,0 +1,89 @@ +import Markdown from '~/vue_shared/components/markdown/non_gfm_markdown.vue'; + +export default { + title: 'vue_shared/non_gfm_markdown', + component: Markdown, + parameters: { + docs: { + description: { + component: ` +This component is designed to render the markdown, which is **not** the GitLab Flavored Markdown. + +It renders the code snippets the same way GitLab Flavored Markdown code snippets are rendered +respecting the user's preferred color scheme and featuring a copy-code button. + +This component can be used to render client-side markdown that doesn't have GitLab-specific markdown elements such as issue links. +`, + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { Markdown }, + props: Object.keys(argTypes), + template: '<markdown v-bind="$props" />', +}); + +const textWithCodeblock = ` +#### Here is the text with the code block. + +\`\`\`javascript +function sayHi(name) { + console.log('Hi ' + name || 'Mark'); +} +\`\`\` + +It *can* have **formatting** as well +`; + +export const OneCodeBlock = Template.bind({}); +OneCodeBlock.args = { markdown: textWithCodeblock }; + +const textWithMultipleCodeBlocks = ` +#### Here is the text with the code block. + +\`\`\`javascript +function sayHi(name) { + console.log('Hi ' + name || 'Mark'); +} +\`\`\` + +Note that the copy buttons are appearing independently + +\`\`\`yaml +stages: + - build + - test + - deploy +\`\`\` +`; + +export const MultipleCodeBlocks = Template.bind({}); +MultipleCodeBlocks.args = { markdown: textWithMultipleCodeBlocks }; + +const textUndefinedLanguage = ` +#### Here is the code block with no language provided. + +\`\`\` +function sayHi(name) { + console.log('Hi ' + name || 'Mark'); +} +\`\`\` +`; + +export const UndefinedLanguage = Template.bind({}); +UndefinedLanguage.args = { markdown: textUndefinedLanguage }; + +const textCodeOneLiner = ` +#### Here is the text with the one-liner code block. + +Note that copy button rendering is ok. + +\`\`\`javascript +const foo = 'bar'; +\`\`\` +`; + +export const CodeOneLiner = Template.bind({}); +CodeOneLiner.args = { markdown: textCodeOneLiner }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue new file mode 100644 index 00000000000..814e59681d0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/non_gfm_markdown.vue @@ -0,0 +1,120 @@ +<script> +/* +This component is designed to render the markdown, which is **not** the GitLab Flavored Markdown. + +It renders the code snippets the same way GitLab Flavored Markdown code snippets are rendered +respecting the user's preferred color scheme and featuring a copy-code button. + +This component can be used to render client-side markdown that doesn't have GitLab-specific markdown elements such as issue links. +*/ +import { marked } from 'marked'; +import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { sanitize } from '~/lib/dompurify'; +import { markdownConfig } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +export default { + components: { + CodeBlockHighlighted, + ModalCopyButton, + }, + directives: { + SafeHtml, + }, + props: { + markdown: { + type: String, + required: true, + }, + }, + data() { + return { + hoverMap: {}, + }; + }, + computed: { + markdownBlocks() { + // we use lexer https://marked.js.org/using_pro#lexer + // to get an array of tokens that marked npm module uses. + // We will use these tokens to override rendering of some of them + // with our vue components + const tokens = marked.lexer(this.markdown); + + // since we only want to differentiate between code and non-code blocks + // we want non-code blocks merged together so that the markdown parser could render + // them according to the markdown rules. + // This way we introduce minimum extra wrapper mark-up + const flattenedTokens = []; + + for (const token of tokens) { + const lastFlattenedToken = flattenedTokens[flattenedTokens.length - 1]; + if (token.type === 'code') { + flattenedTokens.push(token); + } else if (lastFlattenedToken?.type === 'markdown') { + lastFlattenedToken.raw += token.raw; + } else { + flattenedTokens.push({ type: 'markdown', raw: token.raw }); + } + } + + return flattenedTokens; + }, + }, + methods: { + getSafeHtml(markdown) { + return sanitize(marked.parse(markdown), markdownConfig); + }, + setHoverOn(key) { + this.hoverMap = { ...this.hoverMap, [key]: true }; + }, + setHoverOff(key) { + this.hoverMap = { ...this.hoverMap, [key]: false }; + }, + isLastElement(index) { + return index === this.markdownBlocks.length - 1; + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], + }, + i18n: { + copyCodeTitle: __('Copy code'), + }, + fallbackLanguage: 'text', +}; +</script> +<template> + <div> + <template v-for="(block, index) in markdownBlocks"> + <div + v-if="block.type === 'code'" + :key="`code-${index}`" + :class="{ 'gl-relative': true, 'gl-mb-4': !isLastElement(index) }" + data-testid="code-block-wrapper" + @mouseenter="setHoverOn(`code-${index}`)" + @mouseleave="setHoverOff(`code-${index}`)" + > + <modal-copy-button + v-if="hoverMap[`code-${index}`]" + :title="$options.i18n.copyCodeTitle" + :text="block.text" + class="gl-absolute gl-top-3 gl-right-3 gl-z-index-1 gl-transition-duration-medium" + /> + <code-block-highlighted + class="gl-border gl-rounded-0! gl-p-4 gl-mb-0 gl-overflow-y-auto" + :language="block.lang || $options.fallbackLanguage" + :code="block.text" + /> + </div> + <div + v-else + :key="`text-${index}`" + v-safe-html:[$options.safeHtmlConfig]="getSafeHtml(block.raw)" + :class="{ 'non-gfm-markdown-block': true, 'gl-mb-4': !isLastElement(index) }" + data-testid="non-code-markdown" + ></div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 6d1cadf15be..4423b26560f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -40,7 +40,8 @@ export default { }, defaultCommitMessage: { type: String, - required: true, + required: false, + default: null, }, suggestionsCount: { type: Number, @@ -124,7 +125,7 @@ export default { suggestion, batchSuggestionsInfo, helpPagePath, - defaultCommitMessage, + defaultCommitMessage: defaultCommitMessage || '', suggestionsCount, failedToLoadMetadata, }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 4733afb7504..d4b1abedc02 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,24 +1,26 @@ <script> -import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { updateText } from '~/lib/utils/text_markdown'; +import { __, sprintf } from '~/locale'; +import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import EditorModeSwitcher from './editor_mode_switcher.vue'; export default { components: { GlButton, - GlLink, GlLoadingIcon, GlSprintf, GlIcon, + EditorModeSwitcher, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { markdownDocsPath: { type: String, required: true, }, - quickActionsDocsPath: { - type: String, - required: false, - default: '', - }, canAttachFile: { type: Boolean, required: false, @@ -29,10 +31,46 @@ export default { required: false, default: true, }, + showContentEditorSwitcher: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - hasQuickActionsDocsPath() { - return this.quickActionsDocsPath !== ''; + showEditorModeSwitcher() { + return this.showContentEditorSwitcher; + }, + }, + methods: { + insertIntoTextarea(...lines) { + const text = lines.join('\n'); + const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); + if (textArea && !textArea.value) { + updateText({ + textArea, + tag: text, + cursorOffset: 0, + wrap: false, + }); + } + }, + handleEditorModeChanged(isFirstSwitch) { + if (isFirstSwitch) { + this.insertIntoTextarea( + __(`### Rich text editor`), + '', + sprintf( + __( + 'Try out **styling** _your_ content right here or read the [direction](%{directionUrl}).', + ), + { + directionUrl: `${PROMO_URL}/direction/plan/knowledge/content_editor/`, + }, + ), + ); + } + this.$emit('enableContentEditor'); }, }, }; @@ -41,94 +79,80 @@ export default { <template> <div v-if="showCommentToolBar" - class="comment-toolbar gl-mx-2 gl-mb-2 gl-px-4 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base clearfix" + class="comment-toolbar gl-display-flex gl-flex-direction-row gl-px-2 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class=" + showContentEditorSwitcher + ? 'gl-justify-content-space-between gl-align-items-center gl-border-t gl-border-gray-100' + : 'gl-justify-content-end gl-my-2' + " > - <div class="toolbar-text gl-font-sm"> - <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-sprintf - :message=" - s__('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}') - " - > - <template #markdownDocsLink="{ content }"> - <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </template> - <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-sprintf - :message=" - s__( - 'NoteToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}. For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.', - ) - " - > - <template #markdownDocsLink="{ content }"> - <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - <template #keyboard="{ content }"> - <kbd>{{ content }}</kbd> - </template> - <template #quickActionsDocsLink="{ content }"> - <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </template> - </div> - <span v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32"> - <span class="uploading-progress-container hide"> - <gl-icon name="paperclip" /> - <span class="attaching-file-message"></span> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - <span class="uploading-progress">0%</span> - <gl-loading-icon size="sm" inline /> - </span> - <span class="uploading-error-container hide"> - <span class="uploading-error-icon"> + <editor-mode-switcher + v-if="showEditorModeSwitcher" + size="small" + value="markdown" + @switch="handleEditorModeChanged" + /> + <div class="gl-display-flex"> + <div v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32 gl-mr-3"> + <span class="uploading-progress-container hide"> <gl-icon name="paperclip" /> + <span class="attaching-file-message"></span> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <span class="uploading-progress">0%</span> + <gl-loading-icon size="sm" inline /> </span> - <span class="uploading-error-message"></span> + <span class="uploading-error-container hide"> + <span class="uploading-error-icon"> + <gl-icon name="paperclip" /> + </span> + <span class="uploading-error-message"></span> - <gl-sprintf - :message=" - __( - '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.', - ) - " + <gl-sprintf + :message=" + __( + '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.', + ) + " + > + <template #retryButton="{ content }"> + <gl-button + variant="link" + category="primary" + class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!" + > + {{ content }} + </gl-button> + </template> + <template #newFileButton="{ content }"> + <gl-button + variant="link" + category="primary" + class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!" + > + {{ content }} + </gl-button> + </template> + </gl-sprintf> + </span> + <gl-button + variant="link" + category="primary" + class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!" > - <template #retryButton="{ content }"> - <gl-button - variant="link" - category="primary" - class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!" - > - {{ content }} - </gl-button> - </template> - <template #newFileButton="{ content }"> - <gl-button - variant="link" - category="primary" - class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!" - > - {{ content }} - </gl-button> - </template> - </gl-sprintf> - </span> + {{ __('Cancel') }} + </gl-button> + </div> <gl-button - variant="link" - category="primary" - class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!" - > - {{ __('Cancel') }} - </gl-button> - </span> + v-if="markdownDocsPath" + v-gl-tooltip + icon="markdown-mark" + :href="markdownDocsPath" + target="_blank" + category="tertiary" + size="small" + title="Markdown is supported" + class="gl-px-3!" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/tracking.js b/app/assets/javascripts/vue_shared/components/markdown/tracking.js new file mode 100644 index 00000000000..2628054ae5f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/tracking.js @@ -0,0 +1,14 @@ +import Tracking from '~/tracking'; + +export const EDITOR_TRACKING_LABEL = 'editor_tracking'; +export const EDITOR_TYPE_ACTION = 'editor_type_used'; +export const EDITOR_TYPE_PLAIN_TEXT_EDITOR = 'editor_type_plain_text_editor'; +export const EDITOR_TYPE_RICH_TEXT_EDITOR = 'editor_type_rich_text_editor'; + +export const trackSavedUsingEditor = (isRichText, context) => { + Tracking.event(undefined, EDITOR_TYPE_ACTION, { + label: EDITOR_TRACKING_LABEL, + editorType: isRichText ? EDITOR_TYPE_RICH_TEXT_EDITOR : EDITOR_TYPE_PLAIN_TEXT_EDITOR, + context, + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/markdown/utils.js b/app/assets/javascripts/vue_shared/components/markdown/utils.js new file mode 100644 index 00000000000..0227d5a0fbc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/utils.js @@ -0,0 +1,7 @@ +let i = 0; + +export const counter = () => { + const n = i; + i += 1; + return n; +}; diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue index 064458cfc1f..ba557878246 100644 --- a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue @@ -51,6 +51,8 @@ export default { SidebarSubscriptionsWidget, AbuseCategorySelector, NewHeaderActionsPopover, + SummaryNotesToggle: () => + import('ee_component/merge_requests/components/summary_notes_toggle.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -60,6 +62,9 @@ export default { reportAbusePath: { default: '', }, + showSummaryNotesToggle: { + default: false, + }, }, props: { mr: { @@ -71,6 +76,11 @@ export default { default: '', required: false, }, + url: { + type: String, + default: '', + required: false, + }, editUrl: { type: String, default: '', @@ -116,11 +126,6 @@ export default { default: 0, required: false, }, - reportedFromUrl: { - type: String, - default: '', - required: false, - }, }, data() { return { @@ -156,7 +161,7 @@ export default { this.isLoadingDraft = true; axios - .put(`?merge_request[wip_event]=${this.draftState}`, null, { + .put(`${this.url}?merge_request[wip_event]=${this.draftState}`, null, { params: { format: 'json' }, }) .then(({ data }) => { @@ -226,10 +231,12 @@ export default { :auto-close="false" > <template #toggle> - <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!" :aria-label="$options.i18n.mergeRequestActions"> + <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!"> <gl-button class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full" category="secondary" + :aria-label="$options.i18n.mergeRequestActions" + :title="$options.i18n.mergeRequestActions" > <span class="">{{ $options.i18n.mergeRequestActions }}</span> <gl-icon class="dropdown-chevron" name="chevron-down" /> @@ -238,6 +245,8 @@ export default { class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret gl-ml-3" category="tertiary" icon="ellipsis_v" + :aria-label="$options.i18n.mergeRequestActions" + :title="$options.i18n.mergeRequestActions" /> </div> </template> @@ -329,6 +338,8 @@ export default { {{ $options.i18n.copyReferenceText }} </template> </gl-disclosure-dropdown-item> + + <summary-notes-toggle v-if="showSummaryNotesToggle" @action="closeActionsDropdown" /> </gl-disclosure-dropdown-group> <gl-disclosure-dropdown-group @@ -353,7 +364,7 @@ export default { <abuse-category-selector v-if="!isCurrentUser && isReportAbuseDrawerOpen" :reported-user-id="reportedUserId" - :reported-from-url="reportedFromUrl" + :reported-from-url="url" :show-drawer="isReportAbuseDrawerOpen" @close-drawer="reportAbuseAction(false)" /> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 57e3a97244e..ab9e6e092d9 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -116,6 +116,7 @@ export default { unique: true, symbol: '@', token: UserToken, + dataType: 'user', operators: OPERATORS_IS, fetchPath: this.projectPath, fetchUsers: Api.projectUsers.bind(Api), @@ -127,6 +128,7 @@ export default { unique: true, symbol: '@', token: UserToken, + dataType: 'user', operators: OPERATORS_IS, fetchPath: this.projectPath, fetchUsers: Api.projectUsers.bind(Api), diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue index 11aa7b91745..cb8220a0407 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue @@ -30,12 +30,22 @@ export default { type: Array, required: true, }, + showProjectIcon: { + type: Boolean, + required: false, + default: false, + }, }, }; </script> <template> <ul class="gl-p-0 gl-list-style-none"> - <projects-list-item v-for="project in projects" :key="project.id" :project="project" /> + <projects-list-item + v-for="project in projects" + :key="project.id" + :project="project" + :show-project-icon="showProjectIcon" + /> </ul> </template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index 266cce29e50..d919f76e684 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -34,6 +34,7 @@ export default { moreTopics: __('More topics'), updated: __('Updated'), }, + avatarSize: { default: 32, md: 48 }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'], }, @@ -78,6 +79,11 @@ export default { type: Object, required: true, }, + showProjectIcon: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -85,11 +91,14 @@ export default { }; }, computed: { + visibility() { + return this.project.visibility; + }, visibilityIcon() { - return VISIBILITY_TYPE_ICON[this.project.visibility]; + return VISIBILITY_TYPE_ICON[this.visibility]; }, visibilityTooltip() { - return PROJECT_VISIBILITY_TYPE[this.project.visibility]; + return PROJECT_VISIBILITY_TYPE[this.visibility]; }, accessLevel() { return this.project.permissions?.projectAccess?.accessLevel; @@ -150,71 +159,87 @@ export default { <template> <li class="projects-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> - <gl-avatar-labeled - class="gl-flex-grow-1" - :entity-id="project.id" - :entity-name="project.name" - :label="project.name" - :label-link="project.webUrl" - shape="rect" - :size="48" - > - <template #meta> - <gl-icon - v-gl-tooltip="visibilityTooltip" - :name="visibilityIcon" - class="gl-text-secondary gl-ml-3" - /> - <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{ - accessLevelLabel - }}</user-access-role-badge> - </template> - <div - v-if="project.descriptionHtml" - v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml" - class="gl-font-sm gl-overflow-hidden gl-line-height-20 description" - data-testid="project-description" - ></div> - <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> - <div - class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" - > - <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> - <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-icon + v-if="showProjectIcon" + class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" + name="project" + /> + <gl-avatar-labeled + :entity-id="project.id" + :entity-name="project.name" + :label="project.name" + :label-link="project.webUrl" + shape="rect" + :size="$options.avatarSize" + > + <template #meta> + <div class="gl-px-2"> + <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> + <div class="gl-px-2"> + <gl-icon + v-if="visibility" + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary" + /> + </div> + <div class="gl-px-2"> + <user-access-role-badge v-if="shouldShowAccessLevel">{{ + accessLevelLabel + }}</user-access-role-badge> + </div> + </div> </div> - <template v-if="popoverTopics.length"> - <div - :id="topicsPopoverTarget" - class="gl-p-2 gl-text-secondary" - role="button" - tabindex="0" - > - <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> - <template #count>{{ popoverTopics.length }}</template> - </gl-sprintf> + </template> + <div + v-if="project.descriptionHtml" + v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml" + class="gl-font-sm gl-overflow-hidden gl-line-height-20 description md" + data-testid="project-description" + ></div> + <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + <div + class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2" + > + <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span> + <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> </div> - <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> - <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2"> - <div - v-for="topic in popoverTopics" - :key="topic" - class="gl-p-2 gl-display-inline-block" - > - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> - </div> + <template v-if="popoverTopics.length"> + <div + :id="topicsPopoverTarget" + class="gl-p-2 gl-text-secondary" + role="button" + tabindex="0" + > + <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> + <template #count>{{ popoverTopics.length }}</template> + </gl-sprintf> </div> - </gl-popover> - </template> + <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> + <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2"> + <div + v-for="topic in popoverTopics" + :key="topic" + class="gl-p-2 gl-display-inline-block" + > + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + </div> + </gl-popover> + </template> + </div> </div> - </div> - </gl-avatar-labeled> + </gl-avatar-labeled> + </div> <div - class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-pl-10 gl-md-pl-0 gl-md-mt-0" + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0" + :class="showProjectIcon ? 'gl-pl-11' : 'gl-pl-8'" > <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge> @@ -248,7 +273,10 @@ export default { <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span> </gl-link> </div> - <div class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3"> + <div + v-if="project.updatedAt" + class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3" + > <span>{{ $options.i18n.updated }}</span> <time-ago-tooltip :time="project.updatedAt" /> </div> 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 5d0ee6adffe..ccda8c5fea7 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -1,9 +1,13 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { name: 'ListItem', components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { first: { type: Boolean, @@ -27,6 +31,9 @@ export default { detailsSlots: [], }; }, + i18n: { + toggleDetailsLabel: __('Toggle details'), + }, computed: { optionalClasses() { return { @@ -75,10 +82,14 @@ export default { <slot name="left-primary"></slot> <gl-button v-if="detailsSlots.length > 0" + v-gl-tooltip :selected="isDetailsShown" icon="ellipsis_h" size="small" class="gl-ml-2 gl-display-none gl-sm-display-block" + :title="$options.i18n.toggleDetailsLabel" + :aria-label="$options.i18n.toggleDetailsLabel" + :aria-expanded="isDetailsShown" @click="toggleDetails" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue index ff7e803af2a..5d04fd1d8e7 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; export default { components: { @@ -16,7 +17,7 @@ export default { 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation', ), I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'), - HELP_URL: 'https://docs.gitlab.com/runner/install/docker.html', + HELP_URL: `${DOCS_URL}/runner/install/docker.html`, }; </script> <template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue index ee41dab0cec..a769b4a6ad8 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; export default { components: { @@ -16,7 +17,7 @@ export default { 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.', ), I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'), - HELP_URL: 'https://docs.gitlab.com/runner/install/kubernetes.html', + HELP_URL: `${DOCS_URL}/runner/install/kubernetes.html`, }; </script> <template> 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 94aa7bd2f88..3b5086b3c7e 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 @@ -122,14 +122,6 @@ export default { return null; } }, - showDeprecationAlert() { - return ( - // create_runner_workflow_for_admin - this.glFeatures.createRunnerWorkflowForAdmin || - // create_runner_workflow_for_namespace - this.glFeatures.createRunnerWorkflowForNamespace - ); - }, }, updated() { // Refocus on dom changes, after loading data @@ -200,12 +192,7 @@ export default { v-on="$listeners" @shown="onShown" > - <gl-alert - v-if="showDeprecationAlert" - :title="$options.i18n.deprecationAlertTitle" - variant="warning" - :dismissible="false" - > + <gl-alert :title="$options.i18n.deprecationAlertTitle" variant="warning" :dismissible="false"> <gl-sprintf :message="$options.i18n.deprecationAlertContent"> <template #link="{ content }"> <gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL" diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue index d77061d4b31..b89fa3f8292 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -63,9 +63,6 @@ export default { shouldHighlight() { return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); }, - lines() { - return this.content.split('\n'); - }, pageSearchString() { const page = getPageParamValue(this.number); return getPageSearchString(this.blamePath, page); @@ -123,7 +120,7 @@ export default { <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> + ><code v-if="shouldHighlight" 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/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index d694adf7147..3f8a9258fc3 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,6 +1,7 @@ import wrapChildNodes from './wrap_child_nodes'; import linkDependencies from './link_dependencies'; import wrapBidiChars from './wrap_bidi_chars'; +import wrapLines from './wrap_lines'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; @@ -11,10 +12,11 @@ export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; * * @param {Object} hljs - the Highlight.js instance. */ -export const registerPlugins = (hljs, fileType, rawContent) => { +export const registerPlugins = (hljs, fileType, rawContent, shouldWrapLines) => { hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), }); + if (shouldWrapLines) hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapLines }); }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js index a79e88a1132..b972d8ece91 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js @@ -22,7 +22,7 @@ const format = (node, scope = '') => { .split(newlineRegex) .map((newline) => generateHLJSTag(scope, newline, true)) .join('\n'); - } else if (node.scope || node.sublanguage) { + } else if (node.children) { const { children } = node; if (children.length && children.length === 1) { buffer += format(children[0], node.scope); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js new file mode 100644 index 00000000000..384ada30001 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_lines.js @@ -0,0 +1,20 @@ +/** + * Highlight.js plugin for wrapping lines in the correct classes and attributes. + * Needed for things like hash highlighting to work. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ + +function wrapLine(content, number, language) { + return `<div id="LC${number}" lang="${language}" class="line">${content}</div>`; +} + +export default (result) => { + // eslint-disable-next-line no-param-reassign + result.value = result.value + .split(/\r?\n/) + .map((content, index) => wrapLine(content, index + 1, result.language)) + .join('\n'); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index 7e18c8414d5..8e4c438719e 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -2,6 +2,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import LineHighlighter from '~/blob/line_highlighter'; import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk_new.vue'; @@ -19,9 +20,6 @@ export default { SafeHtml, }, mixins: [Tracking.mixin()], - inject: { - highlightWorker: { default: null }, - }, props: { blob: { type: Object, @@ -33,6 +31,11 @@ export default { default: () => [], }, }, + data() { + return { + lineHighlighter: new LineHighlighter(), + }; + }, created() { this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); 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 142c135e9c1..8d8e945cd5f 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,9 +1,13 @@ -import hljs from 'highlight.js'; +import hljs from 'highlight.js/lib/core'; +import json from 'highlight.js/lib/languages/json'; import { registerPlugins } from '../plugins/index'; import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = (fileType, content) => { - registerPlugins(hljs, fileType, content); +const initHighlightJs = (fileType, content, language) => { + // The Highlight Worker is currently scoped to JSON files. + // See the following issue for more: https://gitlab.com/gitlab-org/gitlab/-/issues/415753 + hljs.registerLanguage(language, json); + registerPlugins(hljs, fileType, content, true); }; const splitByLineBreaks = (content = '') => content.split(/\r?\n/); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js index 535e857d7a9..535e857d7a9 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 00720f27934..0949071d4dc 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -18,6 +18,7 @@ */ import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import UserAvatarImage from './user_avatar_image.vue'; export default { @@ -74,6 +75,16 @@ export default { required: false, default: 'top', }, + popoverUserId: { + type: [String, Number], + required: false, + default: '', + }, + popoverUsername: { + type: String, + required: false, + default: '', + }, username: { type: String, required: false, @@ -81,10 +92,17 @@ export default { }, }, computed: { + userId() { + return getIdFromGraphQLId(this.popoverUserId); + }, shouldShowUsername() { return this.username.length > 0; }, avatarTooltipText() { + // Prevent showing tooltip when popoverUserId is present + if (this.popoverUserId) { + return ''; + } return this.shouldShowUsername ? '' : this.tooltipText; }, }, @@ -92,7 +110,12 @@ export default { </script> <template> - <gl-avatar-link :href="linkHref" class="user-avatar-link"> + <gl-avatar-link + :href="linkHref" + :data-user-id="userId" + :data-username="popoverUsername" + class="user-avatar-link js-user-link" + > <user-avatar-image :class="imgCssWrapperClasses" :img-src="imgSrc" 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 335f9ab1df4..258e8b1a6c5 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 @@ -81,6 +81,8 @@ export default { :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" + :popover-user-id="item.id" + :popover-username="item.username" img-css-classes="gl-mr-3" /> <template v-if="hasBreakpoint"> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index e09f193310b..446c8c97df0 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -91,6 +91,9 @@ export default { return ''; }, + userCannotMerge() { + return this.target.dataset.cannotMerge; + }, userIsLoading() { return !this.user?.loaded; }, @@ -123,6 +126,15 @@ export default { username() { return `@${this.user?.username}`; }, + cssClasses() { + const classList = ['user-popover', 'gl-max-w-48', 'gl-overflow-hidden']; + + if (this.userCannotMerge) { + classList.push('user-popover-cannot-merge'); + } + + return classList; + }, }, methods: { async toggleFollow() { @@ -181,7 +193,7 @@ export default { <template> <!-- Delayed so not every mouseover triggers Popover --> <gl-popover - :css-classes="['gl-max-w-48']" + :css-classes="cssClasses" :show="show" :target="target" :delay="$options.USER_POPOVER_DELAY" @@ -190,6 +202,12 @@ export default { triggers="hover focus manual" data-testid="user-popover" > + <template v-if="userCannotMerge" #title> + <div class="gl-pb-3 gl-display-flex gl-align-items-center" data-testid="cannot-merge"> + <gl-icon name="warning-solid" class="gl-mr-2 gl-text-orange-400" /> + <span class="gl-font-weight-normal">{{ __('Cannot merge') }}</span> + </div> + </template> <div class="gl-mb-3"> <div v-if="userIsLoading" class="gl-w-20"> <gl-skeleton-loader :width="160" :height="64"> @@ -204,6 +222,7 @@ export default { :src="user.avatarUrl" :label="user.name" :sub-label="username" + class="gl-w-full" > <template v-if="isBlocked"> <span class="gl-mt-4 gl-font-style-italic">{{ $options.I18N_USER_BLOCKED }}</span> 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 82f4edcbd5f..9a06c0ecf30 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -23,6 +23,7 @@ export const i18n = { }; export default { + name: 'CEWebIdeLink', components: { ActionsButton, GlModal, @@ -319,7 +320,11 @@ export default { :toggle-text="$options.i18n.toggleText" :variant="isBlob ? 'confirm' : 'default'" :category="isBlob ? 'primary' : 'secondary'" - /> + @hidden="$emit('hidden')" + @shown="$emit('shown')" + > + <slot></slot> + </actions-button> <gl-modal v-if="computedShowGitpodButton && !gitpodEnabled" v-model="showEnableGitpodModal" diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 3896e963a53..8946a02e663 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -94,6 +94,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) => }, ); +export const EDITING_MODE_KEY = 'gl-markdown-editor-mode'; export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField'; export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor'; export const CLEAR_AUTOSAVE_ENTRY_EVENT = 'markdown_clear_autosave_entry'; diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 4211b9578a2..a693d4f114d 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -75,3 +75,23 @@ export const SEARCH_RESULTS_ORDER = [ HELP_CATEGORY, ]; export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER; + +export const SEARCH_LABELS = s__('GlobalSearch|Search labels'); + +export const DROPDOWN_HEADER = s__('GlobalSearch|Labels'); + +export const AGGREGATIONS_ERROR_MESSAGE = s__('GlobalSearch|Fetching aggregations error.'); + +export const NO_LABELS_FOUND = s__('GlobalSearch|No labels found'); + +export const I18N = { + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_RESULTS_LOADING, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, + SEARCH_LABELS, + DROPDOWN_HEADER, + AGGREGATIONS_ERROR_MESSAGE, + NO_LABELS_FOUND, +}; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index 3d4eebb9524..53e976d698b 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -9,14 +9,16 @@ import { } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { STATUS_OPEN } from '~/issues/constants'; +import { issuableStatusText, STATUS_OPEN } from '~/issues/constants'; import { isExternal } from '~/lib/utils/url_utility'; import { n__, sprintf } from '~/locale'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; export default { components: { + ConfidentialityBadge, GlIcon, GlBadge, GlButton, @@ -77,8 +79,16 @@ export default { required: false, default: false, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, computed: { + badgeText() { + return issuableStatusText[this.issuableState]; + }, badgeVariant() { return this.issuableState === STATUS_OPEN ? 'success' : 'info'; }, @@ -109,6 +119,7 @@ export default { }, methods: { handleRightSidebarToggleClick() { + this.$emit('toggle'); if (this.toggleSidebarButtonEl) { this.toggleSidebarButtonEl.dispatchEvent(new Event('click')); } @@ -118,21 +129,23 @@ export default { </script> <template> - <div class="detail-page-header"> + <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row"> <div class="detail-page-header-body"> <gl-badge class="issuable-status-badge gl-mr-3" :variant="badgeVariant"> <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> - <span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span> + <span class="gl-display-none gl-sm-display-block gl-ml-2"> + <slot name="status-badge">{{ badgeText }}</slot> + </span> </gl-badge> - <div class="issuable-meta gl-display-flex! gl-align-items-center"> - <div v-if="blocked || confidential" class="gl-display-inline-block"> - <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> - <gl-icon name="lock" :aria-label="__('Blocked')" /> - </div> - <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline"> - <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> - </div> + <div class="issuable-meta gl-display-flex! gl-align-items-center gl-flex-wrap"> + <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> + <gl-icon name="lock" :aria-label="__('Blocked')" /> </div> + <confidentiality-badge + v-if="confidential" + :issuable-type="issuableType" + :workspace-type="workspaceType" + /> <span> <template v-if="showWorkItemTypeIcon"> <work-item-type-icon :work-item-type="issuableType" show-text /> @@ -182,10 +195,7 @@ export default { @click="handleRightSidebarToggleClick" /> </div> - <div - data-testid="header-actions" - class="detail-page-header-actions gl-display-flex gl-md-display-block" - > + <div data-testid="header-actions" class="detail-page-header-actions gl-display-flex"> <slot name="header-actions"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index c33e803c7e1..841d92fd63d 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -81,9 +81,12 @@ export default { data-testid="header" > <div - class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto gl-px-5" > - <gl-badge class="gl-white-space-nowrap gl-mr-3" :variant="badgeVariant"> + <gl-badge + class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" + :variant="badgeVariant" + > <gl-icon v-if="statusIcon" class="gl-sm-display-none" :name="statusIcon" /> <span class="gl-display-none gl-sm-display-block"> <slot name="status-badge"></slot> diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index caa85d3eaaf..1b4da047057 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,11 +1,7 @@ <script> -import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; export default { - directives: { - SafeHtml, - }, mixins: [Tracking.mixin()], props: { title: { @@ -38,9 +34,10 @@ export default { @click="track('click_tab', { label: panel.name })" > <div - v-safe-html="panel.illustration" - class="new-namespace-panel-illustration gl-text-white gl-display-flex gl-flex-shrink-0 gl-justify-content-center" - ></div> + class="new-namespace-panel-illustration gl-display-flex gl-flex-shrink-0 gl-justify-content-center" + > + <img aria-hidden :src="panel.imageSrc" /> + </div> <div class="gl-pl-4"> <h3 class="gl-font-size-h2 gl-reset-color"> {{ panel.title }} diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 5ab2e346a7a..4503ba6e561 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -1,6 +1,5 @@ <script> import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue'; @@ -18,9 +17,6 @@ export default { LegacyContainer, SuperSidebarToggle, }, - directives: { - SafeHtml, - }, props: { title: { type: String, @@ -137,7 +133,9 @@ export default { <template v-if="activePanel"> <div class="gl-display-flex gl-align-items-center gl-py-5"> - <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div> + <div class="col-auto"> + <img aria-hidden :src="activePanel.imageSrc" /> + </div> <div class="col"> <h4>{{ activePanel.title }}</h4> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue deleted file mode 100644 index 4c2b082242b..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; -import { extractSecurityReportArtifactsFromMergeRequest } from '~/vue_shared/security_reports/utils'; - -export default { - components: { - SecurityReportDownloadDropdown, - }, - props: { - reportTypes: { - type: Array, - required: true, - validator: (reportType) => { - return reportType.every((report) => reportTypeToSecurityReportTypeEnum[report]); - }, - }, - targetProjectFullPath: { - type: String, - required: true, - }, - mrIid: { - type: Number, - required: true, - }, - injectedArtifacts: { - type: Array, - required: false, - default: () => [], - }, - }, - data() { - return { - reportArtifacts: [], - }; - }, - apollo: { - reportArtifacts: { - query: securityReportMergeRequestDownloadPathsQuery, - variables() { - return { - projectPath: this.targetProjectFullPath, - iid: String(this.mrIid), - reportTypes: this.reportTypes.map( - (reportType) => reportTypeToSecurityReportTypeEnum[reportType], - ), - }; - }, - update(data) { - return extractSecurityReportArtifactsFromMergeRequest(this.reportTypes, data); - }, - error(error) { - this.showError(error); - }, - }, - }, - computed: { - isLoadingReportArtifacts() { - return this.$apollo.queries.reportArtifacts.loading; - }, - mergedReportArtifacts() { - return [...this.reportArtifacts, ...this.injectedArtifacts]; - }, - }, - methods: { - showError(error) { - createAlert({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }, - }, - i18n: { - apiError: s__( - 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', - ), - }, -}; -</script> - -<template> - <security-report-download-dropdown - :title="s__('SecurityReports|Download results')" - :artifacts="mergedReportArtifacts" - :loading="isLoadingReportArtifacts" - /> -</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js deleted file mode 100644 index 5e8199c1bcd..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js +++ /dev/null @@ -1,8 +0,0 @@ -export const SEVERITY_CLASS_NAME_MAP = { - critical: 'gl-text-red-800', - high: 'gl-text-red-600', - medium: 'gl-text-orange-400', - low: 'gl-text-orange-300', - info: 'gl-text-blue-400', - unknown: 'gl-text-gray-400', -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue deleted file mode 100644 index eed1c86c318..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue +++ /dev/null @@ -1,58 +0,0 @@ -<script> -import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; -import { s__ } from '~/locale'; - -export default { - components: { - GlButton, - GlIcon, - GlLink, - GlPopover, - }, - props: { - helpPath: { - type: String, - required: true, - }, - discoverProjectSecurityPath: { - type: String, - required: false, - default: '', - }, - }, - i18n: { - securityReportsHelp: s__('SecurityReports|Security reports help page link'), - upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'), - upgradeToInteract: s__( - 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.', - ), - }, -}; -</script> - -<template> - <span v-if="discoverProjectSecurityPath"> - <gl-button - ref="discoverProjectSecurity" - icon="question-o" - category="tertiary" - :aria-label="$options.i18n.upgradeToManageVulnerabilities" - /> - - <gl-popover - :target="() => $refs.discoverProjectSecurity.$el" - :title="$options.i18n.upgradeToManageVulnerabilities" - placement="top" - triggers="click blur" - > - {{ $options.i18n.upgradeToInteract }} - <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{ - __('Learn more') - }}</gl-link> - </gl-popover> - </span> - - <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp"> - <gl-icon name="question-o" /> - </gl-link> -</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue deleted file mode 100644 index e3aa25a294e..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { GlSprintf } from '@gitlab/ui'; -import { SEVERITY_CLASS_NAME_MAP } from './constants'; - -export default { - components: { - GlSprintf, - }, - props: { - message: { - type: Object, - required: true, - }, - }, - computed: { - shouldShowCountMessage() { - return !this.message.status && Boolean(this.message.countMessage); - }, - }, - methods: { - getSeverityClass(severity) { - return SEVERITY_CLASS_NAME_MAP[severity]; - }, - }, - slotNames: ['critical', 'high', 'other'], - spacingClasses: { - critical: 'gl-pl-4', - high: 'gl-px-2', - other: 'gl-px-2', - }, -}; -</script> - -<template> - <span> - <gl-sprintf :message="message.message"> - <template #total="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - <span v-if="shouldShowCountMessage" class="gl-font-sm"> - <gl-sprintf :message="message.countMessage"> - <template v-for="slotName in $options.slotNames" #[slotName]="{ content }"> - <span :key="slotName"> - <strong - v-if="message[slotName] > 0" - :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]" - > - {{ content }} - </strong> - <span v-else :class="$options.spacingClasses[slotName]"> - {{ content }} - </span> - </span> - </template> - </gl-sprintf> - </span> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index a1d75e08be9..56c6affebd7 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -28,7 +28,6 @@ export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning'; export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management'; export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; -export const REPORT_TYPE_MANUALLY_ADDED = 'generic'; /** * SecurityReportTypeEnum values for use with GraphQL. diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue deleted file mode 100644 index 0cff5edf628..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ /dev/null @@ -1,238 +0,0 @@ -<script> -import { mapActions, mapGetters } from 'vuex'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import ReportSection from '~/ci/reports/components/report_section.vue'; -import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import HelpIcon from './components/help_icon.vue'; -import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; -import SecuritySummary from './components/security_summary.vue'; -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SECRET_DETECTION, - reportTypeToSecurityReportTypeEnum, -} from './constants'; -import securityReportMergeRequestDownloadPathsQuery from './graphql/queries/security_report_merge_request_download_paths.query.graphql'; -import store from './store'; -import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; -import { extractSecurityReportArtifactsFromMergeRequest } from './utils'; - -export default { - store, - components: { - ReportSection, - HelpIcon, - SecurityReportDownloadDropdown, - SecuritySummary, - }, - mixins: [glFeatureFlagsMixin()], - props: { - pipelineId: { - type: Number, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - securityReportsDocsPath: { - type: String, - required: true, - }, - discoverProjectSecurityPath: { - type: String, - required: false, - default: '', - }, - sastComparisonPath: { - type: String, - required: false, - default: '', - }, - secretDetectionComparisonPath: { - type: String, - required: false, - default: '', - }, - targetProjectFullPath: { - type: String, - required: false, - default: '', - }, - mrIid: { - type: Number, - required: false, - default: 0, - }, - canDiscoverProjectSecurity: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - availableSecurityReports: [], - canShowCounts: false, - - // When core_security_mr_widget_counts is not enabled, the - // error state is shown even when successfully loaded, since success - // state suggests that the security scans detected no security problems, - // which is not necessarily the case. A future iteration will actually - // check whether problems were found and display the appropriate status. - status: ERROR, - }; - }, - apollo: { - reportArtifacts: { - query: securityReportMergeRequestDownloadPathsQuery, - variables() { - return { - projectPath: this.targetProjectFullPath, - iid: String(this.mrIid), - reportTypes: this.$options.reportTypes.map( - (reportType) => reportTypeToSecurityReportTypeEnum[reportType], - ), - }; - }, - update(data) { - return extractSecurityReportArtifactsFromMergeRequest(this.$options.reportTypes, data); - }, - error(error) { - this.showError(error); - }, - result({ loading, data }) { - if (loading || !data) { - return; - } - - // Query has completed, so populate the availableSecurityReports. - this.onCheckingAvailableSecurityReports( - this.reportArtifacts.map(({ reportType }) => reportType), - ); - }, - }, - }, - computed: { - ...mapGetters(['groupedSummaryText', 'summaryStatus']), - hasSecurityReports() { - return this.availableSecurityReports.length > 0; - }, - hasSastReports() { - return this.availableSecurityReports.includes(REPORT_TYPE_SAST); - }, - hasSecretDetectionReports() { - return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); - }, - isLoadingReportArtifacts() { - return this.$apollo.queries.reportArtifacts.loading; - }, - }, - methods: { - ...mapActions(MODULE_SAST, { - setSastDiffEndpoint: 'setDiffEndpoint', - fetchSastDiff: 'fetchDiff', - }), - ...mapActions(MODULE_SECRET_DETECTION, { - setSecretDetectionDiffEndpoint: 'setDiffEndpoint', - fetchSecretDetectionDiff: 'fetchDiff', - }), - fetchCounts() { - if (!this.glFeatures.coreSecurityMrWidgetCounts) { - return; - } - - if (this.sastComparisonPath && this.hasSastReports) { - this.setSastDiffEndpoint(this.sastComparisonPath); - this.fetchSastDiff(); - this.canShowCounts = true; - } - - if (this.secretDetectionComparisonPath && this.hasSecretDetectionReports) { - this.setSecretDetectionDiffEndpoint(this.secretDetectionComparisonPath); - this.fetchSecretDetectionDiff(); - this.canShowCounts = true; - } - }, - onCheckingAvailableSecurityReports(availableSecurityReports) { - this.availableSecurityReports = availableSecurityReports; - this.fetchCounts(); - }, - showError(error) { - createAlert({ - message: this.$options.i18n.apiError, - captureError: true, - error, - }); - }, - }, - reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], - i18n: { - apiError: s__( - 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', - ), - scansHaveRun: s__('SecurityReports|Security scans have run'), - }, - summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], -}; -</script> -<template> - <report-section - v-if="canShowCounts" - :status="summaryStatus" - :has-issues="false" - class="mr-widget-border-top mr-report" - data-testid="security-mr-widget" - track-action="users_expanding_secure_security_report" - > - <template v-for="slot in $options.summarySlots" #[slot]> - <span :key="slot"> - <security-summary :message="groupedSummaryText" /> - - <help-icon - class="gl-ml-3" - :help-path="securityReportsDocsPath" - :discover-project-security-path="discoverProjectSecurityPath" - /> - </span> - </template> - - <template #action-buttons> - <security-report-download-dropdown - :text="s__('SecurityReports|Download results')" - :artifacts="reportArtifacts" - :loading="isLoadingReportArtifacts" - /> - </template> - </report-section> - - <!-- TODO: Remove this section when removing core_security_mr_widget_counts - feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 --> - <report-section - v-else-if="hasSecurityReports" - :status="status" - :has-issues="false" - class="mr-widget-border-top mr-report" - data-testid="security-mr-widget" - track-action="users_expanding_secure_security_report" - > - <template #error> - {{ $options.i18n.scansHaveRun }} - - <help-icon - class="gl-ml-3" - :help-path="securityReportsDocsPath" - :discover-project-security-path="discoverProjectSecurityPath" - /> - </template> - - <template #action-buttons> - <security-report-download-dropdown - :text="s__('SecurityReports|Download results')" - :artifacts="reportArtifacts" - :loading="isLoadingReportArtifacts" - /> - </template> - </report-section> -</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js deleted file mode 100644 index 6aeab56eea2..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Vuex module names corresponding to security scan types. These are similar to - * the snake_case report types from the backend, but should not be considered - * to be equivalent. - */ -export const MODULE_SAST = 'sast'; -export const MODULE_SECRET_DETECTION = 'secretDetection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js deleted file mode 100644 index c274f531139..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/getters.js +++ /dev/null @@ -1,66 +0,0 @@ -import { s__, sprintf } from '~/locale'; -import { LOADING, ERROR, SUCCESS } from '~/ci/reports/constants'; -import { TRANSLATION_IS_LOADING } from './messages'; -import { countVulnerabilities, groupedTextBuilder } from './utils'; - -export const summaryCounts = (state) => - countVulnerabilities( - state.reportTypes.reduce((acc, reportType) => { - acc.push(...state[reportType].newIssues); - return acc; - }, []), - ); - -export const groupedSummaryText = (state, getters) => { - const reportType = s__('ciReport|Security scanning'); - let status = ''; - - // All reports are loading - if (getters.areAllReportsLoading) { - return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) }; - } - - // All reports returned error - if (getters.allReportsHaveError) { - return { message: s__('ciReport|Security scanning failed loading any results') }; - } - - if (getters.areReportsLoading && getters.anyReportHasError) { - status = s__('ciReport|is loading, errors when loading results'); - } else if (getters.areReportsLoading && !getters.anyReportHasError) { - status = s__('ciReport|is loading'); - } else if (!getters.areReportsLoading && getters.anyReportHasError) { - status = s__('ciReport|: Loading resulted in an error'); - } - - const { critical, high, other } = getters.summaryCounts; - - return groupedTextBuilder({ reportType, status, critical, high, other }); -}; - -export const summaryStatus = (state, getters) => { - if (getters.areReportsLoading) { - return LOADING; - } - - if (getters.anyReportHasError || getters.anyReportHasIssues) { - return ERROR; - } - - return SUCCESS; -}; - -export const areReportsLoading = (state) => - state.reportTypes.some((reportType) => state[reportType].isLoading); - -export const areAllReportsLoading = (state) => - state.reportTypes.every((reportType) => state[reportType].isLoading); - -export const allReportsHaveError = (state) => - state.reportTypes.every((reportType) => state[reportType].hasError); - -export const anyReportHasError = (state) => - state.reportTypes.some((reportType) => state[reportType].hasError); - -export const anyReportHasIssues = (state) => - state.reportTypes.some((reportType) => state[reportType].newIssues.length > 0); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js deleted file mode 100644 index 164faa86744..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vuex from 'vuex'; -import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; -import * as getters from './getters'; -import sast from './modules/sast'; -import secretDetection from './modules/secret_detection'; -import state from './state'; - -export default () => - new Vuex.Store({ - modules: { - [MODULE_SAST]: sast, - [MODULE_SECRET_DETECTION]: secretDetection, - }, - getters, - state, - }); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js deleted file mode 100644 index c25e252a768..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/messages.js +++ /dev/null @@ -1,4 +0,0 @@ -import { s__ } from '~/locale'; - -export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading'); -export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error'); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js deleted file mode 100644 index 8aefc13a5fa..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js +++ /dev/null @@ -1,26 +0,0 @@ -import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; -import { fetchDiffData } from '../../utils'; -import * as types from './mutation_types'; - -export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path); - -export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF); - -export const receiveDiffSuccess = ({ commit }, response) => - commit(types.RECEIVE_DIFF_SUCCESS, response); - -export const receiveDiffError = ({ commit }, response) => - commit(types.RECEIVE_DIFF_ERROR, response); - -export const fetchDiff = ({ state, rootState, dispatch }) => { - dispatch('requestDiff'); - - return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST) - .then((data) => { - dispatch('receiveDiffSuccess', data); - return data; - }) - .catch(() => { - dispatch('receiveDiffError'); - }); -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js deleted file mode 100644 index 1d5af1d4fe5..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as actions from './actions'; -import mutations from './mutations'; -import state from './state'; - -export default { - namespaced: true, - state, - mutations, - actions, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js deleted file mode 100644 index aacec0fb679..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS'; -export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR'; -export const REQUEST_DIFF = 'REQUEST_DIFF'; -export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js deleted file mode 100644 index 11aa71d2b6b..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js +++ /dev/null @@ -1,31 +0,0 @@ -import Vue from 'vue'; -import { parseDiff } from '../../utils'; -import * as types from './mutation_types'; - -export default { - [types.SET_DIFF_ENDPOINT](state, path) { - Vue.set(state.paths, 'diffEndpoint', path); - }, - - [types.REQUEST_DIFF](state) { - state.isLoading = true; - }, - - [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) { - const { added, fixed, existing } = parseDiff(diff, enrichData); - const baseReportOutofDate = diff.base_report_out_of_date || false; - const hasBaseReport = Boolean(diff.base_report_created_at); - - state.isLoading = false; - state.newIssues = added; - state.resolvedIssues = fixed; - state.allIssues = existing; - state.baseReportOutofDate = baseReportOutofDate; - state.hasBaseReport = hasBaseReport; - }, - - [types.RECEIVE_DIFF_ERROR](state) { - state.isLoading = false; - state.hasError = true; - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js deleted file mode 100644 index c1b3f546431..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js +++ /dev/null @@ -1,14 +0,0 @@ -export default () => ({ - paths: { - diffEndpoint: null, - }, - - isLoading: false, - hasError: false, - - newIssues: [], - resolvedIssues: [], - allIssues: [], - baseReportOutofDate: false, - hasBaseReport: false, -}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js deleted file mode 100644 index 13ca154bfa7..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js +++ /dev/null @@ -1,26 +0,0 @@ -import { REPORT_TYPE_SECRET_DETECTION } from '~/vue_shared/security_reports/constants'; -import { fetchDiffData } from '../../utils'; -import * as types from './mutation_types'; - -export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path); - -export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF); - -export const receiveDiffSuccess = ({ commit }, response) => - commit(types.RECEIVE_DIFF_SUCCESS, response); - -export const receiveDiffError = ({ commit }, response) => - commit(types.RECEIVE_DIFF_ERROR, response); - -export const fetchDiff = ({ state, rootState, dispatch }) => { - dispatch('requestDiff'); - - return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION) - .then((data) => { - dispatch('receiveDiffSuccess', data); - return data; - }) - .catch(() => { - dispatch('receiveDiffError'); - }); -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js deleted file mode 100644 index 1d5af1d4fe5..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as actions from './actions'; -import mutations from './mutations'; -import state from './state'; - -export default { - namespaced: true, - state, - mutations, - actions, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js deleted file mode 100644 index aacec0fb679..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS'; -export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR'; -export const REQUEST_DIFF = 'REQUEST_DIFF'; -export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js deleted file mode 100644 index ee943b0621c..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js +++ /dev/null @@ -1,30 +0,0 @@ -import { parseDiff } from '~/vue_shared/security_reports/store/utils'; -import * as types from './mutation_types'; - -export default { - [types.SET_DIFF_ENDPOINT](state, path) { - state.paths.diffEndpoint = path; - }, - - [types.REQUEST_DIFF](state) { - state.isLoading = true; - }, - - [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) { - const { added, fixed, existing } = parseDiff(diff, enrichData); - const baseReportOutofDate = diff.base_report_out_of_date || false; - const hasBaseReport = Boolean(diff.base_report_created_at); - - state.isLoading = false; - state.newIssues = added; - state.resolvedIssues = fixed; - state.allIssues = existing; - state.baseReportOutofDate = baseReportOutofDate; - state.hasBaseReport = hasBaseReport; - }, - - [types.RECEIVE_DIFF_ERROR](state) { - state.isLoading = false; - state.hasError = true; - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js deleted file mode 100644 index c1b3f546431..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js +++ /dev/null @@ -1,14 +0,0 @@ -export default () => ({ - paths: { - diffEndpoint: null, - }, - - isLoading: false, - hasError: false, - - newIssues: [], - resolvedIssues: [], - allIssues: [], - baseReportOutofDate: false, - hasBaseReport: false, -}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js deleted file mode 100644 index 5dc4d1ad2fb..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/state.js +++ /dev/null @@ -1,5 +0,0 @@ -import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; - -export default () => ({ - reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION], -}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js deleted file mode 100644 index f620bad8dba..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ /dev/null @@ -1,154 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import pollUntilComplete from '~/lib/utils/poll_until_complete'; -import { __, n__, sprintf } from '~/locale'; -import { CRITICAL, HIGH } from '~/vulnerabilities/constants'; -import { - FEEDBACK_TYPE_DISMISSAL, - FEEDBACK_TYPE_ISSUE, - FEEDBACK_TYPE_MERGE_REQUEST, -} from '../constants'; - -export const fetchDiffData = (state, endpoint, category) => { - const requests = [pollUntilComplete(endpoint)]; - - if (state.canReadVulnerabilityFeedback) { - requests.push(axios.get(state.vulnerabilityFeedbackPath, { params: { category } })); - } - - return Promise.all(requests).then(([diffResponse, enrichResponse]) => ({ - diff: diffResponse.data, - enrichData: enrichResponse?.data ?? [], - })); -}; - -/** - * Returns given vulnerability enriched with the corresponding - * feedback (`dismissal` or `issue` type) - * @param {Object} vulnerabilityObject - * @param {Array} feedbackList - */ -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) => - fb.finding_uuid - ? fb.finding_uuid === vulnerability.uuid - : fb.project_fingerprint === vulnerability.project_fingerprint, - ) - .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; - } - }); - - return vulnerability; -}; - -/** - * Generates the added, fixed, and existing vulnerabilities from the API report. - * - * @param {Object} diff The original reports. - * @param {Object} enrichData Feedback data to add to the reports. - * @returns {Object} - */ -export const parseDiff = (diff, enrichData) => { - const enrichVulnerability = (vulnerability) => ({ - ...enrichVulnerabilityWithFeedback(vulnerability, enrichData), - category: vulnerability.report_type, - title: vulnerability.message || vulnerability.name, - }); - - return { - added: diff.added ? diff.added.map(enrichVulnerability) : [], - fixed: diff.fixed ? diff.fixed.map(enrichVulnerability) : [], - existing: diff.existing ? diff.existing.map(enrichVulnerability) : [], - }; -}; - -const createCountMessage = ({ critical, high, other, total }) => { - const otherMessage = n__('%d Other', '%d Others', other); - const countMessage = __( - '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}', - ); - return total ? sprintf(countMessage, { critical, high, otherMessage }) : ''; -}; - -const createStatusMessage = ({ reportType, status, total }) => { - const vulnMessage = n__('vulnerability', 'vulnerabilities', total); - let message; - if (status) { - message = __('%{reportType} %{status}'); - } else if (!total) { - message = __('%{reportType} detected no new vulnerabilities.'); - } else { - message = __( - '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', - ); - } - return sprintf(message, { reportType, status, total, vulnMessage }); -}; - -/** - * Counts vulnerabilities. - * Returns the amount of critical, high, and other vulnerabilities. - * - * @param {Array} vulnerabilities The raw vulnerabilities to parse - * @returns {{critical: number, high: number, other: number}} - */ -export const countVulnerabilities = (vulnerabilities = []) => - vulnerabilities.reduce( - (acc, { severity }) => { - if (severity === CRITICAL) { - acc.critical += 1; - } else if (severity === HIGH) { - acc.high += 1; - } else { - acc.other += 1; - } - - return acc; - }, - { critical: 0, high: 0, other: 0 }, - ); - -/** - * Takes an object of options and returns the object with an externalized string representing - * the critical, high, and other severity vulnerabilities for a given report. - * - * The resulting string _may_ still contain sprintf-style placeholders. These - * are left in place so they can be replaced with markup, via the - * SecuritySummary component. - * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options - * @returns {Object} the parameters with an externalized string - */ -export const groupedTextBuilder = ({ - reportType = '', - status = '', - critical = 0, - high = 0, - other = 0, -} = {}) => { - const total = critical + high + other; - - return { - countMessage: createCountMessage({ critical, high, other, total }), - message: createStatusMessage({ reportType, status, total }), - critical, - high, - other, - status, - total, - }; -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js index 0add91c402e..aa4f6978552 100644 --- a/app/assets/javascripts/vue_shared/security_reports/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -39,13 +39,3 @@ export const extractSecurityReportArtifacts = (reportTypes, jobs) => { return acc; }, []); }; - -export const extractSecurityReportArtifactsFromPipeline = (reportTypes, data) => { - const jobs = data.project?.pipeline?.jobs?.nodes ?? []; - return extractSecurityReportArtifacts(reportTypes, jobs); -}; - -export const extractSecurityReportArtifactsFromMergeRequest = (reportTypes, data) => { - const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; - return extractSecurityReportArtifacts(reportTypes, jobs); -}; diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 8bb8b6101d4..9053d8972de 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -54,7 +54,7 @@ export default { :label-for="$options.labelId" label-cols="3" label-cols-lg="2" - label-class="gl-pb-0! gl-overflow-wrap-break" + label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label" class="gl-align-items-center" > <gl-form-select @@ -63,7 +63,7 @@ export default { :options="$options.states" :disabled="disabled" data-testid="work-item-state-select" - class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1" + class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1 work-item-field-value" :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" @change="setState" /> 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 1fa217f456e..7903adea9bd 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -114,7 +114,7 @@ export default { :note-id="noteId" :is-system-note="true" > - <span ref="gfm-content" v-safe-html="actionTextHtml"></span> + <span ref="gfm-content" v-safe-html="actionTextHtml" class="gl-word-break-word"></span> <template v-if="canSeeDescriptionVersion" #extra-controls> · <gl-button 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 7ad424868c6..a2667a379e1 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 @@ -18,10 +18,12 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemCommentForm from './work_item_comment_form.vue'; +import WorkItemNoteAwardsList from './work_item_note_awards_list.vue'; export default { name: 'WorkItemNoteThread', components: { + WorkItemNoteAwardsList, TimelineEntryItem, NoteBody, NoteHeader, @@ -101,6 +103,9 @@ export default { author() { return this.note.author; }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, entryClass() { return { 'note note-wrapper note-comment': true, @@ -149,10 +154,10 @@ export default { return window.gon.current_user_id; }, isCurrentUserAuthorOfNote() { - return getIdFromGraphQLId(this.author.id) === this.currentUserId; + return this.authorId === this.currentUserId; }, isWorkItemAuthor() { - return getIdFromGraphQLId(this.workItem?.author?.id) === getIdFromGraphQLId(this.author.id); + return getIdFromGraphQLId(this.workItem?.author?.id) === this.authorId; }, projectName() { return this.workItem?.project?.name; @@ -284,7 +289,12 @@ export default { <template> <timeline-entry-item :id="noteAnchorId" :class="entryClass"> <div :key="note.id" class="timeline-avatar gl-float-left"> - <gl-avatar-link :href="author.webUrl"> + <gl-avatar-link + :href="author.webUrl" + :data-user-id="authorId" + :data-username="author.username" + class="js-user-link" + > <gl-avatar :src="author.avatarUrl" :entity-name="author.username" @@ -323,6 +333,8 @@ export default { <div class="gl-display-inline-flex"> <note-actions :show-award-emoji="hasAwardEmojiPermission" + :work-item-iid="workItemIid" + :note="note" :note-url="noteUrl" :show-reply="showReply" :show-edit="hasAdminPermission" @@ -356,6 +368,9 @@ export default { :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'" /> </div> + <div class="note-awards" :class="isFirstNote ? '' : 'gl-pl-7'"> + <work-item-note-awards-list :note="note" :work-item-iid="workItemIid" :is-modal="isModal" /> + </div> </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 index b32a8c78c93..e5da3d346ae 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -1,17 +1,15 @@ <script> import { GlButton, - GlIcon, GlTooltipDirective, GlDisclosureDropdown, GlDisclosureDropdownItem, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { __, s__, sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; +import { getMutation, optimisticAwardUpdate } from '../../notes/award_utils'; export default { name: 'WorkItemNoteActions', @@ -25,19 +23,26 @@ export default { reportAbuseText: __('Report abuse to administrator'), }, components: { + EmojiPicker: () => import('~/emoji/components/picker.vue'), GlButton, - GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem, ReplyButton, - EmojiPicker: () => import('~/emoji/components/picker.vue'), UserAccessRoleBadge, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], + inject: ['fullPath'], props: { + workItemIid: { + type: String, + required: true, + }, + note: { + type: Object, + required: true, + }, showReply: { type: Boolean, required: true, @@ -126,24 +131,29 @@ export default { methods: { async setAwardEmoji(name) { + const { mutation, mutationName, errorMessage } = getMutation({ note: this.note, name }); + try { - const { - data: { - awardEmojiAdd: { errors = [] }, - }, - } = await this.$apollo.mutate({ - mutation: addAwardEmojiMutation, + await this.$apollo.mutate({ + mutation, variables: { - awardableId: this.noteId, + awardableId: this.note.id, name, }, + optimisticResponse: { + [mutationName]: { + errors: [], + }, + }, + update: optimisticAwardUpdate({ + note: this.note, + name, + fullPath: this.fullPath, + workItemIid: this.workItemIid, + }), }); - - if (errors.length > 0) { - throw new Error(errors[0].message); - } } catch (error) { - this.$emit('error', s__('WorkItem|Failed to award emoji')); + this.$emit('error', errorMessage); Sentry.captureException(error); } }, @@ -185,23 +195,11 @@ export default { {{ __('Contributor') }} </user-access-role-badge> <emoji-picker - v-if="showAwardEmoji && glFeatures.workItemsMvc2" + v-if="showAwardEmoji" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" data-testid="note-emoji-button" @click="setAwardEmoji" - > - <template #button-content> - <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" /> - <gl-icon - class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!" - name="smiley" - /> - <gl-icon - class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!" - name="smile" - /> - </template> - </emoji-picker> + /> <reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" /> <gl-button v-if="showEdit" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue new file mode 100644 index 00000000000..3c30c204ab6 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue @@ -0,0 +1,95 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; +import { getMutation, optimisticAwardUpdate } from '../../notes/award_utils'; + +export default { + components: { + AwardsList, + }, + inject: ['fullPath'], + props: { + workItemIid: { + type: String, + required: true, + }, + note: { + type: Object, + required: true, + }, + isModal: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + awardsListBoundary() { + return this.isModal ? '.modal-body' : ''; + }, + awards() { + return this.note.awardEmoji.nodes.map((award) => { + return { + ...award, + user: { + ...award.user, + id: getIdFromGraphQLId(award.user.id), + }, + }; + }); + }, + hasAwardEmojiPermission() { + return this.note.userPermissions.awardEmoji; + }, + currentUserId() { + return window.gon.current_user_id; + }, + }, + methods: { + async handleAward(name) { + if (!this.hasAwardEmojiPermission) { + return; + } + + const { mutation, mutationName, errorMessage } = getMutation({ note: this.note, name }); + + try { + await this.$apollo.mutate({ + mutation, + variables: { + awardableId: this.note.id, + name, + }, + optimisticResponse: { + [mutationName]: { + errors: [], + }, + }, + update: optimisticAwardUpdate({ + note: this.note, + name, + fullPath: this.fullPath, + workItemIid: this.workItemIid, + }), + }); + } catch (error) { + this.$emit('error', errorMessage); + Sentry.captureException(error); + } + }, + }, +}; +</script> + +<template> + <awards-list + v-if="awards.length" + :awards="awards" + :can-award-emoji="hasAwardEmojiPermission" + :current-user-id="currentUserId" + :boundary="awardsListBoundary" + class="gl-px-2" + @award="handleAward($event)" + /> +</template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index 6ae30e9b084..f343f787358 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -27,6 +27,9 @@ export default { toggleLabel() { return this.isOpen ? __('Collapse') : __('Expand'); }, + isOpenString() { + return this.isOpen ? 'true' : 'false'; + }, }, methods: { hide() { @@ -43,18 +46,10 @@ export default { </script> <template> - <div - id="tasks" - class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5" - > - <div - class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base" - :class="{ - 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!': isOpen, - }" - > - <div class="gl-display-flex gl-flex-grow-1"> - <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24"> + <div id="tasks" class="gl-new-card" :aria-expanded="isOpenString"> + <div class="gl-new-card-header"> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title"> <gl-link id="user-content-tasks-links" class="anchor position-absolute gl-text-decoration-none" @@ -66,7 +61,7 @@ export default { <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"> + <div class="gl-new-card-toggle"> <gl-button category="tertiary" size="small" @@ -80,12 +75,7 @@ export default { <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-3': !error }" - data-testid="widget-body" - > + <div v-if="isOpen" class="gl-new-card-body" :class="{ error: error }" data-testid="widget-body"> <slot name="body"></slot> </div> </div> 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 d0d520ae5b1..f7ac63e16c3 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -303,7 +303,7 @@ export default { <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> <span :id="assigneesTitleId" - class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break work-item-field-label" data-testid="assignees-title" >{{ assigneeText }}</span > @@ -313,11 +313,12 @@ export default { :selected-tokens="localAssignees" :container-class="containerClass" :class="{ 'gl-hover-border-gray-200': canUpdate }" + menu-class="token-selector-menu-class" :dropdown-items="dropdownItems" :loading="isLoadingUsers && !isLoadingMore" :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" + 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 work-item-field-value" data-testid="work-item-assignees-input" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" @@ -339,7 +340,7 @@ export default { class="assign-myself" data-testid="assign-self" @click.stop="assignToCurrentUser" - >{{ __('Assign myself') }}</gl-button + >{{ __('Assign yourself') }}</gl-button > </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue new file mode 100644 index 00000000000..c727075eaac --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -0,0 +1,180 @@ +<script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + sprintfWorkItem, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_HEALTH_STATUS, + WIDGET_TYPE_ITERATION, + WIDGET_TYPE_LABELS, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_PROGRESS, + WIDGET_TYPE_START_AND_DUE_DATE, + WIDGET_TYPE_WEIGHT, +} from '../constants'; +import WorkItemState from './work_item_state.vue'; +import WorkItemDueDate from './work_item_due_date.vue'; +import WorkItemAssignees from './work_item_assignees.vue'; +import WorkItemLabels from './work_item_labels.vue'; +import WorkItemMilestone from './work_item_milestone.vue'; + +export default { + components: { + WorkItemLabels, + WorkItemMilestone, + WorkItemAssignees, + WorkItemDueDate, + WorkItemState, + WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), + WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), + WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemHealthStatus: () => + import('ee_component/work_items/components/work_item_health_status.vue'), + }, + mixins: [glFeatureFlagMixin()], + inject: ['fullPath'], + props: { + workItem: { + type: Object, + required: true, + }, + workItemParentId: { + type: String, + required: false, + default: null, + }, + }, + computed: { + workItemType() { + return this.workItem.workItemType?.name; + }, + canUpdate() { + return this.workItem?.userPermissions?.updateWorkItem; + }, + canDelete() { + return this.workItem?.userPermissions?.deleteWorkItem; + }, + canSetWorkItemMetadata() { + return this.workItem?.userPermissions?.setWorkItemMetadata; + }, + canAssignUnassignUser() { + return this.workItemAssignees && this.canSetWorkItemMetadata; + }, + confidentialTooltip() { + return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); + }, + workItemAssignees() { + return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); + }, + workItemLabels() { + return this.isWidgetPresent(WIDGET_TYPE_LABELS); + }, + workItemDueDate() { + return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); + }, + workItemWeight() { + return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); + }, + workItemProgress() { + return this.isWidgetPresent(WIDGET_TYPE_PROGRESS); + }, + workItemIteration() { + return this.isWidgetPresent(WIDGET_TYPE_ITERATION); + }, + workItemHealthStatus() { + return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); + }, + workItemMilestone() { + return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); + }, + }, + methods: { + isWidgetPresent(type) { + return this.workItem?.widgets?.find((widget) => widget.type === type); + }, + }, +}; +</script> + +<template> + <div class="work-item-attributes-wrapper"> + <work-item-state + :work-item="workItem" + :work-item-parent-id="workItemParentId" + :can-update="canUpdate" + @error="$emit('error', $event)" + /> + <work-item-assignees + v-if="workItemAssignees" + :can-update="canUpdate" + :work-item-id="workItem.id" + :assignees="workItemAssignees.assignees.nodes" + :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" + :work-item-type="workItemType" + :can-invite-members="workItemAssignees.canInviteMembers" + @error="$emit('error', $event)" + /> + <work-item-labels + v-if="workItemLabels" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + @error="$emit('error', $event)" + /> + <work-item-due-date + v-if="workItemDueDate" + :can-update="canUpdate" + :due-date="workItemDueDate.dueDate" + :start-date="workItemDueDate.startDate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-milestone + v-if="workItemMilestone" + :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.milestone" + :work-item-type="workItemType" + :can-update="canUpdate" + @error="$emit('error', $event)" + /> + <work-item-weight + v-if="workItemWeight" + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-progress + v-if="workItemProgress" + class="gl-mb-5" + :can-update="canUpdate" + :progress="workItemProgress.progress" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + <work-item-health-status + v-if="workItemHealthStatus" + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + @error="$emit('error', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 144c29b8ec3..3dd3a072d0f 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -7,9 +7,15 @@ import AwardsList from '~/vue_shared/components/awards_list.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; +import workItemAwardEmojiQuery from '../graphql/award_emoji.query.graphql'; import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql'; -import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; -import { EMOJI_THUMBSDOWN, EMOJI_THUMBSUP, WIDGET_TYPE_AWARD_EMOJI } from '../constants'; +import { + EMOJI_THUMBSDOWN, + EMOJI_THUMBSUP, + WIDGET_TYPE_AWARD_EMOJI, + DEFAULT_PAGE_SIZE_EMOJIS, + I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR, +} from '../constants'; export default { defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN], @@ -26,16 +32,17 @@ export default { type: String, required: true, }, - awardEmoji: { - type: Object, - required: true, - }, workItemIid: { type: String, required: false, default: null, }, }, + data() { + return { + isLoading: false, + }; + }, computed: { currentUserId() { return window.gon.current_user_id; @@ -47,6 +54,10 @@ export default { * Parse and convert award emoji list to a format that AwardsList can understand */ awards() { + if (!this.awardEmoji) { + return []; + } + return this.awardEmoji.nodes.map((emoji) => ({ name: emoji.name, user: { @@ -55,16 +66,56 @@ export default { }, })); }, + pageInfo() { + return this.awardEmoji?.pageInfo; + }, + hasNextPage() { + return this.pageInfo?.hasNextPage; + }, + }, + apollo: { + awardEmoji: { + query: workItemAwardEmojiQuery, + variables() { + return { + iid: this.workItemIid, + fullPath: this.workItemFullpath, + after: this.after, + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + }; + }, + update(data) { + const widgets = data.workspace?.workItems?.nodes[0].widgets; + return widgets?.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI).awardEmoji || {}; + }, + skip() { + return !this.workItemIid; + }, + result() { + if (this.hasNextPage) { + this.fetchAwardEmojis(); + } else { + this.isLoading = false; + } + }, + error() { + this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR); + }, + }, }, methods: { - getAwards() { - return this.awardEmoji.nodes.map((emoji) => ({ - name: emoji.name, - user: { - id: getIdFromGraphQLId(emoji.user.id), - name: emoji.user.name, - }, - })); + async fetchAwardEmojis() { + this.isLoading = true; + try { + await this.$apollo.queries.awardEmoji.fetchMore({ + variables: { + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + after: this.pageInfo?.endCursor, + }, + }); + } catch (error) { + this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR); + } }, isEmojiPresentForCurrentUser(name) { return ( @@ -108,8 +159,12 @@ export default { }, updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) { const query = { - query: workItemByIidQuery, - variables: { fullPath: this.workItemFullpath, iid: this.workItemIid }, + query: workItemAwardEmojiQuery, + variables: { + fullPath: this.workItemFullpath, + iid: this.workItemIid, + pageSize: DEFAULT_PAGE_SIZE_EMOJIS, + }, }; const sourceData = cache.readQuery(query); @@ -117,7 +172,6 @@ export default { const newData = produce(sourceData, (draftState) => { const { widgets } = draftState.workspace.workItems.nodes[0]; const widgetAwardEmoji = widgets.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI); - widgetAwardEmoji.awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn); }); @@ -175,7 +229,7 @@ export default { </script> <template> - <div class="gl-mt-3"> + <div v-if="!isLoading" class="gl-mt-3"> <awards-list data-testid="work-item-award-list" :awards="awards" 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 61dec21cae4..58bf524f450 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -9,7 +9,6 @@ import EditedAt from '~/issues/show/components/edited.vue'; import Tracking from '~/tracking'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { autocompleteDataSources, markdownPreviewPath } from '../utils'; -import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; @@ -37,8 +36,7 @@ export default { required: true, }, }, - markdownDocsPath: helpPagePath('user/project/quick_actions'), - quickActionsDocsPath: helpPagePath('user/project/quick_actions'), + markdownDocsPath: helpPagePath('user/markdown'), data() { return { workItem: {}, @@ -75,14 +73,6 @@ export default { error() { this.$emit('error', i18n.fetchError); }, - subscribeToMore: { - document: workItemDescriptionSubscription, - variables() { - return { - issuableId: this.workItemId, - }; - }, - }, }, }, computed: { 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 9a2cdc1c172..07e03eba1d1 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 @@ -94,7 +94,7 @@ export default { </script> <template> - <div class="gl-mb-5 gl-border-t gl-pt-5"> + <div class="gl-mb-5"> <div class="gl-display-inline-flex gl-align-items-center gl-mb-3"> <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button 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 1ac40fe7dcb..1402b313cee 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -9,6 +9,7 @@ import { GlButton, GlTooltipDirective, GlEmptyState, + GlIntersectionObserver, } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw'; import { s__ } from '~/locale'; @@ -22,27 +23,17 @@ import { sprintfWorkItem, i18n, WIDGET_TYPE_ASSIGNEES, - WIDGET_TYPE_LABELS, WIDGET_TYPE_NOTIFICATIONS, WIDGET_TYPE_CURRENT_USER_TODOS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_AWARD_EMOJI, - WIDGET_TYPE_START_AND_DUE_DATE, - WIDGET_TYPE_WEIGHT, - WIDGET_TYPE_PROGRESS, WIDGET_TYPE_HIERARCHY, - WIDGET_TYPE_MILESTONE, - WIDGET_TYPE_ITERATION, - WIDGET_TYPE_HEALTH_STATUS, WORK_ITEM_TYPE_VALUE_ISSUE, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WIDGET_TYPE_NOTES, } from '../constants'; -import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql'; -import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; -import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql'; -import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql'; +import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; @@ -51,17 +42,13 @@ import { findHierarchyWidgetChildren } from '../utils'; import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemTodos from './work_item_todos.vue'; -import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; +import WorkItemAttributesWrapper from './work_item_attributes_wrapper.vue'; import WorkItemCreatedUpdated from './work_item_created_updated.vue'; import WorkItemDescription from './work_item_description.vue'; -import WorkItemAwardEmoji from './work_item_award_emoji.vue'; -import WorkItemDueDate from './work_item_due_date.vue'; -import WorkItemAssignees from './work_item_assignees.vue'; -import WorkItemLabels from './work_item_labels.vue'; -import WorkItemMilestone from './work_item_milestone.vue'; import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; +import WorkItemAwardEmoji from './work_item_award_emoji.vue'; export default { i18n, @@ -77,27 +64,19 @@ export default { GlSkeletonLoader, GlIcon, GlEmptyState, - WorkItemAssignees, WorkItemActions, WorkItemTodos, WorkItemCreatedUpdated, WorkItemDescription, WorkItemAwardEmoji, - WorkItemDueDate, - WorkItemLabels, WorkItemTitle, - WorkItemState, - WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), - WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), + WorkItemAttributesWrapper, WorkItemTypeIcon, - WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), - WorkItemHealthStatus: () => - import('ee_component/work_items/components/work_item_health_status.vue'), - WorkItemMilestone, WorkItemTree, WorkItemNotes, WorkItemDetailModal, AbuseCategorySelector, + GlIntersectionObserver, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'reportAbusePath'], @@ -129,6 +108,7 @@ export default { isReportDrawerOpen: false, reportedUrl: '', reportedUserId: 0, + isStickyHeaderShowing: false, }; }, apollo: { @@ -165,52 +145,17 @@ export default { document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`; } }, - subscribeToMore: [ - { - document: workItemTitleSubscription, - variables() { - return { - issuableId: this.workItem.id, - }; - }, - skip() { - return !this.workItem?.id; - }, - }, - { - document: workItemDatesSubscription, - variables() { - return { - issuableId: this.workItem.id, - }; - }, - skip() { - return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id; - }, - }, - { - document: workItemAssigneesSubscription, - variables() { - return { - issuableId: this.workItem.id, - }; - }, - skip() { - return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id; - }, + subscribeToMore: { + document: workItemUpdatedSubscription, + variables() { + return { + id: this.workItem.id, + }; }, - { - document: workItemMilestoneSubscription, - variables() { - return { - issuableId: this.workItem.id, - }; - }, - skip() { - return !this.isWidgetPresent(WIDGET_TYPE_MILESTONE) || !this.workItem?.id; - }, + skip() { + return !this.workItem?.id; }, - ], + }, }, }, computed: { @@ -289,38 +234,17 @@ export default { return this.$options.isLoggedIn && this.workItemCurrentUserTodos; }, currentUserTodos() { - return this.workItemCurrentUserTodos?.currentUserTodos?.edges; + return this.workItemCurrentUserTodos?.currentUserTodos?.nodes; }, workItemAssignees() { return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, - workItemLabels() { - return this.isWidgetPresent(WIDGET_TYPE_LABELS); - }, - workItemDueDate() { - return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); - }, - workItemWeight() { - return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); - }, - workItemProgress() { - return this.isWidgetPresent(WIDGET_TYPE_PROGRESS); - }, workItemAwardEmoji() { return this.isWidgetPresent(WIDGET_TYPE_AWARD_EMOJI); }, workItemHierarchy() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, - workItemIteration() { - return this.isWidgetPresent(WIDGET_TYPE_ITERATION); - }, - workItemHealthStatus() { - return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); - }, - workItemMilestone() { - return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); - }, workItemNotes() { return this.isWidgetPresent(WIDGET_TYPE_NOTES); }, @@ -332,6 +256,9 @@ export default { 'gl-pt-5': !this.updateError && !this.isModal, }; }, + showIntersectionObserver() { + return !this.isModal && this.workItemsMvc2Enabled; + }, }, mounted() { if (this.modalWorkItemIid) { @@ -437,6 +364,15 @@ export default { this.reportedUrl = reply.url || {}; this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0; }, + hideStickyHeader() { + this.isStickyHeaderShowing = false; + }, + showStickyHeader() { + // only if scrolled under the work item's title + if (this.$refs?.title?.$el.offsetTop < window.pageYOffset) { + this.isStickyHeaderShowing = true; + } + }, }, WORK_ITEM_TYPE_VALUE_OBJECTIVE, @@ -510,7 +446,9 @@ export default { > <work-item-todos v-if="showWorkItemCurrentUserTodos" - :work-item="workItem" + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :work-item-fullpath="workItem.project.fullPath" :current-user-todos="currentUserTodos" @error="updateError = $event" /> @@ -539,141 +477,148 @@ export default { @click="$emit('close')" /> </div> - <work-item-title - v-if="workItem.title" - :work-item-id="workItem.id" - :work-item-title="workItem.title" - :work-item-type="workItemType" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="updateError = $event" - /> - <work-item-created-updated :work-item-iid="workItemIid" /> - <work-item-state - :work-item="workItem" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="updateError = $event" - /> - <work-item-assignees - v-if="workItemAssignees" - :can-update="canUpdate" - :work-item-id="workItem.id" - :assignees="workItemAssignees.assignees.nodes" - :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" - :work-item-type="workItemType" - :can-invite-members="workItemAssignees.canInviteMembers" - @error="updateError = $event" - /> - <work-item-labels - v-if="workItemLabels" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - @error="updateError = $event" - /> - <work-item-due-date - v-if="workItemDueDate" - :can-update="canUpdate" - :due-date="workItemDueDate.dueDate" - :start-date="workItemDueDate.startDate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-milestone - v-if="workItemMilestone" - :work-item-id="workItem.id" - :work-item-milestone="workItemMilestone.milestone" - :work-item-type="workItemType" - :can-update="canUpdate" - @error="updateError = $event" - /> - <work-item-weight - v-if="workItemWeight" - class="gl-mb-5" - :can-update="canUpdate" - :weight="workItemWeight.weight" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-progress - v-if="workItemProgress" - class="gl-mb-5" - :can-update="canUpdate" - :progress="workItemProgress.progress" - :work-item-id="workItem.id" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-iteration - v-if="workItemIteration" - class="gl-mb-5" - :iteration="workItemIteration.iteration" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-health-status - v-if="workItemHealthStatus" - class="gl-mb-5" - :health-status="workItemHealthStatus.healthStatus" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-description - v-if="hasDescriptionWidget" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - class="gl-pt-5" - @error="updateError = $event" - /> - <work-item-award-emoji - v-if="workItemAwardEmoji" - :work-item-id="workItem.id" - :work-item-fullpath="workItem.project.fullPath" - :award-emoji="workItemAwardEmoji.awardEmoji" - :work-item-iid="workItemIid" - @error="updateError = $event" - /> - <work-item-tree - v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" - :work-item-type="workItemType" - :parent-work-item-type="workItem.workItemType.name" - :work-item-id="workItem.id" - :work-item-iid="workItemIid" - :children="children" - :can-update="canUpdate" - :confidential="workItem.confidential" - @show-modal="openInModal" - /> - <work-item-notes - v-if="workItemNotes" - :work-item-id="workItem.id" - :work-item-iid="workItem.iid" - :work-item-type="workItemType" - :is-modal="isModal" - :assignees="workItemAssignees && workItemAssignees.assignees.nodes" - :can-set-work-item-metadata="canAssignUnassignUser" - :report-abuse-path="reportAbusePath" - class="gl-pt-5" - @error="updateError = $event" - @has-notes="updateHasNotes" - @openReportAbuse="openReportAbuseDrawer" - /> - <gl-empty-state - v-if="error" - :title="$options.i18n.fetchErrorTitle" - :description="error" - :svg-path="noAccessSvgPath" - /> + <div> + <work-item-title + v-if="workItem.title" + ref="title" + :work-item-id="workItem.id" + :work-item-title="workItem.title" + :work-item-type="workItemType" + :work-item-parent-id="workItemParentId" + :can-update="canUpdate" + @error="updateError = $event" + /> + <work-item-created-updated :work-item-iid="workItemIid" /> + </div> + <gl-intersection-observer + v-if="showIntersectionObserver" + @appear="hideStickyHeader" + @disappear="showStickyHeader" + > + <transition name="issuable-header-slide"> + <div + v-if="isStickyHeaderShowing" + class="issue-sticky-header gl-fixed gl-bg-white gl-border-b gl-z-index-3 gl-py-2" + data-testid="work-item-sticky-header" + > + <div + class="gl-align-items-center gl-mx-auto gl-px-5 gl-display-flex gl-max-w-container-xl" + > + <span class="gl-text-truncate gl-font-weight-bold gl-pr-3 gl-mr-auto"> + {{ workItem.title }} + </span> + <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> + <gl-badge + v-if="workItem.confidential" + v-gl-tooltip.bottom + :title="confidentialTooltip" + variant="warning" + icon="eye-slash" + class="gl-mr-3 gl-cursor-help" + >{{ __('Confidential') }}</gl-badge + > + <work-item-todos + v-if="showWorkItemCurrentUserTodos" + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :work-item-fullpath="workItem.project.fullPath" + :current-user-todos="currentUserTodos" + @error="updateError = $event" + /> + <work-item-actions + :work-item-id="workItem.id" + :subscribed-to-notifications="workItemNotificationsSubscribed" + :work-item-type="workItemType" + :work-item-type-id="workItemTypeId" + :can-delete="canDelete" + :can-update="canUpdate" + :is-confidential="workItem.confidential" + :is-parent-confidential="parentWorkItemConfidentiality" + :work-item-reference="workItem.reference" + :work-item-create-note-email="workItem.createNoteEmail" + :is-modal="isModal" + @deleteWorkItem=" + $emit('deleteWorkItem', { workItemType, workItemId: workItem.id }) + " + @toggleWorkItemConfidentiality="toggleConfidentiality" + @error="updateError = $event" + /> + </div> + </div> + </transition> + </gl-intersection-observer> + <div + data-testid="work-item-overview" + :class="{ 'work-item-overview': workItemsMvc2Enabled }" + > + <section> + <work-item-attributes-wrapper + :class="{ 'gl-md-display-none!': workItemsMvc2Enabled }" + class="gl-border-b" + :work-item="workItem" + :work-item-parent-id="workItemParentId" + @error="updateError = $event" + /> + <work-item-description + v-if="hasDescriptionWidget" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + class="gl-pt-5" + @error="updateError = $event" + /> + <work-item-award-emoji + v-if="workItemAwardEmoji" + :work-item-id="workItem.id" + :work-item-fullpath="workItem.project.fullPath" + :award-emoji="workItemAwardEmoji.awardEmoji" + :work-item-iid="workItemIid" + @error="updateError = $event" + /> + <work-item-tree + v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :work-item-type="workItemType" + :parent-work-item-type="workItem.workItemType.name" + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :children="children" + :can-update="canUpdate" + :confidential="workItem.confidential" + @show-modal="openInModal" + /> + <work-item-notes + v-if="workItemNotes" + :work-item-id="workItem.id" + :work-item-iid="workItem.iid" + :work-item-type="workItemType" + :is-modal="isModal" + :assignees="workItemAssignees && workItemAssignees.assignees.nodes" + :can-set-work-item-metadata="canAssignUnassignUser" + :report-abuse-path="reportAbusePath" + class="gl-pt-5" + @error="updateError = $event" + @has-notes="updateHasNotes" + @openReportAbuse="openReportAbuseDrawer" + /> + <gl-empty-state + v-if="error" + :title="$options.i18n.fetchErrorTitle" + :description="error" + :svg-path="noAccessSvgPath" + /> + </section> + <aside + v-if="workItemsMvc2Enabled" + data-testid="work-item-overview-right-sidebar" + class="work-item-overview-right-sidebar gl-display-none gl-md-display-block" + :class="{ 'is-modal': isModal }" + > + <work-item-attributes-wrapper + :work-item="workItem" + :work-item-parent-id="workItemParentId" + @error="updateError = $event" + /> + </aside> + </div> </template> <work-item-detail-modal v-if="!isModal" 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 3e546598dc2..b4b3049d669 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 @@ -88,7 +88,11 @@ export default { return !this.canUpdate && !this.dueDate && !this.startDate; }, labelClass() { - return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!'; + return { + 'work-item-field-label': true, + 'gl-align-self-center gl-pb-0!': this.isReadonlyWithNoDates, + 'gl-mt-3 gl-pb-0!': !this.isReadonlyWithNoDates, + }; }, showDueDateButton() { return this.canUpdate && !this.showDueDateInput; 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 015c86ba043..8676456a6a4 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -7,7 +7,6 @@ import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/g import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; @@ -87,14 +86,6 @@ export default { error() { this.$emit('error', i18n.fetchError); }, - subscribeToMore: { - document: workItemLabelsSubscription, - variables() { - return { - issuableId: this.workItemId, - }; - }, - }, }, searchLabels: { query: labelSearchQuery, @@ -268,7 +259,7 @@ export default { <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap"> <span :id="labelsTitleId" - class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break work-item-field-label" data-testid="labels-title" >{{ __('Labels') }}</span > @@ -281,7 +272,8 @@ export default { :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!" + 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! work-item-field-value" + menu-class="token-selector-menu-class" data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" @input="focusTokenSelector" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 401c8a53eb0..ec44a654e89 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -271,7 +271,7 @@ export default { @click="toggleItem" /> <div - class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base" + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" data-testid="links-child" > <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> 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 b9fc92304c0..bfc6ceefccc 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 @@ -113,7 +113,7 @@ export default { return this.parentIssue?.milestone; }, children() { - return this.workItem ? findHierarchyWidgetChildren(this.workItem) : []; + return findHierarchyWidgetChildren(this.workItem); }, canUpdate() { return this.workItem?.userPermissions.updateWorkItem || false; @@ -205,10 +205,7 @@ export default { > <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 gl-font-weight-bold gl-text-gray-500" - data-testid="children-count" - > + <span class="gl-new-card-count" data-testid="children-count"> <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2" /> {{ childrenCountLabel }} </span> @@ -236,52 +233,53 @@ export default { </gl-dropdown> </template> <template #body> - <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> - - <template v-else> - <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> - <p class="gl-px-3 gl-py-2 gl-mb-0 gl-text-gray-500"> - {{ $options.i18n.emptyStateMessage }} - </p> - </div> - <work-item-links-form - v-if="isShownAddForm" - ref="wiLinksForm" - data-testid="add-links-form" - :issuable-gid="issuableGid" - :work-item-iid="iid" - :children-ids="childrenIds" - :parent-confidential="confidential" - :parent-iteration="issuableIteration" - :parent-milestone="issuableMilestone" - :form-type="formType" - :parent-work-item-type="workItem.workItemType.name" - @cancel="hideAddForm" - /> - <work-item-children-wrapper - :children="children" - :can-update="canUpdate" - :work-item-id="issuableGid" - :work-item-iid="iid" - @error="error = $event" - @show-modal="openChild" - /> - <work-item-detail-modal - ref="modal" - :work-item-id="activeChild.id" - :work-item-iid="activeChild.iid" - @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChild)" - @openReportAbuse="openReportAbuseDrawer" - /> - <abuse-category-selector - v-if="isReportDrawerOpen && reportAbusePath" - :reported-user-id="reportedUserId" - :reported-from-url="reportedUrl" - :show-drawer="isReportDrawerOpen" - @close-drawer="toggleReportAbuseDrawer(false)" - /> - </template> + <div class="gl-new-card-content"> + <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> + <template v-else> + <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> + <p class="gl-new-card-empty"> + {{ $options.i18n.emptyStateMessage }} + </p> + </div> + <work-item-links-form + v-if="isShownAddForm" + ref="wiLinksForm" + data-testid="add-links-form" + :issuable-gid="issuableGid" + :work-item-iid="iid" + :children-ids="childrenIds" + :parent-confidential="confidential" + :parent-iteration="issuableIteration" + :parent-milestone="issuableMilestone" + :form-type="formType" + :parent-work-item-type="workItem.workItemType.name" + @cancel="hideAddForm" + /> + <work-item-children-wrapper + :children="children" + :can-update="canUpdate" + :work-item-id="issuableGid" + :work-item-iid="iid" + @error="error = $event" + @show-modal="openChild" + /> + <work-item-detail-modal + ref="modal" + :work-item-id="activeChild.id" + :work-item-iid="activeChild.iid" + @close="closeModal" + @workItemDeleted="handleWorkItemDeleted(activeChild)" + @openReportAbuse="openReportAbuseDrawer" + /> + <abuse-category-selector + v-if="isReportDrawerOpen && reportAbusePath" + :reported-user-id="reportedUserId" + :reported-from-url="reportedUrl" + :show-drawer="isReportDrawerOpen" + @close-drawer="toggleReportAbuseDrawer(false)" + /> + </template> + </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 289a48b5eaf..db649913602 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 @@ -347,7 +347,8 @@ export default { <template> <gl-form - class="gl-bg-white gl-mt-1 gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base" + class="gl-new-card-add-form" + data-testid="add-item-form" @submit.prevent="addOrCreateMethod" > <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> 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 44e8dac79c4..83f3c391769 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 @@ -127,9 +127,11 @@ export default { </template> <template #body> <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500"> - {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} - </p> + <div class="gl-new-card-content"> + <p class="gl-new-card-empty"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} + </p> + </div> </div> <work-item-links-form v-if="isShownAddForm" diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index 693397686d0..6cc61ed4756 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -208,13 +208,13 @@ export default { class="work-item-dropdown gl-flex-nowrap" :label="$options.i18n.MILESTONE" label-for="milestone-value" - label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break work-item-field-label" label-cols="3" label-cols-lg="2" > <span v-if="!canUpdate" - class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal" + class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal work-item-field-value" data-testid="disabled-text" > {{ dropdownText }} @@ -223,7 +223,7 @@ export default { v-else id="milestone-value" data-testid="work-item-milestone-dropdown" - class="gl-pl-0 gl-max-w-full" + class="gl-pl-0 gl-max-w-full work-item-field-value" :toggle-class="dropdownClasses" :text="dropdownText" :loading="updateInProgress" diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue index 4e787720a42..b21abf21be5 100644 --- a/app/assets/javascripts/work_items/components/work_item_todos.vue +++ b/app/assets/javascripts/work_items/components/work_item_todos.vue @@ -1,10 +1,20 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { produce } from 'immer'; + import { s__ } from '~/locale'; import { updateGlobalTodoCount } from '~/sidebar/utils'; -import { getWorkItemTodoOptimisticResponse } from '../utils'; -import { ADD, MARK_AS_DONE, TODO_ADD_ICON, TODO_DONE_ICON } from '../constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import createWorkItemTodosMutation from '~/work_items/graphql/create_work_item_todos.mutation.graphql'; +import markDoneWorkItemTodosMutation from '~/work_items/graphql/mark_done_work_item_todos.mutation.graphql'; + +import { + TODO_ADD_ICON, + TODO_DONE_ICON, + TODO_PENDING_STATE, + TODO_DONE_STATE, + WIDGET_TYPE_CURRENT_USER_TODOS, +} from '../constants'; export default { i18n: { @@ -19,8 +29,16 @@ export default { GlButton, }, props: { - workItem: { - type: Object, + workItemId: { + type: String, + required: true, + }, + workItemIid: { + type: String, + required: true, + }, + workItemFullpath: { + type: String, required: true, }, currentUserTodos: { @@ -39,8 +57,11 @@ export default { }; }, computed: { + todoId() { + return this.currentUserTodos[0]?.id || ''; + }, pendingTodo() { - return this.currentUserTodos.length > 0; + return this.todoId !== ''; }, buttonIcon() { return this.pendingTodo ? TODO_DONE_ICON : TODO_ADD_ICON; @@ -50,28 +71,60 @@ export default { onToggle() { this.isLoading = true; this.buttonLabel = ''; - const action = this.pendingTodo ? MARK_AS_DONE : ADD; - const inputVariables = { - id: this.workItem.id, - currentUserTodosWidget: { - action, - }, + let mutation = createWorkItemTodosMutation; + let inputVariables = { + targetId: this.workItemId, }; + if (this.pendingTodo) { + mutation = markDoneWorkItemTodosMutation; + inputVariables = { + id: this.todoId, + }; + } + this.$apollo .mutate({ - mutation: updateWorkItemMutation, + mutation, variables: { input: inputVariables, }, - optimisticResponse: getWorkItemTodoOptimisticResponse({ - workItem: this.workItem, - pendingTodo: this.pendingTodo, - }), + optimisticResponse: { + todoMutation: { + todo: { + id: this.todoId, + state: this.pendingTodo ? TODO_DONE_STATE : TODO_PENDING_STATE, + }, + errors: [], + }, + }, + update: ( + cache, + { + data: { + todoMutation: { todo = {} }, + }, + }, + ) => { + const todos = []; + + if (todo.state === TODO_PENDING_STATE) { + todos.push({ + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Todo', + id: todo.id, + }); + } + + this.updateWorkItemCurrentTodosWidgetCache({ + cache, + todos, + }); + }, }) .then( ({ data: { - workItemUpdate: { errors }, + todoMutation: { errors }, }, }) => { if (errors?.length) { @@ -93,6 +146,26 @@ export default { this.isLoading = false; }); }, + updateWorkItemCurrentTodosWidgetCache({ cache, todos }) { + const query = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullpath, iid: this.workItemIid }, + }; + + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { widgets } = draftState.workspace.workItems.nodes[0]; + + const widgetCurrentUserTodos = widgets.find( + (widget) => widget.type === WIDGET_TYPE_CURRENT_USER_TODOS, + ); + + widgetCurrentUserTodos.currentUserTodos.nodes = todos; + }); + + cache.writeQuery({ ...query, data: newData }); + }, }, }; </script> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index f3beaebf403..b8324d7d552 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -79,6 +79,10 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( 'WorkItem|Something went wrong when fetching iterations. Please try again.', ); +export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__( + 'WorkItem|Something went wrong while fetching work item award emojis. Please try again.', +); + export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); @@ -192,6 +196,7 @@ export const FORM_TYPES = { export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; export const DEFAULT_PAGE_SIZE_NOTES = 30; +export const DEFAULT_PAGE_SIZE_EMOJIS = 100; export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item'; @@ -231,16 +236,12 @@ export const TEST_ID_PROMOTE_ACTION = 'promote-action'; export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action'; export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action'; -export const ADD = 'ADD'; -export const MARK_AS_DONE = 'MARK_AS_DONE'; export const TODO_ADD_ICON = 'todo-add'; export const TODO_DONE_ICON = 'todo-done'; -export const TODO_TYPENAME = 'Todo'; -export const TODO_EDGE_TYPENAME = 'TodoEdge'; -export const TODO_CONNECTION_TYPENAME = 'TodoConnection'; +export const TODO_DONE_STATE = 'done'; +export const TODO_PENDING_STATE = 'pending'; + export const CURRENT_USER_TODOS_TYPENAME = 'WorkItemWidgetCurrentUserTodos'; -export const WORK_ITEM_TYPENAME = 'WorkItem'; -export const WORK_ITEM_UPDATE_PAYLOAD_TYPENAME = 'WorkItemUpdatePayload'; export const EMOJI_ACTION_ADD = 'ADD'; export const EMOJI_ACTION_REMOVE = 'REMOVE'; diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql new file mode 100644 index 00000000000..82a532e1bea --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/award_emoji.fragment.graphql" + +query workItemAwardEmojis($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetAwardEmoji { + type + awardEmoji(first: $pageSize, after: $after) { + pageInfo { + ...PageInfo + } + nodes { + ...AwardEmojiFragment + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js index 03b45a45c39..14eedf5cdd8 100644 --- a/app/assets/javascripts/work_items/graphql/cache_utils.js +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -87,6 +87,46 @@ export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => }); }; +function updateNoteAwardEmojiCache(currentNotes, note, callback) { + if (!note.awardEmoji) { + return currentNotes; + } + const { awardEmoji } = note; + + return produce(currentNotes, (draftData) => { + const notesWidget = getNotesWidgetFromSourceData(draftData); + + if (!notesWidget.discussions) { + return; + } + + notesWidget.discussions.nodes.forEach((discussion) => { + discussion.notes.nodes.forEach((n) => { + if (n.id === note.id) { + callback(n, awardEmoji); + } + }); + }); + + updateNotesWidgetDataInDraftData(draftData, notesWidget); + }); +} + +export const updateCacheAfterAddingAwardEmojiToNote = (currentNotes, note) => { + return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => { + n.awardEmoji.nodes.push(awardEmoji); + }); +}; + +export const updateCacheAfterRemovingAwardEmojiFromNote = (currentNotes, note) => { + return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => { + // eslint-disable-next-line no-param-reassign + n.awardEmoji.nodes = n.awardEmoji.nodes.filter((emoji) => { + return emoji.name !== awardEmoji.name || emoji.user.id !== awardEmoji.user.id; + }); + }); +}; + export const addHierarchyChild = (cache, fullPath, iid, workItem) => { const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; const sourceData = cache.readQuery(queryArgs); diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql new file mode 100644 index 00000000000..1eb08f8bf6f --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item_todos.mutation.graphql @@ -0,0 +1,9 @@ +mutation workItemTodoCreate($input: TodoCreateInput!) { + todoMutation: todoCreate(input: $input) { + todo { + id + state + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql b/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql new file mode 100644 index 00000000000..2bfeaf93ae8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/mark_done_work_item_todos.mutation.graphql @@ -0,0 +1,9 @@ +mutation workItemTodoMarkDone($input: TodoMarkDoneInput!) { + todoMutation: todoMarkDone(input: $input) { + todo { + id + state + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql index c8b7d379074..6543e1a52f9 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/work_items/graphql/award_emoji.fragment.graphql" fragment WorkItemNote on Note { id @@ -22,6 +23,11 @@ fragment WorkItemNote on Note { author { ...User } + awardEmoji { + nodes { + ...AwardEmojiFragment + } + } userPermissions { adminNote awardEmoji diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql index dc51c53428b..bc228c0dd3d 100644 --- a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql @@ -1,17 +1,5 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" - mutation workItemNoteAddAwardEmoji($awardableId: AwardableID!, $name: String!) { awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) { - awardEmoji { - name - description - unicode - emoji - unicodeVersion - user { - ...User - } - } errors } } diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql new file mode 100644 index 00000000000..22942fbb823 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql @@ -0,0 +1,5 @@ +mutation workItemNoteRemoveAwardEmoji($awardableId: AwardableID!, $name: String!) { + awardEmojiRemove(input: { awardableId: $awardableId, name: $name }) { + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql deleted file mode 100644 index d5b2de8c4c6..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql +++ /dev/null @@ -1,21 +0,0 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" - -subscription issuableAssignees($issuableId: IssuableID!) { - issuableAssigneesUpdated(issuableId: $issuableId) { - ... on WorkItem { - id - widgets { - ... on WorkItemWidgetAssignees { - type - allowsMultipleAssignees - canInviteMembers - assignees { - nodes { - ...User - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql deleted file mode 100644 index 4eb3d8067d9..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item_description.subscription.graphql +++ /dev/null @@ -1,20 +0,0 @@ -subscription issuableDescription($issuableId: IssuableID!) { - issuableDescriptionUpdated(issuableId: $issuableId) { - ... on WorkItem { - id - widgets { - ... on WorkItemWidgetDescription { - type - description - descriptionHtml - lastEditedAt - lastEditedBy { - id - name - webPath - } - } - } - } - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql deleted file mode 100644 index 86d936bf4dd..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql +++ /dev/null @@ -1,19 +0,0 @@ -#import "~/graphql_shared/fragments/label.fragment.graphql" - -subscription workItemLabels($issuableId: IssuableID!) { - issuableLabelsUpdated(issuableId: $issuableId) { - ... on WorkItem { - id - widgets { - ... on WorkItemWidgetLabels { - type - labels { - nodes { - ...Label - } - } - } - } - } - } -} 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 42c057fb8fe..f303a797e9c 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 @@ -1,7 +1,6 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" -#import "~/work_items/graphql/award_emoji.fragment.graphql" fragment WorkItemMetadataWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { @@ -45,20 +44,12 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { ... on WorkItemWidgetCurrentUserTodos { type currentUserTodos(state: pending) { - edges { - node { - id - state - } + nodes { + id } } } ... on WorkItemWidgetAwardEmoji { type - awardEmoji { - nodes { - ...AwardEmojiFragment - } - } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql deleted file mode 100644 index f5163003fe5..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item_milestone.subscription.graphql +++ /dev/null @@ -1,17 +0,0 @@ -#import "~/work_items/graphql/milestone.fragment.graphql" - -subscription issuableMilestone($issuableId: IssuableID!) { - issuableMilestoneUpdated(issuableId: $issuableId) { - ... on WorkItem { - id - widgets { - ... on WorkItemWidgetMilestone { - type - milestone { - ...MilestoneFragment - } - } - } - } - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql deleted file mode 100644 index 2ac01b79d6f..00000000000 --- a/app/assets/javascripts/work_items/graphql/work_item_title.subscription.graphql +++ /dev/null @@ -1,8 +0,0 @@ -subscription issuableTitleUpdated($issuableId: IssuableID!) { - issuableTitleUpdated(issuableId: $issuableId) { - ... on WorkItem { - id - title - } - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql new file mode 100644 index 00000000000..8b63f46ab28 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_updated.subscription.graphql @@ -0,0 +1,7 @@ +#import "./work_item.fragment.graphql" + +subscription workItemUpdated($id: WorkItemID!) { + workItemUpdated(workItemId: $id) { + ...WorkItem + } +} 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 bf8dc9ce9b0..383d003e78c 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -1,7 +1,6 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" -#import "~/work_items/graphql/award_emoji.fragment.graphql" #import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql" fragment WorkItemWidgets on WorkItemWidget { @@ -93,20 +92,12 @@ fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetCurrentUserTodos { type currentUserTodos(state: pending) { - edges { - node { - id - state - } + nodes { + id } } } ... on WorkItemWidgetAwardEmoji { type - awardEmoji { - nodes { - ...AwardEmojiFragment - } - } } } diff --git a/app/assets/javascripts/work_items/notes/award_utils.js b/app/assets/javascripts/work_items/notes/award_utils.js new file mode 100644 index 00000000000..5351a22d593 --- /dev/null +++ b/app/assets/javascripts/work_items/notes/award_utils.js @@ -0,0 +1,67 @@ +import { __ } from '~/locale'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { + updateCacheAfterAddingAwardEmojiToNote, + updateCacheAfterRemovingAwardEmojiFromNote, +} from '~/work_items/graphql/cache_utils'; +import workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql'; +import addAwardEmojiMutation from '../graphql/notes/work_item_note_add_award_emoji.mutation.graphql'; +import removeAwardEmojiMutation from '../graphql/notes/work_item_note_remove_award_emoji.mutation.graphql'; + +function awardedByCurrentUser(note) { + return (note.awardEmoji?.nodes ?? []) + .filter((award) => { + return getIdFromGraphQLId(award.user.id) === window.gon.current_user_id; + }) + .map((award) => award.name); +} + +export function getMutation({ note, name }) { + if (awardedByCurrentUser(note).includes(name)) { + return { + mutation: removeAwardEmojiMutation, + mutationName: 'awardEmojiRemove', + errorMessage: __('Failed to remove emoji. Please try again'), + }; + } + return { + mutation: addAwardEmojiMutation, + mutationName: 'awardEmojiAdd', + errorMessage: __('Failed to add emoji. Please try again'), + }; +} + +export function optimisticAwardUpdate({ note, name, fullPath, workItemIid }) { + const { mutation } = getMutation({ note, name }); + + const currentUserId = window.gon.current_user_id; + + return (store) => { + store.updateQuery( + { + query: workItemNotesByIidQuery, + variables: { fullPath, iid: workItemIid }, + }, + (sourceData) => { + const updatedNote = { + id: note.id, + awardEmoji: { + __typename: 'AwardEmoji', + name, + user: { + __typename: 'UserCore', + id: convertToGraphQLId(TYPENAME_USER, currentUserId), + name: null, + }, + }, + }; + + if (mutation === removeAwardEmojiMutation) { + return updateCacheAfterRemovingAwardEmojiFromNote(sourceData, updatedNote); + } + return updateCacheAfterAddingAwardEmojiToNote(sourceData, updatedNote); + }, + ); + }; +} diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 13fc521464f..81dbe56b2ea 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,20 +1,10 @@ -import { uniqueId } from 'lodash'; -import { - WIDGET_TYPE_HIERARCHY, - WIDGET_TYPE_CURRENT_USER_TODOS, - CURRENT_USER_TODOS_TYPENAME, - TODO_CONNECTION_TYPENAME, - TODO_EDGE_TYPENAME, - TODO_TYPENAME, - WORK_ITEM_TYPENAME, - WORK_ITEM_UPDATE_PAYLOAD_TYPENAME, -} from '~/work_items/constants'; +import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); export const findHierarchyWidgetChildren = (workItem) => - findHierarchyWidgets(workItem?.widgets)?.children.nodes; + findHierarchyWidgets(workItem?.widgets)?.children?.nodes || []; const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => { return `${ @@ -32,38 +22,3 @@ export const markdownPreviewPath = (fullPath, iid) => `${ gon.relative_url_root || '' }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; - -export const getWorkItemTodoOptimisticResponse = ({ workItem, pendingTodo }) => { - const todo = pendingTodo - ? [ - { - node: { - id: -uniqueId(), - state: 'pending', - __typename: TODO_TYPENAME, - }, - __typename: TODO_EDGE_TYPENAME, - }, - ] - : []; - return { - workItemUpdate: { - errors: [], - workItem: { - ...workItem, - widgets: [ - { - type: WIDGET_TYPE_CURRENT_USER_TODOS, - currentUserTodos: { - edges: todo, - __typename: TODO_CONNECTION_TYPENAME, - }, - __typename: CURRENT_USER_TODOS_TYPENAME, - }, - ], - __typename: WORK_ITEM_TYPENAME, - }, - __typename: WORK_ITEM_UPDATE_PAYLOAD_TYPENAME, - }, - }; -}; diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index 6a6febbf7b4..23a7beb527b 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -189,3 +189,24 @@ $avatar-sizes: ( .avatar-counter { @include avatar-counter(); } + +.user-popover { + // GlAvatarLabeled doesn't expose any prop to override internal classes + + // Max width of popover container is set by gl-max-w-48 + // so we need to ensure that name/username/status container doesn't overflow + .gl-avatar-labeled-labels { + max-width: px-to-rem(290px); + } + + .gl-avatar-labeled-label, + .gl-avatar-labeled-sublabel { + @include gl-text-truncate; + } + + &.user-popover-cannot-merge { + .popover-header { + @include gl-bg-orange-50; + } + } +} diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 2ed955a56b6..08a956bf90f 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -1,18 +1,38 @@ .ProseMirror { + width: calc(100% - 4px); padding-top: $gl-spacing-scale-4; + padding-left: calc(#{$gl-spacing-scale-5} - 2px); + padding-right: $gl-spacing-scale-5; + margin: 2px; min-height: 140px; max-height: 55vh; + position: static; overflow-y: auto; + transition: box-shadow ease-in-out 0.15s; + + .gl-dark & { + width: calc(100% - 6px); + margin: 2px 3px; + padding-left: calc(#{$gl-spacing-scale-5} - 3px); + } ::selection { background-color: transparent; } + &:focus { + @include gl-focus; + } + &:not(.ProseMirror-hideselection) .content-editor-selection, a.ProseMirror-selectednode, span.ProseMirror-selectednode { background-color: $blue-100; - box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100; + box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100; + } + + pre > code { + background-color: transparent; } td, @@ -44,6 +64,48 @@ pointer-events: none; } + .image-resize-container { + position: relative; + } + + .image-resize { + display: inline-block; + position: absolute; + width: 8px; + height: 8px; + background: $blue-200; + outline: 1px solid $white; + } + + .image-resize-nw { + top: -4px; + left: -4px; + cursor: nw-resize; + } + + .image-resize-ne { + top: -4px; + right: -4px; + cursor: ne-resize; + } + + .image-resize-sw { + bottom: 4px; + left: -4px; + cursor: sw-resize; + } + + .image-resize-se { + bottom: 4px; + right: -4px; + cursor: se-resize; + } + + img.ProseMirror-selectednode { + outline: 2px solid $blue-200; + outline-offset: -2px; + } + video { max-width: 400px; } @@ -78,6 +140,81 @@ } } + .suggestion-added, + .suggestion-deleted, + .suggestion-added-input { + white-space: pre-wrap; + + > code { + white-space: pre-wrap; + padding: 0; + display: flex; + } + } + + .suggestion-added-input { + > code { + display: block; + margin-left: 120px; + background-color: transparent !important; + } + } + + .suggestion-added, + .suggestion-deleted { + background-color: $line-added; + width: 100%; + + > code { + border-left: 100px solid $line-number-new; + padding-left: 20px; + border-radius: 0; + background-color: inherit !important; + } + + > code::before { + content: attr(data-line-number); + position: absolute; + width: 100px; + margin-left: -120px; + text-align: right; + padding-right: 10px; + padding-left: 10px; + + @include gl-text-secondary; + } + + > code::after { + content: '+'; + position: absolute; + margin-left: -20px; + width: 20px; + text-align: center; + + @include gl-text-secondary; + } + } + + .suggestion-added > code { + color: rgba($white, 0); + } + + .suggestion-deleted { + background-color: $line-removed; + cursor: not-allowed; + + > code { + border-color: $line-number-old; + } + + > code::before { + padding-right: 60px; + } + + > code::after { + content: '-'; + } + } .dl-content { width: 100%; @@ -135,6 +272,31 @@ } } +.gl-dark .ProseMirror { + .suggestion-added-input, + .suggestion-deleted { + > code { + color: $gray-50; + } + } + + .suggestion-deleted, + .suggestion-added { + > code::before, + > code::after { + color: $gray-400; + } + } +} + +// Fixes a problem with the layout shifting +// when switching between Markdown and the +// Richtext editor due to a loosly defined +// style in typography.scss +.md > .ProseMirror { + margin: 2px; +} + .table-creator-grid-item { box-shadow: inset 0 0 0 $gl-spacing-scale-2 $white, inset $gl-spacing-scale-1 $gl-spacing-scale-1 0 #{$gl-spacing-scale-2 * 3 / 4} $gray-100, @@ -179,6 +341,17 @@ min-width: auto; } +.content-editor-suggestions-dropdown { + .gl-new-dropdown-panel { + width: max-content; + } + + li.focused div.gl-new-dropdown-item-content { + @include gl-focus($inset: true); + @include gl-bg-gray-50; + } +} + .bubble-menu-form { min-width: 320px; } diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index bc49d17fcbb..0d87d49ac18 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -9,15 +9,26 @@ Usage: font-weight: 100 900; font-display: optional; font-style: normal; - font-named-instance: 'Regular'; /* stylelint-disable property-no-unknown */ + /* stylelint-disable-next-line property-no-unknown */ + font-named-instance: 'Regular'; src: font-url('gitlab-sans/GitLabSans.woff2') format('woff2'); } +@font-face { + font-family: 'GitLab Sans'; + font-weight: 100 900; + font-display: optional; + font-style: italic; + /* stylelint-disable-next-line property-no-unknown */ + font-named-instance: 'Regular'; + src: font-url('gitlab-sans/GitLabSans-Italic.woff2') format('woff2'); +} + /* ------------------------------------------------------- Monospaced font: GitLab Mono. Usage: - html { font-family: 'GitLab Mono', sans-serif; } + html { font-family: 'GitLab Mono', monospace; } */ @font-face { font-family: 'GitLab Mono'; @@ -35,45 +46,6 @@ Usage: src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); } -/* ------------------------------------------------------- -Monospaced font: JetBrains Mono. - -All of the definitions below can be removed once -`GitLab Mono` is properly rolled out. - -Usage: - html { font-family: 'JetBrains Mono', sans-serif; } -*/ -@font-face { - font-family: 'JetBrains Mono'; - font-display: optional; - font-style: normal; - src: font-url('jetbrains-mono/JetBrainsMono.woff2') format('woff2'); -} - -@font-face { - font-family: 'JetBrains Mono'; - font-display: optional; - font-weight: bold; - src: font-url('jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2'); -} - -@font-face { - font-family: 'JetBrains Mono'; - font-display: optional; - font-weight: normal; - font-style: italic; - src: font-url('jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2'); -} - -@font-face { - font-family: 'JetBrains Mono'; - font-display: optional; - font-weight: bold; - font-style: italic; - src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2'); -} - // This isn't the best solution, but we needed a quick fix // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/ * { diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index e60353578b0..4d4144fe9dd 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -39,6 +39,7 @@ @import 'framework/contextual_sidebar_header'; @import 'framework/contextual_sidebar'; @import 'framework/super_sidebar'; +@import 'framework/brand_logo'; @import 'framework/tables'; @import 'framework/notes'; @import 'framework/tabs'; @@ -64,3 +65,4 @@ @import 'framework/card'; @import 'framework/source_editor'; @import 'framework/diffs'; +@import 'framework/new_card'; diff --git a/app/assets/stylesheets/framework/brand_logo.scss b/app/assets/stylesheets/framework/brand_logo.scss new file mode 100644 index 00000000000..1bc1ef797a7 --- /dev/null +++ b/app/assets/stylesheets/framework/brand_logo.scss @@ -0,0 +1,29 @@ +$brand-logo-light-background: #e0dfe5; +$brand-logo-dark-background: #53515b; + +.brand-logo { + display: inline-block; + @include gl-rounded-base; + @include gl-p-2; + @include gl-bg-transparent; + @include gl-border-none; + + .tanuki-logo { + @include gl-vertical-align-middle; + } + + &:focus, + &:active { + @include gl-focus; + } + + &:hover, + &:focus, + &:active { + background-color: $brand-logo-light-background; + + .gl-dark & { + background-color: $brand-logo-dark-background; + } + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 2ec7c891197..7b8d9281148 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -34,11 +34,7 @@ --mr-review-bar-height: #{$mr-review-bar-height}; } -@include media-breakpoint-up(md) { - .page-with-contextual-sidebar { - --application-bar-left: #{$contextual-sidebar-collapsed-width}; - } - +@include media-breakpoint-up(sm) { .right-sidebar-collapsed { --application-bar-right: #{$right-sidebar-collapsed-width}; @@ -52,6 +48,12 @@ } } +@include media-breakpoint-up(md) { + .page-with-contextual-sidebar { + --application-bar-left: #{$contextual-sidebar-collapsed-width}; + } +} + @include media-breakpoint-up(xl) { .page-with-contextual-sidebar { --application-bar-left: #{$contextual-sidebar-width}; @@ -330,14 +332,6 @@ li.note { height: 220px; } -.footer-links { - margin-bottom: 20px; - - a { - margin-right: 15px; - } -} - .card.card-body { margin-bottom: $gl-padding; @@ -555,3 +549,16 @@ li.note { See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. **/ .gl-line-height-14 { line-height: $gl-line-height-14; } + +// TODO: To be removed once `split` option for new dropdowns is implemented. +// See issue at https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2263 +.gl-new-dropdown.split:nth-child(n+2) { + .gl-new-dropdown-toggle { + margin-left: 1px; + + &.btn-tertiary, + &.disabled { + margin-left: -1px; + } + } +} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 192cb82aaab..7b35659e90a 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -1,6 +1,6 @@ // Common .diff-file { - padding-bottom: $gl-padding; + margin-bottom: $gl-padding; &.has-body { .file-title { @@ -864,19 +864,6 @@ table.code { } } -// Remove border from collapsed replies widget only on diffs -.diff-grid-comments { - .replies-widget-collapsed { - border-bottom: 0; - } - // Rounded border radius only on diff comments with no replies - .discussion-collapsible { - .discussion-reply-holder:first-of-type { - border-radius: $gl-border-radius-base; - } - } -} - .discussion-body .image .frame { position: relative; } @@ -889,13 +876,6 @@ table.code { } } -.parallel { - .discussion-collapsible { - margin: $gl-padding; - margin-top: 0; - } -} - .image-diff-overlay, .image-diff-overlay-add-comment { top: 0; diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 16ad6f62c64..358f599e0e9 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -3,8 +3,11 @@ gl-emoji { display: inline-flex; vertical-align: baseline; font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - font-size: 1.2em; - line-height: 1; + + img { + width: 1.2em; + height: 1.2em; + } } .user-status-emoji { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index b2ba1d8830d..f5ed85e8845 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px; min-height: $header-height; border: 0; position: fixed; - top: $calc-application-bars-height; + top: $calc-system-headers-height; left: 0; right: 0; border-radius: 0; @@ -322,7 +322,7 @@ $search-input-field-x-min-width: 200px; left: var(--application-bar-left); position: fixed; right: var(--application-bar-right); - top: $calc-application-bars-height; + top: $calc-system-headers-height; width: auto; z-index: $top-bar-z-index; diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7dfbd5485d8..086a16edda2 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -36,7 +36,7 @@ body { } .layout-page { - padding-top: calc(#{$header-height} + #{$calc-application-bars-height}); + padding-top: $calc-application-bars-height; padding-bottom: $calc-application-footer-height; } @@ -62,11 +62,6 @@ body { } } -.navless-container { - margin-top: $header-height; - padding-top: $gl-padding * 2; -} - .container-limited { max-width: $fixed-layout-width; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 5fdab7891ec..f8f54567ef2 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -91,7 +91,7 @@ } .md-preview-holder { - min-height: 176px; + min-height: 173px; padding: 10px 0; overflow-x: auto; } @@ -106,6 +106,7 @@ box-shadow: none; width: 100%; resize: none !important; + transition: box-shadow $gl-transition-duration-medium ease; } .md-suggestion-diff { diff --git a/app/assets/stylesheets/framework/new_card.scss b/app/assets/stylesheets/framework/new_card.scss new file mode 100644 index 00000000000..ef8f5cc1d1b --- /dev/null +++ b/app/assets/stylesheets/framework/new_card.scss @@ -0,0 +1,94 @@ +.gl-new-card { + @include gl-mt-5; + @include gl-bg-gray-10; + @include gl-border-1; + @include gl-border-solid; + @include gl-border-gray-100; + @include gl-rounded-base; + + &-header { + @include gl-px-5; + @include gl-py-4; + @include gl-display-flex; + @include gl-justify-content-space-between; + @include gl-bg-white; + @include gl-border-b-1; + @include gl-border-b-solid; + @include gl-border-b-gray-100; + @include gl-rounded-top-base; + } + + &[aria-expanded=false] &-header { + @include gl-border-bottom-0; + @include gl-rounded-base; + } + + &-title-wrapper { + @include gl-display-flex; + @include gl-flex-grow-1; + } + + &-title { + @include gl-display-flex; + @include gl-font-base; + @include gl-font-weight-bold; + @include gl-relative; + @include gl-m-0; + @include gl-line-height-24; + } + + &-count { + @include gl-mx-3; + @include gl-font-base; + @include gl-font-weight-bold; + @include gl-text-gray-500; + @include gl-display-inline-flex; + @include gl-align-items-center; + } + + &-description { + @include gl-font-sm; + @include gl-text-gray-500; + @include gl-m-0; + } + + &-toggle { + @include gl-pl-3; + @include gl-ml-3; + @include gl-mr-n2; + @include gl-border-l-1; + @include gl-border-l-solid; + @include gl-border-l-gray-100; + } + + &-body { + @include gl-rounded-bottom-base; + @include gl-px-3; + @include gl-py-0; + } + + &-content { + @include gl-px-2; + @include gl-py-3; + } + + &-empty { + @include gl-p-2; + @include gl-mb-0; + @include gl-text-gray-500; + } + + &-footer { + @include gl-bg-white; + } + + &-add-form { + @include gl-p-4; + @include gl-my-2; + @include gl-bg-white; + @include gl-border-1; + @include gl-border-solid; + @include gl-border-gray-100; + @include gl-rounded-base; + } +} diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 98083fbc72a..9bf6ed45483 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -39,6 +39,12 @@ } .approvers-select { + width: calc(70% - #{$gl-spacing-scale-5}); + + .gl-new-dropdown-toggle { + @include gl-w-full; + } + .dropdown-menu { @include gl-w-full; @include gl-max-w-none; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index b7a674a35e7..5f90dd62426 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -317,7 +317,7 @@ .right-sidebar { &:not(.right-sidebar-merge-requests) { @include right-sidebar; - top: calc(#{$header-height} + #{$calc-application-bars-height}); + top: $calc-application-bars-height; @include media-breakpoint-down(md) { z-index: 251; @@ -327,7 +327,7 @@ &.right-sidebar-merge-requests { @include media-breakpoint-down(md) { @include right-sidebar; - top: calc(#{$header-height} + #{$calc-application-bars-height}); + top: $calc-application-bars-height; z-index: 251; } diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index ca67b472322..12801b272e8 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -23,7 +23,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .super-sidebar-skip-to { - top: calc(#{$header-height} + #{$calc-application-bars-height}); + top: $calc-application-bars-height; width: calc(#{$super-sidebar-width} - #{$gl-spacing-scale-5}); z-index: $super-sidebar-skip-to-z-index; } @@ -32,7 +32,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; display: flex; flex-direction: column; position: fixed; - top: calc(#{$header-height} + #{$calc-application-bars-height}); + top: $calc-application-bars-height; bottom: $calc-application-footer-height; left: 0; background-color: var(--gray-10, $gray-10); @@ -57,12 +57,7 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .user-bar { background-color: $t-gray-a-04; - .tanuki-logo { - @include gl-vertical-align-middle; - } - - .user-bar-item, - .tanuki-logo-container { + .user-bar-item { @include gl-rounded-base; @include gl-p-2; @include gl-bg-transparent; @@ -81,21 +76,6 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; @include active-toggle; } } - - $light-mode-btn-bg: #e0dfe5; - $dark-mode-btn-bg: #53515b; - - .tanuki-logo-container { - &:hover, - &:focus, - &:active { - background-color: $light-mode-btn-bg; - - .gl-dark & { - background-color: $dark-mode-btn-bg; - } - } - } } .counter .gl-icon, @@ -313,12 +293,9 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; padding: 0.5rem !important; } - .is-searching { - .in-search-scope-help { - position: absolute; - top: 0.625rem; - right: 2.5rem; - } + .search-scope-help { + top: 0.625rem; + right: 2.5rem; } .gl-search-box-by-type-input-borderless { diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 703c2ca0dad..5a8ef077c9b 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -36,16 +36,6 @@ } } -// System Footer -.with-system-footer { - // navless pages' footer eg: login page - // navless pages' footer border eg: login page - &.devise-layout-html body .footer-container, - &.devise-layout-html body hr.footer-fixed { - bottom: $system-footer-height; - } -} - .fullscreen-layout { .header-message, .footer-message { diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index a3b238d657d..921e03f45f3 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -31,7 +31,6 @@ &:not(.note-form).internal-note .timeline-content, &:not(.note-form).draft-note .timeline-content { background-color: $orange-50 !important; - border-radius: 3px; } .timeline-entry-inner { @@ -40,9 +39,12 @@ &:target, &.target { - .timeline-content, + .timeline-content { + background-color: $line-target-blue; + } + + .public-note.discussion-reply-holder { - background-color: $line-target-blue !important; + padding-top: $gl-padding-12 !important; } &.system-note .note-body .note-text.system-note-commit-list::after { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 88f990d2320..25542a86e8c 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -577,17 +577,6 @@ width: 1%; } - .metrics-embed { - h3.popover-header { - /** Override <h3> .popover-header - * as embed metrics do not follow the same - * style as default md <h3> (which are deeply nested) - */ - margin: 0; - font-size: $gl-font-size-small; - } - } - .gl-dropdown-item { margin: 0; padding: 0; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f77804fb7fc..ebaaece1281 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -10,7 +10,7 @@ $default-transition-duration: 0.15s; $contextual-sidebar-width: 256px; $contextual-sidebar-collapsed-width: 56px; $toggle-sidebar-height: 48px; -$super-sidebar-width: 256px; +$super-sidebar-width: 16rem; $super-sidebar-z-index: 600; $super-sidebar-skip-to-z-index: 601; $super-sidebar-overlay-z-index: 599; @@ -467,7 +467,6 @@ $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; $container-text-max-width: 540px; -$gl-avatar-size: 40px; $border-radius-default: 4px; $border-radius-small: 2px; $border-radius-large: 8px; @@ -502,8 +501,9 @@ $pages-group-name-color: #4c4e54; /* * Calculated heights */ -$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height)); -$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height)); +$calc-system-headers-height: calc(var(--system-header-height) + var(--performance-bar-height)); +$calc-application-bars-height: calc(#{$header-height} + #{$calc-system-headers-height}); +$calc-application-header-height: calc(#{$calc-application-bars-height} + var(--top-bar-height)); $calc-application-footer-height: var(--system-footer-height); $calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); @@ -568,10 +568,12 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts + * The --default-mono-font and --default-regular-font variables give users + * a way to override our font choices for them. */ -$monospace-font: 'GitLab Mono', 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', +$monospace-font: var(--default-mono-font, 'GitLab Mono'), 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; -$regular-font: 'GitLab Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', +$regular-font: var(--default-regular-font, 'GitLab Sans'), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; $gl-monospace-font: $monospace-font; @@ -704,7 +706,6 @@ $environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs * Avatar */ $avatar-radius: 50%; -$gl-avatar-size: 40px; $gl-avatar-border-opacity: 0.1; /* diff --git a/app/assets/stylesheets/page_bundles/branches.scss b/app/assets/stylesheets/page_bundles/branches.scss index a5b201c7dac..daf828fb559 100644 --- a/app/assets/stylesheets/page_bundles/branches.scss +++ b/app/assets/stylesheets/page_bundles/branches.scss @@ -43,3 +43,15 @@ .branches-list .branch-item:not(:last-of-type) { border-bottom: 1px solid $border-color; } + +.branch-item { + .issuable-reference { + max-width: 92px; + } + + .right-block { + @media (min-width: map-get($grid-breakpoints, md)) { + min-width: 200px; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss index c19561a5e5e..e206b5e5b8b 100644 --- a/app/assets/stylesheets/page_bundles/design_management.scss +++ b/app/assets/stylesheets/page_bundles/design_management.scss @@ -115,11 +115,11 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); flex-basis: 28%; .link-inherit-color { + &, &:hover, &:active, &:focus { color: inherit; - text-decoration: none; } } @@ -159,27 +159,14 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); transition: background $gl-transition-duration-medium $general-hover-transition-curve; border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box border-top-right-radius: $border-radius-default; - - a { - color: inherit; - } - - .note-text a { - color: var(--blue-600, $blue-600); - } } .reply-wrapper { - padding: $gl-padding-8 $gl-padding-8 $gl-padding-4; - background: $gray-10; + padding: $gl-padding-8; border-radius: 0 0 $border-radius-default $border-radius-default; } } - .reply-wrapper { - border-top: 1px solid var(--border-color, $border-color); - } - .new-discussion-disclaimer { line-height: 20px; } diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 1b98fd4df07..1b5da0368c6 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -149,6 +149,10 @@ .gl-search-box-by-type button.gl-clear-icon-button:hover { @include gl-bg-transparent; + + &:focus { + @include gl-focus($inset: true); + } } .issuable-move-button:not(.disabled):hover { diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index 2c54c819543..6972e98b0bf 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -9,6 +9,8 @@ @import '@gitlab/ui/src/components/base/alert/alert'; @import '@gitlab/ui/src/components/base/avatar/avatar'; @import '@gitlab/ui/src/components/base/button/button'; +@import '@gitlab/ui/src/components/base/banner/banner'; +@import '@gitlab/ui/src/components/base/card/card'; @import '@gitlab/ui/src/components/base/icon/icon'; @import '@gitlab/ui/src/components/base/link/link'; @import '@gitlab/ui/src/components/base/loading_icon/loading_icon'; @@ -23,7 +25,7 @@ @import '@gitlab/ui/src/components/base/form/form_group/form_group'; @import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type'; -$header-height: 40px; +$header-height: $gl-spacing-scale-8; .jira-connect-header { min-height: $header-height; @@ -35,6 +37,6 @@ $header-height: 40px; .jira-connect-app { margin-top: $header-height; - height: calc(100% - #{$header-height}); - max-width: 1000px; + height: 100%; + max-height: calc(100% - #{$header-height + $gl-spacing-scale-7 * 2}); } diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss index 355d2afc0ba..b63f199f7b9 100644 --- a/app/assets/stylesheets/page_bundles/login.scss +++ b/app/assets/stylesheets/page_bundles/login.scss @@ -196,10 +196,6 @@ } } - .submit-container { - margin-top: 16px; - } - input[type='submit'] { margin-bottom: 0; display: block; @@ -228,65 +224,33 @@ } } -.devise-layout-html { +.html-devise-layout { margin: 0; padding: 0; height: 100%; - &.with-system-header { - .login-page-broadcast { - margin-top: calc(#{$system-header-height} + #{$header-height}); - } - } - - // Fixes footer container to bottom of viewport body { - // offset height of fixed header + 1 to avoid scroll - height: calc(100% - 51px); + padding-top: 48px; // Remove this line when the restyle_login_page feature flag is deleted. Instead, add self-align `center` to container, and maybe a top margin. - // offset without the header - &.navless { - height: calc(100% - 11px); + &.with-system-header { + padding-top: $system-header-height; + padding-top: calc(#{$system-header-height} + 48px); // Remove this line when the restyle_login_page feature flag is deleted } - margin: 0; - padding: 0; - - .page-wrap { - min-height: 100%; - position: relative; - } - - .footer-container, - hr.footer-fixed { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 40px; - background: var(--white, $white); - } - - .login-page-broadcast { - margin-top: 40px; - } - - .navless-container { - padding: 0 15px 65px; // height of footer + bottom padding of email confirmation link - } - - .flash-container { - padding-bottom: 65px; - - @include media-breakpoint-down(xs) { - padding-bottom: 0; + &.with-system-footer { + .footer-container { + padding-bottom: $system-footer-height; } } } } @include media-breakpoint-down(sm) { - .sm-bg-gray-10 { + .sm-bg-gray { @include gl-bg-gray-10; + + .gl-dark & { + background-color: var(--gray-100); + } } } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index fc4a9d3dff9..5e20588dd70 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -226,7 +226,7 @@ $tabs-holder-z-index: 250; clear: left; .note-body { - padding: 0 0 $gl-padding-8; + padding: 0 $gl-padding-8 $gl-padding-8 $gl-padding-32; } } @@ -234,14 +234,15 @@ $tabs-holder-z-index: 250; margin-top: -2px; margin-right: $gl-padding-8; } +} - // tiny adjustment to vertical align with the note header text - .discussion-collapsible { - margin-left: 1rem; +// tiny adjustment to vertical align with the note header text +.discussion-collapsible { + border: 0 !important; + margin: 0; - .timeline-icon { - padding-top: 2px; - } + .timeline-icon { + padding-top: 2px; } } @@ -1275,20 +1276,12 @@ $tabs-holder-z-index: 250; .diff-file-discussions-wrapper { @include gl-w-full; - max-width: 800px; - - .diff-discussions > .notes { - @include gl-p-5; - } - .diff-discussions:not(:first-child) >.notes { @include gl-pt-0; } .note-discussion { - @include gl-rounded-base; - - border: 1px solid var(--gray-100, $gray-100) !important; + border-bottom: 1px solid var(--gray-100, $gray-100) !important; } .discussion-collapsible { diff --git a/app/assets/stylesheets/page_bundles/notifications.scss b/app/assets/stylesheets/page_bundles/notifications.scss index 88437954f4c..a901235df50 100644 --- a/app/assets/stylesheets/page_bundles/notifications.scss +++ b/app/assets/stylesheets/page_bundles/notifications.scss @@ -1,31 +1,7 @@ @import 'mixins_and_variables_and_functions'; .notification-list-item { - @include media-breakpoint-down(sm) { - .notification-dropdown { - width: 100%; - } - - .btn-group { - width: 100%; - } - - .table-section { - border-top: 0; - min-height: unset; - - &:not(:first-child) { - padding-top: 0; - } - } - - .update-notifications { - width: 100%; - } + &:not(:last-of-type) { + border-bottom: 1px solid $gray-100; } } - -.notification { - position: relative; - top: 1px; -} diff --git a/app/assets/stylesheets/page_bundles/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss index c9c78a70163..1a59f96c6ee 100644 --- a/app/assets/stylesheets/page_bundles/profiles/preferences.scss +++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss @@ -66,13 +66,8 @@ .syntax-theme { label { - margin-right: $gl-padding-32; - margin-bottom: $gl-padding; - text-align: center; - .preview { - margin-bottom: 10px; - width: 160px; + margin-bottom: 8px; img { border-radius: 4px; diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss index 68bf2fa0f82..8d8da10268a 100644 --- a/app/assets/stylesheets/page_bundles/project.scss +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -47,12 +47,6 @@ } .project-repo-buttons { - .btn { - svg { - fill: var(--gray-500, $gray-500); - } - } - .download-button { @include media-breakpoint-down(md) { margin-left: 0; diff --git a/app/assets/stylesheets/page_bundles/prometheus.scss b/app/assets/stylesheets/page_bundles/prometheus.scss deleted file mode 100644 index 702c0e4dd72..00000000000 --- a/app/assets/stylesheets/page_bundles/prometheus.scss +++ /dev/null @@ -1,113 +0,0 @@ -@import 'mixins_and_variables_and_functions'; - -.date-time-picker { - .date-time-picker-menu { - width: 400px; - } -} - -.prometheus-graphs { - .dropdown-buttons { - > div { - margin-left: auto; - } - } - - .col-form-label { - line-height: 1; - padding-top: 0; - } - - .form-group { - margin-bottom: map-get($spacing-scale, 3); - } - - .variables-section { - input { - @include media-breakpoint-up(sm) { - width: 160px; - } - } - } - - .links-section { - .gl-hover-text-blue-600-children:hover { - * { - @include gl-text-blue-600; - } - } - } -} - -.draggable { - &.draggable-enabled { - .draggable-panel { - border: $gray-100 1px solid; - border-radius: $border-radius-default; - margin: -1px; - cursor: grab; - } - - .prometheus-graph { - // Make dragging easier by disabling use of chart - pointer-events: none; - } - } - - &.sortable-chosen .draggable-panel { - background: $white; - box-shadow: 0 0 4px $gray-300; - } - - .draggable-remove { - z-index: 1; - - .draggable-remove-link { - cursor: pointer; - color: $gray-400; - background-color: $white; - } - } -} - -.prometheus-graphs-header { - .monitor-environment-dropdown-menu, - .monitor-dashboard-dropdown-menu { - &.show { - display: flex; - flex-direction: column; - overflow: hidden; - } - - .no-matches-message { - padding: $gl-padding-8 $gl-padding-12; - } - } - - .show-last-dropdown { - // same as in .dropdown-menu-toggle - // see app/assets/stylesheets/framework/dropdowns.scss - width: 160px; - } -} - -.prometheus-panel { - margin-top: 20px; -} - -.prometheus-graph-group { - display: flex; - flex-wrap: wrap; -} - -.prometheus-graph { - padding: $gl-padding-8; -} - -.prometheus-panel-builder { - .preview-date-time-picker { - // same as in .dropdown-menu-toggle - // see app/assets/stylesheets/framework/dropdowns.scss - width: 160px; - } -} diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss index d1d14cbcddd..a3a62b44e98 100644 --- a/app/assets/stylesheets/page_bundles/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -69,11 +69,9 @@ $language-filter-max-height: 20rem; } .label-with-color-checkbox { - max-height: $gl-spacing-scale-5; - .custom-control-label { + display: flex; margin-bottom: 0; - max-height: $gl-spacing-scale-5; .label-title { margin-left: -$gl-spacing-scale-2; diff --git a/app/assets/stylesheets/page_bundles/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss index 9a0d7880734..b906a932e70 100644 --- a/app/assets/stylesheets/page_bundles/settings.scss +++ b/app/assets/stylesheets/page_bundles/settings.scss @@ -65,6 +65,8 @@ } .settings-content { + // #416312: Fix white space at bottom of page + position: relative; max-height: 1px; overflow-y: hidden; padding-right: 110px; diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss index a13b8704095..e0ee157187b 100644 --- a/app/assets/stylesheets/page_bundles/tree.scss +++ b/app/assets/stylesheets/page_bundles/tree.scss @@ -205,17 +205,6 @@ margin-top: $gl-padding; } - -.web-ide-promo-popover { - box-shadow: 0 0 18px -1.9px rgba(119, 89, 194, 0.16), - 0 0 12.9px -1.7px rgba(119, 89, 194, 0.16), 0 0 9.2px -1.4px rgba(119, 89, 194, 0.16), - 0 0 6.4px -1.1px rgba(119, 89, 194, 0.16), 0 0 4.5px -0.8px rgba(119, 89, 194, 0.16), - 0 0 3px -0.6px rgba(119, 89, 194, 0.16), 0 0 1.8px -0.3px rgba(119, 89, 194, 0.16), - 0 0 0.6px rgba(119, 89, 194, 0.16); - z-index: 999; -} - -.web-ide-promo-popover-illustration { - width: calc(100% + 24px); - margin: -28px -12px 0; +.edit-dropdown-group-width { + width: 320px; } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index ecbb872e1df..013aa064c4e 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -1,5 +1,8 @@ @import 'mixins_and_variables_and_functions'; +$work-item-overview-right-sidebar-width: 340px; +$work-item-sticky-header-height: 52px; + .gl-token-selector-token-container { display: flex; align-items: center; @@ -104,3 +107,54 @@ @include gl-font-weight-normal; } } + +.work-item-overview { + @include media-breakpoint-up(md) { + display: grid; + grid-template-columns: 1fr $work-item-overview-right-sidebar-width; + gap: 2rem; + } +} + +.work-item-overview-right-sidebar { + @include media-breakpoint-up(md) { + &.is-modal { + .work-item-attributes-wrapper { + top: 0; + } + } + } +} + +.work-item-attributes-wrapper { + .work-item-overview & { + @include media-breakpoint-up(md) { + top: calc(#{$calc-application-header-height} + #{$work-item-sticky-header-height}); + height: calc(#{$calc-application-viewport-height} - #{$work-item-sticky-header-height}); + margin-bottom: calc(#{$content-wrapper-padding} * -1); + position: sticky; + overflow-y: auto; + overflow-x: hidden; + } + } +} + +.work-item-field-label { + .work-item-overview & { + max-width: 30%; + flex: none; + } +} + +.work-item-field-value { + .work-item-overview & { + max-width: 65%; + } +} + +.token-selector-menu-class { + .work-item-overview & { + width: 100%; + min-width: 100%; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b25a5b1c493..8b093e7bb7b 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -307,14 +307,6 @@ } } -.gpg-popover-user-link { - display: flex; - align-items: center; - margin-bottom: $gl-padding / 2; - text-decoration: none; - color: $gl-text-color; -} - .add-review-item { .gl-tab-nav-item { height: 100%; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 322363d7f4b..0c9d151e3cd 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -48,7 +48,7 @@ .common-note-form { .md-area { - border: 1px solid $border-color; + border: 1px solid $gray-400; border-radius: $border-radius-large; transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; @@ -65,19 +65,41 @@ } } - // Disable inner focus - textarea:focus { - @include gl-shadow-none; + &:hover, + &:focus-within { + @include gl-shadow-md; } - } - .comment-warning-wrapper:focus-within { - @include gl-focus; - } -} + &:hover { + border: 1px solid $gray-500; + } -.md-area:focus-within { - @include gl-focus; + &:focus-within { + border: 1px solid $gray-900; + } + + // Add focus + .zen-backdrop:not(.fullscreen) textarea { + width: calc(100% - 4px); + margin: 2px; + padding-left: calc(#{$gl-spacing-scale-5} - 2px); + + .gl-dark & { + width: calc(100% - 6px); + margin: 2px 3px; + padding-left: calc(#{$gl-spacing-scale-5} - 3px); + } + + &:focus { + @include gl-focus; + } + } + + .note-textarea-rounded-bottom { + border-bottom-left-radius: calc(#{$border-radius-large} - 1px); + border-bottom-right-radius: calc(#{$border-radius-large} - 1px); + } + } } .md-header { @@ -217,6 +239,7 @@ table { .md-area { background-color: $white; + @include gl-rounded-base; } } @@ -245,24 +268,21 @@ table { .diff-file, .commit-diff { .discussion-reply-holder { - background-color: $gray-light; border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base; - padding: $gl-padding; + padding: 0 $gl-padding $gl-padding-12 $gl-padding; border-top: 1px solid $gray-50; + .new-note { - background-color: $gray-light; border-top: 1px solid $gray-50; } &.is-replying { - padding-bottom: $gl-padding; - background-color: $white; + padding-top: $gl-padding-12; } &.internal-note, &.internal-note.is-replying { - background-color: $orange-50; + padding-top: $gl-padding-12 !important; } .user-avatar-link { @@ -273,6 +293,11 @@ table { } } +.diff-td > .content > .discussion-reply-holder { + padding-top: $gl-padding-12; + @include gl-bg-gray-10; +} + .discussion-with-resolve-btn { @include media-breakpoint-up(sm) { display: flex; @@ -307,13 +332,19 @@ table { resize: none; padding: $gl-padding-8 $gl-padding-12; line-height: 1; - border: 1px solid $border-color; + border: 1px solid $gray-200; background-color: $white; overflow: hidden; + transition: border-color ease-in-out 0.15s, + box-shadow ease-in-out 0.15s; @include media-breakpoint-down(xs) { margin-bottom: $gl-padding-8; } + + &:hover { + border: 1px solid $gray-500; + } } } @@ -348,10 +379,6 @@ table { .toolbar-text { font-size: 14px; line-height: $gl-spacing-scale-7; - - @include media-breakpoint-up(md) { - float: left; - } } .note-form-actions { @@ -438,9 +465,4 @@ table { .comment-warning-wrapper { transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; - - .md-area { - border: 0; - box-shadow: none; - } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index c5b644bd72f..005fbc8b058 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -100,10 +100,13 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; margin-left: 2.5rem; border: 1px solid $border-color; border-radius: $gl-border-radius-base; - background-color: $white; padding: $gl-padding-4 $gl-padding-8; } + &:not(.target) .timeline-content:not(.flash-container) { + background-color: $white; + } + &.draft-note .timeline-content:not(.flash-container) { border: 0; } @@ -139,11 +142,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; border-top: 1px solid $border-color; border-top-left-radius: $gl-border-radius-base; border-top-right-radius: $gl-border-radius-base; - background-color: $white; padding: $gl-padding-4 $gl-padding-8; } } + &:not(.target) .timeline-content:not(.flash-container) { + background-color: $white; + } + &.draft-note .timeline-content:not(.flash-container) { margin-left: 0; border-top-left-radius: 0; @@ -239,15 +245,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } - .discussion-toggle-replies { - border-top: 0; - border-radius: 4px 4px 0 0; - - &.collapsed { - border-radius: 4px; - } - } - .note-created-ago, .note-updated-at { white-space: normal; @@ -1090,6 +1087,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; margin-left: 0; border-left: 0; border-right: 0; + border-radius: 0 !important; } .discussion-reply-holder { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index ff1987f35b3..8cf0bebfc4e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -518,64 +518,24 @@ } } -.project-refs-form .dropdown-menu { - width: 300px; - @include media-breakpoint-up(sm) { - width: 500px; - } - - a { - white-space: normal; - } -} - -.compare-form-group { - .dropdown-menu, - .inline-input-group { - width: 100%; - - @include media-breakpoint-up(sm) { - width: 300px; +.compare-revision-cards { + @media (max-width: $breakpoint-lg) { + .swap-button { + display: none; } } - + .compare-ellipsis { - width: 100%; - vertical-align: middle; - text-align: center; - margin-top: -20px; - - @include media-breakpoint-up(sm) { - margin: 0 $gl-padding-8; - width: auto; + @media (max-width: $breakpoint-lg) { + .swap-button-mobile { + display: flex; } } - // Remove once gitlab/ui solution is implemented: - // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1157 - // https://gitlab.com/gitlab-org/gitlab/-/issues/300405 - .gl-search-box-by-type-input { - width: 100%; - } - - // Remove once gitlab/ui solution is implemented - // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1158 - // https://gitlab.com/gitlab-org/gitlab/-/issues/300405 - .gl-dropdown-button-text { - @include str-truncated; - } -} - -.compare-revision-cards { @media (min-width: $breakpoint-lg) { .gl-card { width: calc(50% - 15px); } - - .compare-ellipsis { - width: 30px; - } } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 3b5e234c6b8..728eb1fe441 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -75,3 +75,70 @@ } } } + +.settings-section { + @include gl-pt-6; + + &::after { + content: ''; + display: block; + @include gl-pb-5; + } +} + +.settings-section, +.settings-section-no-bottom + .settings-section { + @include gl-pt-0; +} + +.settings-section ~ .settings-section { + @include gl-pt-6; +} + +.settings-section:not(.settings-section-no-bottom) + .settings-section { + @include gl-border-t; +} + +.settings-section-no-bottom::after { + @include gl-pb-0; + + @include media-breakpoint-up(sm) { + @include gl-pb-5; + } +} + +$sticky-header-z-index: 98; + +.settings-sticky-header, +.settings-sticky-footer { + position: sticky; + z-index: $sticky-header-z-index; + background: $body-bg; +} + +.settings-sticky-header { + top: $calc-application-header-height; + + &::before { + content: ''; + display: block; + height: $gl-padding-8; + position: sticky; + top: calc(#{$calc-application-header-height} + 40px); + box-shadow: 0 1px 1px $gray-200; + } +} + +.settings-sticky-header-inner { + position: sticky; + padding: $gl-padding $gl-padding $gl-padding-12; + margin: #{-$gl-padding} #{-$gl-padding} 0; + background: $body-bg; +} + +.settings-sticky-footer { + bottom: 0; + padding-top: $gl-padding-8; + padding-bottom: $gl-padding-8; + box-shadow: 0 #{-$gl-padding-4} $gl-padding-12 $gl-padding-4 $body-bg; +} diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 7be15c2d8f9..60cbcffd506 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -20,9 +20,10 @@ header { } body { margin: 0; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -50,9 +51,9 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", - "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", - "lucida console", monospace; + font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono", + "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", + "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; } img { @@ -415,9 +416,10 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #333238; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 65500800ce3..04c44dd9603 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -20,9 +20,10 @@ header { } body { margin: 0; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -50,9 +51,9 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono", - "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", - "lucida console", monospace; + font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono", + "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", + "Courier New", "andale mono", "lucida console", monospace; font-size: 1em; } img { @@ -415,9 +416,10 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 40e1e4b1996..32da8e1bb6b 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -19,9 +19,10 @@ header { } body { margin: 0; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -382,9 +383,10 @@ input.btn-block[type="submit"] { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, + "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -622,10 +624,6 @@ body.navless { margin-top: 20px; } } -.navless-container { - margin-top: var(--header-height, 48px); - padding-top: 32px; -} .btn { border-radius: 4px; font-size: 0.875rem; @@ -685,12 +683,6 @@ hr { margin: 1.5rem 0; border-top: 1px solid #ececef; } -.footer-links { - margin-bottom: 20px; -} -.footer-links a { - margin-right: 15px; -} .flash-container { margin: 0; margin-bottom: 16px; @@ -777,9 +769,15 @@ svg { .gl-align-items-center { align-items: center; } +.gl-flex-wrap { + flex-wrap: wrap; +} .gl-justify-content-space-between { justify-content: space-between; } +.gl-align-self-end { + align-self: flex-end; +} .gl-w-10 { width: 3.5rem; } @@ -794,6 +792,9 @@ svg { width: 100%; } } +.gl-h-full { + height: 100%; +} .gl-p-5 { padding: 1rem; } @@ -805,6 +806,9 @@ svg { padding-top: 1rem; padding-bottom: 1rem; } +.gl-m-0 { + margin: 0; +} .gl-mt-3 { margin-top: 0.5rem; } @@ -823,6 +827,9 @@ svg { .gl-ml-auto { margin-left: auto; } +.gl-gap-5 { + gap: 1rem; +} @media (min-width: 576px) { .gl-sm-mt-0 { margin-top: 0; diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index e004ca4bb4a..030e41046d3 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -296,8 +296,7 @@ body.gl-dark { } .timeline-entry.internal-note:not(.note-form) .timeline-content, -.timeline-entry.draft-note:not(.note-form) .timeline-content, -.discussion-reply-holder.internal-note { +.timeline-entry.draft-note:not(.note-form) .timeline-content { // soften on darkmode background-color: mix($gray-50, $orange-50, 75%) !important; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 08c4efce542..db9802eeefa 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -42,7 +42,7 @@ // Override Bootstrap class with offset for system-header and // performance bar when present .fixed-top { - top: $calc-application-bars-height; + top: $calc-system-headers-height; } .gl-children-ml-sm-3 > * { @@ -128,24 +128,6 @@ } } -.gl-md-w-15 { - @include gl-media-breakpoint-up(md) { - width: $gl-spacing-scale-15; - } -} - -.gl-md-w-20 { - @include gl-media-breakpoint-up(md) { - width: $gl-spacing-scale-20; - } -} - -.gl-md-w-30 { - @include gl-media-breakpoint-up(md) { - width: $gl-spacing-scale-30; - } -} - .gl-fill-orange-500 { fill: $orange-500; } diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss index b92331facee..cf7dc79c5f5 100644 --- a/app/assets/stylesheets/vendors/atwho.scss +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -2,6 +2,11 @@ overflow-y: auto; overflow-x: hidden; max-width: calc(100% - 6px); + @include gl-border-b-1; + @include gl-border-b-solid; + @include gl-border-b-gray-100; + @include gl-rounded-lg; + @include gl-shadow-md; .name, small.aliases, @@ -44,11 +49,15 @@ // TODO: fallback to global style .atwho-view-ul { - padding: 8px 1px; + @include gl-p-2; + max-height: $gl-max-dropdown-max-height; li { - padding: 8px 16px; + @include gl-px-3; + padding-top: $gl-padding-6; + padding-bottom: $gl-padding-6; border: 0; + @include gl-rounded-base; &.cur { background-color: $gray-darker; @@ -67,15 +76,25 @@ display: inline-flex; justify-content: center; align-items: center; + } - .center { - line-height: 14px; - } + .center { + line-height: 14px; } strong { color: $gl-text-color; } + + gl-emoji { + @include gl-mr-2; + } + + .dropdown-label-box { + position: relative; + top: -1px; + @include gl-mr-2; + } } } } diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml index 4fa2ed09cd3..c2eeae2d8c9 100644 --- a/app/components/pajamas/banner_component.html.haml +++ b/app/components/pajamas/banner_component.html.haml @@ -14,7 +14,7 @@ - if primary_action? = primary_action - else - = link_to @button_text, @button_link, { **@button_options, class: 'btn btn-md btn-confirm gl-button js-close-callout' } + = link_button_to @button_text, @button_link, **@button_options, class: 'js-close-callout', variant: :confirm - actions.each do |action| = action diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb index 6082762f22c..1a03f3fdd58 100644 --- a/app/components/pajamas/banner_component.rb +++ b/app/components/pajamas/banner_component.rb @@ -49,7 +49,7 @@ module Pajamas end end - delegate :sprite_icon, to: :helpers + delegate :sprite_icon, :link_button_to, to: :helpers renders_one :title renders_one :illustration diff --git a/app/components/pajamas/empty_state_component.html.haml b/app/components/pajamas/empty_state_component.html.haml new file mode 100644 index 00000000000..ecd3498c5cd --- /dev/null +++ b/app/components/pajamas/empty_state_component.html.haml @@ -0,0 +1,29 @@ +- empty_state_class = @compact ? 'gl-flex-direction-row gl-align-items-center' : 'gl-text-center gl-flex-direction-column' + +%section.gl-display-flex.empty-state{ **@empty_state_options, class: empty_state_class } + - if @svg_path.present? + - image_class = @compact ? 'gl-display-none gl-sm-display-block gl-px-4' : 'gl-max-w-full' + %div{ class: image_class } + = image_tag @svg_path, alt: "", class: 'gl-dark-invert-keep-hue' + + - content_wrapper_class = @compact ? 'gl-flex-grow-1 gl-flex-basis-0 gl-px-4' : 'gl-max-w-full gl-m-auto pl-p-5' + %div{ class: content_wrapper_class } + - title_class = @compact ? 'gl-mt-0' : 'gl-my-3' + %h1.gl-font-size-h-display.gl-line-height-36{ class: title_class } + = @title + + - if description? + %p.gl-mt-3{ 'data-testid': 'empty-state-description' } + = description + + - if @primary_button_text.present? || @secondary_button_text.present? + - button_wrapper_class = @compact.present? ? '' : 'gl-justify-content-center' + .gl-display-flex.gl-flex-wrap{ class: button_wrapper_class } + + - if @primary_button_text.present? + = render Pajamas::ButtonComponent.new(variant: :confirm, href: @primary_button_link, button_options: { class: 'gl-ml-0!' }) do + = @primary_button_text + + - if @secondary_button_text.present? + = render Pajamas::ButtonComponent.new(variant: :default, href: @secondary_button_link, button_options: { class: ('gl-ml-0!' unless @primary_button_text.present?) }) do + = @secondary_button_text diff --git a/app/components/pajamas/empty_state_component.rb b/app/components/pajamas/empty_state_component.rb new file mode 100644 index 00000000000..d0c0da12d3b --- /dev/null +++ b/app/components/pajamas/empty_state_component.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Pajamas + class EmptyStateComponent < Pajamas::Component + # @param [Boolean] compact + # @param [String] title + # @param [String] svg_path + # @param [String] primary_button_text + # @param [String] primary_button_link + # @param [String] secondary_button_text + # @param [String] secondary_button_link + # @param [Hash] empty_state_options + def initialize( + compact: false, + title: nil, + svg_path: nil, + primary_button_text: nil, + primary_button_link: nil, + secondary_button_text: nil, + secondary_button_link: nil, + empty_state_options: {} + ) + @compact = compact + @title = title + @svg_path = svg_path.to_s + @primary_button_text = primary_button_text + @primary_button_link = primary_button_link + @secondary_button_text = secondary_button_text + @secondary_button_link = secondary_button_link + @empty_state_options = empty_state_options + end + + renders_one :description + end +end diff --git a/app/controllers/admin/application_settings/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb index 719e8e4a913..1a1e85d48da 100644 --- a/app/controllers/admin/application_settings/appearances_controller.rb +++ b/app/controllers/admin/application_settings/appearances_controller.rb @@ -69,7 +69,7 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont @appearance = Appearance.current || Appearance.new end - # Only allow a trusted parameter "white list" through. + # Only allow a trusted parameter "allow list" through. def appearance_params params.require(:appearance).permit(allowed_appearance_params) end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index dff1c04311d..f0b6d86d48d 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -30,7 +30,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController feature_category :continuous_integration, [:ci_cd, :reset_registration_token] urgency :low, [:ci_cd, :reset_registration_token] feature_category :service_ping, [:usage_data, :service_usage_data] - feature_category :integrations, [:integrations] + feature_category :integrations, [:integrations, :slack_app_manifest_share, :slack_app_manifest_download] feature_category :pages, [:lets_encrypt_terms_of_service] feature_category :error_tracking, [:reset_error_tracking_access_token] @@ -114,6 +114,14 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url end + def slack_app_manifest_share + redirect_to Slack::Manifest.share_url + end + + def slack_app_manifest_download + send_data Slack::Manifest.to_json, type: :json, disposition: 'attachment', filename: 'slack_manifest.json' + end + private def set_application_setting diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index f63616a2bea..b368ba6e495 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -3,32 +3,23 @@ class Admin::RunnersController < Admin::ApplicationController include RunnerSetupScripts - before_action :runner, except: [:index, :new, :tag_list, :runner_setup_scripts] - - before_action only: [:index] do - push_frontend_feature_flag(:create_runner_workflow_for_admin, current_user) - end + before_action :runner, only: [:show, :edit, :register, :update] feature_category :runner urgency :low - def index - end + def index; end - def show - end + def show; end def edit assign_projects end - def new - render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) - end + def new; end def register - render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) && - runner.registration_available? + render_404 unless runner.registration_available? end def update diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 3c96e49499f..b75ca2649c3 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -159,7 +159,7 @@ class Admin::UsersController < Admin::ApplicationController end def unlock - if update_user(&:unlock_access!) + if unlock_user redirect_back_or_admin_user(notice: _("Successfully unlocked")) else redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked")) @@ -401,6 +401,11 @@ class Admin::UsersController < Admin::ApplicationController _("You cannot impersonate a user who cannot log in") end end + + # method overriden in EE + def unlock_user + update_user(&:unlock_access!) + end end Admin::UsersController.prepend_mod_with('Admin::UsersController') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 08e4f4956df..8588273a41f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -181,7 +181,7 @@ class ApplicationController < ActionController::Base payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] - payload[:response_bytes] = response.body_parts.sum(&:bytesize) if Feature.enabled?(:log_response_length) + payload[:response_bytes] = response.body_parts.sum(&:bytesize) store_cloudflare_headers!(payload, request) end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 2f6331a6822..b012a4e003e 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -2,7 +2,6 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions - include MetricsDashboard before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache] before_action :user_cluster, only: [:connect] diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 19e458307a1..53dd06ce638 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -43,6 +43,8 @@ module Integrations :external_wiki_url, :google_iap_service_account_json, :google_iap_audience_client_id, + :group_confidential_mention_events, + :group_mention_events, :incident_events, :inherit_from_id, # We're using `issues_events` and `merge_requests_events` diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb index b803be67d2e..c3aa487c805 100644 --- a/app/controllers/concerns/internal_redirect.rb +++ b/app/controllers/concerns/internal_redirect.rb @@ -6,7 +6,7 @@ module InternalRedirect def safe_redirect_path(path) return unless path # Verify that the string starts with a `/` and a known route character. - return unless path =~ %r{\A/[-\w].*\z} + return unless %r{\A/[-\w].*\z}.match?(path) uri = URI(path) # Ignore anything path of the redirect except for the path, querystring and, diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 0ad8a08960a..a326fa308ad 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -12,6 +12,7 @@ module IssuableActions before_action :authorize_destroy_issuable!, only: :destroy before_action :check_destroy_confirmation!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update + before_action :set_application_context!, only: :show end def show @@ -226,6 +227,10 @@ module IssuableActions render_404 unless can?(current_user, :"update_#{resource_name}", issuable) end + def set_application_context! + # no-op. The logic is defined in EE module. + end + def bulk_update_params clean_bulk_update_params( params.require(:update).permit(bulk_update_permitted_keys) diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 31675a58163..0c15c4d0d3f 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -16,7 +16,7 @@ module MembershipActions member_data = if member.expires? { expires_soon: member.expires_soon?, - expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium) + expires_at_formatted: member.expires_at.to_time.in_time_zone.to_fs(:medium) } else {} diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb deleted file mode 100644 index 7a84c597424..00000000000 --- a/app/controllers/concerns/metrics_dashboard.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -# Provides an action which fetches a metrics dashboard according -# to the parameters specified by the controller. -module MetricsDashboard - include RenderServiceResults - include ChecksCollaboration - include EnvironmentsHelper - - extend ActiveSupport::Concern - - def metrics_dashboard - return not_found if Feature.enabled?(:remove_monitor_metrics) - - result = dashboard_finder.find( - project_for_dashboard, - current_user, - decoded_params - ) - - if result - result[:all_dashboards] = all_dashboards if include_all_dashboards? - result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) - end - - respond_to do |format| - if result.nil? - format.json { continue_polling_response } - elsif result[:status] == :success - format.json { render dashboard_success_response(result) } - else - format.json { render dashboard_error_response(result) } - end - end - end - - private - - def all_dashboards - dashboard_finder - .find_all_paths(project_for_dashboard) - .map { |dashboard| amend_dashboard(dashboard) } - end - - def amend_dashboard(dashboard) - project_dashboard = project_for_dashboard && !dashboard[:out_of_the_box_dashboard] - - dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false - dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil - dashboard[:starred] = starred_dashboards.include?(dashboard[:path]) - dashboard[:user_starred_path] = project_for_dashboard ? user_starred_path(project_for_dashboard, dashboard[:path]) : nil - - dashboard - end - - def user_starred_path(project, path) - expose_path(api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: path })) - end - - def dashboard_project_blob_path(dashboard) - project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, ""))) - end - - def can_edit?(dashboard) - can_collaborate_with_project?(project_for_dashboard, ref: project_for_dashboard.default_branch) - end - - # Override in class to provide arguments to the finder. - def metrics_dashboard_params - {} - end - - # Override in class if response requires complete list of - # dashboards in addition to requested dashboard body. - def include_all_dashboards? - false - end - - def dashboard_finder - ::Gitlab::Metrics::Dashboard::Finder - end - - def starred_dashboards - @starred_dashboards ||= - if project_for_dashboard.present? - ::Metrics::UsersStarredDashboardsFinder - .new(user: current_user, project: project_for_dashboard) - .execute - .map(&:dashboard_path) - .to_set - else - Set.new - end - end - - # Project is not defined for group and admin level clusters. - def project_for_dashboard - defined?(project) ? project : nil - end - - def environment_for_dashboard - defined?(environment) ? environment : nil - end - - def dashboard_success_response(result) - { - status: :ok, - json: result.slice(:all_dashboards, :dashboard, :status, :metrics_data) - } - end - - def dashboard_error_response(result) - { - status: result[:http_status] || :bad_request, - json: result.slice(:all_dashboards, :message, :status) - } - end - - def decoded_params - params = metrics_dashboard_params - - params[:dashboard_path] = CGI.unescape(params[:dashboard_path]) if params[:dashboard_path] - - params - end -end diff --git a/app/controllers/concerns/observability/content_security_policy.rb b/app/controllers/concerns/observability/content_security_policy.rb index 1e25dc492a0..e51d986d36c 100644 --- a/app/controllers/concerns/observability/content_security_policy.rb +++ b/app/controllers/concerns/observability/content_security_policy.rb @@ -5,26 +5,23 @@ module Observability extend ActiveSupport::Concern included do - content_security_policy_with_context do |p| - current_group = if defined?(group) - group - else - defined?(project) ? project&.group : nil - end - - next if p.directives.blank? || !Feature.enabled?(:observability_group_tab, current_group) + content_security_policy do |p| + next if p.directives.blank? default_frame_src = p.directives['frame-src'] || p.directives['default-src'] - - # When ObservabilityUI is not authenticated, it needs to be able - # to redirect to the GL sign-in page, hence '/users/sign_in' and '/oauth/authorize' + # When Gitlab Observability Backend is not authenticated, it needs to be able + # to redirect to the GitLab sign-in page, hence '/users/sign_in' and '/oauth/authorize' frame_src_values = Array.wrap(default_frame_src) | [ Gitlab::Observability.observability_url, Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/users/sign_in'), Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/oauth/authorize') ] - p.frame_src(*frame_src_values) + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = + Array.wrap(default_connect_src) | [Gitlab::Observability.observability_url] + p.connect_src(*connect_src_values) end end end diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb new file mode 100644 index 00000000000..986f3f17847 --- /dev/null +++ b/app/controllers/concerns/onboarding/status.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Onboarding + class Status + def initialize(user) + @user = user + end + + def continue_full_onboarding? + false + end + + def single_invite? + # If there are more than one member it will mean we have been invited to multiple projects/groups and + # are not able to distinguish which one we should putting the user in after registration + members.count == 1 + end + + def last_invited_member + members.last + end + + def last_invited_member_source + last_invited_member&.source + end + + def invite_with_tasks_to_be_done? + return false if members.empty? + + MemberTask.for_members(members).exists? + end + + private + + attr_reader :user + + def members + @members ||= user.members + end + end +end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index a7655efe7a9..7f1b961e92a 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -48,9 +48,7 @@ module PreviewMarkdown end.merge( requested_path: params[:path], ref: params[:ref], - # Disable comments in markdown for IE browsers because comments in IE - # could allow script execution. - allow_comments: !browser.ie? + allow_comments: false ) end diff --git a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb index 92574dfade9..97c23a2cf3c 100644 --- a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb +++ b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module RedirectsForMissingPathOnTree - def redirect_to_tree_root_for_missing_path(project, ref, path) - redirect_to project_tree_path(project, ref), notice: missing_path_on_ref(path, ref) + def redirect_to_tree_root_for_missing_path(project, ref, path, ref_type: nil) + redirect_to project_tree_path(project, ref, ref_type: ref_type), notice: missing_path_on_ref(path, ref) end private diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_allowlisted_monitoring_client.rb index ef3d281589a..ad6d4dc548c 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_allowlisted_monitoring_client.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true -module RequiresWhitelistedMonitoringClient +module RequiresAllowlistedMonitoringClient extend ActiveSupport::Concern included do - before_action :validate_ip_whitelisted_or_valid_token! + before_action :validate_ip_allowlisted_or_valid_token! end private - def validate_ip_whitelisted_or_valid_token! - render_404 unless client_ip_whitelisted? || valid_token? + def validate_ip_allowlisted_or_valid_token! + render_404 unless client_ip_allowlisted? || valid_token? end - def client_ip_whitelisted? + def client_ip_allowlisted? # Always allow developers to access http://localhost:3000/-/metrics for # debugging purposes return true if Rails.env.development? && request.local? - ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) } + ip_allowlist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) } end - def ip_whitelist - @ip_whitelist ||= Settings.monitoring.ip_whitelist.map { |ip| IPAddr.new(ip) } + def ip_allowlist + @ip_allowlist ||= Settings.monitoring.ip_whitelist.map { |ip| IPAddr.new(ip) } end def valid_token? diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 222fcc17222..29b61264322 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -110,7 +110,7 @@ module UploadsActions if uploader_mounted? model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend else - build_uploader_from_upload || build_uploader_from_params + build_uploader_from_upload end end strong_memoize_attr :uploader @@ -125,21 +125,6 @@ module UploadsActions end # rubocop: enable CodeReuse/ActiveRecord - def build_uploader_from_params - return unless uploader = build_uploader - - uploader.retrieve_from_store!(params[:filename]) - - Gitlab::AppJsonLogger.info( - message: 'Deprecated usage of build_uploader_from_params', - uploader_class: uploader.class.name, - path: params[:filename], - exists: uploader.exists? - ) - - uploader - end - def build_uploader return unless params[:secret] && params[:filename] diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index 45869c05f41..13378800ea9 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -25,6 +25,7 @@ module VerifiesWithEmail if user.valid_password?(user_params[:password]) # The user has logged in successfully. + if user.unlock_token # Prompt for the token if it already has been set prompt_for_email_verification(user) @@ -32,7 +33,8 @@ module VerifiesWithEmail # require email verification if: # - their account has been locked because of too many failed login attempts, or # - they have logged in before, but never from the current ip address - send_verification_instructions(user) + reason = 'sign in from untrusted IP address' unless user.access_locked? + send_verification_instructions(user, reason: reason) prompt_for_email_verification(user) end end @@ -75,13 +77,13 @@ module VerifiesWithEmail super end - def send_verification_instructions(user) + def send_verification_instructions(user, reason: nil) return if send_rate_limited?(user) service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token, user: user) raw_token, encrypted_token = service.execute user.unlock_token = encrypted_token - user.lock_access!({ send_instructions: false }) + user.lock_access!({ send_instructions: false, reason: reason }) send_verification_instructions_email(user, raw_token) end diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 577bd04d656..b3a1b510db9 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -10,6 +10,7 @@ class Explore::ProjectsController < Explore::ApplicationController MIN_SEARCH_LENGTH = 3 PAGE_LIMIT = 50 + RSS_ENTRIES_LIMIT = 20 before_action :set_non_archived_param before_action :set_sorting @@ -83,6 +84,14 @@ class Explore::ProjectsController < Explore::ApplicationController params[:topic] = @topic.name @projects = load_projects + + respond_to do |format| + format.html + format.atom do + @projects = @projects.projects_order_id_desc.limit(RSS_ENTRIES_LIMIT) + render layout: 'xml' + end + end end private diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 3d3b7f31dfd..5c0c2b4adf2 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -14,6 +14,7 @@ class GraphqlController < ApplicationController # The query string of a standard IntrospectionQuery, used to compare incoming requests for caching CACHED_INTROSPECTION_QUERY_STRING = CachedIntrospectionQuery.query_string + INTROSPECTION_QUERY_OPERATION_NAME = 'IntrospectionQuery' # If a user is using their session to access GraphQL, we need to have session # storage, since the admin-mode check is session wide. @@ -58,7 +59,7 @@ class GraphqlController < ApplicationController urgency :low, [:execute] def execute - result = if Feature.enabled?(:cache_introspection_query) && params[:operationName] == 'IntrospectionQuery' + result = if Feature.enabled?(:cache_introspection_query) && introspection_query? execute_introspection_query else multiplex? ? execute_multiplex : execute_query @@ -276,9 +277,6 @@ class GraphqlController < ApplicationController def execute_introspection_query if introspection_query_can_use_cache? - Gitlab::AppLogger.info(message: "IntrospectionQueryCache hit") - log_introspection_query_cache_details(true) - # Context for caching: https://gitlab.com/gitlab-org/gitlab/-/issues/409448 Rails.cache.fetch( introspection_query_cache_key, @@ -286,17 +284,12 @@ class GraphqlController < ApplicationController execute_query.to_json end else - Gitlab::AppLogger.info(message: "IntrospectionQueryCache miss") - log_introspection_query_cache_details(false) - execute_query end end def introspection_query_can_use_cache? - graphql_query = GraphQL::Query.new(GitlabSchema, query: query, variables: build_variables(params[:variables])) - - CACHED_INTROSPECTION_QUERY_STRING == graphql_query.query_string.squish + CACHED_INTROSPECTION_QUERY_STRING == graphql_query_object.query_string.squish end def introspection_query_cache_key @@ -306,13 +299,17 @@ class GraphqlController < ApplicationController ['introspection-query-cache', Gitlab.revision, context[:remove_deprecated]] end - def log_introspection_query_cache_details(can_use_introspection_query_cache) - Gitlab::AppLogger.info( - message: "IntrospectionQueryCache", - can_use_introspection_query_cache: can_use_introspection_query_cache.to_s, - query: query, - variables: build_variables(params[:variables]).to_s, - introspection_query_cache_key: introspection_query_cache_key.to_s - ) + def introspection_query? + if params.key?(:operationName) + params[:operationName] == INTROSPECTION_QUERY_OPERATION_NAME + else + # If we don't provide operationName param, we infer it from the query + graphql_query_object.selected_operation_name == INTROSPECTION_QUERY_OPERATION_NAME + end + end + + def graphql_query_object + @graphql_query_object ||= GraphQL::Query.new(GitlabSchema, query: query, + variables: build_variables(params[:variables])) end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 5f6b55ea928..cbed75019f2 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -9,6 +9,10 @@ class Groups::MilestonesController < Groups::ApplicationController feature_category :team_planning urgency :low + before_action do + push_frontend_feature_flag(:content_editor_on_issues, group) + end + def index respond_to do |format| format.html do diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index 4b52617d287..2dd0e36b65f 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -6,10 +6,6 @@ class Groups::RunnersController < Groups::ApplicationController before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume] before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register] - before_action only: [:index] do - push_frontend_feature_flag(:create_runner_workflow_for_namespace, group) - end - feature_category :runner urgency :low @@ -20,11 +16,9 @@ class Groups::RunnersController < Groups::ApplicationController Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group) end - def show - end + def show; end - def edit - end + def edit; end def update if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? @@ -34,12 +28,10 @@ class Groups::RunnersController < Groups::ApplicationController end end - def new - render_404 unless create_runner_workflow_for_namespace_enabled? - end + def new; end def register - render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available? + render_404 unless runner.registration_available? end private @@ -67,10 +59,6 @@ class Groups::RunnersController < Groups::ApplicationController render_404 end - - def create_runner_workflow_for_namespace_enabled? - Feature.enabled?(:create_runner_workflow_for_namespace, group) - end end Groups::RunnersController.prepend_mod diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index 4bbaf92b126..169caabf9d8 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -14,6 +14,7 @@ module Groups feature_category :continuous_integration before_action do + push_frontend_feature_flag(:ci_group_env_scope_graphql, group) push_frontend_feature_flag(:ci_variables_pages, current_user) end diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb index cd1ebc39411..d29532f9d6f 100644 --- a/app/controllers/groups/uploads_controller.rb +++ b/app/controllers/groups/uploads_controller.rb @@ -9,7 +9,7 @@ class Groups::UploadsController < Groups::ApplicationController before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] - feature_category :groups_and_projects + feature_category :portfolio_management urgency :low, [:show] private diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb index a2abed7ba4e..a85629985ba 100644 --- a/app/controllers/health_check_controller.rb +++ b/app/controllers/health_check_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class HealthCheckController < HealthCheck::HealthCheckController - include RequiresWhitelistedMonitoringClient + include RequiresAllowlistedMonitoringClient end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 5fac7c0d663..1381999ab4c 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -3,7 +3,7 @@ # rubocop:disable Rails/ApplicationController class HealthController < ActionController::Base protect_from_forgery with: :exception, prepend: true - include RequiresWhitelistedMonitoringClient + include RequiresAllowlistedMonitoringClient CHECKS = [ Gitlab::HealthChecks::MasterCheck diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index bcb6aed9e38..f3a0ce64839 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -82,7 +82,7 @@ class Import::BaseController < ApplicationController # rubocop: disable CodeReuse/ActiveRecord def find_already_added_projects(import_type) - current_user.created_projects.where(import_type: import_type).with_import_state + current_user.created_projects.inc_routes.where(import_type: import_type).with_import_state end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index 40664922d3d..e17cd00d053 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -103,8 +103,8 @@ class Import::BitbucketServerController < Import::BaseController return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present? return render_validation_error('Missing repository slug') unless @repo_slug.present? - return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_PROJECT_CHARS - return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS + return render_validation_error('Invalid project key') unless VALID_BITBUCKET_PROJECT_CHARS.match?(@project_key) + return render_validation_error('Invalid repository slug') unless VALID_BITBUCKET_CHARS.match?(@repo_slug) end def render_validation_error(message) diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 3dfa8d7b11e..9f41c092fa0 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -2,7 +2,7 @@ # rubocop:disable Rails/ApplicationController class MetricsController < ActionController::Base - include RequiresWhitelistedMonitoringClient + include RequiresAllowlistedMonitoringClient protect_from_forgery with: :exception, prepend: true diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 96a3fab7e1a..a1d4df6ff48 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController + include Gitlab::GonHelper include InitializesCurrentUserMode include Gitlab::Utils::StrongMemoize + before_action :add_gon_variables before_action :verify_confirmed_email!, :verify_admin_allowed! layout 'profile' diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb index 5f5a57d176b..43cc7014f62 100644 --- a/app/controllers/organizations/application_controller.rb +++ b/app/controllers/organizations/application_controller.rb @@ -4,6 +4,8 @@ module Organizations class ApplicationController < ::ApplicationController before_action :organization + layout 'organization' + private def organization diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 0eb5c3aa6fd..4781ef995b7 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -6,6 +6,8 @@ module Organizations before_action { authorize_action!(:admin_organization) } - def directory; end + def show; end + + def groups_and_projects; end end end diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index eb64016379d..f618eafea38 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -7,6 +7,7 @@ class Profiles::AccountsController < Profiles::ApplicationController urgency :low, [:show] def show + push_frontend_feature_flag(:delay_delete_own_user) render(locals: show_view_variables) end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index b663a75f04a..1477f8e0aac 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -45,7 +45,7 @@ class Profiles::NotificationsController < Profiles::ApplicationController projects = project_notifications.map(&:source) ActiveRecord::Associations::Preloader.new( records: projects, - associations: { namespace: [:route, :owner], group: [] } + associations: { namespace: [:route, :owner], group: [], creator: [] } ).call Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index 89e8a261288..281ac14d3ce 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -72,7 +72,7 @@ module Projects end def endpoint_identifier - params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIER + params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIERS end def notification_payload diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 28393e1f365..b41e4d11d24 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -49,6 +49,7 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) + push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end @@ -160,6 +161,8 @@ class Projects::BlobController < Projects::ApplicationController end def check_for_ambiguous_ref + return if Feature.enabled?(:redirect_with_ref_type, @project) + @ref_type = ref_type if @ref_type == ExtractsRef::BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref) @@ -169,7 +172,17 @@ class Projects::BlobController < Projects::ApplicationController end def commit - @commit ||= @repository.commit(@ref) + if Feature.enabled?(:redirect_with_ref_type, @project) + response = ::ExtractsRef::RequestedRef.new(@repository, ref_type: ref_type, ref: @ref).find + @commit = response[:commit] + @ref_type = response[:ref_type] + + if response[:ambiguous] + return redirect_to(project_blob_path(@project, File.join(@ref_type ? @ref : @commit.id, @path), ref_type: @ref_type)) + end + else + @commit ||= @repository.commit(@ref) + end return render_404 unless @commit end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 10d0d03e56d..4cc1ed092d2 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -12,12 +12,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:environment_details_vue, @project) end - before_action only: [:index] do - push_frontend_feature_flag(:kas_user_access_project, @project) - end - - before_action only: [:edit, :new] do - push_frontend_feature_flag(:environment_settings_to_graphql, @project) + before_action only: [:index, :edit, :new] do + push_frontend_feature_flag(:kubernetes_namespace_for_environment) end before_action :authorize_read_environment! @@ -28,7 +24,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } - before_action :set_kas_cookie, only: [:index], if: -> { current_user && request.format.html? } + before_action :set_kas_cookie, only: [:index, :edit, :new], if: -> { current_user && request.format.html? } after_action :expire_etag_cache, only: [:cancel_auto_stop] track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal, diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index ff3dc71b6cc..de2040afff3 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -65,9 +65,8 @@ class Projects::ForksController < Projects::ApplicationController end end - # rubocop: disable CodeReuse/ActiveRecord def create - @forked_project = fork_namespace.projects.find_by(path: project.path) + @forked_project = fork_namespace.projects.find_by(path: project.path) # rubocop: disable CodeReuse/ActiveRecord @forked_project = nil unless @forked_project && @forked_project.forked_from_project == project @forked_project ||= fork_service.execute @@ -96,7 +95,9 @@ class Projects::ForksController < Projects::ApplicationController current_user: current_user ).execute + # rubocop: disable CodeReuse/ActiveRecord forks.includes(:route, :creator, :group, :topics, namespace: [:route, :owner]) + # rubocop: enable CodeReuse/ActiveRecord end def fork_service @@ -130,15 +131,21 @@ class Projects::ForksController < Projects::ApplicationController end def load_namespaces_with_associations + # rubocop: disable CodeReuse/ActiveRecord @load_namespaces_with_associations ||= fork_service.valid_fork_targets(only_groups: true).preload(:route) + # rubocop: enable CodeReuse/ActiveRecord end def memberships_hash + # rubocop: disable CodeReuse/ActiveRecord current_user.members.where(source: load_namespaces_with_associations).index_by(&:source_id) + # rubocop: enable CodeReuse/ActiveRecord end def forked_projects_by_namespace(namespaces) + # rubocop: disable CodeReuse/ActiveRecord project.forks.where(namespace: namespaces).includes(:namespace).index_by(&:namespace_id) + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb deleted file mode 100644 index 2cc6c6c35ba..00000000000 --- a/app/controllers/projects/grafana_api_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class Projects::GrafanaApiController < Projects::ApplicationController - include RenderServiceResults - include MetricsDashboard - - before_action :authorize_read_grafana!, only: :proxy - - feature_category :metrics - urgency :low - - def proxy - return not_found if Feature.enabled?(:remove_monitor_metrics) - - result = ::Grafana::ProxyService.new( - project, - params[:datasource_id], - params[:proxy_path], - prometheus_params - ).execute - - return continue_polling_response if result.nil? - return error_response(result) if result[:status] == :error - - success_response(result) - end - - private - - def metrics_dashboard_params - params.permit(:embedded, :grafana_url) - end - - def query_params - params.permit(:query, :start_time, :end_time, :step) - end - - def prometheus_params - query_params.to_h - .except(:start_time, :end_time) - .merge( - start: query_params[:start_time], - end: query_params[:end_time] - ) - end -end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 7121096bd77..6109e29b169 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -11,6 +11,7 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:moved_mr_sidebar, project) + push_frontend_feature_flag(:move_close_into_dropdown, project) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 6311907a859..6a45595580f 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -51,13 +51,14 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project) push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:issues_grid_view) + push_frontend_feature_flag(:service_desk_ticket) end before_action only: [:index, :show] do push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) end - before_action only: :index do + before_action only: [:index, :service_desk] do push_frontend_feature_flag(:or_issuable_queries, project) push_frontend_feature_flag(:frontend_caching, project&.group) end @@ -69,6 +70,7 @@ class Projects::IssuesController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_frontend_feature_flag(:moved_mr_sidebar, project) + push_frontend_feature_flag(:move_close_into_dropdown, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 79ddcbf732d..4e0b304a2ee 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -21,7 +21,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action :push_job_log_jump_to_failures, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] - + before_action :push_ai_build_failure_cause, only: [:show] layout 'project' feature_category :continuous_integration @@ -258,4 +258,8 @@ class Projects::JobsController < Projects::ApplicationController def push_job_log_jump_to_failures push_frontend_feature_flag(:job_log_jump_to_failures, @project) end + + def push_ai_build_failure_cause + push_frontend_feature_flag(:ai_build_failure_cause, @project) + end end diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index 76a233afa13..66a358963e2 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -15,7 +15,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap respond_to do |format| format.html do @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') - Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_loading_conflict_ui_action(user: current_user) + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + .track_loading_conflict_ui_action(user: current_user) end format.json do @@ -23,12 +24,14 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap render json: @conflicts_list elsif @merge_request.can_be_merged? render json: { - message: _('The merge conflicts for this merge request have already been resolved. Please return to the merge request.'), + message: _('The merge conflicts for this merge request have already been resolved. ' \ + 'Please return to the merge request.'), type: 'error' } else render json: { - message: _('The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.'), + message: _('The merge conflicts for this merge request cannot be resolved through GitLab. ' \ + 'Please try to resolve them locally.'), type: 'error' } end @@ -52,7 +55,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_resolve_conflict_action(user: current_user) if @merge_request.can_be_merged? - render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') } + render status: :bad_request, + json: { message: _('The merge conflicts for this merge request have already been resolved.') } return end @@ -71,6 +75,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap private + alias_method :issuable, :merge_request + def authorize_can_resolve_conflicts! @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request) diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 06381315614..6a3523b82d9 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -11,6 +11,12 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] + before_action only: [:new] do + if can?(current_user, :fill_in_merge_request_template, project) + push_frontend_feature_flag(:fill_in_mr_template, project) + end + end + urgency :low, [ :new, :create, @@ -25,7 +31,9 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def create - @merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute + @merge_request = ::MergeRequests::CreateService + .new(project: project, current_user: current_user, params: merge_request_params) + .execute if @merge_request.valid? incr_count_webide_merge_request @@ -82,7 +90,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def branch_to @target_project = selected_target_project - if @target_project && params[:ref].present? && Ability.allowed?(current_user, :create_merge_request_in, @target_project) + if @target_project && + params[:ref].present? && + Ability.allowed?(current_user, :create_merge_request_in, @target_project) + @ref = params[:ref] @commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref) end @@ -104,10 +115,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) + new_params = merge_request_params.merge(diff_options: diff_options) # Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/58096 Gitlab::GitalyClient.allow_n_plus_1_calls do - @merge_request = ::MergeRequests::BuildService.new(project: project, current_user: current_user, params: merge_request_params.merge(diff_options: diff_options)).execute + @merge_request = ::MergeRequests::BuildService + .new(project: project, current_user: current_user, params: new_params) + .execute end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index f3a01fd3223..5bd0063ab95 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -49,7 +49,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic pagination_data: diffs.pagination_data } - # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues. + # NOTE: Any variables that would affect the resulting json needs to be added to the cache_context + # to avoid stale cache issues. cache_context = [ current_user&.cache_key, unfoldable_positions.map(&:to_h), @@ -130,7 +131,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: disable CodeReuse/ActiveRecord def commit return unless commit_id = params[:commit_id].presence - return unless @merge_request.all_commits.exists?(sha: commit_id) || @merge_request.recent_context_commits.map(&:id).include?(commit_id) + return unless @merge_request.all_commits.exists?(sha: commit_id) || + @merge_request.recent_context_commits.map(&:id).include?(commit_id) @commit ||= @project.commit(commit_id) end @@ -160,7 +162,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end - return @merge_request.context_commits_diff if show_only_context_commits? && !@merge_request.context_commits_diff.empty? + if show_only_context_commits? && !@merge_request.context_commits_diff.empty? + return @merge_request.context_commits_diff + end + return @merge_request.merge_head_diff if render_merge_ref_head_diff? if @start_sha diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index ca6ab83b877..74c495261a3 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -27,17 +27,23 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli draft_note = create_service.execute + if draft_note.errors.present? + render json: { errors: draft_note.errors.full_messages.to_sentence }, status: :unprocessable_entity + return + end + prepare_notes_for_rendering(draft_note) render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note) end def update - draft_note.update!(draft_note_params) - - prepare_notes_for_rendering(draft_note) - - render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note) + if draft_note.update(draft_note_params) + prepare_notes_for_rendering(draft_note) + render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note) + else + render json: { errors: draft_note.errors.full_messages.to_sentence }, status: :unprocessable_entity + end end def destroy @@ -57,10 +63,13 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli 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) + 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 + return render json: { message: _('An error occurred while approving, please try again.') }, + status: :internal_server_error end end @@ -101,7 +110,9 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli # rubocop: disable CodeReuse/ActiveRecord def merge_request - @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) + @merge_request ||= MergeRequestsFinder + .new(current_user, project_id: @project.id) + .find_by!(iid: params[:merge_request_id]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 60f619a8d20..2172c91fc76 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -41,19 +41,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) push_frontend_feature_flag(:core_security_mr_widget_counts, project) push_frontend_feature_flag(:issue_assignees_widget, @project) - push_frontend_feature_flag(:deprecate_vulnerabilities_feedback, @project) push_frontend_feature_flag(:moved_mr_sidebar, project) + push_frontend_feature_flag(:sast_reports_in_inline_diff, project) push_frontend_feature_flag(:mr_experience_survey, project) push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:code_quality_inline_drawer, project) - push_frontend_feature_flag(:auto_merge_labels_mr_widget, project) push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) push_frontend_feature_flag(:mr_activity_filters, current_user) push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project) - push_frontend_feature_flag(:comment_on_files, current_user) push_frontend_feature_flag(:ci_job_failures_in_mr, project) end + before_action only: [:edit] do + if can?(current_user, :fill_in_merge_request_template, project) + push_frontend_feature_flag(:fill_in_mr_template, project) + end + end + around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] after_action :log_merge_request_show, only: [:show, :diffs] @@ -124,16 +128,31 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.recent_context_commits ) - per_page = [(params[:per_page] || MergeRequestDiff::COMMITS_SAFE_SIZE).to_i, MergeRequestDiff::COMMITS_SAFE_SIZE].min - recent_commits = @merge_request.recent_commits(load_from_gitaly: true, limit: per_page, page: params[:page]).with_latest_pipeline(@merge_request.source_branch).with_markdown_cache + per_page = [ + (params[:per_page] || MergeRequestDiff::COMMITS_SAFE_SIZE).to_i, + MergeRequestDiff::COMMITS_SAFE_SIZE + ].min + recent_commits = @merge_request + .recent_commits(load_from_gitaly: true, limit: per_page, page: params[:page]) + .with_latest_pipeline(@merge_request.source_branch) + .with_markdown_cache @next_page = recent_commits.next_page @commits = set_commits_for_rendering( recent_commits, commits_count: @merge_request.commits_count ) - commits_count = @merge_request.preparing? ? '-' : @merge_request.commits_count + @merge_request.context_commits_count - render json: { html: view_to_html_string('projects/merge_requests/_commits'), next_page: @next_page, count: commits_count } + commits_count = if @merge_request.preparing? + '-' + else + @merge_request.commits_count + @merge_request.context_commits_count + end + + render json: { + html: view_to_html_string('projects/merge_requests/_commits'), + next_page: @next_page, + count: commits_count + } end def pipelines @@ -221,7 +240,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def update - @merge_request = ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: merge_request_update_params).execute(@merge_request) + @merge_request = ::MergeRequests::UpdateService + .new(project: project, current_user: current_user, params: merge_request_update_params) + .execute(@merge_request) respond_to do |format| format.html do @@ -287,7 +308,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def assign_related_issues - result = ::MergeRequests::AssignIssuesService.new(project: project, current_user: current_user, params: { merge_request: @merge_request }).execute + result = ::MergeRequests::AssignIssuesService + .new(project: project, current_user: current_user, params: { merge_request: @merge_request }) + .execute case result[:count] when 0 @@ -317,7 +340,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def rebase - @merge_request.rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false)) + @merge_request + .rebase_async(current_user.id, skip_ci: Gitlab::Utils.to_boolean(merge_params[:skip_ci], default: false)) head :ok rescue MergeRequest::RebaseLockTimeout => e @@ -334,7 +358,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo IssuableExportCsvWorker.perform_async(:merge_request, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker index_path = project_merge_requests_path(project) - message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email_or_default } + message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % + { email: current_user.notification_email_or_default } redirect_to(index_path, notice: message) end @@ -432,10 +457,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @commits_count = @merge_request.commits_count + @merge_request.context_commits_count @diffs_count = get_diffs_count @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') - @current_user_data = Gitlab::Json.dump(UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity)) + @current_user_data = Gitlab::Json + .dump(UserSerializer.new(project: @project) + .represent(current_user, {}, MergeRequestCurrentUserEntity)) @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs @file_by_file_default = current_user&.view_diffs_file_by_file - @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports? + if @merge_request.has_coverage_reports? + @coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) + end + @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) @@ -478,12 +508,18 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def merge! # Disable the CI check if auto_merge_strategy is specified since we have # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?) + skipped_checks = @merge_request.skipped_mergeable_checks( + auto_merge_requested: auto_merge_requested?, + auto_merge_strategy: params[:auto_merge_strategy] + ) + + unless @merge_request.mergeable?(**skipped_checks) return :failed end squashing = params.fetch(:squash, false) - merge_service = ::MergeRequests::MergeService.new(project: @project, current_user: current_user, params: merge_params) + merge_service = ::MergeRequests::MergeService + .new(project: @project, current_user: current_user, params: merge_params) unless merge_service.hooks_validation_pass?(@merge_request, validate_squash_message: squashing) return :hook_validation_error @@ -500,7 +536,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo AutoMergeService.new(project, current_user, merge_params).update(merge_request) else AutoMergeService.new(project, current_user, merge_params) - .execute(merge_request, params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + .execute( + merge_request, + params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS + ) end else @merge_request.merge_async(current_user.id, merge_params) @@ -595,7 +634,9 @@ 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 = 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) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 35b65dbce7e..1f4e5b54500 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -24,6 +24,10 @@ class Projects::MilestonesController < Projects::ApplicationController feature_category :team_planning urgency :low + before_action do + push_frontend_feature_flag(:content_editor_on_issues, @project) + end + def index @sort = params[:sort] || 'due_date_asc' @milestones = milestones.sort_by_attribute(@sort) diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index ed7155fc5f4..9905e454acb 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -3,7 +3,9 @@ module Projects module Ml class CandidatesController < ApplicationController - before_action :check_feature_enabled, :set_candidate + before_action :set_candidate + before_action :check_read, only: [:show] + before_action :check_write, only: [:destroy] feature_category :mlops @@ -26,9 +28,13 @@ module Projects render_404 unless @candidate.present? end - def check_feature_enabled + def check_read render_404 unless can?(current_user, :read_model_experiments, @project) end + + def check_write + render_404 unless can?(current_user, :write_model_experiments, @project) + end end end end diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index a620e9919e7..85e7f63779c 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -5,7 +5,8 @@ module Projects class ExperimentsController < ::Projects::ApplicationController include Projects::Ml::ExperimentsHelper - before_action :check_feature_enabled + before_action :check_read, only: [:show, :index] + before_action :check_write, only: [:destroy] before_action :set_experiment, only: [:show, :destroy] feature_category :mlops @@ -55,10 +56,14 @@ module Projects private - def check_feature_enabled + def check_read render_404 unless can?(current_user, :read_model_experiments, @project) end + def check_write + render_404 unless can?(current_user, :write_model_experiments, @project) + end + def set_experiment @experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:iid]) diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb new file mode 100644 index 00000000000..77855b73cbd --- /dev/null +++ b/app/controllers/projects/ml/models_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ModelsController < ::Projects::ApplicationController + before_action :check_feature_enabled + feature_category :mlops + + def index + @models = ::Projects::Ml::ModelFinder.new(@project).execute + end + + private + + def check_feature_enabled + render_404 unless can?(current_user, :read_model_registry, @project) + end + end + end +end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 054e8c302c9..7fcdf220bd2 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Projects::NotesController < Projects::ApplicationController + extend Gitlab::Utils::Override include RendersNotes include NotesActions include NotesHelper @@ -11,10 +12,30 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_create_note!, only: [:create] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - feature_category :team_planning + feature_category :team_planning, [:index, :create, :update, :destroy, :delete_attachment, :toggle_award_emoji] + feature_category :code_review_workflow, [:resolve, :unresolve, :outdated_line_change] urgency :medium, [:index] urgency :low, [:create, :update, :destroy, :resolve, :unresolve, :toggle_award_emoji, :outdated_line_change] + override :feature_category + def feature_category + if %w[index create].include?(params[:action]) + category = feature_category_override_for_target_type(params[:target_type]) + return category if category + end + + super + end + + def feature_category_override_for_target_type(target_type) + case target_type + when 'merge_request' + 'code_review_workflow' + when 'commit', 'project_snippet' + 'source_code_management' + end + end + def delete_attachment note.remove_attachment! note.update_attribute(:attachment, nil) diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 332d33b8e52..6cfbb61fbb2 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Projects::PagesController < Projects::ApplicationController - layout :resolve_layout - before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] before_action :authorize_update_pages!, except: [:show, :destroy] @@ -10,10 +8,6 @@ class Projects::PagesController < Projects::ApplicationController feature_category :pages - before_action do - push_frontend_feature_flag(:show_pages_in_deployments_menu, current_user, type: :experiment) - end - def new @pipeline_wizard_data = { project_path: @project.full_path, @@ -66,10 +60,6 @@ class Projects::PagesController < Projects::ApplicationController private - def resolve_layout - 'project_settings' unless Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment) - end - def project_params params.require(:project).permit(project_params_attributes) end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index fb332fec3b5..4fd307b5105 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -25,14 +25,25 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def create - @schedule = Ci::CreatePipelineScheduleService - .new(@project, current_user, schedule_params) - .execute - - if @schedule.persisted? - redirect_to pipeline_schedules_path(@project) + if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, @project) + response = Ci::PipelineSchedules::CreateService.new(@project, current_user, schedule_params).execute + @schedule = response.payload + + if response.success? + redirect_to pipeline_schedules_path(@project) + else + render :new + end else - render :new + @schedule = Ci::CreatePipelineScheduleService + .new(@project, current_user, schedule_params) + .execute + + if @schedule.persisted? + redirect_to pipeline_schedules_path(@project) + else + render :new + end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 98e6459b543..a96ee2215c2 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -22,7 +22,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] - before_action :push_frontend_feature_flags, only: [:show, :builds, :dag, :failures, :test_report] # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } @@ -350,10 +349,6 @@ class Projects::PipelinesController < Projects::ApplicationController def tracking_project_source project end - - def push_frontend_feature_flags - push_frontend_feature_flag(:pipeline_details_header_vue, @project) - end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 2b2c2cef8e2..db19ca23e9f 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -12,8 +12,7 @@ class Projects::RunnersController < Projects::ApplicationController redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') end - def edit - end + def edit; end def update if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? @@ -23,12 +22,10 @@ class Projects::RunnersController < Projects::ApplicationController end end - def new - render_404 unless create_runner_workflow_for_namespace_enabled? - end + def new; end def register - render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available? + render_404 unless runner.registration_available? end def destroy @@ -55,8 +52,7 @@ class Projects::RunnersController < Projects::ApplicationController end end - def show - end + def show; end def toggle_shared_runners update_params = { shared_runners_enabled: !project.shared_runners_enabled } @@ -84,8 +80,4 @@ class Projects::RunnersController < Projects::ApplicationController def runner_params params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) end - - def create_runner_workflow_for_namespace_enabled? - Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) - end end diff --git a/app/controllers/projects/service_desk/custom_email_controller.rb b/app/controllers/projects/service_desk/custom_email_controller.rb new file mode 100644 index 00000000000..fb5e87f9a97 --- /dev/null +++ b/app/controllers/projects/service_desk/custom_email_controller.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Projects + module ServiceDesk + class CustomEmailController < Projects::ApplicationController + before_action :check_feature_flag_enabled + before_action :authorize_admin_project! + + feature_category :service_desk + urgency :low + + def create + response = ::ServiceDesk::CustomEmails::CreateService.new( + project: project, + current_user: current_user, + params: params + ).execute + + json_response(service_response: response) + end + + def update + response = ServiceDeskSettings::UpdateService.new(project, current_user, update_setting_params).execute + + if response.error? + json_response( + error_message: s_("ServiceDesk|Cannot update custom email"), + status: :unprocessable_entity + ) + return + end + + json_response + end + + def destroy + response = ::ServiceDesk::CustomEmails::DestroyService.new( + project: project, + current_user: current_user + ).execute + + json_response(service_response: response) + end + + def show + json_response + end + + private + + def update_setting_params + params.permit(:custom_email_enabled) + end + + def json_response(error_message: nil, status: :ok, service_response: nil) + if service_response.present? + status = service_response.success? ? :ok : :unprocessable_entity + error_message = service_response.message + end + + respond_to do |format| + format.json { render json: custom_email_attributes(error_message: error_message), status: status } + end + end + + def custom_email_attributes(error_message:) + setting = project.service_desk_setting + + { + custom_email: setting&.custom_email, + custom_email_enabled: setting&.custom_email_enabled || false, + custom_email_verification_state: setting&.custom_email_verification&.state, + custom_email_verification_error: setting&.custom_email_verification&.error, + custom_email_smtp_address: setting&.custom_email_credential&.smtp_address, + error_message: error_message + } + end + + def check_feature_flag_enabled + render_404 unless Feature.enabled?(:service_desk_custom_email, @project) + end + end + end +end diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb index 8f576b8d72b..b1e30e7a45b 100644 --- a/app/controllers/projects/service_desk_controller.rb +++ b/app/controllers/projects/service_desk_controller.rb @@ -13,12 +13,12 @@ class Projects::ServiceDeskController < Projects::ApplicationController def update Projects::UpdateService.new(project, current_user, { service_desk_enabled: params[:service_desk_enabled] }).execute - result = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute + response = ServiceDeskSettings::UpdateService.new(project, current_user, setting_params).execute - if result[:status] == :success + if response.success? json_response else - render json: { message: result[:message] }, status: :unprocessable_entity + render json: { message: response.message }, status: :unprocessable_entity end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index ce760051f79..0e892ef3faa 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -14,10 +14,6 @@ module Projects before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) - push_frontend_feature_flag(:ci_limit_environment_scope, @project) - push_frontend_feature_flag(:create_runner_workflow_for_namespace, @project.namespace) - push_frontend_feature_flag(:frozen_outbound_job_token_scopes, @project) - push_frontend_feature_flag(:frozen_outbound_job_token_scopes_override, @project) end helper_method :highlight_badge diff --git a/app/controllers/projects/tracing_controller.rb b/app/controllers/projects/tracing_controller.rb new file mode 100644 index 00000000000..d1218ebf344 --- /dev/null +++ b/app/controllers/projects/tracing_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Projects + class TracingController < Projects::ApplicationController + include ::Observability::ContentSecurityPolicy + + feature_category :tracing + + before_action :check_tracing_enabled + + def index; end + + private + + def check_tracing_enabled + render_404 unless Gitlab::Observability.tracing_enabled?(project) + end + end +end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index c8f698d6193..b961339111b 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -12,12 +12,14 @@ class Projects::TreeController < Projects::ApplicationController before_action :require_non_empty_project, except: [:new, :create] before_action :assign_ref_vars + before_action :find_requested_ref, only: [:show] before_action :assign_dir_vars, only: [:create_dir] before_action :authorize_read_code! before_action :authorize_edit_tree!, only: [:create_dir] before_action do push_frontend_feature_flag(:highlight_js, @project) + push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) end @@ -28,18 +30,20 @@ class Projects::TreeController < Projects::ApplicationController def show return render_404 unless @commit - @ref_type = ref_type - if @ref_type == BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref) - branch = @project.repository.find_branch(@ref) - if branch - redirect_to project_tree_path(@project, branch.target) - return + unless Feature.enabled?(:redirect_with_ref_type, @project) + @ref_type = ref_type + if @ref_type == BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref) + branch = @project.repository.find_branch(@ref) + if branch + redirect_to project_tree_path(@project, branch.target) + return + end end end if tree.entries.empty? if @repository.blob_at(@commit.id, @path) - redirect_to project_blob_path(@project, File.join(@ref, @path)) + redirect_to project_blob_path(@project, File.join(@ref, @path), ref_type: @ref_type) elsif @path.present? redirect_to_tree_root_for_missing_path(@project, @ref, @path) end @@ -59,6 +63,23 @@ class Projects::TreeController < Projects::ApplicationController private + def find_requested_ref + return unless Feature.enabled?(:redirect_with_ref_type, @project) + + @ref_type = ref_type + if @ref_type.present? + @tree = @repo.tree(@ref, @path, ref_type: @ref_type) + else + response = ExtractsPath::RequestedRef.new(@repository, ref_type: nil, ref: @ref).find + @ref_type = response[:ref_type] + @commit = response[:commit] + + if response[:ambiguous] + redirect_to(project_tree_path(@project, File.join(@ref_type ? @ref : @commit.id, @path), ref_type: @ref_type)) + end + end + end + def redirect_renamed_default_branch? action_name == 'show' end diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 8f4987a07f6..48399e17b25 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -11,7 +11,7 @@ class Projects::UploadsController < Projects::ApplicationController before_action :authorize_upload_file!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + feature_category :team_planning private diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 81f205a6457..51f6158d9c0 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -38,9 +38,11 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) + push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project) + push_frontend_feature_flag(:service_desk_custom_email, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) @@ -172,7 +174,9 @@ class ProjectsController < Projects::ApplicationController flash.now[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name } end - if ambiguous_ref?(@project, @ref) + if Feature.enabled?(:redirect_with_ref_type, @project) + @ref_type = 'heads' + elsif ambiguous_ref?(@project, @ref) branch = @project.repository.find_branch(@ref) # The files view would render a ref other than the default branch diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 76aa4afbe80..76f181e3ce8 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -7,10 +7,10 @@ module Registrations include ::Gitlab::Utils::StrongMemoize layout 'minimal' - skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] - before_action :require_current_user + skip_before_action :required_signup_info, :check_two_factor_requirement helper_method :welcome_update_params + helper_method :onboarding_status feature_category :user_management @@ -25,7 +25,7 @@ module Registrations if result.success? track_event('successfully_submitted_form') - finish_onboarding_on_welcome_page unless complete_signup_onboarding? + successful_update_hooks redirect_to update_success_path else @@ -35,14 +35,10 @@ module Registrations private - def registering_from_invite?(members) - # If there are more than one member it will mean we have been invited to multiple projects/groups and - # are not able to distinguish which one we should putting the user in after registration - members.count == 1 && members.last.source.present? - end + def authenticate_user! + return if current_user - def require_current_user - return redirect_to new_user_registration_path unless current_user + redirect_to new_user_registration_path end def completed_welcome_step? @@ -54,33 +50,28 @@ module Registrations end def path_for_signed_in_user(user) - stored_location_for(user) || members_activity_path(user.members) - end - - def members_activity_path(members) - return dashboard_projects_path unless members.any? - return dashboard_projects_path unless members.last.source.present? - - members.last.source.activity_path + stored_location_for(user) || last_member_activity_path end # overridden in EE def complete_signup_onboarding? - false + onboarding_status.continue_full_onboarding? end - def invites_with_tasks_to_be_done? - MemberTask.for_members(user_members).exists? + def last_member_activity_path + return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? + + onboarding_status.last_invited_member_source.activity_path end def update_success_path - if invites_with_tasks_to_be_done? + if onboarding_status.invite_with_tasks_to_be_done? issues_dashboard_path(assignee_username: current_user.username) elsif complete_signup_onboarding? # trials/regular registration on .com signup_onboarding_path - elsif registering_from_invite?(user_members) # invites w/o tasks due to order - flash[:notice] = helpers.invite_accepted_notice(user_members.last) - members_activity_path(user_members) + elsif onboarding_status.single_invite? # invites w/o tasks due to order + flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) + onboarding_status.last_invited_member_source.activity_path else # Subscription registrations goes through here as well. # Invites will come here too if there is more than 1. @@ -88,13 +79,8 @@ module Registrations end end - def user_members - current_user.members - end - strong_memoize_attr :user_members - # overridden in EE - def finish_onboarding_on_welcome_page; end + def successful_update_hooks; end # overridden in EE def signup_onboarding_path; end @@ -106,6 +92,11 @@ module Registrations def welcome_update_params {} end + + def onboarding_status + Onboarding::Status.new(current_user) + end + strong_memoize_attr :onboarding_status end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index f481681da02..76b7d30cd51 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -184,8 +184,6 @@ class RegistrationsController < Devise::RegistrationsController end def check_captcha - ensure_correct_params! - return unless show_recaptcha_sign_up? return unless Gitlab::Recaptcha.load_configurations! @@ -224,6 +222,7 @@ class RegistrationsController < Devise::RegistrationsController end def sign_up_params + ensure_correct_params! params.require(:user).permit(sign_up_params_attributes) end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index b797a204d7f..6d3811514d9 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -29,7 +29,7 @@ class UploadsController < ApplicationController before_action :authorize_create_access!, only: [:create, :authorize] before_action :verify_workhorse_api!, only: [:authorize] - feature_category :team_planning + feature_category :groups_and_projects def self.model_classes MODEL_CLASSES diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4db5745c005..88a8851607b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -97,11 +97,11 @@ class UsersController < ApplicationController end def groups - load_groups - respond_to do |format| format.html { render 'show' } format.json do + load_groups + render json: { html: view_to_html_string("shared/groups/_list", groups: @groups) } @@ -110,36 +110,36 @@ class UsersController < ApplicationController end def projects - load_projects - - present_projects(@projects) + present_projects do + load_projects + end end def contributed - load_contributed_projects - - present_projects(@contributed_projects) + present_projects do + load_contributed_projects + end end def starred - load_starred_projects - - present_projects(@starred_projects) + present_projects do + load_starred_projects + end end def followers - @user_followers = user.followers.page(params[:page]) - - present_users(@user_followers) + present_users do + @user_followers = user.followers.page(params[:page]) + end end def following - @user_following = user.followees.page(params[:page]) - - present_users(@user_following) + present_users do + @user_following = user.followees.page(params[:page]) + end end - def present_projects(projects) + def present_projects skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination]) skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace]) compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode]) @@ -147,17 +147,19 @@ class UsersController < ApplicationController respond_to do |format| format.html { render 'show' } format.json do + projects = yield + pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode) end end end def snippets - load_snippets - respond_to do |format| format.html { render 'show' } format.json do + load_snippets + render json: { html: view_to_html_string("snippets/_snippets", collection: @snippets) } @@ -281,10 +283,11 @@ class UsersController < ApplicationController access_denied! unless can?(current_user, :read_user_profile, user) end - def present_users(users) + def present_users respond_to do |format| format.html { render 'show' } format.json do + users = yield render json: { html: view_to_html_string("shared/users/index", users: users) } diff --git a/app/experiments/concerns/project_commit_count.rb b/app/experiments/concerns/project_commit_count.rb deleted file mode 100644 index 3f08538c21f..00000000000 --- a/app/experiments/concerns/project_commit_count.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module ProjectCommitCount - include Gitlab::Git::WrapsGitalyErrors - - def commit_count_for(project, default_count: 0, max_count: nil, **exception_details) - raw_repo = project.repository&.raw_repository - root_ref = raw_repo&.root_ref - - return default_count unless root_ref - - Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(root_ref, { - all: true, # include all branches - max_count: max_count # limit as an optimization - }) - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, exception_details) - - default_count - end -end diff --git a/app/experiments/empty_repo_upload_experiment.rb b/app/experiments/empty_repo_upload_experiment.rb deleted file mode 100644 index c8c75f32d69..00000000000 --- a/app/experiments/empty_repo_upload_experiment.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class EmptyRepoUploadExperiment < ApplicationExperiment - include ProjectCommitCount - - TRACKING_START_DATE = DateTime.parse('2021/4/20') - INITIAL_COMMIT_COUNT = 1 - - def track_initial_write - return unless should_track? # early return if we don't need to ask for commit counts - return unless context.project.created_at > TRACKING_START_DATE # early return for older projects - return unless commit_count == INITIAL_COMMIT_COUNT - - track(:initial_write, project: context.project) - end - - private - - def commit_count - commit_count_for(context.project, max_count: INITIAL_COMMIT_COUNT, experiment: name) - end -end diff --git a/app/experiments/force_company_trial_experiment.rb b/app/experiments/force_company_trial_experiment.rb deleted file mode 100644 index e7b98bb18ad..00000000000 --- a/app/experiments/force_company_trial_experiment.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ForceCompanyTrialExperiment < ApplicationExperiment - exclude :setup_for_personal - - private - - def setup_for_personal - !context.user.setup_for_company - end -end diff --git a/app/experiments/logged_out_marketing_header_experiment.rb b/app/experiments/logged_out_marketing_header_experiment.rb deleted file mode 100644 index 3d88d94aec4..00000000000 --- a/app/experiments/logged_out_marketing_header_experiment.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class LoggedOutMarketingHeaderExperiment < ApplicationExperiment - # These default behaviors are overriden in ApplicationHelper and header - # template partial - control {} - candidate {} - variant(:trial_focused) {} -end diff --git a/app/finders/award_emojis_finder.rb b/app/finders/award_emojis_finder.rb index 709d3f3e593..2f34e6ec4d3 100644 --- a/app/finders/award_emojis_finder.rb +++ b/app/finders/award_emojis_finder.rb @@ -33,7 +33,7 @@ class AwardEmojisFinder def validate_params return unless params.present? - validate_name_param + validate_name_param unless Feature.enabled?(:custom_emoji) validate_awarded_by_param end diff --git a/app/finders/ci/group_variables_finder.rb b/app/finders/ci/group_variables_finder.rb new file mode 100644 index 00000000000..e4697b07e64 --- /dev/null +++ b/app/finders/ci/group_variables_finder.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Ci + class GroupVariablesFinder + def initialize(project, sort_key = nil) + @project = project + @params = sort_to_params_map(sort_key) + end + + def execute + variables = ::Ci::GroupVariable.for_groups(project.group&.self_and_ancestor_ids) + + return Ci::GroupVariable.none if variables.empty? + + sort(variables) + end + + private + + SORT_TO_PARAMS_MAP = { + created_desc: { order_by: 'created_at', sort: 'desc' }, + created_asc: { order_by: 'created_at', sort: 'asc' }, + key_desc: { order_by: 'key', sort: 'desc' }, + key_asc: { order_by: 'key', sort: 'asc' } + }.freeze + + def sort_to_params_map(sort_key) + SORT_TO_PARAMS_MAP[sort_key] || {} + end + + def sort(variables) + return variables unless params[:order_by] + + variables.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") + end + + attr_reader :project, :params + end +end diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index e52fc510628..6ba2ae91d6c 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -164,7 +164,7 @@ module Ci :id end - sort = if params[:sort] =~ /\A(ASC|DESC)\z/i + sort = if /\A(ASC|DESC)\z/i.match?(params[:sort]) params[:sort] else :desc diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 5f03ae77338..630be17e64b 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -4,7 +4,6 @@ module Ci class RunnersFinder < UnionFinder include Gitlab::Allowable - ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc].freeze DEFAULT_SORT = 'created_at_desc' def initialize(current_user:, params:) @@ -31,11 +30,17 @@ module Ci end def sort_key - ALLOWED_SORTS.include?(@params[:sort]) ? @params[:sort] : DEFAULT_SORT + allowed_sorts.include?(@params[:sort]) ? @params[:sort] : DEFAULT_SORT end private + attr_reader :group, :project + + def allowed_sorts + %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc] + end + def search! if @project project_runners @@ -128,3 +133,5 @@ module Ci end end end + +Ci::RunnersFinder.prepend_mod diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 5241a3b3907..800158dfd0a 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -128,6 +128,7 @@ class DeploymentsFinder def build_sort_params order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE + order_by = DEFAULT_SORT_VALUE if order_by == 'ref' && Feature.enabled?(:remove_deployments_api_ref_sort) order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION { order_by => order_direction } diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 4ed447a90ce..7bccfe453ab 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -61,6 +61,7 @@ class EventsFinder def by_current_user_access(events) events.merge(Project.public_or_visible_to_user(current_user)) .joins(:project) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 07f39f98b12..72ab30cf567 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -109,11 +109,7 @@ class GroupDescendantsFinder group_ids = base_for_ancestors.except(:select, :sort).select(:id) groups = Group.where(id: group_ids) - if Feature.enabled?(:linear_group_descendants_finder_upto, current_user) - groups.self_and_ancestors(upto: parent_group.id) - else - Gitlab::ObjectHierarchy.new(groups).base_and_ancestors(upto: parent_group.id) - end + groups.self_and_ancestors(upto: parent_group.id) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index 00b700a101e..db8a0f14fbc 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -24,6 +24,7 @@ # with_issues_enabled: boolean # with_merge_requests_enabled: boolean # min_access_level: int +# owned: boolean # class GroupProjectsFinder < ProjectsFinder DEFAULT_PROJECTS_LIMIT = 100 @@ -83,7 +84,9 @@ class GroupProjectsFinder < ProjectsFinder def filter_by_visibility(relation) if current_user - if min_access_level? + if owned_projects? + relation.visible_to_user_and_access_level(current_user, Gitlab::Access::OWNER) + elsif min_access_level? relation.visible_to_user_and_access_level(current_user, params[:min_access_level]) else relation.public_or_visible_to_user(current_user) @@ -105,6 +108,10 @@ class GroupProjectsFinder < ProjectsFinder options.fetch(:only_owned, false) end + def owned_projects? + params.fetch(:owned, false) + end + def only_shared? options.fetch(:only_shared, false) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 478a2ba622c..bbbf14bb0d0 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -346,8 +346,7 @@ class IssuableFinder def use_full_text_search? klass.try(:pg_full_text_searchable_columns).present? && - params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX && - Feature.enabled?(:issues_full_text_search, params.project || params.group) + params[:search] =~ FULL_TEXT_SEARCH_TERM_REGEX end def filter_by_full_text_search(items) diff --git a/app/finders/issuables/assignee_filter.rb b/app/finders/issuables/assignee_filter.rb index 2e58a6b34c9..c97fdffd32e 100644 --- a/app/finders/issuables/assignee_filter.rb +++ b/app/finders/issuables/assignee_filter.rb @@ -5,6 +5,8 @@ module Issuables def filter(issuables) filtered = by_assignee(issuables) filtered = by_assignee_union(filtered) + # Cross Joins Fails tests in bin/rspec spec/requests/api/graphql/boards/board_list_issues_query_spec.rb + filtered = filtered.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") by_negated_assignee(filtered) end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 3c0714441b2..6348bceb157 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -90,8 +90,10 @@ class MembersFinder # enumerate the columns here since we are enumerating them in the union and want to be immune to # column caching issues when adding/removing columns - Member.select(*Member.column_names) + members = Member.select(*Member.column_names) .includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord + # The left join with the table users in the method distinct_on needs to be resolved + members.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") end def distinct_on(union) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index f7ee90ab870..95b5b267089 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -73,6 +73,7 @@ class MergeRequestsFinder < IssuableFinder items = by_deployments(items) items = by_reviewer(items) items = by_source_project_id(items) + items = items.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") by_approved(items) end diff --git a/app/finders/packages/ml_model/package_finder.rb b/app/finders/packages/ml_model/package_finder.rb new file mode 100644 index 00000000000..a550ad0fa34 --- /dev/null +++ b/app/finders/packages/ml_model/package_finder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Packages + module MlModel + class PackageFinder + def initialize(project) + @project = project + end + + def execute!(package_name, package_version) + project + .packages + .installable + .ml_model + .by_name_and_version!(package_name, package_version) + end + + private + + attr_reader :project + end + end +end diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb index 953e8299138..339b3f531c6 100644 --- a/app/finders/packages/npm/package_finder.rb +++ b/app/finders/packages/npm/package_finder.rb @@ -5,27 +5,16 @@ module Packages delegate :find_by_version, to: :execute delegate :last, to: :execute - # /!\ CAUTION: don't use last_of_each_version: false with find_by_version. Ordering is not - # guaranteed! - def initialize(package_name, project: nil, namespace: nil, last_of_each_version: true) + def initialize(package_name, project: nil, namespace: nil) @package_name = package_name @project = project @namespace = namespace - @last_of_each_version = last_of_each_version end def execute - result = base.npm - .with_name(@package_name) - .installable - - return result unless @last_of_each_version - - if Feature.enabled?(:npm_allow_packages_in_multiple_projects) - Packages::Package.id_in(result.last_of_each_version_ids) - else - result.last_of_each_version - end + base.npm + .with_name(@package_name) + .installable end private diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb new file mode 100644 index 00000000000..9ef5dacb551 --- /dev/null +++ b/app/finders/projects/ml/model_finder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ModelFinder + def initialize(project) + @project = project + end + + def execute + @project + .packages + .installable + .ml_model + .order_name_desc_version_desc + .select_only_first_by_name + .limit(100) # This is a temporary limit before we add pagination + end + end + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 57a9538db15..e6ee4355fd4 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -53,6 +53,10 @@ class ProjectsFinder < UnionFinder init_collection end + if Feature.enabled?(:hide_projects_of_banned_users) + collection = without_created_and_owned_by_banned_user(collection) + end + use_cte = params.delete(:use_cte) collection = Project.wrap_with_cte(collection) if use_cte collection = filter_projects(collection) @@ -282,6 +286,12 @@ class ProjectsFinder < UnionFinder { min_access_level: params[:min_access_level] } end + + def without_created_and_owned_by_banned_user(projects) + return projects if current_user&.can?(:admin_all_resources) + + projects.without_created_and_owned_by_banned_user + end end ProjectsFinder.prepend_mod_with('ProjectsFinder') diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 57dbeca5c51..88ba635e20b 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -80,15 +80,11 @@ class UsersFinder def by_search(users) return users unless params[:search].present? - if Feature.enabled?(:autocomplete_users_use_search_service) - users.search( - params[:search], - with_private_emails: current_user&.can_admin_all_resources?, - use_minimum_char_limit: params[:use_minimum_char_limit] - ) - else - users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?) - end + users.search( + params[:search], + with_private_emails: current_user&.can_admin_all_resources?, + use_minimum_char_limit: params[:use_minimum_char_limit] + ) end def by_blocked(users) @@ -103,13 +99,11 @@ class UsersFinder users.active end - # rubocop: disable CodeReuse/ActiveRecord def by_external_identity(users) - return users unless current_user&.can_admin_all_resources? && params[:extern_uid] && params[:provider] + return users unless params[:extern_uid] && params[:provider] - users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) + users.by_provider_and_extern_uid(params[:provider], params[:extern_uid]) end - # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def by_external(users) diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index eed7959a2f1..0c7195c5be3 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -15,9 +15,6 @@ class GitlabSchema < GraphQL::Schema use Gitlab::Graphql::Tracers::MetricsTracer use Gitlab::Graphql::Tracers::LoggerTracer - # TODO: Old tracer which will be removed eventually - # See https://gitlab.com/gitlab-org/gitlab/-/issues/345396 - use Gitlab::Graphql::GenericTracing use Gitlab::Graphql::Tracers::TimerTracer use Gitlab::Graphql::Subscriptions::ActionCableWithLoadBalancing diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb index 9c3aefce033..b06a4f58df5 100644 --- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb +++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb @@ -17,7 +17,7 @@ module Mutations description: 'Whether the integration is receiving alerts.' argument :api_url, GraphQL::Types::String, - required: true, + required: false, description: 'Endpoint at which Prometheus can be queried.' 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 6071d6750c2..0358bb11c58 100644 --- a/app/graphql/mutations/ci/job_token_scope/add_project.rb +++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb @@ -35,14 +35,13 @@ module Mutations def resolve(project_path:, target_project_path:, direction: nil) project = authorized_find!(project_path) target_project = Project.find_by_full_path(target_project_path) - frozen_outbound = project.frozen_outbound_job_token_scopes? - if direction == :outbound && frozen_outbound + if direction == :outbound raise Gitlab::Graphql::Errors::ArgumentError, 'direction: OUTBOUND scope entries can only be removed. ' \ 'Only INBOUND scope can be expanded.' end - direction ||= frozen_outbound ? :inbound : :outbound + direction ||= :inbound result = ::Ci::JobTokenScope::AddProjectService .new(project, current_user) diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb index 65b355cd80f..71a366ed342 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/create.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb @@ -51,14 +51,28 @@ module Mutations params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) - schedule = ::Ci::CreatePipelineScheduleService - .new(project, current_user, params) - .execute - - unless schedule.persisted? - return { - pipeline_schedule: nil, errors: schedule.errors.full_messages - } + if ::Feature.enabled?(:ci_refactoring_pipeline_schedule_create_service, project) + response = ::Ci::PipelineSchedules::CreateService + .new(project, current_user, params) + .execute + + schedule = response.payload + + unless response.success? + return { + pipeline_schedule: nil, errors: response.errors + } + end + else + schedule = ::Ci::CreatePipelineScheduleService + .new(project, current_user, params) + .execute + + unless schedule.persisted? + return { + pipeline_schedule: nil, errors: schedule.errors.full_messages + } + end end { diff --git a/app/graphql/mutations/ci/pipeline_schedule/update.rb b/app/graphql/mutations/ci/pipeline_schedule/update.rb index a0b5e793ecb..aff0a5494e7 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/update.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/update.rb @@ -43,7 +43,7 @@ module Mutations def resolve(id:, variables: [], **pipeline_schedule_attrs) schedule = authorized_find!(id: id) - params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) + params = pipeline_schedule_attrs.merge(variables_attributes: variable_attributes_for(variables)) service_response = ::Ci::PipelineSchedules::UpdateService .new(schedule, current_user, params) @@ -54,6 +54,18 @@ module Mutations errors: service_response.errors } end + + private + + def variable_attributes_for(variables) + variables.map do |variable| + variable.to_h.tap do |hash| + hash[:id] = GlobalID::Locator.locate(hash[:id]).id if hash[:id] + + hash[:_destroy] = hash.delete(:destroy) + end + end + end end end end diff --git a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb index 54a6ad92448..eb6a78eb67a 100644 --- a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb +++ b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb @@ -8,11 +8,18 @@ module Mutations description 'Attributes for the pipeline schedule variable.' + PipelineScheduleVariableID = ::Types::GlobalIDType[::Ci::PipelineScheduleVariable] + + argument :id, PipelineScheduleVariableID, required: false, description: 'ID of the variable to mutate.' + argument :key, GraphQL::Types::String, required: true, description: 'Name of the variable.' argument :value, GraphQL::Types::String, required: true, description: 'Value of the variable.' argument :variable_type, Types::Ci::VariableTypeEnum, required: true, description: 'Type of the variable.' + + argument :destroy, GraphQL::Types::Boolean, required: false, + description: 'Boolean option to destroy the variable.' 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 d4e55fd1792..082c345adf6 100644 --- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb @@ -39,7 +39,7 @@ module Mutations def resolve(full_path:, **args) project = authorized_find!(full_path) - if args[:job_token_scope_enabled] && project.frozen_outbound_job_token_scopes? + if args[:job_token_scope_enabled] raise Gitlab::Graphql::Errors::ArgumentError, 'job_token_scope_enabled can only be set to false' end diff --git a/app/graphql/mutations/ci/runner/create.rb b/app/graphql/mutations/ci/runner/create.rb index 7eca6c27d10..4d4134781a5 100644 --- a/app/graphql/mutations/ci/runner/create.rb +++ b/app/graphql/mutations/ci/runner/create.rb @@ -37,8 +37,6 @@ module Mutations parse_gid(**args) - check_feature_flag(**args) - super end @@ -79,28 +77,6 @@ module Mutations GitlabSchema.parse_gid(args[:project_id], expected_type: ::Project) end end - - def check_feature_flag(**args) - case args[:runner_type] - when 'instance_type' - if Feature.disabled?(:create_runner_workflow_for_admin, current_user) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - '`create_runner_workflow_for_admin` feature flag is disabled.' - end - when 'group_type' - namespace = find_object(**args).sync - if Feature.disabled?(:create_runner_workflow_for_namespace, namespace) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - '`create_runner_workflow_for_namespace` feature flag is disabled.' - end - when 'project_type' - project = find_object(**args).sync - if project && Feature.disabled?(:create_runner_workflow_for_namespace, project.namespace) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - '`create_runner_workflow_for_namespace` feature flag is disabled.' - end - end - end end end end diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb index 271585eb06c..f18ce0eba97 100644 --- a/app/graphql/mutations/environments/create.rb +++ b/app/graphql/mutations/environments/create.rb @@ -35,6 +35,11 @@ module Mutations required: false, description: 'Cluster agent of the environment.' + argument :kubernetes_namespace, + GraphQL::Types::String, + required: false, + description: 'Kubernetes namespace of the environment.' + field :environment, Types::EnvironmentType, null: true, diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb index 431a7add00e..07ab22685cc 100644 --- a/app/graphql/mutations/environments/update.rb +++ b/app/graphql/mutations/environments/update.rb @@ -28,6 +28,11 @@ module Mutations required: false, description: 'Cluster agent of the environment.' + argument :kubernetes_namespace, + GraphQL::Types::String, + required: false, + description: 'Kubernetes namespace of the environment.' + field :environment, Types::EnvironmentType, null: true, diff --git a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb index 225e20bab83..ac04e0967e6 100644 --- a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb +++ b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb @@ -35,7 +35,7 @@ module Resolvers end def http_integrations - ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute + ::AlertManagement::HttpIntegrationsFinder.new(project, { type_identifier: :http }).execute end end end diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb index a97650e95d9..9b20d3367f1 100644 --- a/app/graphql/resolvers/alert_management/integrations_resolver.rb +++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb @@ -40,7 +40,7 @@ module Resolvers def http_integrations return [] unless http_integrations_allowed? - ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute + ::AlertManagement::HttpIntegrationsFinder.new(project, { type_identifier: :http }).execute end def prometheus_integrations_allowed? diff --git a/app/graphql/resolvers/ci/inherited_variables_resolver.rb b/app/graphql/resolvers/ci/inherited_variables_resolver.rb index 01f966942a4..4e83265e247 100644 --- a/app/graphql/resolvers/ci/inherited_variables_resolver.rb +++ b/app/graphql/resolvers/ci/inherited_variables_resolver.rb @@ -5,8 +5,12 @@ module Resolvers class InheritedVariablesResolver < BaseResolver type Types::Ci::ProjectVariableType.connection_type, null: true - def resolve - object.group&.self_and_ancestors&.flat_map(&:variables) || [] + argument :sort, Types::Ci::GroupVariablesSortEnum, + required: false, default_value: :created_desc, + description: 'Sort variables by the criteria.' + + def resolve(sort:) + ::Ci::GroupVariablesFinder.new(object, sort).execute end end end diff --git a/app/graphql/resolvers/ci/runner_job_count_resolver.rb b/app/graphql/resolvers/ci/runner_job_count_resolver.rb new file mode 100644 index 00000000000..a43d3f3a100 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_job_count_resolver.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerJobCountResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type GraphQL::Types::Int, null: true + + authorize :read_runner + authorizes_object! + + argument :statuses, [::Types::Ci::JobStatusEnum], + required: false, + description: 'Filter jobs by status.', + alpha: { milestone: '16.2' } + + alias_method :runner, :object + + def resolve(statuses: nil) + BatchLoader::GraphQL.for(runner.id).batch(key: [:job_count, statuses]) do |runner_ids, loader, _args| + counts_by_runner = calculate_job_count_per_runner(runner_ids, statuses) + + runner_ids.each do |runner_id| + loader.call(runner_id, counts_by_runner[runner_id]&.count || 0) + end + end + end + + private + + def calculate_job_count_per_runner(runner_ids, statuses) + # rubocop: disable CodeReuse/ActiveRecord + builds_tbl = ::Ci::Build.arel_table + runners_tbl = ::Ci::Runner.arel_table + lateral_query = ::Ci::Build.select(1).where(builds_tbl['runner_id'].eq(runners_tbl['id'])) + lateral_query = lateral_query.where(status: statuses) if statuses + # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT + lateral_query = lateral_query.limit(::Types::Ci::RunnerType::JOB_COUNT_LIMIT + 1) + ::Ci::Runner.joins("JOIN LATERAL (#{lateral_query.to_sql}) builds_with_limit ON true") + .id_in(runner_ids) + .select(:id, Arel.star.count.as('count')) + .group(:id) + .index_by(&:id) + # rubocop: enable CodeReuse/ActiveRecord + end + end + end +end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 735e38c1a5c..632655d3681 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -4,6 +4,7 @@ module Resolvers module Ci class RunnersResolver < BaseResolver include LooksAhead + include Gitlab::Graphql::Authorize::AuthorizeResource type Types::Ci::RunnerType.connection_type, null: true @@ -105,3 +106,5 @@ module Resolvers end end end + +Resolvers::Ci::RunnersResolver.prepend_mod diff --git a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb index 2ea7a02bf15..d9bcf39b818 100644 --- a/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb +++ b/app/graphql/resolvers/concerns/issues/look_ahead_preloads.rb @@ -20,17 +20,15 @@ module Issues end def preloads - preload_hash = { + { alert_management_alert: [:alert_management_alert], assignees: [:assignees], participants: Issue.participant_includes, timelogs: [:timelogs], customer_relations_contacts: { customer_relations_contacts: [:group] }, - escalation_status: [:incident_management_issuable_escalation_status] + escalation_status: [:incident_management_issuable_escalation_status], + type: :work_item_type } - preload_hash[:type] = :work_item_type if Feature.enabled?(:issue_type_uses_work_item_types_table) - - preload_hash end end end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index b9326015ac0..c0a068097a7 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -11,6 +11,11 @@ module ResolvesMergeRequests end def resolve_with_lookahead(**args) + if args[:group_id] + args[:group_id] = ::GitlabSchema.parse_gid(args[:group_id], expected_type: ::Group).model_id + args[:include_subgroups] = true + end + mr_finder = MergeRequestsFinder.new(current_user, args.compact) finder = Gitlab::Graphql::Loaders::IssuableLoader.new(mr_parent, mr_finder) diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb index fefd17d5e20..495b72231fc 100644 --- a/app/graphql/resolvers/issues/base_resolver.rb +++ b/app/graphql/resolvers/issues/base_resolver.rb @@ -16,6 +16,9 @@ module Resolvers argument :assignee_usernames, [GraphQL::Types::String], required: false, description: 'Usernames of users assigned to the issue.' + argument :assignee_wildcard_id, ::Types::AssigneeWildcardIdEnum, + required: false, + description: 'Filter by assignee wildcard. Incompatible with assigneeUsername and assigneeUsernames.' argument :author_username, GraphQL::Types::String, required: false, description: 'Username of the author of the issue.' @@ -148,6 +151,7 @@ module Resolvers rewrite_param_name(args, :assignee_usernames, :assignee_username) rewrite_param_name(args[:or], :assignee_usernames, :assignee_username) rewrite_param_name(args[:not], :assignee_usernames, :assignee_username) + rewrite_param_name(args, :assignee_wildcard_id, :assignee_id) end def rewrite_param_name(params, old_name, new_name) @@ -163,7 +167,7 @@ module Resolvers end def mutually_exclusive_assignee_username_args - [:assignee_usernames, :assignee_username] + [:assignee_usernames, :assignee_username, :assignee_wildcard_id] end def params_not_mutually_exclusive(args, mutually_exclusive_args) @@ -171,7 +175,7 @@ module Resolvers arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') raise ::Gitlab::Graphql::Errors::ArgumentError, - "only one of [#{arg_str}] arguments is allowed at the same time." + "only one of [#{arg_str}] arguments is allowed at the same time." end end # rubocop:enable Graphql/ResolverType diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb deleted file mode 100644 index 5abad0de539..00000000000 --- a/app/graphql/resolvers/metrics/dashboard_resolver.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - module Metrics - class DashboardResolver < Resolvers::BaseResolver - type Types::Metrics::DashboardType, null: true - calls_gitaly! - - argument :path, GraphQL::Types::String, - required: true, - description: <<~MD - Path to a file which defines a metrics dashboard eg: `"config/prometheus/common_metrics.yml"`. - MD - - alias_method :environment, :object - - def resolve(path:) - return if Feature.enabled?(:remove_monitor_metrics) - return unless environment - - ::PerformanceMonitoring::PrometheusDashboard.find_for(path: path, **service_params) - end - - private - - def service_params - { - project: environment.project, - user: current_user, - options: { environment: environment } - } - end - end - end -end diff --git a/app/graphql/resolvers/user_merge_requests_resolver_base.rb b/app/graphql/resolvers/user_merge_requests_resolver_base.rb index b2d85307c49..72dbc0a93e9 100644 --- a/app/graphql/resolvers/user_merge_requests_resolver_base.rb +++ b/app/graphql/resolvers/user_merge_requests_resolver_base.rb @@ -4,6 +4,14 @@ module Resolvers class UserMergeRequestsResolverBase < MergeRequestsResolver include ResolvesProject + argument :group_id, + type: ::Types::GlobalIDType[::Group], + required: false, + description: <<~DESC + The global ID of the group the authored merge requests should be in. + Merge requests in subgroups are included. + DESC + argument :project_path, type: GraphQL::Types::String, required: false, diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 36dd930c3d9..c17406b3e56 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -111,13 +111,6 @@ module Types null: true, description: 'Assignees of the alert.' - field :metrics_dashboard_url, - GraphQL::Types::String, - null: true, - description: 'URL for metrics embed for the alert.', - deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0', - milestone: '16.0' } - field :runbook, GraphQL::Types::String, null: true, @@ -143,12 +136,6 @@ module Types method: :details_url, null: false, description: 'URL of the alert.' - - def metrics_dashboard_url - return if Feature.enabled?(:remove_monitor_metrics) - - object.metrics_dashboard_url - end end end end diff --git a/app/graphql/types/assignee_wildcard_id_enum.rb b/app/graphql/types/assignee_wildcard_id_enum.rb new file mode 100644 index 00000000000..09afab7de37 --- /dev/null +++ b/app/graphql/types/assignee_wildcard_id_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class AssigneeWildcardIdEnum < BaseEnum + graphql_name 'AssigneeWildcardId' + description 'Assignee ID wildcard values' + + value 'NONE', 'No assignee is assigned.' + value 'ANY', 'An assignee is assigned.' + end +end diff --git a/app/graphql/types/boards/assignee_wildcard_id_enum.rb b/app/graphql/types/boards/assignee_wildcard_id_enum.rb deleted file mode 100644 index ba9058a78d9..00000000000 --- a/app/graphql/types/boards/assignee_wildcard_id_enum.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Types - module Boards - class AssigneeWildcardIdEnum < BaseEnum - graphql_name 'AssigneeWildcardId' - description 'Assignee ID wildcard values' - - value 'NONE', 'No assignee is assigned.' - value 'ANY', 'An assignee is assigned.' - end - end -end diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb index 897e3d05948..ea7c207cda2 100644 --- a/app/graphql/types/boards/board_issue_input_type.rb +++ b/app/graphql/types/boards/board_issue_input_type.rb @@ -17,9 +17,9 @@ module Types required: false, description: 'Search query for issue title or description.' - argument :assignee_wildcard_id, ::Types::Boards::AssigneeWildcardIdEnum, + argument :assignee_wildcard_id, ::Types::AssigneeWildcardIdEnum, required: false, - description: 'Filter by assignee wildcard. Incompatible with assigneeUsername.' + description: 'Filter by assignee wildcard. Incompatible with assigneeUsername and assigneeUsernames.' argument :confidential, GraphQL::Types::Boolean, required: false, diff --git a/app/graphql/types/ci/config/include_type.rb b/app/graphql/types/ci/config/include_type.rb index 71eb8f755ab..b5816453a51 100644 --- a/app/graphql/types/ci/config/include_type.rb +++ b/app/graphql/types/ci/config/include_type.rb @@ -15,22 +15,22 @@ module Types field :location, GraphQL::Types::String, null: true, - description: 'File location. It can be masked if it contains masked variables, e.g., ' \ - '".gitlab/ci/build-images.gitlab-ci.yml".' + description: 'File location. It can be masked if it contains masked variables. For example, ' \ + '`".gitlab/ci/build-images.gitlab-ci.yml"`.' field :blob, GraphQL::Types::String, null: true, - description: 'File blob location. It can be masked if it contains masked variables, e.g., ' \ - '"https://gitlab.com/gitlab-org/gitlab/-/blob/e52d6d0246d7375291850e61f0abc101fbda9dc2' \ - '/.gitlab/ci/build-images.gitlab-ci.yml".' + description: 'File blob location. It can be masked if it contains masked variables. For example, ' \ + '`"https://gitlab.com/gitlab-org/gitlab/-/blob/e52d6d0246d7375291850e61f0abc101fbda9dc2' \ + '/.gitlab/ci/build-images.gitlab-ci.yml"`.' field :raw, GraphQL::Types::String, null: true, - description: 'File raw location. It can be masked if it contains masked variables, e.g., ' \ - '"https://gitlab.com/gitlab-org/gitlab/-/raw/e52d6d0246d7375291850e61f0abc101fbda9dc2' \ - '/.gitlab/ci/build-images.gitlab-ci.yml".' + description: 'File raw location. It can be masked if it contains masked variables. For example, ' \ + '`"https://gitlab.com/gitlab-org/gitlab/-/raw/e52d6d0246d7375291850e61f0abc101fbda9dc2' \ + '/.gitlab/ci/build-images.gitlab-ci.yml"`.' field :extra, # rubocop:disable Graphql/JSONType GraphQL::Types::JSON, diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb index f9ed54f0d10..7e2afba0d53 100644 --- a/app/graphql/types/ci/group_variable_type.rb +++ b/app/graphql/types/ci/group_variable_type.rb @@ -21,6 +21,10 @@ module Types field :protected, GraphQL::Types::Boolean, null: true, description: 'Indicates whether the variable is protected.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the variable.' end end end diff --git a/app/graphql/types/ci/group_variables_sort_enum.rb b/app/graphql/types/ci/group_variables_sort_enum.rb new file mode 100644 index 00000000000..5cf9fd4039b --- /dev/null +++ b/app/graphql/types/ci/group_variables_sort_enum.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Ci + # Not inheriting from Types::SortEnum since we only want + # to implement a subset of the sort values it defines. + class GroupVariablesSortEnum < BaseEnum + graphql_name 'CiGroupVariablesSort' + description 'Values for sorting inherited variables' + + # Borrowed from Types::SortEnum + # These values/descriptions should stay in-sync as much as possible. + value 'CREATED_DESC', 'Created at descending order.', value: :created_desc + value 'CREATED_ASC', 'Created at ascending order.', value: :created_asc + + value 'KEY_DESC', 'Key by descending order.', value: :key_desc + value 'KEY_ASC', 'Key by ascending order.', value: :key_asc + end + end +end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index a779ceb2e2a..02b10f3e4bd 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -87,8 +87,10 @@ module Types description: 'Play path of the job.' field :playable, GraphQL::Types::Boolean, null: false, method: :playable?, description: 'Indicates the job can be played.' - field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true, - description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' + field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, + null: true, + description: 'Jobs that must complete before the job runs. Returns `BuildNeed`, ' \ + 'which is the needed jobs if the job uses the `needs` keyword, or the previous stage jobs otherwise.' field :ref_name, GraphQL::Types::String, null: true, description: 'Ref name of the job.' field :ref_path, GraphQL::Types::String, null: true, @@ -179,7 +181,9 @@ module Types stages = pipeline.stages.by_position(positions) stages.each do |stage| - loader.call([pipeline, stage.position], stage.latest_statuses) + # Without `.to_a`, the memoization will only preserve the activerecord relation object. And when there is + # a call, the SQL query will be executed again. + loader.call([pipeline, stage.position], stage.latest_statuses.to_a) end end end diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb index 2a5375045e5..a9679000511 100644 --- a/app/graphql/types/ci/project_variable_type.rb +++ b/app/graphql/types/ci/project_variable_type.rb @@ -21,6 +21,10 @@ module Types field :masked, GraphQL::Types::Boolean, null: true, description: 'Indicates whether the variable is masked.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Description of the variable.' end end end diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb index 8f2a13bd699..4195eb043ed 100644 --- a/app/graphql/types/ci/runner_sort_enum.rb +++ b/app/graphql/types/ci/runner_sort_enum.rb @@ -15,3 +15,5 @@ module Types end end end + +Types::Ci::RunnerSortEnum.prepend_mod diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 8e509cc8493..2baf64ca663 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -24,8 +24,9 @@ module Types field :admin_url, GraphQL::Types::String, null: true, description: 'Admin URL of the runner. Only available for administrators.' field :architecture_name, GraphQL::Types::String, null: true, - description: 'Architecture provided by the the runner.', - method: :architecture + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'Architecture provided by the the runner.', + method: :architecture field :contacted_at, Types::TimeType, null: true, description: 'Timestamp of last contact from this runner.', method: :contacted_at @@ -46,17 +47,20 @@ module Types description: 'URL of the registration page of the runner manager. Only available for the creator of the runner for a limited time during registration.', alpha: { milestone: '15.11' } field :executor_name, GraphQL::Types::String, null: true, - description: 'Executor last advertised by the runner.', - method: :executor_name + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'Executor last advertised by the runner.', + method: :executor_name field :groups, null: true, resolver: ::Resolvers::Ci::RunnerGroupsResolver, description: 'Groups the runner is associated with. For group runners only.' field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, description: 'ID of the runner.' field :ip_address, GraphQL::Types::String, null: true, - description: 'IP address of the runner.' + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'IP address of the runner.' field :job_count, GraphQL::Types::Int, null: true, - description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." + description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist).", + resolver: ::Resolvers::Ci::RunnerJobCountResolver field :job_execution_status, Types::Ci::RunnerJobExecutionStatusEnum, null: true, @@ -82,8 +86,9 @@ module Types field :paused, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is paused and not available to run jobs.' field :platform_name, GraphQL::Types::String, null: true, - description: 'Platform provided by the runner.', - method: :platform + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'Platform provided by the runner.', + method: :platform field :project_count, GraphQL::Types::Int, null: true, description: 'Number of projects that the runner is associated with.' field :projects, @@ -94,7 +99,8 @@ module Types field :register_admin_url, GraphQL::Types::String, null: true, description: 'URL of the temporary registration page of the runner. Only available before the runner is registered. Only available for administrators.' field :revision, GraphQL::Types::String, null: true, - description: 'Revision of the runner.' + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'Revision of the runner.' field :run_untagged, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is able to run untagged jobs.' field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false, @@ -112,7 +118,8 @@ module Types description: 'Runner token expiration time.', method: :token_expires_at field :version, GraphQL::Types::String, null: true, - description: 'Version of the runner.' + deprecated: { reason: "Use field in `manager` object instead", milestone: '16.2' }, + description: 'Version of the runner.' markdown_field :maintenance_note_html, null: true @@ -120,28 +127,6 @@ module Types ::MarkupHelper.markdown(object.maintenance_note, context.to_h.dup) end - def job_count - BatchLoader::GraphQL.for(runner.id).batch(key: :job_count) do |runner_ids, loader, _args| - # rubocop: disable CodeReuse/ActiveRecord - # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT - builds_tbl = ::Ci::Build.arel_table - runners_tbl = ::Ci::Runner.arel_table - lateral_query = ::Ci::Build.select(1) - .where(builds_tbl['runner_id'].eq(runners_tbl['id'])) - .limit(JOB_COUNT_LIMIT + 1) - counts = ::Ci::Runner.joins("JOIN LATERAL (#{lateral_query.to_sql}) builds_with_limit ON true") - .id_in(runner_ids) - .select(:id, Arel.star.count.as('count')) - .group(:id) - .index_by(&:id) - # rubocop: enable CodeReuse/ActiveRecord - - runner_ids.each do |runner_id| - loader.call(runner_id, counts[runner_id]&.count || 0) - end - end - end - def admin_url Gitlab::Routing.url_helpers.admin_runner_url(runner) if can_admin_runners? end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index c0f3d1db57b..a9d8075329d 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -33,7 +33,7 @@ module Types by_pipeline = keys.group_by(&:pipeline) include_needs = keys.any? do |k| k.requires?(%i[nodes jobs nodes needs]) || - k.requires?(%i[nodes jobs nodes previousStageJobsAndNeeds]) + k.requires?(%i[nodes jobs nodes previousStageJobsOrNeeds]) end by_pipeline.each do |pl, key_group| diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb index 4c4cb516979..d5441ea1d15 100644 --- a/app/graphql/types/current_user_todos.rb +++ b/app/graphql/types/current_user_todos.rb @@ -17,7 +17,8 @@ module Types def current_user_todos(state: nil) state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending` - key = [state, unpresented.class.name] + target_type_name = unpresented.try(:todoable_target_type_name) || unpresented.class.name + key = [state, target_type_name] BatchLoader::GraphQL.for(unpresented).batch(default_value: [], key: key) do |targets, loader, args| state, klass_name = args[:key] diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index 936ad52200c..aee09e5a143 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -33,6 +33,9 @@ module Types field :external_url, GraphQL::Types::String, null: true, description: 'External URL of the environment.' + field :kubernetes_namespace, GraphQL::Types::String, null: true, + description: 'Kubernetes namespace of the environment.' + field :created_at, Types::TimeType, description: 'When the environment was created.' @@ -51,11 +54,6 @@ module Types field :environment_type, GraphQL::Types::String, description: 'Folder name of the environment.' - field :metrics_dashboard, Types::Metrics::DashboardType, null: true, - description: 'Metrics dashboard schema for the environment.', - resolver: Resolvers::Metrics::DashboardResolver, - deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0', milestone: '16.0' } - field :latest_opened_most_severe_alert, Types::AlertManagement::AlertType, null: true, diff --git a/app/graphql/types/ide_type.rb b/app/graphql/types/ide_type.rb new file mode 100644 index 00000000000..34447577f23 --- /dev/null +++ b/app/graphql/types/ide_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class IdeType < BaseObject + graphql_name 'Ide' + description 'IDE settings and feature flags.' + + authorize :read_user + + field :code_suggestions_enabled, GraphQL::Types::Boolean, null: false, + description: 'Indicates whether AI assisted code suggestions are enabled.' + + def code_suggestions_enabled + object.can?(:access_code_suggestions) + end + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index f32dfc0dbcf..99c719f1402 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -35,7 +35,7 @@ module Types field :iid, GraphQL::Types::String, null: false, description: 'Internal ID of the merge request.' field :merge_when_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' + description: 'Indicates if the merge has been set to auto-merge.' field :merged_at, Types::TimeType, null: true, complexity: 5, description: 'Timestamp of when the merge request was merged, null if not merged.' field :project, Types::ProjectType, null: false, @@ -207,7 +207,7 @@ module Types field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?, description: 'Indicates if the merge request has CI.' field :merge_user, Types::UserType, null: true, - description: 'User who merged this merge request or set it to merge when pipeline succeeds.' + description: 'User who merged this merge request or set it to auto-merge.' field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true, description: 'Indicates if the merge request is mergeable.' field :security_auto_fix, GraphQL::Types::Boolean, null: true, diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb deleted file mode 100644 index 5570b904d79..00000000000 --- a/app/graphql/types/metrics/dashboard_type.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Types - module Metrics - # rubocop: disable Graphql/AuthorizeTypes - # Authorization is performed at environment level - class DashboardType < ::Types::BaseObject - graphql_name 'MetricsDashboard' - - field :path, GraphQL::Types::String, null: true, - description: 'Path to a file with the dashboard definition.' - - field :schema_validation_warnings, - [GraphQL::Types::String], - null: true, - description: 'Dashboard schema validation warnings.' - - field :annotations, - Types::Metrics::Dashboards::AnnotationType.connection_type, - null: true, - description: 'Annotations added to the dashboard.', - resolver: Resolvers::Metrics::Dashboards::AnnotationResolver - - # In order to maintain backward compatibility we need to return NULL when there are no warnings - # and dashboard validation returns an empty array when there are no issues. - def schema_validation_warnings - warnings = object.schema_validation_warnings - warnings unless warnings.empty? - end - end - # rubocop: enable Graphql/AuthorizeTypes - end -end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index a1d721856a9..ef4edcddbe9 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -35,3 +35,5 @@ module Types description: 'Wiki size of the project in bytes.' end end + +Types::ProjectStatisticsType.prepend_mod_with('Types::ProjectStatisticsType') diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index f8a516501c3..992663b4d98 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -727,6 +727,8 @@ module Types if minimum_access_level.nil? object.forks.public_or_visible_to_user(current_user) else + return [] if current_user.nil? + object.forks.visible_to_user_and_access_level(current_user, minimum_access_level) end end diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index 67ee0589882..dbed51ac71a 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -8,11 +8,19 @@ module Types field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.' field :container_registry_size, GraphQL::Types::Float, null: false, description: 'Container Registry size in bytes.' + field :container_registry_size_is_estimated, GraphQL::Types::Boolean, method: :registry_size_estimated, null: false, + description: 'Indicates whether the deduplicated Container Registry size for ' \ + 'the namespace is an estimated value or not.' field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.' field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.' field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.' - field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.' - field :registry_size_estimated, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the deduplicated Container Registry size for the namespace is an estimated value or not.' + field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, + description: 'CI pipeline artifacts size in bytes.' + field :registry_size_estimated, GraphQL::Types::Boolean, + null: false, + deprecated: { reason: 'Use `container_registry_size_is_estimated`', milestone: '16.2' }, + description: 'Indicates whether the deduplicated Container Registry size for ' \ + 'the namespace is an estimated value or not.' field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.' field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.' field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.' @@ -20,3 +28,5 @@ module Types field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.' end end + +Types::RootStorageStatisticsType.prepend_mod_with('Types::RootStorageStatisticsType') diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 5357f2f8e66..9e5f6810aca 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -197,6 +197,17 @@ module Types null: true, description: 'Timestamp of when the user was created.' + field :pronouns, + type: ::GraphQL::Types::String, + null: true, + description: 'Pronouns of the user.' + + field :ide, + type: Types::IdeType, + null: true, + description: 'IDE settings.', + method: :itself + definition_methods do def resolve_type(object, context) # in the absence of other information, we cannot tell - just default to diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb index 0a7f20caa02..9ea07ba4e6e 100644 --- a/app/helpers/admin/application_settings/settings_helper.rb +++ b/app/helpers/admin/application_settings/settings_helper.rb @@ -16,12 +16,23 @@ module Admin project.repository&.gitlab_ci_yml.blank? end + def code_suggestions_description + link_start = code_suggestions_link_start(code_suggestions_docs_url) + + # rubocop:disable Layout/LineLength + # rubocop:disable Style/FormatString + s_('CodeSuggestionsSM|Enable Code Suggestions for users of this instance. %{link_start}What are Code Suggestions?%{link_end}') + .html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + # rubocop:enable Style/FormatString + # rubocop:enable Layout/LineLength + end + def code_suggestions_token_explanation link_start = code_suggestions_link_start(code_suggestions_pat_docs_url) # rubocop:disable Layout/LineLength # rubocop:disable Style/FormatString - s_('CodeSuggestionsSM|Your personal access token from GitLab.com. See the %{link_start}documentation%{link_end} for information on creating a personal access token.') + s_('CodeSuggestionsSM|On GitLab.com, create a token. This token is required to use Code Suggestions on your self-managed instance. %{link_start}How do I create a token?%{link_end}') .html_safe % { link_start: link_start, link_end: '</a>'.html_safe } # rubocop:enable Style/FormatString # rubocop:enable Layout/LineLength @@ -33,8 +44,8 @@ module Admin # rubocop:disable Layout/LineLength # rubocop:disable Style/FormatString - s_('CodeSuggestionsSM|• Agree to the %{terms_link_start}GitLab Testing Agreement%{link_end}.%{br} • Acknowledge that GitLab will send data from the instance, including personal data, to Google for cloud hosting.%{br} We may also send data to %{ai_docs_link_start}third-party AI providers%{link_end} to provide this feature.') - .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe, br: '</br>'.html_safe } + s_('CodeSuggestionsSM|By enabling this feature, you agree to the %{terms_link_start}GitLab Testing Agreement%{link_end} and acknowledge that GitLab will send data from the instance, including personal data, to our %{ai_docs_link_start}AI providers%{link_end} to provide this feature.') + .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe } # rubocop:enable Style/FormatString # rubocop:enable Layout/LineLength end @@ -53,7 +64,7 @@ module Admin end def code_suggestions_ai_docs_url - 'https://docs.gitlab.com/ee/user/ai_features.html' + 'https://docs.gitlab.com/ee/user/ai_features.html#third-party-services' end def code_suggestions_pat_docs_url diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7f1c28de8a7..ce338a8afdc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -124,7 +124,8 @@ module ApplicationHelper page: body_data_page, page_type_id: controller.params[:id], find_file: find_file_path, - group: @group&.path + group: @group&.path, + group_full_path: @group&.full_path }.merge(project_data) end @@ -135,6 +136,7 @@ module ApplicationHelper project_id: @project.id, project: @project.path, group: @project.group&.path, + group_full_path: @project.group&.full_path, namespace_id: @project.namespace&.id } end @@ -274,15 +276,7 @@ module ApplicationHelper end def stylesheet_link_tag_defer(path) - if startup_css_enabled? - stylesheet_link_tag(path, media: "print", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) - else - stylesheet_link_tag(path, media: "all", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) - end - end - - def startup_css_enabled? - !Feature.enabled?(:remove_startup_css) && !params.has_key?(:no_startup_css) + stylesheet_link_tag(path, media: "all", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) end def sign_in_with_redirect? @@ -336,7 +330,7 @@ module ApplicationHelper class_names << 'with-system-header' if appearance.show_header? class_names << 'with-system-footer' if appearance.show_footer? - class_names + class_names.join(' ') end # Returns active css class when condition returns true @@ -354,7 +348,7 @@ module ApplicationHelper def linkedin_url(user) name = user.linkedin - if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/} + if %r{\Ahttps?://(www\.)?linkedin\.com/in/}.match?(name) name else "https://www.linkedin.com/in/#{name}" @@ -363,7 +357,7 @@ module ApplicationHelper def twitter_url(user) name = user.twitter - if name =~ %r{\Ahttps?://(www\.)?twitter\.com/} + if %r{\Ahttps?://(www\.)?twitter\.com/}.match?(name) name else "https://twitter.com/#{name}" diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index adbf7ab7cf2..aa2466372e1 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -497,7 +497,8 @@ module ApplicationSettingsHelper :projects_api_rate_limit_unauthenticated, :gitlab_dedicated_instance, :ci_max_includes, - :allow_account_deletion + :allow_account_deletion, + :gitlab_shell_operation_limit ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 0feaee2bd93..c928c6479de 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -53,7 +53,8 @@ module AuthHelper saml: 'saml_login_button', openid_connect: 'oidc_login_button', github: 'github_login_button', - gitlab: 'gitlab_oauth_login_button' + gitlab: 'gitlab_oauth_login_button', + facebook: 'facebook_login_button' }[provider.to_sym] end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index be9306ce80b..6746e6549ec 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -118,8 +118,8 @@ module BlobHelper "#{blob_raw_path.rpartition('/').first}/" end - # SVGs can contain malicious JavaScript; only include whitelisted - # elements and attributes. Note that this whitelist is by no means complete + # SVGs can contain malicious JavaScript; only include allowlisted + # elements and attributes. Note that this allowlist is by no means complete # and may omit some elements. def sanitize_svg_data(data) Gitlab::Sanitizers::SVG.clean(data) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 64d6ba155cd..6e0ba748d85 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -98,6 +98,68 @@ module ButtonHelper href: href, data: data end + + # Creates a link that looks like a button. + # + # It renders a Pajamas::ButtonComponent. + # + # It has the same API as `link_to`, but with some additional options + # specific to button rendering. + # + # Examples: + # # Default button + # link_button_to _('Foo'), some_path + # + # # Default button using a block + # link_button_to some_path do + # _('Foo') + # end + # + # # Confirm variant + # link_button_to _('Foo'), some_path, variant: :confirm + # + # # With icon + # link_button_to _('Foo'), some_path, icon: 'pencil' + # + # # Icon-only + # # NOTE: The content must be `nil` in order to correctly render. Use aria-label + # # to ensure the link is accessible. + # link_button_to nil, some_path, icon: 'pencil', 'aria-label': _('Foo') + # + # # Small button + # link_button_to _('Foo'), some_path, size: :small + # + # # Secondary category danger button + # link_button_to _('Foo'), some_path, variant: :danger, category: :secondary + # + # For accessibility, ensure that icon-only links have aria-label set. + def link_button_to(name = nil, href = nil, options = nil, &block) + if block + options = href + href = name + end + + options ||= {} + + # Ignore args that don't make sense for links, like disabled, loading, etc. + options_for_button = %i[ + category + variant + size + block + selected + icon + target + method + ] + + args = options.slice(*options_for_button) + button_options = options.except(*options_for_button) + + render Pajamas::ButtonComponent.new(href: href, **args, button_options: button_options) do + block.present? ? yield : name + end + end end ButtonHelper.prepend_mod_with('ButtonHelper') diff --git a/app/helpers/calendar_helper.rb b/app/helpers/calendar_helper.rb index ad4116fc3da..d70a860d468 100644 --- a/app/helpers/calendar_helper.rb +++ b/app/helpers/calendar_helper.rb @@ -3,7 +3,7 @@ module CalendarHelper def calendar_url_options { format: :ics, - feed_token: current_user.try(:feed_token), + feed_token: generate_feed_token(:ics), due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, sort: 'closest_future_date' } end diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index a7e1de173bd..991b1f4d74e 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -2,16 +2,16 @@ module Ci module JobsHelper - def jobs_data + def jobs_data(project, build) { - "endpoint" => project_job_path(@project, @build, format: :json), - "project_path" => @project.full_path, + "endpoint" => project_job_path(project, build, format: :json), + "project_path" => project.full_path, "artifact_help_url" => help_page_path('user/gitlab_com/index.md', anchor: 'gitlab-cicd'), "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'), - "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'), - "page_path" => project_job_path(@project, @build), - "build_status" => @build.status, - "build_stage" => @build.stage_name, + "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'), + "page_path" => project_job_path(project, build), + "build_status" => build.status, + "build_stage" => build.stage_name, "log_state" => '', "build_options" => javascript_build_options, "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb new file mode 100644 index 00000000000..e5125353b99 --- /dev/null +++ b/app/helpers/ci/pipeline_schedules_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedulesHelper + def js_pipeline_schedules_form_data(project, schedule) + { + full_path: project.full_path, + daily_limit: schedule.daily_limit, + timezone_data: timezone_data.to_json, + project_id: project.id, + default_branch: project.default_branch, + settings_link: project_settings_ci_cd_path(project), + schedules_path: pipeline_schedules_path(project) + } + end + end +end + +Ci::PipelineSchedulesHelper.prepend_mod_with('Ci::PipelineSchedulesHelper') diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index b222ca5538d..a034e4331c0 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -68,18 +68,6 @@ module Ci ] end - def has_pipeline_badges?(pipeline) - pipeline.schedule? || - pipeline.child? || - pipeline.latest? || - pipeline.merge_train_pipeline? || - pipeline.has_yaml_errors? || - pipeline.failure_reason? || - pipeline.auto_devops_source? || - pipeline.detached_merge_request_pipeline? || - pipeline.stuck? - end - def pipelines_list_data(project, list_url) artifacts_endpoint_placeholder = ':pipeline_artifacts_id' diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 458d81b3401..5c410a28229 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -57,12 +57,6 @@ module ClustersHelper render_if_exists 'clusters/clusters/environments' when 'apps' render 'applications' - when 'integrations' - if Feature.enabled?(:remove_monitor_metrics) - render('details', expanded: expanded) - else - render 'integrations' - end when 'settings' render 'advanced_settings_container' else diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb index bc72122220a..80cf6f197e5 100644 --- a/app/helpers/colors_helper.rb +++ b/app/helpers/colors_helper.rb @@ -4,7 +4,9 @@ module ColorsHelper HEX_COLOR_PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze def hex_color_to_rgb_array(hex_color) - raise ArgumentError, "invalid hex color `#{hex_color}`" unless hex_color =~ HEX_COLOR_PATTERN + unless hex_color.is_a?(String) && HEX_COLOR_PATTERN.match?(hex_color) + raise ArgumentError, "invalid hex color `#{hex_color}`" + end hex_color.length == 7 ? hex_color[1, 7].scan(/.{2}/).map(&:hex) : hex_color[1, 4].scan(/./).map { |v| (v * 2).hex } end diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 9f4ed6b8150..7213bd074fc 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -41,7 +41,7 @@ module EmailsHelper end def sanitize_name(name) - if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF] + if URI::DEFAULT_PARSER.regexp[:URI_REF].match?(name) name.tr('.', '_') else name diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 00109212934..8140ee97291 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -79,7 +79,6 @@ module EnvironmentHelper can_destroy_environment: can_destroy_environment?(environment), can_stop_environment: can?(current_user, :stop_environment, environment), can_admin_environment: can?(current_user, :admin_environment, project), - **environment_metrics_path(project, environment), environments_fetch_path: project_environments_path(project, format: :json), environment_edit_path: edit_project_environment_path(project, environment), environment_stop_path: stop_project_environment_path(project, environment), @@ -96,10 +95,4 @@ module EnvironmentHelper def environments_detail_data_json(user, project, environment) environments_detail_data(user, project, environment).to_json end - - def environment_metrics_path(project, environment) - return {} if Feature.enabled?(:remove_monitor_metrics) - - { environment_metrics_path: project_metrics_dashboard_path(project, environment: environment) } - end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 525fdd3e9f6..3360a5256af 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -26,7 +26,7 @@ module EnvironmentsHelper metrics_data = {} metrics_data.merge!(project_metrics_data(project)) if project - metrics_data.merge!(environment_metrics_data(environment, project)) if environment + metrics_data.merge!(environment_metrics_data(environment)) if environment metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment metrics_data.merge!(static_metrics_data) @@ -46,14 +46,6 @@ module EnvironmentsHelper can?(current_user, :destroy_environment, environment) end - def environment_data(environment) - Gitlab::Json.generate({ - id: environment.id, - name: environment.name, - external_url: environment.external_url - }) - end - private def project_metrics_data(project) @@ -74,34 +66,20 @@ module EnvironmentsHelper } end - def environment_metrics_data(environment, project = nil) + def environment_metrics_data(environment) return {} unless environment { - 'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project), 'current_environment_name' => environment.name, 'has_metrics' => environment.has_metrics?.to_s, 'environment_state' => environment.state.to_s } end - def metrics_dashboard_base_path(environment, project) - # This is needed to support our transition from environment scoped metric paths to project scoped. - if project - path = project_metrics_dashboard_path(project) - - return path if request.path.include?(path) - end - - project_metrics_dashboard_path(project, environment: environment) - end - def project_and_environment_metrics_data(project, environment) return {} unless project && environment { - 'metrics_endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), - 'dashboard_endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json), 'deployments_endpoint' => project_environment_deployments_path(project, environment, format: :json), 'operations_settings_path' => project_settings_operations_path(project), 'can_access_operations_settings' => can?(current_user, :admin_operations, project).to_s, diff --git a/app/helpers/feed_token_helper.rb b/app/helpers/feed_token_helper.rb new file mode 100644 index 00000000000..751a8df4782 --- /dev/null +++ b/app/helpers/feed_token_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FeedTokenHelper + def generate_feed_token(type) + feed_token = current_user&.feed_token + return unless feed_token + + final_path = "#{current_request.path}.#{type}" + digest = OpenSSL::HMAC.hexdigest("SHA256", feed_token, final_path) + "#{User::FEED_TOKEN_PREFIX}#{digest}-#{current_user.id}" + end +end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 3d0b899e867..d5f38debae4 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -165,8 +165,8 @@ module FormHelper def multiple_assignees_dropdown_options(options) new_options = options.dup - new_options[:title] = _('Select assignee(s)') - new_options[:data][:'dropdown-header'] = 'Assignee(s)' + new_options[:title] = _('Select assignees') + new_options[:data][:'dropdown-header'] = 'Assignees' new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS new_options @@ -175,8 +175,8 @@ module FormHelper def multiple_reviewers_dropdown_options(options) new_options = options.dup - new_options[:title] = _('Select reviewer(s)') - new_options[:data][:'dropdown-header'] = _('Reviewer(s)') + new_options[:title] = _('Select reviewers') + new_options[:data][:'dropdown-header'] = _('Reviewers') new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index a4f463a23be..e552b01f7ba 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -25,6 +25,10 @@ module GroupsHelper Ability.allowed?(current_user, :admin_group_member, group) end + def can_admin_service_accounts?(group) + false + end + def group_icon_url(group, options = {}) if group.is_a?(String) group = Group.find_by_full_path(group) @@ -143,6 +147,7 @@ module GroupsHelper def group_overview_tabs_app_data(group) { + group_id: group.id, subgroups_and_projects_endpoint: group_children_path(group, format: :json), shared_projects_endpoint: group_shared_projects_path(group, format: :json), archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'), diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index ffea23bf55d..4b5fadf3397 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -30,6 +30,10 @@ module IntegrationsHelper _("Alert") when "incident" _("Incident") + when "group_mention" + _("Group mention in public") + when "group_confidential_mention" + _("Group mention in private") end end # rubocop:enable Metrics/CyclomaticComplexity @@ -290,6 +294,10 @@ module IntegrationsHelper s_("ProjectService|Trigger event when a new, unique alert is recorded.") when "incident" s_("ProjectService|Trigger event when an incident is created.") + when "group_mention" + s_("ProjectService|Trigger event when a group is mentioned in a public context.") + when "group_confidential_mention" + s_("ProjectService|Trigger event when a group is mentioned in a confidential context.") end end # rubocop:enable Metrics/CyclomaticComplexity diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index e247577aed0..e921e9bae4d 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -43,14 +43,10 @@ module IssuablesHelper due_date_with_remaining_days(milestone[:due_date], milestone[:start_date]) end - def sidebar_due_date_tooltip_label(due_date) - [_('Due date'), due_date_with_remaining_days(due_date)].compact.join('<br/>') - end - def due_date_with_remaining_days(due_date, start_date = nil) return unless due_date - "#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date, start_date)})" + "#{due_date.to_fs(:medium)} (#{remaining_days_in_words(due_date, start_date)})" end def multi_label_name(current_labels, default_label) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 341c50abf84..d9b9b27d16c 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -231,13 +231,15 @@ module IssuesHelper can_read_crm_organization: can?(current_user, :read_crm_organization, group).to_s, has_any_issues: @has_issues.to_s, has_any_projects: @has_projects.to_s, - new_project_path: new_project_path(namespace_id: group.id) + new_project_path: new_project_path(namespace_id: group.id), + group_id: group.id ) end def dashboard_issues_list_data(current_user) { autocomplete_award_emojis_path: autocomplete_award_emojis_path, + autocomplete_users_path: autocomplete_users_path, calendar_path: url_for(safe_params.merge(calendar_url_options)), dashboard_labels_path: dashboard_labels_path(format: :json, include_ancestor_groups: true), dashboard_milestones_path: dashboard_milestones_path(format: :json), diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 01030690daf..ff5e4248d98 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -5,14 +5,6 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - def namespace_icon(namespace, size = 40) - if namespace.is_a?(Group) - group_icon_url(namespace) - else - avatar_icon_for_user(namespace.owner, size) - end - end - def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index 201007863b2..306c4d8694e 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -70,7 +70,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', track_property: 'navigation_top', qa_selector: 'new_issue_link' } + data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'new_issue_link' } ) ) end @@ -116,7 +116,7 @@ module Nav id: 'general_new_project', title: _('New project/repository'), href: new_project_path, - data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_project_link' } + data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_project_link' } ) ) end @@ -127,7 +127,7 @@ module Nav id: 'general_new_group', title: _('New group'), href: new_group_path, - data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_group_link' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_group_link' } ) ) end @@ -138,7 +138,7 @@ module Nav id: 'general_new_snippet', title: _('New snippet'), href: new_snippet_path, - data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', qa_selector: 'global_new_snippet_link' } + data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_snippet_link' } ) ) end diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index c41cf7f500f..d74efac76aa 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -109,7 +109,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", track_property: "navigation_top", qa_selector: "projects_dropdown" }, + data: { track_label: "projects_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "projects_dropdown" }, view: PROJECTS_VIEW, shortcut_href: dashboard_projects_path, **projects_menu_item_attrs @@ -123,7 +123,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", track_property: "navigation_top", qa_selector: "groups_dropdown" }, + data: { track_label: "groups_dropdown", track_action: "click_dropdown", track_property: "navigation_top", testid: "groups_dropdown" }, view: GROUPS_VIEW, shortcut_href: dashboard_groups_path, **groups_menu_item_attrs @@ -218,7 +218,7 @@ module Nav active: active_nav_link?(controller: 'admin/sessions'), icon: 'lock', href: new_admin_session_path, - data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } ) end end @@ -316,7 +316,7 @@ module Nav id: 'your', title: title, href: dashboard_projects_path, - data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } ) end @@ -330,7 +330,7 @@ module Nav id: 'your', title: title, href: dashboard_groups_path, - data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + data: { testid: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } ) builder.build end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 8861f1ffe9a..31fcc77925b 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -74,6 +74,16 @@ module PackagesHelper Ability.allowed?(current_user, :admin_group, group) end + def can_delete_packages?(project) + Gitlab.config.packages.enabled && + Ability.allowed?(current_user, :destroy_package, project) + end + + def can_delete_group_packages?(group) + group.packages_feature_enabled? && + Ability.allowed?(current_user, :destroy_package, group) + end + def cleanup_settings_data { project_id: @project.id, diff --git a/app/helpers/projects/observability_helper.rb b/app/helpers/projects/observability_helper.rb new file mode 100644 index 00000000000..24bc1928a36 --- /dev/null +++ b/app/helpers/projects/observability_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Projects + module ObservabilityHelper + def observability_tracing_view_model(project) + Gitlab::Json.generate({ + tracingUrl: Gitlab::Observability.tracing_url(project), + provisioningUrl: Gitlab::Observability.provisioning_url(project), + oauthUrl: Gitlab::Observability.oauth_url + }) + end + end +end diff --git a/app/helpers/projects/pages_helper.rb b/app/helpers/projects/pages_helper.rb index f46c11db1db..d90ea0ec598 100644 --- a/app/helpers/projects/pages_helper.rb +++ b/app/helpers/projects/pages_helper.rb @@ -7,5 +7,17 @@ module Projects (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) && project.can_create_custom_domains? end + + def pages_subdomain(project) + Gitlab::Pages::UrlBuilder + .new(project) + .project_namespace + end + + def build_pages_url(project, with_unique_domain:) + Gitlab::Pages::UrlBuilder + .new(project) + .pages_url(with_unique_domain: with_unique_domain) + end end end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index caebbd5250e..42e8e44c94c 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -44,7 +44,7 @@ module Projects failed: pipeline.failure_reason?.to_s, auto_devops: pipeline.auto_devops_source?.to_s, detached: pipeline.detached_merge_request_pipeline?.to_s, - stuck: pipeline.stuck?, + stuck: pipeline.stuck?.to_s, ref_text: pipeline.ref_text } end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 9415e7d4dc3..e27ee1acb22 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -172,18 +172,6 @@ module ProjectsHelper project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source) end - def project_search_tabs?(tab) - return false unless @project.present? - - abilities = Array(search_tab_ability_map[tab]) - - if @project.respond_to?(:each) # support multi-project select - @project.any? { |project| abilities.any? { |ability| can?(current_user, ability, project) } } - else - abilities.any? { |ability| can?(current_user, ability, @project) } - end - end - def can_change_visibility_level?(project, current_user) can?(current_user, :change_visibility_level, project) end @@ -511,9 +499,9 @@ module ProjectsHelper def clusters_deprecation_alert_message if has_active_license? - s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. Contact GitLab Support if you have any additional questions.') + s_('ClusterIntegration|The certificate-based Kubernetes integration is deprecated and will be removed in the future. You should %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. For more information, see the %{deprecationLinkStart}deprecation epic%{deprecationLinkEnd}, or contact GitLab support.') else - s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.') + s_('ClusterIntegration|The certificate-based Kubernetes integration is deprecated and will be removed in the future. You should %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. For more information, see the %{deprecationLinkStart}deprecation epic%{deprecationLinkEnd}.') end end @@ -547,8 +535,32 @@ module ProjectsHelper project.ssh_url_to_repo end + def can_view_branch_rules? + can?(current_user, :maintainer_access, @project) + end + + def can_push_code? + current_user&.can?(:push_code, @project) + end + + def can_admin_associated_clusters?(project) + can_admin_project_clusters?(project) || can_admin_group_clusters?(project) + end + + def branch_rules_path + project_settings_repository_path(@project, anchor: 'js-branch-rules') + end + private + def can_admin_project_clusters?(project) + project.clusters.any? && can?(current_user, :admin_cluster, project) + end + + def can_admin_group_clusters?(project) + project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group) + end + def create_merge_request_path(project, source_project, ref, merge_request) return if merge_request.present? return unless can?(current_user, :create_merge_request_from, project) @@ -590,41 +602,6 @@ module ProjectsHelper s_(str).html_safe % { provider: provider, link_start: link_start, link_end: '</a>'.html_safe } end - def tab_ability_map - { - cycle_analytics: :read_cycle_analytics, - environments: :read_environment, - metrics_dashboards: :metrics_dashboard, - milestones: :read_milestone, - snippets: :read_snippet, - settings: :admin_project, - builds: :read_build, - clusters: :read_cluster, - serverless: :read_cluster, - terraform: :read_terraform_state, - error_tracking: :read_sentry_issue, - alert_management: :read_alert_management_alert, - incidents: :read_issue, - labels: :read_label, - issues: :read_issue, - project_members: :read_project_member, - wiki: :read_wiki, - feature_flags: :read_feature_flag, - analytics: :read_analytics - } - end - - def search_tab_ability_map - @search_tab_ability_map ||= tab_ability_map.merge( - blobs: :read_code, - commits: :read_code, - merge_requests: :read_merge_request, - notes: [:read_merge_request, :read_code, :read_issue, :read_snippet], - users: :read_project_member, - wiki_blobs: :read_wiki - ) - end - def project_lfs_status(project) if project.lfs_enabled? content_tag(:span, class: 'lfs-enabled') do @@ -880,24 +857,4 @@ module ProjectsHelper end end -def can_admin_associated_clusters?(project) - can_admin_project_clusters?(project) || can_admin_group_clusters?(project) -end - -def can_admin_project_clusters?(project) - project.clusters.any? && can?(current_user, :admin_cluster, project) -end - -def can_admin_group_clusters?(project) - project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group) -end - -def can_view_branch_rules? - can?(current_user, :maintainer_access, @project) -end - -def branch_rules_path - project_settings_repository_path(@project, anchor: 'js-branch-rules') -end - ProjectsHelper.prepend_mod_with('ProjectsHelper') diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb index 67c7d244f11..90dd4e8fedb 100644 --- a/app/helpers/rss_helper.rb +++ b/app/helpers/rss_helper.rb @@ -2,6 +2,6 @@ module RssHelper def rss_url_options - { format: :atom, feed_token: current_user.try(:feed_token) } + { format: :atom, feed_token: generate_feed_token(:atom) } end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 8fbbd18c9ae..cd32023adb6 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -373,17 +373,10 @@ module SearchHelper def users_autocomplete(term, limit = 5) return [] unless current_user && Ability.allowed?(current_user, :read_users_list) - users = if Feature.enabled?(:autocomplete_users_use_search_service) - ::SearchService - .new(current_user, { scope: 'users', per_page: limit, search: term }) - .search_objects - else - is_current_user_admin = current_user.can_admin_all_resources? - scope = is_current_user_admin ? User.all : User.without_forbidden_states - scope.search(term, with_private_emails: is_current_user_admin, use_minimum_char_limit: false).limit(limit) - end - - users.map do |user| + ::SearchService + .new(current_user, { scope: 'users', per_page: limit, search: term }) + .search_objects + .map do |user| { category: "Users", id: user.id, @@ -471,65 +464,15 @@ module SearchHelper result end - def show_code_search_tab? - return true if project_search_tabs?(:blobs) - - @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab) - end - - def show_wiki_search_tab? - return true if project_search_tabs?(:wiki_blobs) - - @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_wiki_tab) - end - - def show_commits_search_tab? - return true if project_search_tabs?(:commits) - - @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab) - end - - def show_issues_search_tab? - return true if project_search_tabs?(:issues) - - @project.nil? && feature_flag_tab_enabled?(:global_search_issues_tab) - end - - def show_merge_requests_search_tab? - return true if project_search_tabs?(:merge_requests) - - @project.nil? && feature_flag_tab_enabled?(:global_search_merge_requests_tab) - end - - def show_comments_search_tab? - return true if project_search_tabs?(:notes) - - @project.nil? && search_service.show_elasticsearch_tabs? - end - - def show_snippets_search_tab? - search_service.show_snippets? && @project.nil? && feature_flag_tab_enabled?(:global_search_snippet_titles_tab) - end - - # search page scope navigation - def search_navigation + def nav_options { - projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? }, - blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: show_code_search_tab? }, - # sort: 3 is reserved for EE items - issues: { sort: 4, label: _("Issues"), condition: show_issues_search_tab? }, - merge_requests: { sort: 5, label: _("Merge requests"), condition: show_merge_requests_search_tab? }, - wiki_blobs: { sort: 6, label: _("Wiki"), condition: show_wiki_search_tab? }, - commits: { sort: 7, label: _("Commits"), condition: show_commits_search_tab? }, - notes: { sort: 8, label: _("Comments"), condition: show_comments_search_tab? }, - milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? }, - users: { sort: 10, label: _("Users"), condition: show_user_search_tab? }, - snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: show_snippets_search_tab? } + show_snippets: search_service.show_snippets? } end def search_navigation_json - sorted_navigation = search_navigation.sort_by { |_, h| h[:sort] } + search_navigation = Search::Navigation.new(user: current_user, project: @project, group: @group, options: nav_options) + sorted_navigation = search_navigation.tabs.sort_by { |_, h| h[:sort] } sorted_navigation.each_with_object({}) do |(key, value), hash| hash[key] = search_filter_link_json(key, value[:label], value[:data], value[:search]) if value[:condition] @@ -611,14 +554,6 @@ module SearchHelper simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-gray-900 gl-font-weight-bold">\1</span>') end - def show_user_search_tab? - return project_search_tabs?(:users) if @project - return false unless can?(current_user, :read_users_list) - return true if @group - - Feature.enabled?(:global_search_users_tab, current_user, type: :ops) - end - def issuable_state_to_badge_class(issuable) # Closed is considered "danger" for MR so we need to handle separately if issuable.is_a?(::MergeRequest) @@ -647,10 +582,6 @@ module SearchHelper end end - def feature_flag_tab_enabled?(flag) - @group.present? || Feature.enabled?(flag, current_user, type: :ops) - end - def sanitized_search_params sanitized_params = params.dup @@ -664,6 +595,10 @@ module SearchHelper sanitized_params end + + def wiki_blob_link(wiki_blob) + project_wiki_path(wiki_blob.project, wiki_blob.basename) + end end SearchHelper.prepend_mod_with('SearchHelper') diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 02a912d0227..90917cb96e0 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -23,6 +23,10 @@ module SidebarsHelper end end + def organization_sidebar_context(organization, user, **args) + Sidebars::Context.new(container: organization, current_user: user, **args) + end + def project_sidebar_context(project, user, current_ref, ref_type: nil, **args) context_data = project_sidebar_context_data(project, user, current_ref, ref_type: ref_type) Sidebars::Projects::Context.new(**context_data, **args) @@ -95,7 +99,7 @@ module SidebarsHelper def super_sidebar_nav_panel( nav: nil, project: nil, user: nil, group: nil, current_ref: nil, ref_type: nil, - viewed_user: nil) + viewed_user: nil, organization: nil) context_adds = { route_is_active: method(:active_nav_link?), is_super_sidebar: true } case nav when 'project' @@ -117,12 +121,25 @@ module SidebarsHelper Sidebars::Search::Panel.new(context) when 'admin' Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds)) + when 'organization' + context = organization_sidebar_context(organization, user, **context_adds) + Sidebars::Organizations::SuperSidebarPanel.new(context) else context = your_work_sidebar_context(user, **context_adds) Sidebars::YourWork::Panel.new(context) end end + def command_palette_data(project: nil) + return {} unless project&.repo_exists? + return {} if project.empty_repo? + + { + project_files_url: project_files_path(project, project.default_branch, format: :json), + project_blob_url: project_blob_path(project, project.default_branch) + } + end + private def search_data @@ -142,7 +159,8 @@ module SidebarsHelper customized: user.status&.customized?, availability: user.status&.availability.to_s, emoji: user.status&.emoji, - message: user.status&.message_html&.html_safe, + message_html: user.status&.message_html&.html_safe, + message: user.status&.message, clear_after: user_clear_status_at(user) } end @@ -162,7 +180,7 @@ module SidebarsHelper 'data-track-label': item[:id], 'data-track-action': 'click_link', 'data-track-property': 'nav_create_menu', - 'data-qa-selector': 'create_menu_item', + 'data-testid': 'create_menu_item', 'data-qa-create-menu-item': item[:id] } } diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 9038d972f65..1405bc7be37 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -2,6 +2,7 @@ module SortingHelper include SortingTitlesValuesHelper + include ButtonHelper # rubocop: disable Metrics/AbcSize def sort_options_hash @@ -167,10 +168,6 @@ module SortingHelper } end - def sortable_item(item, path, sorted_by) - link_to item, path, class: sorted_by == item ? 'is-active' : '' - end - def issuable_sort_option_overrides { sort_value_oldest_created => sort_value_created_date, @@ -275,7 +272,7 @@ module SortingHelper end def sort_direction_button(reverse_url, reverse_sort, sort_value) - link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn rspec-reverse-sort' + link_class = 'has-tooltip reverse-sort-btn rspec-reverse-sort' icon = sort_direction_icon(sort_value) url = reverse_url @@ -284,9 +281,7 @@ module SortingHelper link_class += ' disabled' end - link_to(url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do - sprite_icon(icon) - end + link_button_to nil, url, class: link_class, title: s_('SortOptions|Sort direction'), icon: icon end def issuable_sort_direction_button(sort_value) diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index a60143db739..669d13c14c2 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -21,6 +21,10 @@ module StorageHelper counter_uploads: storage_counter(statistics.uploads_size) } - _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters + _( + "Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / " \ + "Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / " \ + "Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}" + ) % counters end end diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index cb6f60ab79b..ad473875a53 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -17,10 +17,6 @@ module TimeHelper end end - def date_from_to(from, to) - "#{from.to_s(:short)} - #{to.to_s(:short)}" - end - def duration_in_numbers(duration_in_seconds) seconds = duration_in_seconds % 1.minute minutes = (duration_in_seconds / 1.minute) % (1.hour / 1.minute) diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 66c9011fbcc..cb6ed059ec9 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -109,7 +109,7 @@ module TimeboxesHelper content = [ title, "<br />", - date.to_s(:medium), + date.to_fs(:medium), "(#{time_ago} #{state})" ].join(" ") @@ -172,7 +172,7 @@ module TimeboxesHelper def milestone_tooltip_due_date(milestone) if milestone.due_date - "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})" + "#{milestone.due_date.to_fs(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})" else _('Milestone') end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index acc7d8a5a10..29998a996e2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -12,7 +12,7 @@ module UsersHelper # The user.status can be nil when the user has no status, so we need to protect against that case. # iso8601 is the official RFC supported format for frontend parsing of date: # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date - user.status&.clear_status_at&.to_s(:iso8601) + user.status&.clear_status_at&.to_fs(:iso8601) end def user_link(user) @@ -190,7 +190,9 @@ module UsersHelper user_activity_path: user_activity_path(user, :json), utc_offset: local_timezone_instance(user.timezone).now.utc_offset, user_id: user.id, - snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg') + snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg'), + new_snippet_path: (new_snippet_path if can?(current_user, :create_snippet)), + follow_empty_state: image_path('illustrations/empty-state/empty-friends-md.svg') } end diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb index 9ec22a659d3..185e1b8e0a8 100644 --- a/app/helpers/web_ide_button_helper.rb +++ b/app/helpers/web_ide_button_helper.rb @@ -33,10 +33,6 @@ module WebIdeButtonHelper can_view_pipeline_editor?(project) && path == project.ci_config_path_or_default end - def can_push_code? - current_user&.can?(:push_code, @project) - end - def fork? !project_fork.nil? && !can_push_code? end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 0328d262dc7..52a16475c07 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -190,7 +190,7 @@ module Emails to: @recipient.notification_email_for(@project.group), subject: subject("#{@issue.title} (##{@issue.iid})"), 'X-GitLab-NotificationReason' => reason, - 'X-GitLab-ConfidentialIssue' => confidentiality + 'X-GitLab-ConfidentialIssue' => confidentiality.to_s } end end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 1e254a32885..bdd63dfc62c 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -15,7 +15,14 @@ module Emails @issue = @note.noteable @target_url = project_issue_url(*note_target_url_options) - mail_answer_note_thread(@issue, @note, note_thread_options(reason)) + mail_answer_note_thread( + @issue, + @note, + note_thread_options( + reason, + confidentiality: @issue.confidential? + ) + ) end def note_merge_request_email(recipient_id, note_id, reason = nil) @@ -62,13 +69,15 @@ module Emails { anchor: "note_#{@note.id}" } end - def note_thread_options(reason) + def note_thread_options(reason, confidentiality: nil) { from: sender(@note.author_id), to: @recipient.notification_email_for(@project&.group || @group), subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"), 'X-GitLab-NotificationReason' => reason - } + }.tap do |options| + options['X-GitLab-ConfidentialIssue'] = confidentiality.to_s unless confidentiality.nil? + end end def setup_note_mail(note_id, recipient_id) diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 54a4c4be6a8..a382ca15e46 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -65,9 +65,7 @@ module Emails @target_url = profile_personal_access_tokens_url @token_name = token_name - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) end def access_token_about_to_expire_email(user, token_names) @@ -78,9 +76,7 @@ module Emails @target_url = profile_personal_access_tokens_url @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) end def access_token_expired_email(user, token_names = []) @@ -90,9 +86,7 @@ module Emails @token_names = token_names @target_url = profile_personal_access_tokens_url - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens have expired"))) - end + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens have expired"))) end def access_token_revoked_email(user, token_name, source = nil) @@ -103,9 +97,7 @@ module Emails @target_url = profile_personal_access_tokens_url @source = source - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked"))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked"))) end def ssh_key_expired_email(user, fingerprints) @@ -115,9 +107,7 @@ module Emails @fingerprints = fingerprints @target_url = profile_keys_url - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) end def ssh_key_expiring_soon_email(user, fingerprints) @@ -127,9 +117,7 @@ module Emails @fingerprints = fingerprints @target_url = profile_keys_url - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon."))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon."))) end def unknown_sign_in_email(user, ip, time) @@ -138,11 +126,9 @@ module Emails @time = time @target_url = edit_profile_password_url - Gitlab::I18n.with_locale(@user.preferred_language) do - email_with_layout( - to: @user.notification_email_or_default, - subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host })) - end + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host })) end def two_factor_otp_attempt_failed_email(user, ip, time = Time.current) @@ -150,11 +136,9 @@ module Emails @ip = ip @time = time - Gitlab::I18n.with_locale(@user.preferred_language) do - email_with_layout( - to: @user.notification_email_or_default, - subject: subject(_("Attempted sign in to %{host} using an incorrect verification code") % { host: Gitlab.config.gitlab.host })) - end + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(_("Attempted sign in to %{host} using an incorrect verification code") % { host: Gitlab.config.gitlab.host })) end def disabled_two_factor_email(user) @@ -162,9 +146,7 @@ module Emails @user = user - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) end def new_email_address_added_email(user, email) @@ -173,9 +155,7 @@ module Emails @user = user @email = email - Gitlab::I18n.with_locale(@user.preferred_language) do - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) - end + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) end def new_achievement_email(user, achievement) @@ -184,11 +164,9 @@ module Emails @user = user @achievement = achievement - Gitlab::I18n.with_locale(@user.preferred_language) do - email_with_layout( - to: @user.notification_email_or_default, - subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name })) - end + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name })) end end end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index d91f69cdd4b..576dbdd8b52 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -64,6 +64,11 @@ class NotifyPreview < ActionMailer::Preview Notify.access_token_created_email(user, 'token_name').message end + def access_token_expired_email + token_names = [] + Notify.access_token_expired_email(user, token_names).message + end + def access_token_revoked_email Notify.access_token_revoked_email(user, 'token_name').message end diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb index b7ed504a0ba..2e8b7ed6686 100644 --- a/app/models/abuse/trust_score.rb +++ b/app/models/abuse/trust_score.rb @@ -2,9 +2,6 @@ module Abuse class TrustScore < ApplicationRecord - MAX_EVENTS = 100 - SPAMCHECK_HAM_THRESHOLD = 0.5 - self.table_name = 'abuse_trust_scores' enum source: Enums::Abuse::Source.sources @@ -15,6 +12,9 @@ module Abuse validates :score, presence: true validates :source, presence: true + scope :order_created_at_asc, -> { order(created_at: :asc) } + scope :order_created_at_desc, -> { order(created_at: :desc) } + before_create :assign_correlation_id after_commit :remove_old_scores @@ -25,14 +25,7 @@ module Abuse end def remove_old_scores - count = user.trust_scores_for_source(source).count - return unless count > MAX_EVENTS - - TrustScore.delete( - user.trust_scores_for_source(source) - .order(created_at: :asc) - .limit(count - MAX_EVENTS) - ) + Abuse::UserTrustScore.new(user).remove_old_scores(source) end end end diff --git a/app/models/abuse/user_trust_score.rb b/app/models/abuse/user_trust_score.rb new file mode 100644 index 00000000000..3a935e230ae --- /dev/null +++ b/app/models/abuse/user_trust_score.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Abuse + class UserTrustScore + MAX_EVENTS = 100 + SPAMCHECK_HAM_THRESHOLD = 0.5 + + def initialize(user) + @user = user + end + + def spammer? + spam_score > SPAMCHECK_HAM_THRESHOLD + end + + def spam_score + user_scores.spamcheck.average(:score) || 0.0 + end + + def telesign_score + user_scores.telesign.order_created_at_desc.first&.score || 0.0 + end + + def arkose_global_score + user_scores.arkose_global_score.order_created_at_desc.first&.score || 0.0 + end + + def arkose_custom_score + user_scores.arkose_custom_score.order_created_at_desc.first&.score || 0.0 + end + + def trust_scores_for_source(source) + user_scores.where(source: source) + end + + def remove_old_scores(source) + count = trust_scores_for_source(source).count + return unless count > MAX_EVENTS + + Abuse::TrustScore.delete( + trust_scores_for_source(source) + .order_created_at_asc + .limit(count - MAX_EVENTS) + ) + end + + private + + def user_scores + Abuse::TrustScore.where(user_id: @user.id) + end + end +end diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb new file mode 100644 index 00000000000..863bdfc7899 --- /dev/null +++ b/app/models/ai/service_access_token.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ai + class ServiceAccessToken < ApplicationRecord + self.table_name = 'service_access_tokens' + + scope :expired, -> { where('expires_at < :now', now: Time.current) } + scope :for_category, ->(category) { where(category: category) } + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + + validates :token, :expires_at, presence: true + + enum category: { + code_suggestions: 1 + } + + validates :category, presence: true + end +end diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index d5162865a79..a70168dc0d8 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -4,7 +4,7 @@ module AlertManagement class HttpIntegration < ApplicationRecord include ::Gitlab::Routing - LEGACY_IDENTIFIER = 'legacy' + LEGACY_IDENTIFIERS = %w[legacy legacy-prometheus].freeze belongs_to :project, inverse_of: :alert_management_http_integrations @@ -20,8 +20,8 @@ module AlertManagement validates :token, presence: true, format: { with: /\A\h{32}\z/ } validates :name, presence: true, length: { maximum: 255 } validates :type_identifier, presence: true - validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ } - validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active? + validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9-]+\z/ } + validates :endpoint_identifier, uniqueness: { scope: [:project_id] } validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' } before_validation :prevent_token_assignment @@ -33,7 +33,6 @@ module AlertManagement scope :for_type, ->(type) { where(type_identifier: type) } scope :for_project, ->(project_ids) { where(project: project_ids) } scope :active, -> { where(active: true) } - scope :legacy, -> { for_endpoint_identifier(LEGACY_IDENTIFIER) } scope :ordered_by_type_and_id, -> { order(:type_identifier, :id) } enum type_identifier: { @@ -42,16 +41,18 @@ module AlertManagement } def url - if legacy? - return project_alerts_notify_url(project, format: :json) if http? - return notify_project_prometheus_alerts_url(project, format: :json) if prometheus? + case endpoint_identifier + when 'legacy' + project_alerts_notify_url(project, format: :json) + when 'legacy-prometheus' + notify_project_prometheus_alerts_url(project, format: :json) + else + project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json) end - - project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json) end def legacy? - endpoint_identifier == LEGACY_IDENTIFIER + LEGACY_IDENTIFIERS.include?(endpoint_identifier) end private diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb index c7bff7c8d7f..6f152e7749e 100644 --- a/app/models/analytics/cycle_analytics/stage.rb +++ b/app/models/analytics/cycle_analytics/stage.rb @@ -3,6 +3,8 @@ module Analytics module CycleAnalytics class Stage < ApplicationRecord + MAX_STAGES_PER_VALUE_STREAM = 15 + self.table_name = :analytics_cycle_analytics_group_stages include DatabaseEventTracking @@ -10,6 +12,8 @@ module Analytics include Analytics::CycleAnalytics::Parentable validates :name, uniqueness: { scope: [:group_id, :group_value_stream_id] } + validate :max_stages_count, on: :create + belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ValueStream', foreign_key: :group_value_stream_id, inverse_of: :stages @@ -49,6 +53,15 @@ module Analytics name group_value_stream_id ].freeze + + private + + def max_stages_count + return unless value_stream + return unless value_stream.stages.count >= MAX_STAGES_PER_VALUE_STREAM + + errors.add(:value_stream, _('Maximum number of stages per value stream exceeded')) + end end end end diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb index 31e06075bcb..16446a5b463 100644 --- a/app/models/analytics/cycle_analytics/value_stream.rb +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -3,6 +3,8 @@ module Analytics module CycleAnalytics class ValueStream < ApplicationRecord + MAX_VALUE_STREAMS_PER_NAMESPACE = 50 + self.table_name = :analytics_cycle_analytics_group_value_streams include Analytics::CycleAnalytics::Parentable @@ -15,6 +17,7 @@ module Analytics validates :name, presence: true validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :group_id } + validate :max_value_streams_count, on: :create accepts_nested_attributes_for :stages, allow_destroy: true @@ -35,6 +38,13 @@ module Analytics private + def max_value_streams_count + return unless namespace + return unless namespace.value_streams.count >= MAX_VALUE_STREAMS_PER_NAMESPACE + + errors.add(:namespace, _('Maximum number of value streams per namespace exceeded')) + end + def ensure_aggregation_record_presence Analytics::CycleAnalytics::Aggregation.safe_create_for_namespace(namespace) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index a71b47e88d8..827f8bc93be 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -38,7 +38,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord encrypted_tofa_url encrypted_tofa_url_iv vertex_project - ], remove_with: '16.2', remove_after: '2023-06-22' + ], remove_with: '16.3', remove_after: '2023-07-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -596,6 +596,13 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_blank: true, public_url: ADDRESSABLE_URL_VALIDATION_OPTIONS + with_options(presence: true, if: :slack_app_enabled?) do + validates :slack_app_id + validates :slack_app_secret + validates :slack_app_signing_secret + validates :slack_app_verification_token + end + with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do validates :throttle_unauthenticated_api_requests_per_period validates :throttle_unauthenticated_api_period_in_seconds diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 9370982be47..163e741d990 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -100,40 +100,6 @@ class AuditEvent < ApplicationRecord super || details[:target_details] end - def self.by_group(group) - group_id = group.id - - # Bring entity_type and entity_id from projects and group into one query - scope1 = Group.find(group_id).all_projects.select("'Project' as entity_type", 'id AS entity_id') - scope2 = Project.from("(VALUES ('Group', #{group_id})) as projects(entity_type, entity_id)").select('entity_type', - 'entity_id') - array_scope = Project.from_union([scope1, scope2], remove_duplicates: false).select(:entity_type, :entity_id) - - # order by created_at (id is the tie breaker) - scope = AuditEvent.order(:created_at, :id) - - array_mapping_scope = ->(entity_type_expression, entity_id_expression) do - AuditEvent.where(AuditEvent.arel_table[:entity_id].eq(entity_id_expression)) - .where(AuditEvent.arel_table[:entity_type].eq(entity_type_expression)) - end - - finder_query = ->(created_at_expression, id_expression) do - # we need to add created_at filter as well because that's the partitioning key - AuditEvent.where( - AuditEvent.arel_table[:id].eq(id_expression) - ).where( - AuditEvent.arel_table[:created_at].eq(created_at_expression) - ) - end - - Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( - scope: scope, - array_scope: array_scope, - array_mapping_scope: array_mapping_scope, - finder_query: finder_query - ).execute - end - private def sanitize_message diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 31bee8db1b4..ebc43b04b1b 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -31,6 +31,7 @@ class AwardEmoji < ApplicationRecord after_destroy :expire_cache after_save :expire_cache + after_commit :broadcast_note_update, if: -> { !importing? && awardable.is_a?(Note) } class << self def votes_for_collection(ids, type) @@ -73,11 +74,19 @@ class AwardEmoji < ApplicationRecord def expire_cache awardable.try(:bump_updated_at) - awardable.expire_etag_cache if awardable.is_a?(Note) awardable.try(:update_upvotes_count) if upvote? end + def broadcast_note_update + awardable.expire_etag_cache + awardable.trigger_note_subscription_update + end + def to_ability_name 'emoji' end + + def hook_attrs + Gitlab::HookData::EmojiBuilder.new(self).build + end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index bf25ea7367c..ccc5ca7395d 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -3,7 +3,6 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord include CacheMarkdownField include Sortable - include IgnorableColumns ALLOWED_TARGET_ACCESS_LEVELS = [ Gitlab::Access::GUEST, @@ -13,8 +12,6 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord Gitlab::Access::OWNER ].freeze - ignore_column :namespace_id, remove_with: '16.0', remove_after: '2022-06-22' - cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true validates :message, presence: true diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index c2d7529f468..fde528e3fa0 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -58,6 +58,10 @@ class BulkImport < ApplicationRecord Gitlab::VersionInfo.new(MIN_MAJOR_VERSION, MIN_MINOR_VERSION_FOR_PROJECT) end + def self.min_gl_version_for_migration_in_batches + Gitlab::VersionInfo.new(16, 2) + end + def self.all_human_statuses state_machine.states.map(&:human_name) end @@ -68,4 +72,8 @@ class BulkImport < ApplicationRecord update!(has_failures: true) end + + def supports_batched_export? + source_version_info >= self.class.min_gl_version_for_migration_in_batches + end end diff --git a/app/models/bulk_imports/batch_tracker.rb b/app/models/bulk_imports/batch_tracker.rb index df1fab89ee6..2e79d41d46e 100644 --- a/app/models/bulk_imports/batch_tracker.rb +++ b/app/models/bulk_imports/batch_tracker.rb @@ -25,9 +25,7 @@ module BulkImports end event :finish do - transition started: :finished - transition failed: :failed - transition skipped: :skipped + transition any => :finished end event :skip do diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 94e4a8165eb..4f50a112141 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -144,12 +144,27 @@ class BulkImports::Entity < ApplicationRecord end end - def export_relations_url_path - "#{base_resource_path}/export_relations" + def export_relations_url_path_base + File.join(base_resource_path, 'export_relations') end - def relation_download_url_path(relation) - "#{export_relations_url_path}/download?relation=#{relation}" + def export_relations_url_path(batched: false) + if batched && bulk_import.supports_batched_export? + Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: batched) + else + export_relations_url_path_base + end + end + + def relation_download_url_path(relation, batch_number = nil) + url = File.join(export_relations_url_path_base, 'download') + params = { relation: relation } + + if batch_number && bulk_import.supports_batched_export? + params.merge!(batched: true, batch_number: batch_number) + end + + Gitlab::Utils.add_url_parameters(url, params) end def wikis_url_path diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb index 93cf047c690..5c3f8e4b8d4 100644 --- a/app/models/bulk_imports/export.rb +++ b/app/models/bulk_imports/export.rb @@ -32,9 +32,7 @@ module BulkImports end event :finish do - transition started: :finished - transition finished: :finished - transition failed: :failed + transition any => :finished end event :fail_op do @@ -63,5 +61,12 @@ module BulkImports FileTransfer.config_for(portable) end end + + def remove_existing_upload! + return unless upload&.export_file&.file + + upload.remove_export_file! + upload.save! + end end end diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb index cbd7b189007..3d820e65d5b 100644 --- a/app/models/bulk_imports/export_status.rb +++ b/app/models/bulk_imports/export_status.rb @@ -13,28 +13,48 @@ module BulkImports end def started? - !empty? && export_status['status'] == Export::STARTED + !empty? && status['status'] == Export::STARTED end def failed? - !empty? && export_status['status'] == Export::FAILED + !empty? && status['status'] == Export::FAILED end def empty? - export_status.nil? + status.nil? end def error - export_status['error'] + status['error'] + end + + def batched? + status['batched'] == true + end + + def batches_count + status['batches_count'].to_i + end + + def batch(batch_number) + raise ArgumentError if batch_number < 1 + + return unless batched? + + status['batches'].find { |item| item['batch_number'] == batch_number } end private attr_reader :client, :entity, :relation, :pipeline_tracker - def export_status - strong_memoize(:export_status) do - fetch_export_status&.find { |item| item['relation'] == relation } + def status + strong_memoize(:status) do + status = fetch_status + + next status if status.is_a?(Hash) || status.nil? + + status.find { |item| item['relation'] == relation } rescue BulkImports::NetworkError => e raise BulkImports::RetryPipelineError.new(e.message, 2.seconds) if e.retriable?(pipeline_tracker) @@ -44,12 +64,12 @@ module BulkImports end end - def fetch_export_status - client.get(status_endpoint).parsed_response + def fetch_status + client.get(status_endpoint, relation: relation).parsed_response end def status_endpoint - File.join(entity.export_relations_url_path, 'status') + File.join(entity.export_relations_url_path_base, 'status') end def default_error_response(message) diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index 55502721a76..d1a6f3b9a80 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -24,6 +24,7 @@ class BulkImports::Tracker < ApplicationRecord delegate :file_extraction_pipeline?, to: :pipeline_class DEFAULT_PAGE_SIZE = 500 + STALE_AFTER = 4.hours scope :next_pipeline_trackers_for, -> (entity_id) { entity_scope = where(bulk_import_entity_id: entity_id) @@ -89,4 +90,8 @@ class BulkImports::Tracker < ApplicationRecord transition [:created, :started] => :timeout end end + + def stale? + created_at < STALE_AFTER.ago + end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb index f87b18d516f..1f6d218b015 100644 --- a/app/models/ci/artifact_blob.rb +++ b/app/models/ci/artifact_blob.rb @@ -4,8 +4,6 @@ module Ci class ArtifactBlob include BlobLike - EXTENSIONS_SERVED_BY_PAGES = %w[.html .htm .txt .json .xml .log].freeze - attr_reader :entry def initialize(entry) @@ -35,31 +33,18 @@ module Ci :build_artifact end - def external_url(project, job) - return unless external_link?(job) - - url_project_path = project.full_path.partition('/').last - - artifact_path = [ - '-', url_project_path, '-', - 'jobs', job.id, - 'artifacts', path - ].join('/') - - "#{project.pages_namespace_url}/#{artifact_path}" + def external_url(job) + pages_url_builder(job.project).artifact_url(entry, job) end def external_link?(job) - pages_config.enabled && - pages_config.artifacts_server && - EXTENSIONS_SERVED_BY_PAGES.include?(File.extname(name)) && - (pages_config.access_control || job.project.public?) + pages_url_builder(job.project).artifact_url_available?(entry, job) end private - def pages_config - Gitlab.config.pages + def pages_url_builder(project) + @pages_url_builder ||= Gitlab::Pages::UrlBuilder.new(project) end end end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 7cdd0d56a98..5052d84378f 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -224,15 +224,46 @@ module Ci end end + def target_revision_ref + downstream_pipeline_params.dig(:target_revision, :ref) + end + def downstream_variables - calculate_downstream_variables - .reverse # variables priority - .uniq { |var| var[:key] } # only one variable key to pass - .reverse + Gitlab::Ci::Variables::Downstream::Generator.new(self).calculate end - def target_revision_ref - downstream_pipeline_params.dig(:target_revision, :ref) + def variables + strong_memoize(:variables) do + Gitlab::Ci::Variables::Collection.new + .concat(scoped_variables) + .concat(pipeline.persisted_variables) + end + end + + def pipeline_variables + pipeline.variables + end + + def pipeline_schedule_variables + return [] unless pipeline.pipeline_schedule + + pipeline.pipeline_schedule.variables.to_a + end + + def forward_yaml_variables? + strong_memoize(:forward_yaml_variables) do + result = options&.dig(:trigger, :forward, :yaml_variables) + + result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result + end + end + + def forward_pipeline_variables? + strong_memoize(:forward_pipeline_variables) do + result = options&.dig(:trigger, :forward, :pipeline_variables) + + result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result + end end private @@ -273,70 +304,6 @@ module Ci } } end - - def calculate_downstream_variables - expand_variables = scoped_variables - .concat(pipeline.persisted_variables) - .to_runner_variables - - # The order of this list refers to the priority of the variables - downstream_yaml_variables(expand_variables) + - downstream_pipeline_variables(expand_variables) + - downstream_pipeline_schedule_variables(expand_variables) - end - - def downstream_yaml_variables(expand_variables) - return [] unless forward_yaml_variables? - - yaml_variables.to_a.map do |hash| - if hash[:raw] - { key: hash[:key], value: hash[:value], raw: true } - else - { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) } - end - end - end - - def downstream_pipeline_variables(expand_variables) - return [] unless forward_pipeline_variables? - - pipeline.variables.to_a.map do |variable| - if variable.raw? - { key: variable.key, value: variable.value, raw: true } - else - { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) } - end - end - end - - def downstream_pipeline_schedule_variables(expand_variables) - return [] unless forward_pipeline_variables? - return [] unless pipeline.pipeline_schedule - - pipeline.pipeline_schedule.variables.to_a.map do |variable| - if variable.raw? - { key: variable.key, value: variable.value, raw: true } - else - { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) } - end - end - end - - def forward_yaml_variables? - strong_memoize(:forward_yaml_variables) do - result = options&.dig(:trigger, :forward, :yaml_variables) - - result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result - end - end - - def forward_pipeline_variables? - strong_memoize(:forward_pipeline_variables) do - result = options&.dig(:trigger, :forward, :pipeline_variables) - - result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result - end - end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 382f861a802..4c723bb7c0c 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -10,11 +10,9 @@ module Ci include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize - include SafelyChangeColumnDefault self.table_name = 'p_ci_builds_metadata' self.primary_key = 'id' - columns_changing_default :partition_id partitionable scope: :build diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 940221619b3..317f2523f69 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -3,8 +3,12 @@ module Ci class BuildNeed < Ci::ApplicationRecord include Ci::Partitionable - include BulkInsertSafe include IgnorableColumns + include SafelyChangeColumnDefault + include BulkInsertSafe + + columns_changing_default :partition_id + ignore_column :id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22' belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 966884ae158..0b88f745d78 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -2,6 +2,9 @@ class Ci::BuildPendingState < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id, inverse_of: :pending_state diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index b2d99fab295..90b621b8da1 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -3,6 +3,9 @@ module Ci class BuildReportResult < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id self.primary_key = :build_id diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 5773b6132be..eaa2e1c428e 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -5,6 +5,9 @@ module Ci # Data will be removed after transitioning from running to any state. class BuildRunnerSession < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 03b59b19ef1..0a0f401c9d5 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -8,6 +8,9 @@ module Ci include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::OptimisticLocking + include SafelyChangeColumnDefault + + columns_changing_default :partition_id belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :trace_chunks @@ -166,7 +169,7 @@ module Ci raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save? self.class.with_read_consistency(build) do - reset.then(&:unsafe_persist_data!) + reset.unsafe_persist_data! end end rescue FailedToObtainLockError @@ -242,7 +245,7 @@ module Ci ## # We need to so persist data then save a new store identifier before we # remove data from the previous store to make this operation - # trasnaction-safe. `unsafe_set_data! calls `save!` because of this + # transaction-safe. `unsafe_set_data! calls `save!` because of this # reason. # # TODO consider using callbacks and state machine to remove old data diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 4c76089617f..c5ad3d19425 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -3,6 +3,9 @@ module Ci class BuildTraceMetadata < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id MAX_ATTEMPTS = 5 self.table_name = 'ci_build_trace_metadata' diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 77cfe91ddd6..38603ddfe59 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -19,6 +19,8 @@ module Ci delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project + enum state: { draft: 0, published: 1 } + def versions project.releases.order_released_desc end diff --git a/app/models/ci/external_pull_request.rb b/app/models/ci/external_pull_request.rb new file mode 100644 index 00000000000..bd37aa9f85a --- /dev/null +++ b/app/models/ci/external_pull_request.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# This model stores pull requests coming from external providers, such as +# GitHub, when GitLab project is set as CI/CD only and remote mirror. +# +# When setting up a remote mirror with GitHub we subscribe to push and +# pull_request webhook events. When a pull request is opened on GitHub, +# a webhook is sent out, we create or update the status of the pull +# request locally. +# +# When the mirror is updated and changes are pushed to branches we check +# if there are open pull requests for the source and target branch. +# If so, we create pipelines for external pull requests. +module Ci + class ExternalPullRequest < Ci::ApplicationRecord + include Gitlab::Utils::StrongMemoize + include ShaAttribute + include EachBatch + + belongs_to :project + + sha_attribute :source_sha + sha_attribute :target_sha + + validates :source_branch, presence: true + validates :target_branch, presence: true + validates :source_sha, presence: true + validates :target_sha, presence: true + validates :source_repository, presence: true + validates :target_repository, presence: true + validates :status, presence: true + + enum status: { + open: 1, + closed: 2 + } + + # We currently don't support pull requests from fork, so + # we are going to return an error to the webhook + validate :not_from_fork + + scope :by_source_branch, ->(branch) { where(source_branch: branch) } + scope :by_source_repository, ->(repository) { where(source_repository: repository) } + + # Needed to override Ci::ApplicationRecord as this does not have ci_ table prefix + self.table_name = 'external_pull_requests' + + def self.create_or_update_from_params(params) + find_params = params.slice(:project_id, :source_branch, :target_branch) + + safe_find_or_initialize_and_update(find: find_params, update: params) do |pull_request| + yield(pull_request) if block_given? + end + end + + def actual_branch_head? + actual_source_branch_sha == source_sha + end + + def from_fork? + source_repository != target_repository + end + + def source_ref + Gitlab::Git::BRANCH_REF_PREFIX + source_branch + end + + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME', value: target_branch) + end + end + + def modified_paths + project.repository.diff_stats(target_sha, source_sha).paths + end + + private + + def actual_source_branch_sha + project.commit(source_ref)&.sha + end + + def not_from_fork + return unless from_fork? + + errors.add(:base, _('Pull requests from fork are not supported')) + end + + def self.safe_find_or_initialize_and_update(find:, update:) + safe_ensure_unique(retries: 1) do + model = find_or_initialize_by(find) + + yield(model) if model.update(update) && block_given? + + model + end + end + end +end diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 5522a01758f..25d0228beb0 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -14,6 +14,7 @@ module Ci alias_attribute :secret_value, :value + validates :description, length: { maximum: 255 }, allow_blank: true validates :key, uniqueness: { scope: [:group_id, :environment_scope], message: "(%{value}) has already been taken" @@ -36,6 +37,12 @@ module Ci .pluck(:environment_scope) end + # Sorting + scope :order_created_asc, -> { reorder(created_at: :asc) } + scope :order_created_desc, -> { reorder(created_at: :desc) } + scope :order_key_asc, -> { reorder(key: :asc) } + scope :order_key_desc, -> { reorder(key: :desc) } + self.limit_name = 'group_ci_variables' self.limit_scope = :group @@ -50,5 +57,14 @@ module Ci def group_ci_cd_settings_path Gitlab::Routing.url_helpers.group_settings_ci_cd_path(group) end + + def self.sort_by_attribute(method) + case method.to_s + when 'created_at_asc' then order_created_asc + when 'created_at_desc' then order_created_desc + when 'key_asc' then order_key_asc + when 'key_desc' then order_key_desc + end + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 5cd7988837e..11d70e088e9 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -13,6 +13,9 @@ module Ci include FileStoreMounter include EachBatch include Gitlab::Utils::StrongMemoize + include SafelyChangeColumnDefault + + columns_changing_default :partition_id enum accessibility: { public: 0, private: 1 }, _suffix: true diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb index 573999995bc..21c9842399e 100644 --- a/app/models/ci/job_variable.rb +++ b/app/models/ci/job_variable.rb @@ -5,8 +5,11 @@ module Ci include Ci::Partitionable include Ci::NewHasVariable include Ci::RawVariable + include SafelyChangeColumnDefault include BulkInsertSafe + columns_changing_default :partition_id + belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id, inverse_of: :job_variables partitionable scope: :job diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index 14050a1e78e..dc9a8b7a1bf 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -4,6 +4,9 @@ module Ci class PendingBuild < Ci::ApplicationRecord include EachBatch include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id belongs_to :project belongs_to :build, class_name: 'Ci::Build' diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index 57aa1962bd2..f713d5952bc 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -19,6 +19,11 @@ module Ci false end + # This needs to be kept in sync with `Ci::Pipeline#after_transition` calling `pipeline.persistent_ref.delete` + def should_delete? + pipeline.status.to_sym.in?(::Ci::Pipeline.stopped_statuses) + end + def create create_ref(sha, path) rescue StandardError => e @@ -27,6 +32,8 @@ module Ci end def delete + return unless should_delete? + delete_refs(path) rescue Gitlab::Git::Repository::NoRepository # no-op diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 6f2939583e0..bd327cfbe7b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -17,6 +17,9 @@ module Ci include UpdatedAtFilterable include EachBatch include FastDestroyAll::Helpers + include SafelyChangeColumnDefault + + columns_changing_default :partition_id include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' @@ -51,7 +54,7 @@ module Ci belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline', inverse_of: :auto_canceled_pipelines belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' belongs_to :merge_request, class_name: 'MergeRequest' - belongs_to :external_pull_request + belongs_to :external_pull_request, class_name: 'Ci::ExternalPullRequest' belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines has_internal_id :iid, scope: :project, presence: false, @@ -335,9 +338,14 @@ module Ci end end + # This needs to be kept in sync with `Ci::PipelineRef#should_delete?` after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline| pipeline.run_after_commit do - pipeline.persistent_ref.delete + if Feature.enabled?(:pipeline_cleanup_ref_worker_async, pipeline.project) + ::Ci::PipelineCleanupRefWorker.perform_async(pipeline.id) + else + pipeline.persistent_ref.delete + end end end diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index f2457af0074..9747f9ef527 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -5,9 +5,12 @@ module Ci include Ci::Partitionable include Ci::HasVariable include Ci::RawVariable - include IgnorableColumns + include SafelyChangeColumnDefault + + columns_changing_default :partition_id ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' + ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' belongs_to :pipeline diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 6319163b0d7..4eb5c3c9ed2 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -72,6 +72,7 @@ module Ci has_many :runner_managers, inverse_of: :runner has_many :builds + has_many :running_builds, inverse_of: :runner has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects, disable_joins: true has_many :runner_namespaces, inverse_of: :runner, autosave: true @@ -198,6 +199,7 @@ module Ci scope :order_created_at_desc, -> { order(created_at: :desc) } scope :order_token_expires_at_asc, -> { order(token_expires_at: :asc) } scope :order_token_expires_at_desc, -> { order(token_expires_at: :desc) } + scope :with_tags, -> { preload(:tags) } scope :with_creator, -> { preload(:creator) } @@ -456,7 +458,7 @@ module Ci end new_version = values[:version] - schedule_runner_version_update(new_version) if new_version && values[:version] != version + schedule_runner_version_update(new_version) if new_version && new_version != version merge_cache_attributes(values) diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb index e36024d9f5b..3a3f95a8c69 100644 --- a/app/models/ci/runner_manager.rb +++ b/app/models/ci/runner_manager.rb @@ -44,6 +44,10 @@ module Ci remove_duplicates: false).where(created_some_time_ago) end + scope :for_runner, ->(runner_id) do + where(runner_id: runner_id) + end + def self.online_contact_time_deadline Ci::Runner.online_contact_time_deadline end @@ -52,6 +56,13 @@ module Ci STALE_TIMEOUT.ago end + def self.aggregate_upgrade_status_by_runner_id + joins(:runner_version) + .group(:runner_id) + .maximum(:status) + .transform_values { |s| Ci::RunnerVersion.statuses.key(s).to_sym } + end + def heartbeat(values, update_contacted_at: true) ## # We can safely ignore writes performed by a runner heartbeat. We do @@ -66,7 +77,7 @@ module Ci end new_version = values[:version] - schedule_runner_version_update(new_version) if new_version && values[:version] != version + schedule_runner_version_update(new_version) if new_version && new_version != version merge_cache_attributes(values) diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb index e6f80658f5d..cfdc47de531 100644 --- a/app/models/ci/running_build.rb +++ b/app/models/ci/running_build.rb @@ -10,6 +10,9 @@ module Ci # of the running builds there is worth the additional pressure. class RunningBuild < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id partitionable scope: :build diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 719d19f4169..4853c57d41f 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -5,6 +5,9 @@ module Ci class Pipeline < Ci::ApplicationRecord include Ci::Partitionable include Ci::NamespacedModelName + include SafelyChangeColumnDefault + + columns_changing_default :partition_id self.table_name = "ci_sources_pipelines" diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index d61760bd0fc..4f9a2e44562 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -7,6 +7,9 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id partitionable scope: :pipeline @@ -148,7 +151,7 @@ module Ci end def manual_playable? - blocked? || skipped? + blocked? end # This will be removed with ci_remove_ensure_stage_service diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index cfef1249164..37893f6cdae 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -3,6 +3,9 @@ module Ci class UnitTestFailure < Ci::ApplicationRecord include Ci::Partitionable + include SafelyChangeColumnDefault + + columns_changing_default :partition_id REPORT_WINDOW = 14.days diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 23fe89c38df..6f5972ebefa 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -14,6 +14,7 @@ module Ci alias_attribute :secret_value, :value + validates :description, length: { maximum: 255 }, allow_blank: true validates :key, uniqueness: { scope: [:project_id, :environment_scope], message: "(%{value}) has already been taken" diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index 372fdfda1ea..8dc866929f3 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -66,7 +66,6 @@ module Clusters def ci_access_authorized_for?(user) return false unless user - return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project) all_ci_access_authorized_projects_for(user).exists? || all_ci_access_authorized_namespaces_for(user).exists? @@ -74,7 +73,6 @@ module Clusters def user_access_authorized_for?(user) return false unless user - return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project) Clusters::Agents::Authorizations::UserAccess::Finder .new(user, agent: self, preload: false, limit: 1).execute.any? diff --git a/app/models/clusters/concerns/prometheus_client.rb b/app/models/clusters/concerns/prometheus_client.rb index 10cb307addd..d2f69b813aa 100644 --- a/app/models/clusters/concerns/prometheus_client.rb +++ b/app/models/clusters/concerns/prometheus_client.rb @@ -29,7 +29,7 @@ module Clusters rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH # If users have mistakenly set parameters or removed the depended clusters, # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. - # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab, + # Since `PrometheusAdapter#can_query?` is eargely loaded on environment pages in gitlab, # we need to silence the exceptions end diff --git a/app/models/commit.rb b/app/models/commit.rb index 26412205899..ded4b06a028 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -149,6 +149,10 @@ class Commit from_hash(hash, project) end + + def underscore + 'commit' + end end attr_accessor :raw diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 90cdd267cbd..c6e507e4b6c 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -64,7 +64,7 @@ class CommitRange range_string = range_string.strip - unless range_string =~ /\A#{PATTERN}\z/o + unless /\A#{PATTERN}\z/o.match?(range_string) raise ArgumentError, "invalid CommitRange string format: #{range_string}" end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index f26831c1049..3f631f583b6 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -8,13 +8,11 @@ class CommitStatus < Ci::ApplicationRecord include Presentable include BulkInsertableAssociations include TaggableQueries - include SafelyChangeColumnDefault self.table_name = 'ci_builds' self.sequence_name = 'ci_builds_id_seq' self.primary_key = :id partitionable scope: :pipeline - columns_changing_default :partition_id belongs_to :user belongs_to :project @@ -290,7 +288,7 @@ class CommitStatus < Ci::ApplicationRecord def sortable_name name.to_s.split(/(\d+)/).map do |v| - v =~ /\d+/ ? v.to_i : v + /\d+/.match?(v) ? v.to_i : v end end diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 5dac3c7833a..5bdf6bb31bf 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -16,7 +16,8 @@ module CommitSignature unverified_key: 4, unknown_key: 5, multiple_signatures: 6, - revoked_key: 7 + revoked_key: 7, + verified_system: 8 } belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb index 26e184c202f..7e2f445189e 100644 --- a/app/models/concerns/database_event_tracking.rb +++ b/app/models/concerns/database_event_tracking.rb @@ -3,8 +3,6 @@ module DatabaseEventTracking extend ActiveSupport::Concern - FEATURE_FLAG_BATCH2_CLASSES = %w[Vulnerability MergeRequest::Metrics].freeze - included do after_create_commit :publish_database_create_event after_destroy_commit :publish_database_destroy_event @@ -24,9 +22,6 @@ module DatabaseEventTracking end def publish_database_event(name) - return unless database_events_for_class_enabled? - return unless database_events_feature_flag_enabled? - # Gitlab::Tracking#event is triggering Snowplow event # Snowplow events are sent with usage of # https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html @@ -54,14 +49,4 @@ module DatabaseEventTracking .with_indifferent_access .slice(*self.class::SNOWPLOW_ATTRIBUTES) end - - def database_events_for_class_enabled? - is_batch2 = FEATURE_FLAG_BATCH2_CLASSES.include?(self.class.to_s) - - !is_batch2 || Feature.enabled?(:product_intelligence_database_event_tracking_batch2) - end - - def database_events_feature_flag_enabled? - Feature.enabled?(:product_intelligence_database_event_tracking) - end end diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index d798a13741f..f5ffeb8c425 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -85,7 +85,8 @@ module Enums external_project_source: 5, bridge_source: 6, parameter_source: 7, - compliance_source: 8 + compliance_source: 8, + security_policies_default_source: 9 } end end diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 4b325de61bc..dbf05dbc428 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -50,6 +50,10 @@ module Enums CONFIDENCE_LEVELS end + def self.parse_confidence_level(input) + input&.downcase.then { |value| confidence_levels.key?(value) ? value : 'unknown' } + end + def self.report_types REPORT_TYPES end @@ -58,6 +62,10 @@ module Enums SEVERITY_LEVELS end + def self.parse_severity_level(input) + input&.downcase.then { |value| severity_levels.key?(value) ? value : 'unknown' } + end + def self.detection_methods DETECTION_METHODS end diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index cc55315d6d7..af139e735af 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -6,10 +6,8 @@ module Expirable DAYS_TO_EXPIRE = 7 included do - scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) } - - scope :expired, -> { where.not(expires_at: nil).where(arel_table[:expires_at].lteq(Time.current)) } - scope :not_expired, -> { self.not(expired) } + scope :expired, -> { where(arel_table[:expires_at].lteq(Time.current)) } + scope :not_expired, -> { where(arel_table[:expires_at].gt(Time.current)).or(where(expires_at: nil)) } end def expired? diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 9d4b8328e8d..2d0ff82e624 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -14,7 +14,7 @@ module HasUserType migration_bot: 7, security_bot: 8, automation_bot: 9, - security_policy_bot: 10, # Currently not in use. See https://gitlab.com/gitlab-org/gitlab/-/issues/384174 + security_policy_bot: 10, admin_bot: 11, suggested_reviewers_bot: 12, service_account: 13, diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb index 4cbcb25406d..249d0b99494 100644 --- a/app/models/concerns/ignorable_columns.rb +++ b/app/models/concerns/ignorable_columns.rb @@ -18,7 +18,7 @@ module IgnorableColumns # # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release) def ignore_columns(*columns, remove_after:, remove_with:) - raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after =~ Gitlab::Regex.utc_date_regex + raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless Gitlab::Regex.utc_date_regex.match?(remove_after) raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 209456f8b67..3f65e701da7 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -19,7 +19,7 @@ module IssueAvailableFeatures end included do - scope :with_feature, ->(feature) { where(issue_type: available_features_for_issue_types[feature]) } + scope :with_feature, ->(feature) { with_issue_type(available_features_for_issue_types[feature]) } end def issue_type_supports?(feature) diff --git a/app/models/concerns/issues/forbid_issue_type_column_usage.rb b/app/models/concerns/issues/forbid_issue_type_column_usage.rb deleted file mode 100644 index 46a8a0278d9..00000000000 --- a/app/models/concerns/issues/forbid_issue_type_column_usage.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699 -module Issues - module ForbidIssueTypeColumnUsage - extend ActiveSupport::Concern - - ForbiddenColumnUsed = Class.new(StandardError) - - included do - WorkItems::Type.base_types.each do |base_type, _value| - define_method "#{base_type}?".to_sym do - error_message = <<~ERROR - `#{model_name.element}.#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column, - its usage is forbidden. You should use the `work_item_types` table instead. - - # Before - - #{model_name.element}.#{base_type}? => true - - # After - - #{model_name.element}.work_item_type.#{base_type}? => true - - More details in https://gitlab.com/groups/gitlab-org/-/epics/10529 - ERROR - - raise ForbiddenColumnUsed, error_message - end - - define_singleton_method base_type.to_sym do - error = ForbiddenColumnUsed.new( - <<~ERROR - `#{name}.#{base_type}` uses the `issue_type` column underneath. As we want to remove the column, - its usage is forbidden. You should use the `work_item_types` table instead. - - # Before - - #{name}.#{base_type} - - # After - - #{name}.with_issue_type(:#{base_type}) - - More details in https://gitlab.com/groups/gitlab-org/-/epics/10529 - ERROR - ) - - Gitlab::ErrorTracking.track_and_raise_for_dev_exception( - error, - method_name: "#{name}.#{base_type}" - ) - - with_issue_type(base_type.to_sym) - end - end - end - end -end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 4f2ea58f36d..3d9e09acf44 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -51,6 +51,7 @@ module Milestoneish def issue_participants_visible_by_user(user) User.joins(:issue_assignees) .where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id)) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417457") .distinct end @@ -90,9 +91,9 @@ module Milestoneish def expires_at if due_date if due_date.past? - "expired on #{due_date.to_s(:medium)}" + "expired on #{due_date.to_fs(:medium)}" else - "expires on #{due_date.to_s(:medium)}" + "expires on #{due_date.to_fs(:medium)}" end end end diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb index cc7279d05f8..90d3abddbf1 100644 --- a/app/models/concerns/packages/debian/component_file.rb +++ b/app/models/concerns/packages/debian/component_file.rb @@ -10,8 +10,6 @@ module Packages include FileStoreMounter include IgnorableColumns - ignore_column :file_md5, remove_with: '16.2', remove_after: '2023-06-22' - def self.container_foreign_key "#{container_type}_id".to_sym end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 76c733b1c0b..c70100c03c8 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -4,7 +4,7 @@ # # After migrating issues_enabled merge_requests_enabled builds_enabled snippets_enabled and wiki_enabled # fields to a new table "project_features", support for the old fields is still needed in the API. -require 'gitlab/utils' +require 'gitlab/utils/all' module ProjectFeaturesCompatibility extend ActiveSupport::Concern diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 7e1ebd1eba3..a87eadb9332 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -32,7 +32,12 @@ module ProtectedRef # to fail. has_many :"#{type}_access_levels", inverse_of: self.model_name.singular - validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, unless: -> { allow_multiple?(type) } + validates :"#{type}_access_levels", + length: { + is: 1, + message: "are restricted to a single instance per #{self.model_name.human}." + }, + unless: -> { allow_multiple?(type) } accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index c1c670db543..f0bb1cc359b 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -29,14 +29,30 @@ module ProtectedRefAccess def humanize(access_level) human_access_levels[access_level] end + + def non_role_types + [] + end end included do scope :maintainer, -> { where(access_level: Gitlab::Access::MAINTAINER) } scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } - scope :for_role, -> { where(user_id: nil, group_id: nil) } - - validates :access_level, presence: true, if: :role?, inclusion: { in: allowed_access_levels } + scope :for_role, -> { + if non_role_types.present? + where.missing(*non_role_types) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417457") + else + all + end + } + + protected_ref_fk = "#{module_parent.model_name.singular}_id" + validates :access_level, + presence: true, + inclusion: { in: allowed_access_levels }, + uniqueness: { scope: protected_ref_fk, conditions: -> { for_role } }, + if: :role? end def humanize diff --git a/app/models/concerns/protected_ref_deploy_key_access.rb b/app/models/concerns/protected_ref_deploy_key_access.rb new file mode 100644 index 00000000000..4275476a1ff --- /dev/null +++ b/app/models/concerns/protected_ref_deploy_key_access.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module ProtectedRefDeployKeyAccess + extend ActiveSupport::Concern + + included do + belongs_to :deploy_key + + protected_ref_fk = "#{module_parent.model_name.singular}_id" + validates :deploy_key_id, uniqueness: { scope: protected_ref_fk, allow_nil: true } + validate :validate_deploy_key_membership + end + + class_methods do + def non_role_types + super << :deploy_key + end + end + + def type + return :deploy_key if deploy_key.present? + + super + end + + def humanize + return deploy_key.title if deploy_key? + + super + end + + def check_access(current_user) + super do + break enabled_deploy_key_for_user?(current_user) if deploy_key? + + yield if block_given? + end + end + + private + + def deploy_key? + type == :deploy_key + end + + def validate_deploy_key_membership + return if deploy_key.nil? || deploy_key_has_write_access_to_project? + + errors.add(:deploy_key, 'is not enabled for this project') + end + + def enabled_deploy_key_for_user?(current_user) + current_user.can?(:read_project, project) && + deploy_key.user_id == current_user.id && + deploy_key_has_write_access_to_project? + end + + def deploy_key_has_write_access_to_project? + DeployKey.with_write_access_for_project(project, deploy_key: deploy_key).exists? + end +end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 6550c5a94a0..5986f8f5b5f 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -138,7 +138,7 @@ module Spammable result.reject(&:blank?).join("\n") end - # Override in Spammable if further checks are necessary + # Override in included class if further checks are necessary def check_for_spam?(*) spammable_attribute_changed? end @@ -153,8 +153,8 @@ module Spammable end end - # Override in Spammable if differs - def allow_possible_spam? + # Override in included class if you want to allow possible spam under specific circumstances + def allow_possible_spam?(*) Gitlab::CurrentSettings.allow_possible_spam end end diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index e3800caa43f..0e72bd30a37 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -17,7 +17,8 @@ module TriggerableHooks feature_flag_hooks: :feature_flag_events, release_hooks: :releases_events, member_hooks: :member_events, - subgroup_hooks: :subgroup_events + subgroup_hooks: :subgroup_events, + emoji_hooks: :emoji_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index a5b69997900..e8a50497b20 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -59,6 +59,7 @@ module VulnerabilityFindingHelpers evidence = Vulnerabilities::Finding::Evidence.new(data: report_finding.evidence.data) if report_finding.evidence Vulnerabilities::Finding.new(finding_data).tap do |finding| + finding.uuid = security_finding.uuid finding.location_fingerprint = report_finding.location.fingerprint finding.vulnerability = vulnerability_for(security_finding.uuid) finding.project = project diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb index 71a12b4077b..a225625815b 100644 --- a/app/models/concerns/vulnerability_finding_signature_helpers.rb +++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb @@ -2,12 +2,17 @@ module VulnerabilityFindingSignatureHelpers extend ActiveSupport::Concern + # If the location object describes a physical location within a file # (filename + line numbers), the 'location' algorithm_type should be used # If the location object describes arbitrary data, then the 'hash' # algorithm_type should be used. - - ALGORITHM_TYPES = { hash: 1, location: 2, scope_offset: 3 }.with_indifferent_access.freeze + ALGORITHM_TYPES = { + hash: 1, + location: 2, + scope_offset: 3, + scope_offset_compressed: 4 + }.with_indifferent_access.freeze class_methods do def priority(algorithm_type) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 0f0abeae795..6a52f6a0112 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -403,7 +403,7 @@ class ContainerRepository < ApplicationRecord end def migrated? - Gitlab.com? + Gitlab.com_except_jh? end def last_import_step_done_at @@ -526,7 +526,7 @@ class ContainerRepository < ApplicationRecord def size strong_memoize(:size) do - next unless Gitlab.com? + next unless Gitlab.com_except_jh? next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) && self.migration_state != 'import_done' next unless gitlab_api_client.supports_gitlab_api? diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1e3a80087c8..b59b22c10c4 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -351,7 +351,7 @@ class Deployment < ApplicationRecord end def formatted_deployment_time - deployed_at&.to_time&.in_time_zone&.to_s(:medium) + deployed_at&.to_time&.in_time_zone&.to_fs(:medium) end def deployed_by @@ -447,7 +447,7 @@ class Deployment < ApplicationRecord # when refs_by_oid is passed an SHA, returns refs for that commit def tags(limit: 100) strong_memoize_with(:tag, limit) do - project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) || [] + project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) end end diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb index 39077fdbcb1..7410944e174 100644 --- a/app/models/design_management/repository.rb +++ b/app/models/design_management/repository.rb @@ -8,7 +8,7 @@ module DesignManagement belongs_to :project, inverse_of: :design_management_repository validates :project, presence: true, uniqueness: true - delegate :lfs_enabled?, :storage, :repository_storage, to: :project + delegate :lfs_enabled?, :storage, :repository_storage, :run_after_commit, to: :project def repository ::DesignManagement::GitRepository.new( diff --git a/app/models/environment.rb b/app/models/environment.rb index 8480272eced..241b454f5ce 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -18,7 +18,7 @@ class Environment < ApplicationRecord belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments use_fast_destroy :all_deployments - nullify_if_blank :external_url + nullify_if_blank :external_url, :kubernetes_namespace has_many :all_deployments, class_name: 'Deployment' has_many :deployments, -> { visible } @@ -70,13 +70,15 @@ class Environment < ApplicationRecord length: { maximum: 255 }, allow_nil: true - # Currently, the tier presence is validaed for newly created environments. - # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/385253. - # Todo: Remove along with FF `validate_environment_tier_presence`. - validates :tier, presence: true, on: :create, unless: :validate_environment_tier_present? + validates :kubernetes_namespace, + allow_nil: true, + length: 1..63, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } - validates :tier, presence: true, if: :validate_environment_tier_present? + validates :tier, presence: true validate :safe_external_url validate :merge_request_not_changed @@ -602,10 +604,6 @@ 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/external_issue.rb b/app/models/external_issue.rb index 36030b80370..06dc9cad5f9 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -44,7 +44,7 @@ class ExternalIssue end def reference_link_text(from = nil) - return "##{id}" if id =~ /^\d+$/ + return "##{id}" if /^\d+$/.match?(id) id end diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb deleted file mode 100644 index 94c242782c1..00000000000 --- a/app/models/external_pull_request.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -# This model stores pull requests coming from external providers, such as -# GitHub, when GitLab project is set as CI/CD only and remote mirror. -# -# When setting up a remote mirror with GitHub we subscribe to push and -# pull_request webhook events. When a pull request is opened on GitHub, -# a webhook is sent out, we create or update the status of the pull -# request locally. -# -# When the mirror is updated and changes are pushed to branches we check -# if there are open pull requests for the source and target branch. -# If so, we create pipelines for external pull requests. -class ExternalPullRequest < Ci::ApplicationRecord - include Gitlab::Utils::StrongMemoize - include ShaAttribute - include EachBatch - - belongs_to :project - - sha_attribute :source_sha - sha_attribute :target_sha - - validates :source_branch, presence: true - validates :target_branch, presence: true - validates :source_sha, presence: true - validates :target_sha, presence: true - validates :source_repository, presence: true - validates :target_repository, presence: true - validates :status, presence: true - - enum status: { - open: 1, - closed: 2 - } - - # We currently don't support pull requests from fork, so - # we are going to return an error to the webhook - validate :not_from_fork - - scope :by_source_branch, ->(branch) { where(source_branch: branch) } - scope :by_source_repository, -> (repository) { where(source_repository: repository) } - - # Needed to override Ci::ApplicationRecord as this does not have ci_ table prefix - self.table_name = 'external_pull_requests' - - def self.create_or_update_from_params(params) - find_params = params.slice(:project_id, :source_branch, :target_branch) - - safe_find_or_initialize_and_update(find: find_params, update: params) do |pull_request| - yield(pull_request) if block_given? - end - end - - def actual_branch_head? - actual_source_branch_sha == source_sha - end - - def from_fork? - source_repository != target_repository - end - - def source_ref - Gitlab::Git::BRANCH_REF_PREFIX + source_branch - end - - def predefined_variables - Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch) - variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME', value: target_branch) - end - end - - def modified_paths - project.repository.diff_stats(target_sha, source_sha).paths - end - - private - - def actual_source_branch_sha - project.commit(source_ref)&.sha - end - - def not_from_fork - if from_fork? - errors.add(:base, _('Pull requests from fork are not supported')) - end - end - - def self.safe_find_or_initialize_and_update(find:, update:) - safe_ensure_unique(retries: 1) do - model = find_or_initialize_by(find) - - if model.update(update) - yield(model) if block_given? - end - - model - end - end -end diff --git a/app/models/group.rb b/app/models/group.rb index 85971c48567..2b5a392e02c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -510,7 +510,9 @@ class Group < Namespace members_with_parents(only_active_users: false) end - members_from_hiearchy.all_owners.left_outer_joins(:user).merge(User.without_project_bot) + members_from_hiearchy.all_owners.left_outer_joins(:user) + .merge(User.without_project_bot) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") end def ldap_synced? @@ -663,13 +665,24 @@ class Group < Namespace # 2. They belong to a project that belongs to the group # 3. They belong to a sub-group or project in such sub-group # 4. They belong to an ancestor group - def direct_and_indirect_users + # 5. They belong to a group that is shared with this group, if share_with_groups is true + def direct_and_indirect_users(share_with_groups: false) + members = if share_with_groups + # We only need :user_id column, but + # `members_from_self_and_ancestor_group_shares` needs more + # columns to make the CTE query work. + GroupMember.from_union([ + direct_and_indirect_members.select(:user_id, :source_type, :type), + members_from_self_and_ancestor_group_shares.reselect(:user_id, :source_type, :type) + ]) + else + direct_and_indirect_members + end + User.from_union([ - User - .where(id: direct_and_indirect_members.select(:user_id)) - .reorder(nil), - project_users_with_descendants - ]) + User.where(id: members.select(:user_id)).reorder(nil), + project_users_with_descendants + ]) end # Returns all users (also inactive) that are members of the group because: @@ -683,7 +696,7 @@ class Group < Namespace .where(id: direct_and_indirect_members_with_inactive.select(:user_id)) .reorder(nil), project_users_with_descendants - ]) + ]).allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") # failed in spec/tasks/gitlab/user_management_rake_spec.rb end def users_count @@ -696,6 +709,7 @@ class Group < Namespace User .joins(projects: :group) .where(namespaces: { id: self_and_descendants.select(:id) }) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") end # Return the highest access level for a user @@ -802,8 +816,11 @@ class Group < Namespace end def execute_integrations(data, hooks_scope) - # NOOP - # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904 + return unless Feature.enabled?(:group_mentions, self) + + integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend + integration.async_execute(data) + end end def preload_shared_group_links @@ -813,16 +830,6 @@ class Group < Namespace ).call end - def update_shared_runners_setting!(state) - raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state) - - case state - when SR_DISABLED_AND_UNOVERRIDABLE then disable_shared_runners! # also disallows override - when SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE then disable_shared_runners_and_allow_override! - when SR_ENABLED then enable_shared_runners! # set both to true - end - end - def first_owner owners.first || parent&.first_owner || owner end @@ -969,12 +976,14 @@ class Group < Namespace end def max_member_access(user_ids) - Gitlab::SafeRequestLoader.execute( - resource_key: max_member_access_for_resource_key(User), - resource_ids: user_ids, - default_value: Gitlab::Access::NO_ACCESS - ) do |user_ids| - members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level) + ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") do + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(User), + resource_ids: user_ids, + default_value: Gitlab::Access::NO_ACCESS + ) do |user_ids| + members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level) + end end end @@ -1057,45 +1066,6 @@ class Group < Namespace Arel::Nodes::SqlLiteral.new(column_alias)) end - def disable_shared_runners! - update!( - shared_runners_enabled: false, - allow_descendants_override_disabled_shared_runners: false) - - group_ids = descendants - unless group_ids.empty? - Group.by_id(group_ids).update_all( - shared_runners_enabled: false, - allow_descendants_override_disabled_shared_runners: false) - end - - all_projects.update_all(shared_runners_enabled: false) - end - - def disable_shared_runners_and_allow_override! - # enabled -> disabled_and_overridable - if shared_runners_enabled? - update!( - shared_runners_enabled: false, - allow_descendants_override_disabled_shared_runners: true) - - group_ids = descendants - unless group_ids.empty? - Group.by_id(group_ids).update_all(shared_runners_enabled: false) - end - - all_projects.update_all(shared_runners_enabled: false) - - # disabled_and_unoverridable -> disabled_and_overridable - else - update!(allow_descendants_override_disabled_shared_runners: true) - end - end - - def enable_shared_runners! - update!(shared_runners_enabled: true) - end - def runners_token_prefix RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 695041f0247..05c5ad22218 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -21,7 +21,8 @@ class ProjectHook < WebHook :wiki_page_hooks, :deployment_hooks, :feature_flag_hooks, - :release_hooks + :release_hooks, + :emoji_hooks ] belongs_to :project diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index e08294058e4..4c35f699468 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -66,7 +66,7 @@ class WebHookLog < ApplicationRecord def redact_user_emails self.request_data.deep_transform_values! do |value| - value.to_s =~ URI::MailTo::EMAIL_REGEXP ? _('[REDACTED]') : value + URI::MailTo::EMAIL_REGEXP.match?(value.to_s) ? _('[REDACTED]') : value end end diff --git a/app/models/integration.rb b/app/models/integration.rb index f2f242136ab..f823a385022 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -90,6 +90,8 @@ class Integration < ApplicationRecord attribute :push_events, default: true attribute :tag_push_events, default: true attribute :wiki_page_events, default: true + attribute :group_mention_events, default: false + attribute :group_confidential_mention_events, default: false after_initialize :initialize_properties @@ -137,6 +139,8 @@ class Integration < ApplicationRecord scope :alert_hooks, -> { where(alert_events: true, active: true) } scope :incident_hooks, -> { where(incident_events: true, active: true) } scope :deployment, -> { where(category: 'deployment') } + scope :group_mention_hooks, -> { where(group_mention_events: true, active: true) } + scope :group_confidential_mention_hooks, -> { where(group_confidential_mention_events: true, active: true) } class << self private @@ -586,6 +590,7 @@ class Integration < ApplicationRecord end def async_execute(data) + return if ::Gitlab::SilentMode.enabled? return unless supported_events.include?(data[:object_kind]) Integrations::ExecuteWorker.perform_async(id, data) @@ -600,6 +605,10 @@ class Integration < ApplicationRecord category == :chat end + def ci? + category == :ci + end + private # Ancestors sorted by hierarchy depth in bottom-top order. diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 4477f3d207f..c9de4d2b3bb 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -262,11 +262,11 @@ module Integrations end def project_name - project.full_name + project.try(:full_name) end def project_url - project.web_url + project.try(:web_url) end def update?(data) diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb index c83a559e0da..29a20419809 100644 --- a/app/models/integrations/base_slack_notification.rb +++ b/app/models/integrations/base_slack_notification.rb @@ -7,6 +7,8 @@ module Integrations ].freeze prop_accessor EVENT_CHANNEL['alert'] + prop_accessor EVENT_CHANNEL['group_mention'] + prop_accessor EVENT_CHANNEL['group_confidential_mention'] override :default_channel_placeholder def default_channel_placeholder @@ -16,15 +18,20 @@ module Integrations override :get_message def get_message(object_kind, data) return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' + return Integrations::ChatMessage::GroupMentionMessage.new(data) if object_kind == 'group_mention' super end override :supported_events def supported_events - additional = ['alert'] + additional = %w[alert] - super + additional + if group_level? && Feature.enabled?(:group_mentions, group) + additional += %w[group_mention group_confidential_mention] + end + + (super + additional).freeze end override :configurable_channels? diff --git a/app/models/integrations/chat_message/group_mention_message.rb b/app/models/integrations/chat_message/group_mention_message.rb new file mode 100644 index 00000000000..a2bc00ddbd9 --- /dev/null +++ b/app/models/integrations/chat_message/group_mention_message.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class GroupMentionMessage < BaseMessage + ISSUE_KIND = 'issue' + MR_KIND = 'merge_request' + NOTE_KIND = 'note' + + KNOWN_KINDS = [ISSUE_KIND, MR_KIND, NOTE_KIND].freeze + + def initialize(params) + super + params = HashWithIndifferentAccess.new(params) + + @group_name, @group_url = params[:mentioned].values_at(:name, :url) + @detail = nil + + obj_attr = params[:object_attributes] + obj_kind = obj_attr[:object_kind] + raise NotImplementedError unless KNOWN_KINDS.include?(obj_kind) + + case obj_kind + when 'issue' + @source_name, @title = get_source_for_issue(obj_attr) + @detail = obj_attr[:description] + when 'merge_request' + @source_name, @title = get_source_for_merge_request(obj_attr) + @detail = obj_attr[:description] + when 'note' + if params[:commit] + @source_name, @title = get_source_for_commit(params[:commit]) + elsif params[:issue] + @source_name, @title = get_source_for_issue(params[:issue]) + elsif params[:merge_request] + @source_name, @title = get_source_for_merge_request(params[:merge_request]) + else + raise NotImplementedError + end + + @detail = obj_attr[:note] + end + + @source_url = obj_attr[:url] + end + + def attachments + if markdown + detail + else + [{ text: format(detail), color: attachment_color }] + end + end + + def activity + { + title: "Group #{group_link} was mentioned in #{source_link}", + subtitle: "of #{project_link}", + text: strip_markup(formatted_title), + image: user_avatar + } + end + + private + + attr_reader :group_name, :group_url, :source_name, :source_url, :title, :detail + + def get_source_for_commit(params) + commit_sha = Commit.truncate_sha(params[:id]) + ["commit #{commit_sha}", params[:title]] + end + + def get_source_for_issue(params) + ["issue ##{params[:iid]}", params[:title]] + end + + def get_source_for_merge_request(params) + ["merge request !#{params[:iid]}", params[:title]] + end + + def message + "Group #{group_link} was mentioned in #{source_link} of #{project_link}: *#{formatted_title}*" + end + + def formatted_title + strip_markup(title.lines.first.chomp) + end + + def group_link + link(group_name, group_url) + end + + def source_link + link(source_name, source_url) + end + + def project_link + link(project_name, project_url) + end + end + end +end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index ad82f1b916f..7ba9bbc38e6 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -2,6 +2,23 @@ module Integrations class HangoutsChat < BaseChatNotification + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + help: 'https://chat.googleapis.com/v1/spaces…', + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION + + field :branches_to_be_notified, + type: 'select', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title 'Google Chat' end @@ -19,25 +36,15 @@ module Integrations s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def default_channel_placeholder + def fields + self.class.fields + build_event_channels end - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] + def default_channel_placeholder end - def default_fields - [ - { type: 'text', name: 'webhook', help: 'https://chat.googleapis.com/v1/spaces…' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end private diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index d6cbe5760e8..a9ed0bd3da1 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -2,6 +2,24 @@ module Integrations class MicrosoftTeams < BaseChatNotification + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + help: 'https://outlook.office.com/webhook/…', + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION, + help: 'If selected, successful pipelines do not trigger a notification event.' + + field :branches_to_be_notified, + type: 'select', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title 'Microsoft Teams notifications' end @@ -26,23 +44,8 @@ module Integrations pipeline wiki_page] end - def default_fields - [ - { type: 'text', section: SECTION_TYPE_CONNECTION, name: 'webhook', help: 'https://outlook.office.com/webhook/…', required: true }, - { - type: 'checkbox', - section: SECTION_TYPE_CONFIGURATION, - name: 'notify_only_broken_pipelines', - help: 'If selected, successful pipelines do not trigger a notification event.' - }, - { - type: 'select', - section: SECTION_TYPE_CONFIGURATION, - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] + def fields + self.class.fields + build_event_channels end def sections diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 2dc0fd7d011..8969c6c13b2 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -15,7 +15,7 @@ module Integrations title: 'API URL', placeholder: -> { s_('PrometheusService|https://prometheus.example.com/') }, help: -> { s_('PrometheusService|The Prometheus API base URL.') }, - required: true + required: false field :google_iap_audience_client_id, title: 'Google IAP Audience Client ID', @@ -34,8 +34,8 @@ module Integrations # to allow localhost URLs when the following conditions are true: # 1. api_url is the internal Prometheus URL. with_options presence: true do - validates :api_url, public_url: true, if: ->(object) { object.manual_configuration? && !object.allow_local_api_url? } - validates :api_url, url: true, if: ->(object) { object.manual_configuration? && object.allow_local_api_url? } + validates :api_url, public_url: true, if: ->(object) { object.api_url.present? && object.manual_configuration? && !object.allow_local_api_url? } + validates :api_url, url: true, if: ->(object) { object.api_url.present? && object.manual_configuration? && object.allow_local_api_url? } end before_save :synchronize_service_state diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index aa19133b8c2..6c447c8f4e4 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -2,6 +2,23 @@ module Integrations class UnifyCircuit < BaseChatNotification + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…', + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION + + field :branches_to_be_notified, + type: 'select', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title 'Unify Circuit' end @@ -14,6 +31,10 @@ module Integrations 'unify_circuit' end + def fields + self.class.fields + build_event_channels + end + def help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer' s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } @@ -27,19 +48,6 @@ module Integrations pipeline wiki_page] end - def default_fields - [ - { type: 'text', name: 'webhook', help: 'https://yourcircuit.com/rest/v2/webhooks/incoming/…', required: true }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] - end - private def notify(message, opts) diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 8e6f5ca6d17..ef1bc81ea58 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -2,6 +2,23 @@ module Integrations class WebexTeams < BaseChatNotification + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + help: 'https://api.ciscospark.com/v1/webhooks/incoming/...', + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION + + field :branches_to_be_notified, + type: 'select', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title s_("WebexTeamsService|Webex Teams") end @@ -14,6 +31,10 @@ module Integrations 'webex_teams' end + def fields + self.class.fields + build_event_channels + end + def help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } @@ -23,21 +44,7 @@ module Integrations end def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] - end - - def default_fields - [ - { type: 'text', name: 'webhook', help: 'https://api.ciscospark.com/v1/webhooks/incoming/...', required: true }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { - type: 'select', - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end private diff --git a/app/models/issue.rb b/app/models/issue.rb index 890af8a27a0..6e48dcab9ed 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -56,6 +56,8 @@ class Issue < ApplicationRecord # This default came from the enum `issue_type` column. Defined as default in the DB DEFAULT_ISSUE_TYPE = :issue + ignore_column :issue_type, remove_with: '16.4', remove_after: '2023-08-22' + belongs_to :project belongs_to :namespace, inverse_of: :issues @@ -133,12 +135,6 @@ class Issue < ApplicationRecord validate :allowed_work_item_type_change, on: :update, if: :work_item_type_id_changed? validate :due_date_after_start_date validate :parent_link_confidentiality - # using a custom validation since we are overwriting the `issue_type` method to use the work_item_types table - validate :issue_type_attribute_present - - enum issue_type: WorkItems::Type.base_types - # TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699 - include ::Issues::ForbidIssueTypeColumnUsage alias_method :issuing_parent, :project alias_attribute :issuing_parent_id, :project_id @@ -187,7 +183,10 @@ class Issue < ApplicationRecord scope :order_closed_at_desc, -> { reorder(arel_table[:closed_at].desc.nulls_last) } scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } - scope :with_web_entity_associations, -> { preload(:author, :namespace, project: [:project_feature, :route, namespace: :route]) } + scope :with_web_entity_associations, -> do + preload(:author, :namespace, :labels, project: [:project_feature, :route, namespace: :route]) + end + scope :preload_awardable, -> { preload(:award_emoji) } scope :with_alert_management_alerts, -> { joins(:alert_management_alert) } scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) } @@ -201,24 +200,17 @@ class Issue < ApplicationRecord scope :with_issue_type, ->(types) { types = Array(types) - if Feature.enabled?(:issue_type_uses_work_item_types_table) - # Using != 1 since we also want the guard clause to handle empty arrays - return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1 + # Using != 1 since we also want the guard clause to handle empty arrays + return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1 - where( - '"issues"."work_item_type_id" = (?)', - WorkItems::Type.by_type(types.first).select(:id).limit(1) - ) - else - where(issue_type: types) - end + # This optimization helps the planer use the correct indexes when filtering by a single type + where( + '"issues"."work_item_type_id" = (?)', + WorkItems::Type.by_type(types.first).select(:id).limit(1) + ) } scope :without_issue_type, ->(types) { - if Feature.enabled?(:issue_type_uses_work_item_types_table) - joins(:work_item_type).where.not(work_item_types: { base_type: types }) - else - where.not(issue_type: types) - end + joins(:work_item_type).where.not(work_item_types: { base_type: types }) } scope :public_only, -> { where(confidential: false) } @@ -258,7 +250,6 @@ class Issue < ApplicationRecord scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') } before_validation :ensure_namespace_id, :ensure_work_item_type - before_save :check_issue_type_in_sync! after_save :ensure_metrics!, unless: :importing? after_commit :expire_etag_cache, unless: :importing? @@ -588,16 +579,12 @@ class Issue < ApplicationRecord user, project.external_authorization_classification_label) end - def check_for_spam?(user:) - # content created via support bots is always checked for spam, EVEN if - # the issue is not publicly visible and/or confidential - return true if user.support_bot? && spammable_attribute_changed? - - # Only check for spam on issues which are publicly visible (and thus indexed in search engines) - return false unless publicly_visible? + # Always enforce spam check for support bot but allow for other users when issue is not publicly visible + def allow_possible_spam?(user) + return true if Gitlab::CurrentSettings.allow_possible_spam + return false if user.support_bot? - # Only check for spam if certain attributes have changed - spammable_attribute_changed? + !publicly_visible? end def supports_recaptcha? @@ -753,11 +740,7 @@ class Issue < ApplicationRecord end def issue_type - if ::Feature.enabled?(:issue_type_uses_work_item_types_table) - work_item_type_with_default.base_type - else - super - end + work_item_type_with_default.base_type end def unsubscribe_email_participant(email) @@ -766,41 +749,11 @@ class Issue < ApplicationRecord issue_email_participants.find_by_email(email)&.destroy end - private - - def check_issue_type_in_sync! - # We might have existing records out of sync, so we need to skip this check unless the value is changed - # so those records can still be updated until we fix them and remove the issue_type column - # https://gitlab.com/gitlab-org/gitlab/-/work_items/403158 - return unless (changes.keys & %w[issue_type work_item_type_id]).any? - - # Do not replace the use of attributes with `issue_type` here - if attributes['issue_type'] != work_item_type.base_type - error = IssueTypeOutOfSyncError.new( - <<~ERROR - Issue `issue_type` out of sync with `work_item_type_id` column. - `issue_type` must be equal to `work_item.base_type`. - You can assign the correct work_item_type like this for example: - - Issue.new(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident)) - - More details in https://gitlab.com/gitlab-org/gitlab/-/issues/338005 - ERROR - ) - - Gitlab::ErrorTracking.track_and_raise_for_dev_exception( - error, - issue_type: attributes['issue_type'], - work_item_type_id: work_item_type_id - ) - end + def hook_attrs + Gitlab::HookData::IssueBuilder.new(self).build end - def issue_type_attribute_present - return if attributes['issue_type'].present? - - errors.add(:issue_type, 'Must be present') - end + private def due_date_after_start_date return unless start_date.present? && due_date.present? @@ -834,12 +787,6 @@ class Issue < ApplicationRecord Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id)) end - def spammable_attribute_changed? - # NOTE: We need to check them for spam when issues are made non-confidential, because spam - # may have been added while they were confidential and thus not being checked for spam. - super || confidential_changed?(from: true, to: false) - end - def ensure_metrics! Issue::Metrics.record!(self) end @@ -868,9 +815,7 @@ class Issue < ApplicationRecord def ensure_work_item_type return if work_item_type_id.present? || work_item_type_id_change&.last.present? - # TODO: We should switch to DEFAULT_ISSUE_TYPE here when the issue_type column is dropped - # https://gitlab.com/gitlab-org/gitlab/-/work_items/402700 - self.work_item_type = WorkItems::Type.default_by_type(attributes['issue_type']) + self.work_item_type = WorkItems::Type.default_by_type(DEFAULT_ISSUE_TYPE) end def allowed_work_item_type_change diff --git a/app/models/member.rb b/app/models/member.rb index 0700b1a8448..f164ea244b4 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -66,6 +66,7 @@ class Member < ApplicationRecord scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') .select('members.*', 'invited_user.state as invited_user_state') + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") end scope :in_hierarchy, ->(source) do @@ -174,7 +175,10 @@ class Member < ApplicationRecord scope :by_access_level, -> (access_level) { active.where(access_level: access_level) } scope :all_by_access_level, -> (access_level) { where(access_level: access_level) } - scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) } + scope :preload_user_and_notification_settings, -> do + preload(user: :notification_settings) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") + end scope :with_source_id, ->(source_id) { where(source_id: source_id) } scope :including_source, -> { includes(:source) } @@ -288,7 +292,9 @@ class Member < ApplicationRecord class << self def search(query) - scope = joins(:user).merge(User.search(query, use_minimum_char_limit: false)) + scope = joins(:user) + .merge(User.search(query, use_minimum_char_limit: false)) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") return scope unless Gitlab::Pagination::Keyset::Order.keyset_aware?(scope) @@ -347,6 +353,7 @@ class Member < ApplicationRecord def left_join_users left_outer_joins(:user) + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417456") end def access_for_user_ids(user_ids) diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 237054587bc..ada89345a7f 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -20,7 +20,6 @@ class GroupMember < Member scope :of_groups, ->(groups) { where(source_id: groups&.select(:id)) } scope :of_ldap_type, -> { where(ldap: true) } scope :count_users_by_group_id, -> { group(:source_id).count } - scope :with_user, -> (user) { where(user: user) } after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 116108ceaf9..2773569161d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -421,7 +421,9 @@ class MergeRequest < ApplicationRecord scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: { merge_request_diff_commits: [:commit_author, :committer] }) } scope :preload_milestoneish_associations, -> { preload_routables.preload(:assignees, :labels) } - scope :with_web_entity_associations, -> { preload(:author, target_project: [:project_feature, group: [:route, :parent], namespace: :route]) } + scope :with_web_entity_associations, -> do + preload(:author, :labels, target_project: [:project_feature, group: [:route, :parent], namespace: :route]) + end scope :with_auto_merge_enabled, -> do with_state(:opened).where(auto_merge_enabled: true) @@ -1199,10 +1201,17 @@ class MergeRequest < ApplicationRecord end alias_method :wip_title, :draft_title - def mergeable?(skip_ci_check: false, skip_discussions_check: false) + def skipped_mergeable_checks(options = {}) + { + skip_ci_check: options.fetch(:auto_merge_requested, false) + } + end + + def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false) return false unless mergeable_state?( skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check + skip_discussions_check: skip_discussions_check, + skip_approved_check: skip_approved_check ) check_mergeability @@ -1223,11 +1232,12 @@ class MergeRequest < ApplicationRecord ] end - def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) + def mergeable_state?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false) additional_checks = execute_merge_checks( params: { skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check + skip_discussions_check: skip_discussions_check, + skip_approved_check: skip_approved_check } ) additional_checks.success? @@ -1526,6 +1536,14 @@ class MergeRequest < ApplicationRecord "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/train" end + def schedule_cleanup_refs(only: :all) + if Feature.enabled?(:merge_request_cleanup_ref_worker_async, target_project) + MergeRequests::CleanupRefWorker.perform_async(id, only.to_s) + else + cleanup_refs(only: only) + end + end + def cleanup_refs(only: :all) target_refs = [] target_refs << ref_path if %i[all head].include?(only) diff --git a/app/models/merge_request/diff_llm_summary.rb b/app/models/merge_request/diff_llm_summary.rb deleted file mode 100644 index e13fe5e1f50..00000000000 --- a/app/models/merge_request/diff_llm_summary.rb +++ /dev/null @@ -1,14 +0,0 @@ -# rubocop:disable Style/ClassAndModuleChildren -# frozen_string_literal: true - -class MergeRequest::DiffLlmSummary < ApplicationRecord - belongs_to :merge_request_diff - belongs_to :user, optional: true - - validates :merge_request_diff_id, uniqueness: true - validates :provider, presence: true - validates :content, presence: true, length: { maximum: 2056 } - - enum provider: { openai: 0 } -end -# rubocop:enable Style/ClassAndModuleChildren diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 70216144035..a13cb353c7b 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -13,7 +13,7 @@ class MergeRequest::Metrics < ApplicationRecord before_save :ensure_target_project_id scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) } - scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) } + scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date.is_a?(Time) ? date.end_of_day : date)) } 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) } diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d300b938fc0..8de717fb61d 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -10,6 +10,7 @@ class Milestone < ApplicationRecord include IidRoutes include UpdatedAtFilterable include EachBatch + include Spammable prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule @@ -62,6 +63,9 @@ class Milestone < ApplicationRecord validate :parent_type_check validate :uniqueness_of_title, if: :title_changed? + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true + state_machine :state, initial: :active do event :close do transition active: :closed @@ -255,6 +259,10 @@ class Milestone < ApplicationRecord end end + def check_for_spam?(*) + spammable_attribute_changed? && parent.public? + end + private def timebox_format_reference(format = :iid) diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index d1277efac7b..5c5f8d3b2db 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -11,6 +11,7 @@ module Ml belongs_to :project belongs_to :user + belongs_to :model, optional: true, inverse_of: :default_experiment has_many :candidates, class_name: 'Ml::Candidate' has_many :metadata, class_name: 'Ml::ExperimentMetadata' @@ -22,10 +23,21 @@ module Ml has_internal_id :iid, scope: :project + before_destroy :stop_destroy + def package_name "#{PACKAGE_PREFIX}#{iid}" end + def stop_destroy + return unless model_id + + errors[:base] << "Cannot delete an experiment associated to a model" + # According to docs, throw is the correct way to stop on a callback + # https://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html#module-ActiveRecord::Callbacks-label-Canceling+callbacks + throw :abort # rubocop:disable Cop/BanCatchThrow + end + class << self def by_project_id_and_iid(project_id, iid) find_by(project_id: project_id, iid: iid) diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb new file mode 100644 index 00000000000..684b8e1983b --- /dev/null +++ b/app/models/ml/model.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ml + class Model < ApplicationRecord + validates :project, :default_experiment, presence: true + validates :name, + format: Gitlab::Regex.ml_model_name_regex, + uniqueness: { scope: :project }, + presence: true, + length: { maximum: 255 } + + validate :valid_default_experiment? + + has_one :default_experiment, class_name: 'Ml::Experiment' + belongs_to :project + has_many :versions, class_name: 'Ml::ModelVersion' + + def valid_default_experiment? + return unless default_experiment + + errors.add(:default_experiment) unless default_experiment.name == name + errors.add(:default_experiment) unless default_experiment.project_id == project_id + end + end +end diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb new file mode 100644 index 00000000000..540fe6018a1 --- /dev/null +++ b/app/models/ml/model_version.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ml + class ModelVersion < ApplicationRecord + validates :project, :model, presence: true + + validates :version, + format: Gitlab::Regex.ml_model_version_regex, + uniqueness: { scope: [:project, :model_id] }, + presence: true, + length: { maximum: 255 } + + validate :valid_model?, :valid_package? + + belongs_to :model, class_name: 'Ml::Model' + belongs_to :project + belongs_to :package, class_name: 'Packages::Package', optional: true + + delegate :name, to: :model + + private + + def valid_model? + return unless model + + errors.add(:model, 'model project must be the same') unless model.project_id == project_id + end + + def valid_package? + return unless package + + errors.add(:package, 'package must be ml_model') unless package.ml_model? + errors.add(:package, 'package name must be the same') unless package.name == name + errors.add(:package, 'package version must be the same') unless package.version == version + errors.add(:package, 'package project must be the same') unless package.project_id == project_id + end + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 7b3bb04da5b..5449f006a2e 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -57,6 +57,7 @@ class Namespace < ApplicationRecord # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. belongs_to :owner, class_name: 'User' + belongs_to :organization, class_name: 'Organizations::Organization' belongs_to :parent, class_name: "Namespace" has_many :children, -> { where(type: Group.sti_name) }, class_name: "Namespace", foreign_key: :parent_id @@ -305,7 +306,7 @@ class Namespace < ApplicationRecord end def first_project_with_container_registry_tags - if ContainerRegistry::GitlabApiClient.supports_gitlab_api? + if Gitlab.com_except_jh? && ContainerRegistry::GitlabApiClient.supports_gitlab_api? ContainerRegistry::GitlabApiClient.one_project_with_container_registry_tag(full_path) else all_projects.includes(:container_repositories).find(&:has_container_registry_tags?) @@ -423,6 +424,10 @@ class Namespace < ApplicationRecord false end + def all_project_ids + all_projects.pluck(:id) + end + def all_project_ids_except(ids) all_projects.where.not(id: ids).pluck(:id) end @@ -478,7 +483,7 @@ class Namespace < ApplicationRecord def container_repositories_size strong_memoize(:container_repositories_size) do - next unless Gitlab.com? + next unless Gitlab.com_except_jh? next unless root? next unless ContainerRegistry::GitlabApiClient.supports_gitlab_api? next 0 if all_container_repositories.empty? diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 9006f104c64..1ca3c8e85f3 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -96,27 +96,6 @@ module Namespaces traversal_ids.present? end - def use_traversal_ids_for_self_and_hierarchy? - return false unless use_traversal_ids? - return false unless Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy, root_ancestor) - - traversal_ids.present? - end - - def use_traversal_ids_for_ancestors? - return false unless use_traversal_ids? - return false unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor) - - traversal_ids.present? - end - - def use_traversal_ids_for_ancestors_upto? - return false unless use_traversal_ids? - return false unless Feature.enabled?(:use_traversal_ids_for_ancestors_upto, root_ancestor) - - traversal_ids.present? - end - def root_ancestor strong_memoize(:root_ancestor) do if association(:parent).loaded? && parent.present? @@ -150,13 +129,13 @@ module Namespaces end def self_and_hierarchy - return super unless use_traversal_ids_for_self_and_hierarchy? + return super unless use_traversal_ids? self_and_descendants.or(ancestors) end def ancestors(hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestors? + return super unless use_traversal_ids? return self.class.none if parent_id.blank? @@ -164,7 +143,7 @@ module Namespaces end def ancestor_ids(hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestors? + return super unless use_traversal_ids? hierarchy_order == :desc ? traversal_ids[0..-2] : traversal_ids[0..-2].reverse end @@ -176,7 +155,7 @@ module Namespaces # This copies the behavior of the recursive method. We will deprecate # this behavior soon. def ancestors_upto(top = nil, hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestors_upto? + return super unless use_traversal_ids? # We can't use a default value in the method definition above because # we need to preserve those specific parameters for super. @@ -198,7 +177,7 @@ module Namespaces end def self_and_ancestors(hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestors? + return super unless use_traversal_ids? return self.class.where(id: id) if parent_id.blank? @@ -206,7 +185,7 @@ module Namespaces end def self_and_ancestor_ids(hierarchy_order: nil) - return super unless use_traversal_ids_for_ancestors? + return super unless use_traversal_ids? hierarchy_order == :desc ? traversal_ids : traversal_ids.reverse end diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index c50d3dd1de6..6e79e3ac9a1 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -18,7 +18,7 @@ module Namespaces end def roots - return super unless use_traversal_ids_roots? + return super unless use_traversal_ids? root_ids = all.select("#{quoted_table_name}.traversal_ids[1]").distinct unscoped.where(id: root_ids) @@ -37,13 +37,13 @@ module Namespaces end def self_and_descendants(include_self: true) - return super unless use_traversal_ids_for_descendants_scopes? + return super unless use_traversal_ids? self_and_descendants_with_comparison_operators(include_self: include_self) end def self_and_descendant_ids(include_self: true) - return super unless use_traversal_ids_for_descendants_scopes? + return super unless use_traversal_ids? self_and_descendants(include_self: include_self).as_ids end @@ -78,16 +78,6 @@ module Namespaces Feature.enabled?(:use_traversal_ids) end - def use_traversal_ids_roots? - Feature.enabled?(:use_traversal_ids_roots) && - use_traversal_ids? - end - - def use_traversal_ids_for_descendants_scopes? - Feature.enabled?(:use_traversal_ids_for_descendants_scopes) && - use_traversal_ids? - end - def use_traversal_ids_for_self_and_hierarchy_scopes? Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy_scopes) && use_traversal_ids? diff --git a/app/models/note.rb b/app/models/note.rb index 09ff7ad3979..2df643c46aa 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -26,7 +26,7 @@ class Note < ApplicationRecord include IgnorableColumns include Spammable - ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/.freeze @@ -756,7 +756,7 @@ class Note < ApplicationRecord Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id) end - # Method necesary while we transition into the new format for task system notes + # Method necessary while we transition into the new format for task system notes # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/369923 def note return super unless system? && for_issue? && super&.match?(ISSUE_TASK_SYSTEM_NOTE_PATTERN) @@ -792,6 +792,14 @@ class Note < ApplicationRecord true end + # Use attributes.keys instead of attribute_names to filter out the fields that are skipped during export: + # + # - note_html + # - cached_markdown_version + def attribute_names_for_serialization + attributes.keys + end + private def trigger_note_subscription? diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index ce89f57a73b..8aeca2eb137 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -8,6 +8,14 @@ module Organizations before_destroy :check_if_default_organization + has_many :namespaces + has_many :groups + + has_one :settings, class_name: "OrganizationSetting" + + has_many :organization_users, inverse_of: :organization + has_many :users, through: :organization_users, inverse_of: :organizations + validates :name, presence: true, length: { maximum: 255 } diff --git a/app/models/organizations/organization_setting.rb b/app/models/organizations/organization_setting.rb new file mode 100644 index 00000000000..108531e6701 --- /dev/null +++ b/app/models/organizations/organization_setting.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationSetting < ApplicationRecord + belongs_to :organization + + validates :settings, json_schema: { filename: "organization_settings" } + + jsonb_accessor :settings, + restricted_visibility_levels: [:integer, { array: true }] + + validates_each :restricted_visibility_levels do |record, attr, value| + value&.each do |level| + unless Gitlab::VisibilityLevel.options.value?(level) + record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level)) + end + end + end + end +end diff --git a/app/models/organizations/organization_user.rb b/app/models/organizations/organization_user.rb new file mode 100644 index 00000000000..5aa1133b017 --- /dev/null +++ b/app/models/organizations/organization_user.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationUser < ApplicationRecord + belongs_to :organization, inverse_of: :organization_users, optional: false + belongs_to :user, inverse_of: :organization_users, optional: false + end +end diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb index ccbf056ec7b..2fc1c05cd48 100644 --- a/app/models/packages/npm/metadatum.rb +++ b/app/models/packages/npm/metadatum.rb @@ -26,6 +26,11 @@ class Packages::Npm::Metadatum < ApplicationRecord def ensure_package_json_size return if package_json.to_s.size < MAX_PACKAGE_JSON_SIZE - errors.add(:package_json, _('structure is too large')) + errors.add(:package_json, :too_large, + message: format( + _('structure is too large. Maximum size is %{max_size} characters'), + max_size: MAX_PACKAGE_JSON_SIZE + ) + ) end end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 58305b45457..b618c7c20c4 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -83,7 +83,7 @@ class Packages::Package < ApplicationRecord validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? validates :name, format: { with: Gitlab::Regex.helm_package_regex }, if: :helm? - validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm? + validates :name, format: { with: Gitlab::Regex.npm_package_name_regex, message: Gitlab::Regex.npm_package_name_regex_message }, if: :npm? validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module? validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? @@ -94,7 +94,8 @@ class Packages::Package < ApplicationRecord validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? validates :version, format: { with: Gitlab::Regex.helm_version_regex }, if: :helm? - validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? } + validates :version, format: { with: Gitlab::Regex.semver_regex, message: Gitlab::Regex.semver_regex_message }, + if: -> { composer_tag_version? || npm? || terraform_module? } validates :version, presence: true, @@ -166,16 +167,16 @@ 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.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 } + scope :select_only_first_by_name, -> { select('DISTINCT ON (name) *') } # Sorting scope :order_created, -> { reorder(created_at: :asc) } scope :order_created_desc, -> { reorder(created_at: :desc) } scope :order_name, -> { reorder(name: :asc) } scope :order_name_desc, -> { reorder(name: :desc) } + scope :order_name_desc_version_desc, -> { reorder(name: :desc, version: :desc) } scope :order_version, -> { reorder(version: :asc) } scope :order_version_desc, -> { reorder(version: :desc) } scope :order_type, -> { reorder(package_type: :asc) } diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 864ea04c019..2ffb2e84cbf 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -46,7 +46,7 @@ module Pages strong_memoize_attr :source def prefix - if project.pages_namespace_url == project.pages_url + if url_builder.namespace_pages? '/' else "#{project.full_path.delete_prefix(trim_prefix)}/" @@ -55,9 +55,7 @@ module Pages strong_memoize_attr :prefix def unique_host - return unless project.project_setting.pages_unique_domain_enabled? - - project.pages_unique_host + url_builder.unique_host end strong_memoize_attr :unique_host @@ -76,5 +74,10 @@ module Pages project.pages_metadatum.pages_deployment end strong_memoize_attr :deployment + + def url_builder + Gitlab::Pages::UrlBuilder.new(project) + end + strong_memoize_attr :url_builder end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 2749404b7b5..08f725de980 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -20,6 +20,7 @@ class PersonalAccessToken < ApplicationRecord serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user + belongs_to :previous_personal_access_token, class_name: 'PersonalAccessToken' after_initialize :set_default_scopes, if: :persisted? before_save :ensure_token @@ -99,9 +100,13 @@ class PersonalAccessToken < ApplicationRecord def expires_at_before_instance_max_expiry_date return unless expires_at - if expires_at > MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now - errors.add(:expires_at, _('must expire in 365 days')) - end + max_expiry_date = Date.current.advance(days: MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS) + return unless expires_at > max_expiry_date + + errors.add( + :expires_at, + format(_("must be before %{expiry_date}"), expiry_date: max_expiry_date) + ) end end diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index 6795e7a3049..245c0719439 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -2,6 +2,9 @@ class PlanLimits < ApplicationRecord include IgnorableColumns + ALLOWED_LIMITS_HISTORY_ATTRIBUTES = %i[notification_limit enforcement_limit storage_size_limit + dashboard_limit_enabled_at].freeze + ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22' ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22' ignore_column :ci_active_pipelines, remove_with: '16.3', remove_after: '2022-07-22' @@ -50,32 +53,23 @@ class PlanLimits < ApplicationRecord false end - def log_limits_changes(user, new_limits) - new_limits.each do |attribute, value| + def format_limits_history(user, new_limits) + allowed_limits = new_limits.slice(*ALLOWED_LIMITS_HISTORY_ATTRIBUTES) + return {} if allowed_limits.empty? + + allowed_limits.each do |attribute, value| + next if value == self[attribute] + limits_history[attribute] ||= [] limits_history[attribute] << { - user_id: user&.id, - username: user&.username, - timestamp: Time.current.utc.to_i, - value: value + "user_id" => user.id, + "username" => user.username, + "timestamp" => Time.current.utc.to_i, + "value" => value } end - update(limits_history: limits_history) - end - - def limit_attribute_changes(attribute) - limit_history = limits_history[attribute] - return [] unless limit_history - - limit_history.map do |entry| - { - timestamp: entry[:timestamp], - value: entry[:value], - username: entry[:username], - user_id: entry[:user_id] - } - end + limits_history end end diff --git a/app/models/project.rb b/app/models/project.rb index 452a5c8973c..931f4db3a54 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -415,7 +415,7 @@ 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 :external_pull_requests, inverse_of: :project + has_many :external_pull_requests, inverse_of: :project, class_name: 'Ci::ExternalPullRequest' has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id @@ -692,6 +692,10 @@ class Project < ApplicationRecord scope :with_integration, -> (integration_class) { joins(:integrations).merge(integration_class.all) } scope :with_active_integration, -> (integration_class) { with_integration(integration_class).merge(integration_class.active) } scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) } + # .with_slack_integration can generate poorly performing queries. It is intended only for UsagePing. + scope :with_slack_integration, -> { joins(:slack_integration) } + # .with_slack_slash_commands_integration can generate poorly performing queries. It is intended only for UsagePing. + scope :with_slack_slash_commands_integration, -> { joins(:slack_slash_commands_integration) } scope :inside_path, ->(path) do # We need routes alias rs for JOIN so it does not conflict with # includes(:route) which we use in ProjectsFinder. @@ -775,6 +779,7 @@ class Project < ApplicationRecord scope :pending_data_repair_analysis, -> do left_outer_joins(:container_registry_data_repair_detail) .where(container_registry_data_repair_details: { project_id: nil }) + .order(id: :desc) end enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -904,6 +909,16 @@ class Project < ApplicationRecord 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] }) } + scope :without_created_and_owned_by_banned_user, -> do + where_not_exists( + Users::BannedUser.joins( + 'INNER JOIN project_authorizations ON project_authorizations.user_id = banned_users.user_id' + ).where('projects.creator_id = banned_users.user_id') + .where('project_authorizations.project_id = projects.id') + .where(project_authorizations: { access_level: Gitlab::Access::OWNER }) + ) + end + class << self # Searches for a list of projects based on the query given in `query`. # @@ -1840,10 +1855,12 @@ class Project < ApplicationRecord triggered.add_hooks(hooks) end - def execute_integrations(data, hooks_scope = :push_hooks) + def execute_integrations(data, hooks_scope = :push_hooks, skip_ci: false) # Call only service hooks that are active for this scope run_after_commit_or_now do association("#{hooks_scope}_integrations").reader.each do |integration| + next if skip_ci && integration.ci? + integration.async_execute(data) end end @@ -2201,42 +2218,6 @@ class Project < ApplicationRecord pages_metadatum&.deployed? end - def pages_url(with_unique_domain: false) - return pages_unique_url if with_unique_domain && pages_unique_domain_enabled? - - url = pages_namespace_url - url_path = full_path.partition('/').last - namespace_url = "#{Settings.pages.protocol}://#{url_path}".downcase - - if Rails.env.development? - url_without_port = URI.parse(url) - url_without_port.port = nil - - return url if url_without_port.to_s == namespace_url - end - - # If the project path is the same as host, we serve it as group page - return url if url == namespace_url - - "#{url}/#{url_path}" - end - - def pages_unique_url - pages_url_for(project_setting.pages_unique_domain) - end - - def pages_unique_host - URI(pages_unique_url).host - end - - def pages_namespace_url - pages_url_for(pages_subdomain) - end - - def pages_subdomain - full_path.partition('/').first - end - def pages_path # TODO: when we migrate Pages to work with new storage types, change here to use disk_path File.join(Settings.pages.path, full_path) @@ -2483,7 +2464,7 @@ class Project < ApplicationRecord break unless pages_enabled? variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) - variables.append(key: 'CI_PAGES_URL', value: pages_url) + variables.append(key: 'CI_PAGES_URL', value: Gitlab::Pages::UrlBuilder.new(self).pages_url) end end @@ -3167,6 +3148,10 @@ class Project < ApplicationRecord pending_delete? || hidden? end + def created_and_owned_by_banned_user? + creator.banned? && team.max_member_access(creator.id) == Gitlab::Access::OWNER + end + def content_editor_on_issues_feature_flag_enabled? group&.content_editor_on_issues_feature_flag_enabled? || Feature.enabled?(:content_editor_on_issues, self) end @@ -3236,25 +3221,8 @@ class Project < ApplicationRecord group.crm_enabled? end - def frozen_outbound_job_token_scopes? - Feature.enabled?(:frozen_outbound_job_token_scopes, self) && Feature.disabled?(:frozen_outbound_job_token_scopes_override, self) - end - strong_memoize_attr :frozen_outbound_job_token_scopes? - private - def pages_unique_domain_enabled? - Feature.enabled?(:pages_unique_domain, self) && - project_setting.pages_unique_domain_enabled? - end - - def pages_url_for(domain) - # The host in URL always needs to be downcased - Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix| - "#{prefix}#{domain}." - end.downcase - end - # overridden in EE def project_group_links_with_preload project_group_links diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index aa65f27870d..cc9003423be 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -2,7 +2,6 @@ class ProjectCiCdSetting < ApplicationRecord include ChronicDurationAttribute - include IgnorableColumns belongs_to :project, inverse_of: :ci_cd_settings @@ -23,8 +22,6 @@ class ProjectCiCdSetting < ApplicationRecord chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval - ignore_column :opt_in_jwt, remove_with: '16.2', remove_after: '2023-07-01' - def keep_latest_artifacts_available? # The project level feature can only be enabled when the feature is enabled instance wide Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact? && keep_latest_artifact? diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 14f6a90e5ed..365bb5237c3 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -34,7 +34,6 @@ class ProjectStatistics < ApplicationRecord :build_artifacts_size, :packages_size, :snippets_size, - :pipeline_artifacts_size, :uploads_size ].freeze diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index ed1795b43e0..347d65841ed 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -71,7 +71,7 @@ module Projects # /\R/ - A linebreak: \n, \v, \f, \r \u0085 (NEXT LINE), # \u2028 (LINE SEPARATOR), \u2029 (PARAGRAPH SEPARATOR) or \r\n. - return unless name =~ /\R/ + return unless /\R/.match?(name) errors.add(:name, 'has characters that are not allowed') end diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb index e3aa3d106b7..1f51ced5b57 100644 --- a/app/models/projects/triggered_hooks.rb +++ b/app/models/projects/triggered_hooks.rb @@ -17,6 +17,8 @@ module Projects # Assumes that the relations implement TriggerableHooks @relations.each do |hooks| hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook| + next if @scope == :emoji_hooks && Feature.disabled?(:emoji_webhooks, hook.parent) + hook.async_execute(@data, @scope.to_s) end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index c86ca5723fa..53cec0c5511 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -3,49 +3,7 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord include Importable include ProtectedBranchAccess + include ProtectedRefDeployKeyAccess # default value for the access_level column GITLAB_DEFAULT_ACCESS_LEVEL = Gitlab::Access::MAINTAINER - - belongs_to :deploy_key - - validates :access_level, uniqueness: { scope: :protected_branch_id, if: :role?, - conditions: -> { where(user_id: nil, group_id: nil, deploy_key_id: nil) } } - validates :deploy_key_id, uniqueness: { scope: :protected_branch_id, allow_nil: true } - validate :validate_deploy_key_membership - - def type - if self.deploy_key.present? - :deploy_key - else - super - end - end - - def humanize - return "Deploy key" if deploy_key.present? - - super - end - - def check_access(user) - 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 - - unless project.deploy_keys_projects.where(deploy_key: deploy_key).exists? - self.errors.add(:deploy_key, 'is not enabled for this project') - end - end - - def enabled_deploy_key_for_user?(deploy_key, user) - deploy_key.user_id == user.id && DeployKey.with_write_access_for_project(protected_branch.project, deploy_key: deploy_key).any? - end end diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb index 5837f3a5afb..0eff9924153 100644 --- a/app/models/protected_tag/create_access_level.rb +++ b/app/models/protected_tag/create_access_level.rb @@ -3,48 +3,5 @@ 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 - return :deploy_key if deploy_key.present? - - super - end - - def humanize - return "Deploy key" if deploy_key.present? - - super - end - - def check_access(current_user) - super do - break enabled_deploy_key_for_user?(current_user) if deploy_key? - end - end - - private - - def deploy_key? - type == :deploy_key - end - - 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?(current_user) - current_user.can?(:read_project, project) && - deploy_key.user_id == current_user.id && - DeployKey.with_write_access_for_project(protected_tag.project, deploy_key: deploy_key).any? - end + include ProtectedRefDeployKeyAccess end diff --git a/app/models/release.rb b/app/models/release.rb index 7f74872cf67..f0ba56390ab 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -64,10 +64,10 @@ class Release < ApplicationRecord end # This query uses LATERAL JOIN to find the latest release for each project. To avoid - # joining the `releases` table, we build an in-memory table using the project ids. + # joining the `projects` table, we build an in-memory table using the project ids. # Example: # SELECT ... - # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) project_ids (id) + # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) projects (id) # INNER JOIN LATERAL (...) def latest_for_projects(projects, order_by: 'released_at') return Release.none if projects.empty? diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 8b2f3bdcedf..934053cb92d 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -137,6 +137,7 @@ class RemoteMirror < ApplicationRecord return false unless project.remote_mirror_available? return false unless project.repository_exists? return false if project.pending_delete? + return false if Gitlab::SilentMode.enabled? true end diff --git a/app/models/repository.rb b/app/models/repository.rb index b21df6baf0e..1321c9da780 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -838,7 +838,7 @@ class Repository files = ls_files(options[:branch_name]) options[:actions] = files.each_with_object([]) do |item, list| - next unless item =~ regex + next unless regex.match?(item) list.push( action: :move, diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index 4216ad7e70f..6560b25b39c 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -21,6 +21,7 @@ class ServiceDeskSetting < ApplicationRecord validates :project_id, presence: true validate :valid_issue_template validate :valid_project_key + validate :custom_email_enabled_state validates :outgoing_name, length: { maximum: 255 }, allow_blank: true validates :project_key, length: { maximum: 255 }, @@ -86,6 +87,14 @@ class ServiceDeskSetting < ApplicationRecord end end + def custom_email_enabled_state + return unless custom_email_enabled? + + if custom_email_verification.blank? || !custom_email_verification.finished? + errors.add(:custom_email_enabled, 'cannot be enabled until verification process has finished.') + end + end + private def source_template_project diff --git a/app/models/system_access.rb b/app/models/system_access.rb new file mode 100644 index 00000000000..9ffc63c5ca8 --- /dev/null +++ b/app/models/system_access.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module SystemAccess + def self.table_name_prefix + 'system_access_' + end +end diff --git a/app/models/todo.rb b/app/models/todo.rb index 724f97c4812..f202e1a266d 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -117,7 +117,18 @@ class Todo < ApplicationRecord # target - The value of the `target_type` column, such as `Issue`. # state - The value of the `state` column, such as `pending` or `done`. def any_for_target?(target, state = nil) - state.nil? ? exists?(target: target) : exists?(target: target, state: state) + conditions = {} + + if target.respond_to?(:todoable_target_type_name) + conditions[:target_type] = target.todoable_target_type_name + conditions[:target_id] = target.id + else + conditions[:target] = target + end + + conditions[:state] = state unless state.nil? + + exists?(conditions) end # Updates attributes of a relation of todos to the new state. diff --git a/app/models/user.rb b/app/models/user.rb index 96cdbb192bc..4a57cc2e2e2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,7 +60,7 @@ class User < ApplicationRecord INCOMING_MAIL_TOKEN_PREFIX = 'glimt-' FEED_TOKEN_PREFIX = 'glft-' - columns_changing_default :notified_of_own_activity + columns_changing_default :project_view # lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token } @@ -170,8 +170,11 @@ class User < ApplicationRecord has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser' has_many :followers, through: :following_users - # Groups + # Namespaces has_many :members + has_many :member_namespaces, through: :members + + # Groups has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember' has_many :groups, through: :group_members has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group @@ -256,6 +259,9 @@ class User < ApplicationRecord has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' + has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user + has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users + has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user has_one :status, class_name: 'UserStatus' @@ -1541,7 +1547,7 @@ class User < ApplicationRecord end def full_website_url - return "http://#{website_url}" if website_url !~ %r{\Ahttps?://} + return "http://#{website_url}" unless %r{\Ahttps?://}.match?(website_url) website_url end @@ -1827,8 +1833,12 @@ class User < ApplicationRecord Project.where(id: events).not_aimed_for_deletion end + # Returns true if the user can be removed, false otherwise. + # A user can be removed if they do not own any groups where they are the sole owner + # Method `none?` is used to ensure faster retrieval, See https://gitlab.com/gitlab-org/gitlab/-/issues/417105 + def can_be_removed? - !solo_owned_groups.present? + solo_owned_groups.none? end def can_remove_self? @@ -2063,9 +2073,17 @@ class User < ApplicationRecord # override, from Devise def lock_access!(opts = {}) Gitlab::AppLogger.info("Account Locked: username=#{username}") + audit_lock_access(reason: opts.delete(:reason)) super end + # override, from Devise + def unlock_access!(unlocked_by: self) + audit_unlock_access(author: unlocked_by) + + super() + end + # Determine the maximum access level for a group of projects in bulk. # # Returns a Hash mapping project ID -> maximum access level. @@ -2103,7 +2121,7 @@ class User < ApplicationRecord end def terms_accepted? - return true if project_bot? + return true if project_bot? || service_account? || security_policy_bot? accepted_term_id.present? end @@ -2279,30 +2297,6 @@ class User < ApplicationRecord namespace_commit_emails.find_by(namespace: project.root_namespace) end - def spammer? - spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD - end - - def spam_score - abuse_trust_scores.spamcheck.average(:score) || 0.0 - end - - def telesign_score - abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0 - end - - def arkose_global_score - abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0 - end - - def arkose_custom_score - abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0 - end - - def trust_scores_for_source(source) - abuse_trust_scores.where(source: source) - end - def abuse_metadata { account_age: account_age_in_days, @@ -2310,6 +2304,10 @@ class User < ApplicationRecord } end + def allow_possible_spam? + custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? + end + def namespace_commit_email_for_namespace(namespace) return if namespace.nil? @@ -2330,7 +2328,7 @@ class User < ApplicationRecord return super if ::Gitlab::CurrentSettings.email_confirmation_setting_soft? # Following devise logic for method, we want to return `true` - # See: https://github.com/heartcombo/devise/blob/main/lib/devise/models/confirmable.rb#L191-L218 + # See: https://github.com/heartcombo/devise/blob/ec0674523e7909579a5a008f16fb9fe0c3a71712/lib/devise/models/confirmable.rb#L191-L218 true end alias_method :in_confirmation_period?, :confirmation_period_valid? @@ -2355,7 +2353,8 @@ class User < ApplicationRecord private def block_or_ban - if spammer? && account_age_in_days < 7 + user_scores = Abuse::UserTrustScore.new(self) + if user_scores.spammer? && account_age_in_days < 7 ban_and_report else block @@ -2608,6 +2607,12 @@ class User < ApplicationRecord def prefix_for_feed_token FEED_TOKEN_PREFIX end + + # method overriden in EE + def audit_lock_access(reason: nil); end + + # method overriden in EE + def audit_unlock_access(author: self); end end User.prepend_mod_with('User') diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 63a5ee9770f..425f2cc062b 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -15,6 +15,8 @@ class UserCustomAttribute < ApplicationRecord UNBLOCKED_BY = 'unblocked_by' ARKOSE_RISK_BAND = 'arkose_risk_band' AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id' + ALLOW_POSSIBLE_SPAM = 'allow_possible_spam' + IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' class << self def upsert_custom_attributes(custom_attributes) diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 4d517408154..c263d552d40 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -2,15 +2,12 @@ class UserPreference < ApplicationRecord include IgnorableColumns - include SafelyChangeColumnDefault # We could use enums, but Rails 4 doesn't support multiple # enum options with same name for multiple fields, also it creates # extra methods that aren't really needed here. NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze - columns_changing_default :tab_width, :time_display_relative, :render_whitespace_in_code - belongs_to :user scope :with_user, -> { joins(:user) } @@ -31,7 +28,6 @@ class UserPreference < ApplicationRecord validates :pinned_nav_items, json_schema: { filename: 'pinned_nav_items' } ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' - ignore_columns :time_format_in_24h, remove_with: '16.2', remove_after: '2023-07-22' # 2023-06-22 is after 16.1 release and during 16.2 release https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#ignoring-the-column-release-m ignore_columns :use_legacy_web_ide, remove_with: '16.2', remove_after: '2023-06-22' diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 38e518b6d3e..0d02a3b99aa 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -55,10 +55,10 @@ module Users submit_license_usage_data_banner: 52, # EE-only personal_project_limitations_banner: 53, # EE-only mr_experience_survey: 54, - namespace_storage_limit_banner_info_threshold: 55, # EE-only - namespace_storage_limit_banner_warning_threshold: 56, # EE-only - namespace_storage_limit_banner_alert_threshold: 57, # EE-only - namespace_storage_limit_banner_error_threshold: 58, # EE-only + # 55 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920 + namespace_storage_limit_alert_warning_threshold: 56, # EE-only + namespace_storage_limit_alert_alert_threshold: 57, # EE-only + namespace_storage_limit_alert_error_threshold: 58, # EE-only project_quality_summary_feedback: 59, # EE-only merge_request_settings_moved_callout: 60, new_top_level_group_alert: 61, @@ -66,13 +66,14 @@ module Users # 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233 branch_rules_info_callout: 65, create_runner_workflow_banner: 66, - repository_storage_limit_banner_info_threshold: 67, # EE-only - repository_storage_limit_banner_warning_threshold: 68, # EE-only - repository_storage_limit_banner_alert_threshold: 69, # EE-only - repository_storage_limit_banner_error_threshold: 70, # EE-only + # 67 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920 + project_repository_limit_alert_warning_threshold: 68, # EE-only + project_repository_limit_alert_alert_threshold: 69, # EE-only + project_repository_limit_alert_error_threshold: 70, # EE-only new_navigation_callout: 71, code_suggestions_third_party_callout: 72, # EE-only - namespace_over_storage_users_combined_alert: 73 # EE-only + namespace_over_storage_users_combined_alert: 73, # EE-only + rich_text_editor: 74 } validates :feature_name, diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index c5946197b6f..74b653b5777 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -17,19 +17,19 @@ module Users preview_user_over_limit_free_plan_alert: 7, # EE-only user_reached_limit_free_plan_alert: 8, # EE-only free_group_limited_alert: 9, # EE-only - namespace_storage_limit_banner_info_threshold: 10, # EE-only - namespace_storage_limit_banner_warning_threshold: 11, # EE-only - namespace_storage_limit_banner_alert_threshold: 12, # EE-only - namespace_storage_limit_banner_error_threshold: 13, # EE-only + # 10 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920 + namespace_storage_limit_alert_warning_threshold: 11, # EE-only + namespace_storage_limit_alert_alert_threshold: 12, # EE-only + namespace_storage_limit_alert_error_threshold: 13, # EE-only usage_quota_trial_alert: 14, # EE-only preview_usage_quota_free_plan_alert: 15, # EE-only enforcement_at_limit_alert: 16, # EE-only web_hook_disabled: 17, # EE-only unlimited_members_during_trial_alert: 18, # EE-only - repository_storage_limit_banner_info_threshold: 19, # EE-only - repository_storage_limit_banner_warning_threshold: 20, # EE-only - repository_storage_limit_banner_alert_threshold: 21, # EE-only - repository_storage_limit_banner_error_threshold: 22, # EE-only + # 19 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920 + project_repository_limit_alert_warning_threshold: 20, # EE-only + project_repository_limit_alert_alert_threshold: 21, # EE-only + project_repository_limit_alert_error_threshold: 22, # EE-only namespace_over_storage_users_combined_alert: 23 # EE-only } diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb index c8b2513e702..5480b9e9c4a 100644 --- a/app/models/webauthn_registration.rb +++ b/app/models/webauthn_registration.rb @@ -3,10 +3,6 @@ # Registration information for WebAuthn credentials class WebauthnRegistration < ApplicationRecord - include IgnorableColumns - - ignore_column :u2f_registration_id, remove_with: '16.2', remove_after: '2023-06-22' - belongs_to :user validates :credential_xid, :public_key, :counter, presence: true diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 9f28ffbf7b6..adf424a1d94 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -65,6 +65,12 @@ class WorkItem < Issue 'issue' end + # Todo: remove method after target_type cleanup + # See https://gitlab.com/gitlab-org/gitlab/-/issues/416009 + def todoable_target_type_name + %w[Issue WorkItem] + end + def widgets strong_memoize(:widgets) do work_item_type.widgets.map do |widget_class| @@ -114,7 +120,9 @@ class WorkItem < Issue .filter { |param_name| common_params.key?(param_name) } .each do |param_name| widget_params[widget.api_symbol] ||= {} - widget_params[widget.api_symbol][param_name] = common_params.delete(param_name) + param_value = common_params.delete(param_name) + + widget_params[widget.api_symbol].merge!(widget.process_quick_action_param(param_name, param_value)) end end diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb index a8b1b3f9a59..c4e87decdbf 100644 --- a/app/models/work_items/widgets/base.rb +++ b/app/models/work_items/widgets/base.rb @@ -15,6 +15,10 @@ module WorkItems [] end + def self.process_quick_action_param(param_name, value) + { param_name => value } + end + def self.callback_class WorkItems::Callbacks.const_get(name.demodulize, false) rescue NameError diff --git a/app/models/work_items/widgets/current_user_todos.rb b/app/models/work_items/widgets/current_user_todos.rb index 61c4fcb453b..64297b433dd 100644 --- a/app/models/work_items/widgets/current_user_todos.rb +++ b/app/models/work_items/widgets/current_user_todos.rb @@ -3,6 +3,19 @@ module WorkItems module Widgets class CurrentUserTodos < Base + def self.quick_action_commands + [:todo, :done] + end + + def self.quick_action_params + [:todo_event] + end + + def self.process_quick_action_param(param_name, value) + return super unless param_name == :todo_event + + { action: value == 'done' ? 'mark_as_done' : 'add' } + end end end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index b96ad9a73c8..bf7bfe36254 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -22,10 +22,6 @@ class GlobalPolicy < BasePolicy condition(:project_bot, scope: :user) { @user&.project_bot? } condition(:migration_bot, scope: :user) { @user&.migration_bot? } - condition(:create_runner_workflow_enabled, scope: :user) do - Feature.enabled?(:create_runner_workflow_for_admin, @user) - end - condition(:service_account, scope: :user) { @user&.service_account? } rule { anonymous }.policy do @@ -128,10 +124,6 @@ class GlobalPolicy < BasePolicy enable :create_instance_runner end - rule { ~create_runner_workflow_enabled }.policy do - prevent :create_instance_runner - end - # We can't use `read_statistics` because the user may have different permissions for different projects rule { admin }.enable :use_project_statistics_filters diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 94a67f5b5c8..29b966b43e2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -97,10 +97,6 @@ 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_for_namespace, group) - end - condition(:achievements_enabled, scope: :subject) do Feature.enabled?(:achievements, @subject) end @@ -375,10 +371,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :admin_observability end - rule { ~create_runner_workflow_enabled }.policy do - prevent :create_runner - end - # Should be matched with ProjectPolicy#read_internal_note rule { admin | reporter }.enable :read_internal_note diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index 49f9225a1d3..090be645b21 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -16,6 +16,10 @@ class MergeRequestPolicy < IssuablePolicy prevent :accept_merge_request end + rule { can?(:read_merge_request) }.policy do + enable :generate_diff_summary + end + rule { can_approve }.policy do enable :approve_merge_request end @@ -43,6 +47,10 @@ class MergeRequestPolicy < IssuablePolicy enable :set_merge_request_metadata end + rule { llm_bot }.policy do + enable :generate_diff_summary + end + private def can_approve? diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index c70dc288710..ad6155258ab 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -253,12 +253,12 @@ class ProjectPolicy < BasePolicy !Gitlab.config.terraform_state.enabled end - condition(:create_runner_workflow_enabled) do - Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) - end - condition(:namespace_catalog_available) { namespace_catalog_available? } + condition(:created_and_owned_by_banned_user, scope: :subject) do + Feature.enabled?(:hide_projects_of_banned_users) && @subject.created_and_owned_by_banned_user? + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -886,10 +886,6 @@ class ProjectPolicy < BasePolicy enable :read_code end - rule { ~create_runner_workflow_enabled }.policy do - prevent :create_runner - end - # Should be matched with GroupPolicy#read_internal_note rule { admin | can?(:reporter_access) }.enable :read_internal_note @@ -909,6 +905,14 @@ class ProjectPolicy < BasePolicy enable :read_model_experiments end + rule { can?(:reporter_access) & model_experiments_enabled }.policy do + enable :write_model_experiments + end + + rule { ~admin & created_and_owned_by_banned_user }.policy do + prevent :read_project + end + private def user_is_user? diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index 659e991e9d8..60fa351b449 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -10,7 +10,7 @@ module AlertManagement MARKDOWN_LINE_BREAK = " \n" HORIZONTAL_LINE = "\n\n---\n\n" - delegate :metrics_dashboard_url, :runbook, to: :parsed_payload + delegate :runbook, to: :parsed_payload def initialize(alert, **attributes) super @@ -44,22 +44,10 @@ module AlertManagement project.incident_management_setting&.create_issue? end - def show_performance_dashboard_link? - prometheus_alert.present? - end - def incident_issues_link project_incidents_url(project) end - def performance_dashboard_link - if environment - metrics_project_environment_url(project, environment) - else - metrics_project_environments_url(project) - end - end - def email_title [environment&.name, query_title].compact.join(': ') end @@ -72,8 +60,7 @@ module AlertManagement def issue_summary_markdown <<~MARKDOWN.chomp - #{metadata_list} - #{metric_embed_for_alert} + #{metadata_list}\n MARKDOWN end @@ -92,10 +79,6 @@ module AlertManagement metadata.join(MARKDOWN_LINE_BREAK) end - def metric_embed_for_alert - "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url - end - def list_item(key, value) "**#{key}:** #{value}".strip end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index cd473152b41..bc12d210334 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -86,7 +86,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def find_file_path - url_helpers.project_find_file_path(project, blob.commit_id) + url_helpers.project_find_file_path(project, commit_id, ref_type: ref_type) end def blame_path @@ -131,13 +131,13 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def can_modify_blob? - super(blob, project, blob.commit_id) + super(blob, project, commit_id) end def can_current_user_push_to_branch? - return false unless current_user && project.repository.branch_exists?(blob.commit_id) + return false unless current_user && project.repository.branch_exists?(commit_id) - user_access(project).can_push_to_branch?(blob.commit_id) + user_access(project).can_push_to_branch?(commit_id) end def archived? @@ -145,7 +145,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def ide_edit_path - super(project, blob.commit_id, blob.path) + super(project, commit_id, blob.path) end def external_storage_url @@ -159,7 +159,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def project_blob_path_root - project_blob_path(project, blob.commit_id) + project_blob_path(project, commit_id) end private @@ -181,7 +181,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def environment - environment_params = project.repository.branch_exists?(blob.commit_id) ? { ref: blob.commit_id } : { sha: blob.commit_id } + environment_params = project.repository.branch_exists?(commit_id) ? { ref: commit_id } : { sha: commit_id } environment_params[:find_latest] = true ::Environments::EnvironmentsByDeploymentsFinder.new(project, current_user, environment_params).execute.last end @@ -190,12 +190,13 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated blob.repository.project end - def ref_qualified_path + def commit_id # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`. # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`. + ExtractsRef.unqualify_ref(blob.commit_id, ref_type) + end - commit_id = ExtractsRef.unqualify_ref(blob.commit_id, ref_type) - + def ref_qualified_path File.join(commit_id, blob.path) end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 3aba5a2c7ed..762ee0d92cd 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -65,28 +65,6 @@ module Ci '%.2f' % pipeline.coverage end - def ref_text_legacy - if pipeline.detached_merge_request_pipeline? - _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}") - .html_safe % { - link_to_merge_request: link_to_merge_request, - link_to_merge_request_source_branch: link_to_merge_request_source_branch - } - elsif pipeline.merged_result_pipeline? - _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}") - .html_safe % { - link_to_merge_request: link_to_merge_request, - link_to_merge_request_source_branch: link_to_merge_request_source_branch, - link_to_merge_request_target_branch: link_to_merge_request_target_branch - } - elsif pipeline.ref && pipeline.ref_exists? - _("for %{link_to_pipeline_ref}") - .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } - elsif pipeline.ref - _("for %{ref}").html_safe % { ref: plain_ref_name } - end - end - def ref_text if pipeline.detached_merge_request_pipeline? _("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}") @@ -109,22 +87,6 @@ module Ci end end - def all_related_merge_request_text(limit: nil) - if all_related_merge_requests.none? - _("No related merge requests found.") - else - (_("%{count} related %{pluralized_subject}: %{links}") % { - count: all_related_merge_requests.count, - pluralized_subject: n_('merge request', 'merge requests', all_related_merge_requests.count), - links: all_related_merge_request_links(limit: limit).join(', ') - }).html_safe - end - end - - def has_many_merge_requests? - all_related_merge_requests.count > 1 - end - def link_to_pipeline_ref ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), diff --git a/app/presenters/ml/models_index_presenter.rb b/app/presenters/ml/models_index_presenter.rb new file mode 100644 index 00000000000..e2cb8e2d6c1 --- /dev/null +++ b/app/presenters/ml/models_index_presenter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ml + class ModelsIndexPresenter + def initialize(models) + @models = models + end + + def present + data = @models.map do |m| + { + name: m.name, + version: m.version, + path: Gitlab::Routing.url_helpers.project_package_path(m.project, m) + } + end + + Gitlab::Json.generate({ models: data }) + end + end +end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 856eba5aadc..4533ef3633d 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -28,6 +28,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated [ commits_anchor_data, branches_anchor_data, + terraform_states_anchor_data, tags_anchor_data, storage_anchor_data, releases_anchor_data, @@ -236,6 +237,21 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated ) end + def terraform_states_anchor_data + if project.terraform_states.exists? && can_read_terraform_state? + AnchorData.new( + true, + statistic_icon('terraform') + + n_('%{strong_start}%{terraform_states_count}%{strong_end} Terraform State', '%{strong_start}%{terraform_states_count}%{strong_end} Terraform States', project.terraform_states.count).html_safe % { + terraform_states_count: number_with_delimiter(project.terraform_states.count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, + project_terraform_index_path(project) + ) + end + end + def tags_anchor_data AnchorData.new( true, @@ -488,6 +504,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end end + def can_read_terraform_state? + current_user && can?(current_user, :read_terraform_state, project) + end + # Avoid including ActionView::Helpers::UrlHelper def content_tag(...) ActionController::Base.helpers.content_tag(...) diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index 8ff9d9612c6..f8d9778a3ee 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -5,7 +5,7 @@ class DiffViewerEntity < Grape::Entity expose :render_error, as: :error expose :render_error_message, as: :error_message expose :collapsed?, as: :collapsed - expose :whitespace_only, if: ->(_, _) { Feature.enabled?(:add_ignore_all_white_spaces) } do |_, options| + expose :whitespace_only do |_, options| options[:whitespace_only] end end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 6457127d831..0a3bf4c2a7b 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -27,10 +27,6 @@ class EnvironmentEntity < Grape::Entity ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT)) end - expose :metrics_path, if: -> (*) { expose_metrics_path? } do |environment| - metrics_project_environment_path(environment.project, environment) - end - expose :environment_path do |environment| project_environment_path(environment.project, environment) end @@ -101,10 +97,6 @@ class EnvironmentEntity < Grape::Entity def cluster deployment_platform.cluster end - - def expose_metrics_path? - !Feature.enabled?(:remove_monitor_metrics) && environment.has_metrics? - end end EnvironmentEntity.prepend_mod_with('EnvironmentEntity') diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb index 9318e0c1de8..8865c030d94 100644 --- a/app/serializers/environment_status_entity.rb +++ b/app/serializers/environment_status_entity.rb @@ -15,10 +15,6 @@ class EnvironmentStatusEntity < Grape::Entity metrics_project_environment_deployment_path(es.project, es.environment, es.deployment) end - expose :metrics_monitoring_url, if: ->(*) { can_read_environment? } do |es| - project_metrics_dashboard_path(es.project, environment: es.environment) - end - expose :stop_url, if: ->(*) { can_stop_environment? } do |es| stop_project_environment_path(es.project, es.environment) end diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb index 7961c4e666b..dd109cba015 100644 --- a/app/serializers/lfs_file_lock_entity.rb +++ b/app/serializers/lfs_file_lock_entity.rb @@ -5,9 +5,9 @@ class LfsFileLockEntity < Grape::Entity expose :path expose(:id) { |entity| entity.id.to_s } - expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) } + expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_fs(:iso8601) } expose :owner do - expose(:name) { |entity| entity.user&.name } + expose(:name) { |entity| entity.user&.username } end end diff --git a/app/serializers/prometheus_alert_entity.rb b/app/serializers/prometheus_alert_entity.rb deleted file mode 100644 index fb25889e4db..00000000000 --- a/app/serializers/prometheus_alert_entity.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class PrometheusAlertEntity < Grape::Entity - include RequestAwareEntity - - expose :id - expose :title - expose :query - expose :threshold - expose :runbook_url - - expose :operator do |prometheus_alert| - prometheus_alert.computed_operator - end - - private - - alias_method :prometheus_alert, :object - - def can_read_prometheus_alerts? - can?(request.current_user, :read_prometheus_alerts, prometheus_alert.project) - end -end diff --git a/app/serializers/prometheus_alert_serializer.rb b/app/serializers/prometheus_alert_serializer.rb deleted file mode 100644 index 4dafb7216db..00000000000 --- a/app/serializers/prometheus_alert_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class PrometheusAlertSerializer < BaseSerializer - entity PrometheusAlertEntity -end diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb index a71d1f14112..cda9a7e7f8c 100644 --- a/app/services/admin/plan_limits/update_service.rb +++ b/app/services/admin/plan_limits/update_service.rb @@ -7,26 +7,34 @@ module Admin @current_user = current_user @params = params @plan = plan + @plan_limits = plan.actual_limits end def execute return error(_('Access denied'), :forbidden) unless can_update? - if plan.actual_limits.update(parsed_params) + add_history_to_params! + + if plan_limits.update(parsed_params) success else - error(plan.actual_limits.errors.full_messages, :bad_request) + error(plan_limits.errors.full_messages, :bad_request) end end private - attr_accessor :current_user, :params, :plan + attr_accessor :current_user, :params, :plan, :plan_limits def can_update? current_user.can_admin_all_resources? end + def add_history_to_params! + formatted_limits_history = plan_limits.format_limits_history(current_user, parsed_params) + parsed_params.merge!(limits_history: formatted_limits_history) unless formatted_limits_history.empty? + end + # Overridden in EE def parsed_params params diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 7728982779e..6d484c4fa22 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -26,6 +26,7 @@ module ApplicationSettings end update_terms(@params.delete(:terms)) + update_default_branch_protection_defaults(@params[:default_branch_protection]) add_to_outbound_local_requests_whitelist(@params.delete(:add_to_outbound_local_requests_whitelist)) @@ -77,6 +78,19 @@ module ApplicationSettings @application_setting.reset_memoized_terms end + def update_default_branch_protection_defaults(default_branch_protection) + return unless default_branch_protection.present? + + # We are migrating default_branch_protection from an integer + # column to a jsonb column. While completing the rest of the + # work, we want to start translating the updates sent to the + # existing column into the json. Eventually, we will be updating + # the jsonb column directly and deprecating the original update + # path. Until then, we want to sync up both columns. + protection = Gitlab::Access::BranchProtection.new(default_branch_protection.to_i) + @application_setting.default_branch_protection_defaults = protection.to_hash + end + def process_performance_bar_allowed_group_id group_full_path = params.delete(:performance_bar_allowed_group_path) enable_param_on = Gitlab::Utils.to_boolean(params.delete(:performance_bar_enabled)) diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index 2bbb8f925a4..cb8e531f0e1 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -4,9 +4,7 @@ module AutoMerge class MergeWhenPipelineSucceedsService < AutoMerge::BaseService def execute(merge_request) super do - if merge_request.saved_change_to_auto_merge_enabled? - SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.actual_head_pipeline.sha) - end + add_system_note(merge_request) end end @@ -36,12 +34,20 @@ module AutoMerge def available_for?(merge_request) super do - merge_request.actual_head_pipeline&.active? + check_availability(merge_request) end end private + def add_system_note(merge_request) + SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.actual_head_pipeline.sha) if merge_request.saved_change_to_auto_merge_enabled? + end + + def check_availability(merge_request) + merge_request.actual_head_pipeline&.active? + end + def notify(merge_request) notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled? end diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb index f45a4330c09..065ef9dc708 100644 --- a/app/services/award_emojis/add_service.rb +++ b/app/services/award_emojis/add_service.rb @@ -27,6 +27,8 @@ module AwardEmojis def after_create(award) TodoService.new.new_award_emoji(todoable, current_user) if todoable + + execute_hooks(award, 'award') end def todoable diff --git a/app/services/award_emojis/base_service.rb b/app/services/award_emojis/base_service.rb index 626e26d63b5..274c528acf2 100644 --- a/app/services/award_emojis/base_service.rb +++ b/app/services/award_emojis/base_service.rb @@ -11,6 +11,13 @@ module AwardEmojis super(awardable.project, current_user) end + def execute_hooks(award_emoji, action) + return unless awardable.project&.has_active_hooks?(:emoji_hooks) + + hook_data = Gitlab::DataBuilder::Emoji.build(award_emoji, current_user, action) + awardable.project.execute_hooks(hook_data, :emoji_hooks) + end + private def normalize_name(name) diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb index 47dc8418e07..b7146d69bf0 100644 --- a/app/services/award_emojis/destroy_service.rb +++ b/app/services/award_emojis/destroy_service.rb @@ -22,6 +22,7 @@ module AwardEmojis private def after_destroy(award) + execute_hooks(award, 'revoke') end end end diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb index bf68aee2c1f..a4b1be1e599 100644 --- a/app/services/boards/base_items_list_service.rb +++ b/app/services/boards/base_items_list_service.rb @@ -13,14 +13,17 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def metadata(required_fields = [:issue_count, :total_issue_weight]) - fields = metadata_fields(required_fields) - keys = fields.keys - # TODO: eliminate need for SQL literal fragment - columns = Arel.sql(fields.values_at(*keys).join(', ')) - results = item_model.where(id: collection_ids) - results = results.select(columns) - - Hash[keys.zip(results.pluck(columns).flatten)] + # Failing tests in spec/requests/api/graphql/boards/board_lists_query_spec.rb + ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417465") do + fields = metadata_fields(required_fields) + keys = fields.keys + # TODO: eliminate need for SQL literal fragment + columns = Arel.sql(fields.values_at(*keys).join(', ')) + results = item_model.where(id: collection_ids) + results = results.select(columns) + + Hash[keys.zip(results.pluck(columns).flatten)] + end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index 636c636255f..7fc3511a253 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -105,7 +105,7 @@ module BulkImports def validate_setting_enabled! source_full_path, source_type = Array.wrap(params)[0].values_at(:source_full_path, :source_type) entity_type = ENTITY_TYPES_MAPPING.fetch(source_type) - if source_full_path =~ /^[0-9]+$/ + if /^[0-9]+$/.match?(source_full_path) query = query_type(entity_type) response = graphql_client.execute( graphql_client.parse(query.to_s), @@ -154,7 +154,7 @@ module BulkImports end def validate_destination_slug(destination_slug) - return if destination_slug =~ Gitlab::Regex.oci_repository_path_regex + return if Gitlab::Regex.oci_repository_path_regex.match?(destination_slug) raise BulkImports::Error.destination_slug_validation_failure end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index 142bc48efe3..ed71c09420b 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -16,7 +16,7 @@ module BulkImports def execute find_or_create_export! do |export| - remove_existing_export_file!(export) + export.remove_existing_upload! export_service.execute compress_exported_relation upload_compressed_file(export) @@ -45,15 +45,6 @@ module BulkImports fail_export!(export, e) end - def remove_existing_export_file!(export) - upload = export.upload - - return unless upload&.export_file&.file - - upload.remove_export_file! - upload.save! - end - def export_service @export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation) TreeExportService.new(portable, export_path, relation, user) diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb index 0d5f50c26a1..4fdd65bcdb4 100644 --- a/app/services/ci/create_pipeline_schedule_service.rb +++ b/app/services/ci/create_pipeline_schedule_service.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Ci + # This class is deprecated and will be removed with the FF ci_refactoring_pipeline_schedule_create_service class CreatePipelineScheduleService < BaseService def execute project.pipeline_schedules.create(pipeline_schedule_params) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index a8da83e84a1..fe0e842f542 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -40,22 +40,22 @@ module Ci # Create a new pipeline in the specified project. # - # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline - # creation. - # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment - # is present in the commit body - # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an - # error during creation (e.g. invalid yaml) - # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation. - # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation. - # @param [MergeRequest] merge_request The merge request triggers the pipeline creation. - # @param [ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation. - # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation. - # @param [String] content The content of .gitlab-ci.yml to override the default config - # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for - # generating a dangling pipeline. + # @param [Symbol] source What event (Ci::Pipeline.sources) triggers the pipeline + # creation. + # @param [Boolean] ignore_skip_ci Whether skipping a pipeline creation when `[skip ci]` comment + # is present in the commit body + # @param [Boolean] save_on_errors Whether persisting an invalid pipeline when it encounters an + # error during creation (e.g. invalid yaml) + # @param [Ci::TriggerRequest] trigger_request The pipeline trigger triggers the pipeline creation. + # @param [Ci::PipelineSchedule] schedule The pipeline schedule triggers the pipeline creation. + # @param [MergeRequest] merge_request The merge request triggers the pipeline creation. + # @param [Ci::ExternalPullRequest] external_pull_request The external pull request triggers the pipeline creation. + # @param [Ci::Bridge] bridge The bridge job that triggers the downstream pipeline creation. + # @param [String] content The content of .gitlab-ci.yml to override the default config + # contents (e.g. .gitlab-ci.yml in repostiry). Mainly used for + # generating a dangling pipeline. # - # @return [Ci::Pipeline] The created Ci::Pipeline object. + # @return [Ci::Pipeline] The created Ci::Pipeline object. # rubocop: disable Metrics/ParameterLists def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block) @logger = build_logger diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index bdec13f98a7..a9d2e17657e 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -7,7 +7,7 @@ module Ci Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true) - # ensure cancellation happens sync so we accumulate compute credits successfully + # ensure cancellation happens sync so we accumulate compute minutes successfully # before deleting the pipeline. ::Ci::CancelPipelineService.new( pipeline: pipeline, diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index c0ffbb401f6..8211507fb95 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -23,14 +23,12 @@ module Ci success = try_obtain_lease { process! } if success - if ::Feature.enabled?(:ci_reset_skipped_jobs_in_atomic_processing, project) - # If any jobs changed from stopped to alive status during pipeline processing, we must - # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539. - new_alive_jobs.group_by(&:user).each do |user, jobs| - log_running_reset_skipped_jobs_service(jobs) - - ResetSkippedJobsService.new(project, user).execute(jobs) - end + # If any jobs changed from stopped to alive status during pipeline processing, we must + # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539. + new_alive_jobs.group_by(&:user).each do |user, jobs| + log_running_reset_skipped_jobs_service(jobs) + + ResetSkippedJobsService.new(project, user).execute(jobs) end # Re-schedule if we need further processing diff --git a/app/services/ci/pipeline_schedules/create_service.rb b/app/services/ci/pipeline_schedules/create_service.rb new file mode 100644 index 00000000000..c1825865bc0 --- /dev/null +++ b/app/services/ci/pipeline_schedules/create_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedules + class CreateService + def initialize(project, user, params) + @project = project + @user = user + @params = params + + @schedule = project.pipeline_schedules.new + end + + def execute + return forbidden unless allowed? + + schedule.assign_attributes(params.merge(owner: user)) + + if schedule.save + ServiceResponse.success(payload: schedule) + else + ServiceResponse.error(payload: schedule, message: schedule.errors.full_messages) + end + end + + private + + attr_reader :project, :user, :params, :schedule + + def allowed? + user.can?(:create_pipeline_schedule, schedule) + end + + def forbidden + # We add the error to the base object too + # because model errors are used in the API responses and the `form_errors` helper. + schedule.errors.add(:base, forbidden_message) + + ServiceResponse.error(payload: schedule, message: [forbidden_message], reason: :forbidden) + end + + def forbidden_message + _('The current user is not authorized to create the pipeline schedule') + end + end + end +end diff --git a/app/services/ci/pipeline_schedules/update_service.rb b/app/services/ci/pipeline_schedules/update_service.rb index 2412b5cbd81..28c22e0a868 100644 --- a/app/services/ci/pipeline_schedules/update_service.rb +++ b/app/services/ci/pipeline_schedules/update_service.rb @@ -12,7 +12,9 @@ module Ci def execute return forbidden unless allowed? - if schedule.update(@params) + schedule.assign_attributes(params) + + if schedule.save ServiceResponse.success(payload: schedule) else ServiceResponse.error(message: schedule.errors.full_messages) @@ -21,17 +23,22 @@ module Ci private - attr_reader :schedule, :user + attr_reader :schedule, :user, :params 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 - ) + # We add the error to the base object too + # because model errors are used in the API responses and the `form_errors` helper. + schedule.errors.add(:base, forbidden_message) + + ServiceResponse.error(message: [forbidden_message], reason: :forbidden) + end + + def forbidden_message + _('The current user is not authorized to update the pipeline schedule') end end end diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb index efa9716d2c8..136afd108e7 100644 --- a/app/services/clusters/agent_tokens/create_service.rb +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -40,8 +40,6 @@ module Clusters end def active_tokens_limit_reached? - return false unless Feature.enabled?(:cluster_agents_limit_tokens_created) - ::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute.count >= ACTIVE_TOKENS_LIMIT end diff --git a/app/services/clusters/agents/authorize_proxy_user_service.rb b/app/services/clusters/agents/authorize_proxy_user_service.rb index fbcf25153c1..abf451ed350 100644 --- a/app/services/clusters/agents/authorize_proxy_user_service.rb +++ b/app/services/clusters/agents/authorize_proxy_user_service.rb @@ -11,17 +11,14 @@ module Clusters end def execute - return forbidden unless user_access_config.present? + return forbidden('`user_access` keyword is not found in agent config file.') unless user_access_config.present? access_as = user_access_config['access_as'] - return forbidden unless access_as.present? - return forbidden if access_as.size != 1 - if payload = handle_access(access_as) - return success(payload: payload) - end + return forbidden('`access_as` is not found under the `user_access` keyword.') unless access_as.present? + return forbidden('`access_as` must exist only once under the `user_access` keyword.') if access_as.size != 1 - forbidden + handle_access(access_as) end private @@ -52,9 +49,11 @@ module Clusters end def access_as_agent - return if authorizations.empty? + if authorizations.empty? + return forbidden('You must be a member of `projects` or `groups` under the `user_access` keyword.') + end - response_base.merge(access_as: { agent: {} }) + success(payload: response_base.merge(access_as: { agent: {} })) end def user_access_config @@ -64,8 +63,8 @@ module Clusters delegate :success, to: ServiceResponse, private: true - def forbidden - ServiceResponse.error(reason: :forbidden, message: '403 Forbidden') + def forbidden(message) + ServiceResponse.error(reason: :forbidden, message: message) end end end diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb index 80192aa14ab..f6ac06d0594 100644 --- a/app/services/clusters/cleanup/project_namespace_service.rb +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -29,7 +29,7 @@ module Clusters rescue Kubeclient::HttpError => e # unauthorized, forbidden: GitLab's access has been revoked # certificate verify failed: Cluster is probably gone forever - raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i + raise unless /unauthorized|forbidden|certificate verify failed/i.match?(e.message) end kubernetes_namespace.destroy! diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb index dce41d2a39c..0ce4bf9bb9c 100644 --- a/app/services/clusters/cleanup/service_account_service.rb +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -27,7 +27,7 @@ module Clusters rescue Kubeclient::HttpError => e # unauthorized, forbidden: GitLab's access has been revoked # certificate verify failed: Cluster is probably gone forever - raise unless e.message =~ /unauthorized|forbidden|certificate verify failed/i + raise unless /unauthorized|forbidden|certificate verify failed/i.match?(e.message) end end end diff --git a/app/services/clusters/integrations/prometheus_health_check_service.rb b/app/services/clusters/integrations/prometheus_health_check_service.rb deleted file mode 100644 index cd06e59449c..00000000000 --- a/app/services/clusters/integrations/prometheus_health_check_service.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Integrations - class PrometheusHealthCheckService - include Gitlab::Utils::StrongMemoize - include Gitlab::Routing - - def initialize(cluster) - @cluster = cluster - @logger = Gitlab::AppJsonLogger.build - end - - def execute - raise 'Invalid cluster type. Only project types are allowed.' unless @cluster.project_type? - - return unless prometheus_integration.enabled - - project = @cluster.clusterable - - @logger.info( - message: 'Prometheus health check', - cluster_id: @cluster.id, - newly_unhealthy: became_unhealthy?, - currently_healthy: currently_healthy?, - was_healthy: was_healthy? - ) - - send_notification(project) if became_unhealthy? - - prometheus_integration.update_columns(health_status: current_health_status) if health_changed? - end - - private - - def prometheus_integration - strong_memoize(:prometheus_integration) do - @cluster.integration_prometheus - end - end - - def current_health_status - if currently_healthy? - :healthy - else - :unhealthy - end - end - - def currently_healthy? - strong_memoize(:currently_healthy) do - prometheus_integration.prometheus_client.healthy? - end - end - - def became_unhealthy? - strong_memoize(:became_unhealthy) do - (was_healthy? || was_unknown?) && !currently_healthy? - end - end - - def was_healthy? - strong_memoize(:was_healthy) do - prometheus_integration.healthy? - end - end - - def was_unknown? - strong_memoize(:was_unknown) do - prometheus_integration.unknown? - end - end - - def health_changed? - was_healthy? != currently_healthy? - end - - def send_notification(project) - notification_payload = build_notification_payload(project) - integration = project.alert_management_http_integrations.active.first - - Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration) - - @logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id) - end - - def build_notification_payload(project) - cluster_path = namespace_project_cluster_path( - project_id: project.path, - namespace_id: project.namespace.path, - id: @cluster.id - ) - - { - title: "Prometheus is Unhealthy. Cluster Name: #{@cluster.name}", - description: "Prometheus is unhealthy for the cluster: [#{@cluster.name}](#{cluster_path}) attached to project #{project.name}." - } - end - end - end -end diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index b3427697052..fcef22a8cab 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -77,5 +77,20 @@ module Integrations release.to_hook_data('create') end + + def emoji_events_data + no_data_error(s_('TestHooks|Ensure the project has notes.')) unless project.notes.any? + + award_emoji = AwardEmoji.new( + id: 1, + name: 'thumbsup', + user: current_user, + awardable: project.notes.last, + created_at: Time.zone.now, + updated_at: Time.zone.now + ) + + Gitlab::DataBuilder::Emoji.build(award_emoji, current_user, 'award') + end end end diff --git a/app/services/concerns/projects/remove_refs.rb b/app/services/concerns/projects/remove_refs.rb new file mode 100644 index 00000000000..d133aa0ced6 --- /dev/null +++ b/app/services/concerns/projects/remove_refs.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Projects + module RemoveRefs + extend ActiveSupport::Concern + include Gitlab::ExclusiveLeaseHelpers + + LOCK_RETRY = 3 + LOCK_TTL = 5.minutes + LOCK_SLEEP = 0.5.seconds + + def serialized_remove_refs(project_id, &blk) + in_lock("projects/#{project_id}/serialized_remove_refs", **lock_params, &blk) + end + + def lock_params + { + ttl: LOCK_TTL, + retries: LOCK_RETRY, + sleep_sec: LOCK_SLEEP + } + end + end +end diff --git a/app/services/draft_notes/create_service.rb b/app/services/draft_notes/create_service.rb index 5ff971b66c1..e5a070e9db7 100644 --- a/app/services/draft_notes/create_service.rb +++ b/app/services/draft_notes/create_service.rb @@ -25,7 +25,8 @@ module DraftNotes draft_note = DraftNote.new(params) draft_note.merge_request = merge_request draft_note.author = current_user - draft_note.save + + return draft_note unless draft_note.save if in_reply_to_discussion_id.blank? && draft_note.diff_file&.unfolded? merge_request.diffs.clear_cache diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb index 9e1e381c568..a7a2ad63c1c 100644 --- a/app/services/draft_notes/publish_service.rb +++ b/app/services/draft_notes/publish_service.rb @@ -49,6 +49,7 @@ module DraftNotes notification_service.async.new_review(review) MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request) GraphqlTriggers.merge_request_merge_status_updated(merge_request) + after_publish(review) end def create_note_from_draft(draft, skip_capture_diff_note_position: false, skip_keep_around_commits: false, skip_merge_status_trigger: false) @@ -108,5 +109,11 @@ module DraftNotes project.repository.keep_around(*shas) end end + + def after_publish(review) + # Overridden in EE + end end end + +DraftNotes::PublishService.prepend_mod diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb index 760c8a6e306..fd78a886e29 100644 --- a/app/services/environments/create_service.rb +++ b/app/services/environments/create_service.rb @@ -2,7 +2,7 @@ module Environments class CreateService < BaseService - ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent].freeze + ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent kubernetes_namespace].freeze def execute unless can?(current_user, :create_environment, project) diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb index 5eb4880ec4b..52f6198bada 100644 --- a/app/services/environments/update_service.rb +++ b/app/services/environments/update_service.rb @@ -2,7 +2,7 @@ module Environments class UpdateService < BaseService - ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent].freeze + ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent kubernetes_namespace].freeze def execute(environment) unless can?(current_user, :update_environment, environment) diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index acf54dec51b..f9280be7ee2 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -67,7 +67,10 @@ module Git # Creating push_data invokes one CommitDelta RPC per commit. Only # build this data if we actually need it. project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name) - project.execute_integrations(push_data, hook_name) if project.has_active_integrations?(hook_name) + + return unless project.has_active_integrations?(hook_name) + + project.execute_integrations(push_data, hook_name, skip_ci: integration_push_options&.fetch(:skip_ci).present?) end def enqueue_invalidate_cache @@ -101,7 +104,19 @@ module Git def ci_variables_from_push_options strong_memoize(:ci_variables_from_push_options) do - params[:push_options]&.deep_symbolize_keys&.dig(:ci, :variable) + push_options&.dig(:ci, :variable) + end + end + + def integration_push_options + strong_memoize(:integration_push_options) do + push_options&.dig(:integrations) + end + end + + def push_options + strong_memoize(:push_options) do + params[:push_options]&.deep_symbolize_keys end end diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index 1de2b3c5a2e..e939d27d464 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -2,6 +2,7 @@ module Groups class ParticipantsService < Groups::BaseService + include Gitlab::Utils::StrongMemoize include Users::ParticipableService def execute(noteable) @@ -17,15 +18,20 @@ module Groups render_participants_as_hash(participants.uniq) end + private + def all_members - count = group_members.count - [{ username: "all", name: "All Group Members", count: count }] + return [] if group.nil? || Feature.enabled?(:disable_all_mention) + + [{ username: "all", name: "All Group Members", count: group.users_count }] end def group_members return [] unless group - @group_members ||= sorted(group.direct_and_indirect_users) + sorted( + group.direct_and_indirect_users(share_with_groups: group.member?(current_user)) + ) end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 16454360ee2..81d4dfddaab 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -197,6 +197,11 @@ module Groups return if @new_parent_group return unless @group.owners.empty? + add_owner_on_transferred_group + end + + # Overridden in EE + def add_owner_on_transferred_group @group.add_owner(current_user) end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 925a2acbb58..df6ede87ef9 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -117,6 +117,7 @@ module Groups def handle_settings_update settings_params = params.slice(*allowed_settings_params) + settings_params.merge!({ default_branch_protection: params[:default_branch_protection] }.compact) allowed_settings_params.each { |param| params.delete(param) } ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb index c09dce0761f..08b43037c4c 100644 --- a/app/services/groups/update_shared_runners_service.rb +++ b/app/services/groups/update_shared_runners_service.rb @@ -25,7 +25,14 @@ module Groups end def update_shared_runners - group.update_shared_runners_setting!(params[:shared_runners_setting]) + case params[:shared_runners_setting] + when Namespace::SR_DISABLED_AND_UNOVERRIDABLE + set_shared_runners_enabled!(false) + when Namespace::SR_DISABLED_WITH_OVERRIDE, Namespace::SR_DISABLED_AND_OVERRIDABLE + disable_shared_runners_and_allow_override! + when Namespace::SR_ENABLED + set_shared_runners_enabled!(true) + end end def update_pending_builds? @@ -41,5 +48,38 @@ module Groups ::Ci::UpdatePendingBuildService.new(group, pending_builds_params).execute end end + + def set_shared_runners_enabled!(enabled) + group.update!( + shared_runners_enabled: enabled, + allow_descendants_override_disabled_shared_runners: false) + + group_ids = group.descendants + unless group_ids.empty? + Group.by_id(group_ids).update_all( + shared_runners_enabled: enabled, + allow_descendants_override_disabled_shared_runners: false) + end + + group.all_projects.update_all(shared_runners_enabled: enabled) + end + + def disable_shared_runners_and_allow_override! + # enabled -> disabled_and_overridable + if group.shared_runners_enabled? + group.update!( + shared_runners_enabled: false, + allow_descendants_override_disabled_shared_runners: true) + + group_ids = group.descendants + Group.by_id(group_ids).update_all(shared_runners_enabled: false) unless group_ids.empty? + + group.all_projects.update_all(shared_runners_enabled: false) + + # disabled_and_unoverridable -> disabled_and_overridable + else + group.update!(allow_descendants_override_disabled_shared_runners: true) + end + end end end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 7e7f7ea9810..df255a7ae24 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -16,7 +16,7 @@ module Import track_access_level('github') if project.persisted? - store_import_settings(project) + store_import_settings(project, access_params) success(project) elsif project.errors[:import_source_disabled].present? error(project.errors[:import_source_disabled], :forbidden) @@ -134,8 +134,13 @@ module Import error(translated_message, http_status) end - def store_import_settings(project) - Gitlab::GithubImport::Settings.new(project).write(params[:optional_stages]) + def store_import_settings(project, access_params) + Gitlab::GithubImport::Settings + .new(project) + .write( + optional_stages: params[:optional_stages], + additional_access_tokens: access_params[:additional_access_tokens] + ) end end end diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb index cfaf3e831eb..9c1bad9e7da 100644 --- a/app/services/import_csv/base_service.rb +++ b/app/services/import_csv/base_service.rb @@ -8,7 +8,7 @@ module ImportCsv @user = user @project = project @csv_io = csv_io - @results = { success: 0, error_lines: [], parse_error: false } + @results = { success: 0, error_lines: [], parse_error: false, preprocess_errors: {} } end PreprocessError = Class.new(StandardError) diff --git a/app/services/import_csv/preprocess_milestones_service.rb b/app/services/import_csv/preprocess_milestones_service.rb new file mode 100644 index 00000000000..97fb381c58e --- /dev/null +++ b/app/services/import_csv/preprocess_milestones_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ImportCsv + class PreprocessMilestonesService < BaseService + def initialize(user, project, provided_titles) + @user = user + @project = project + @provided_titles = provided_titles + + @results = { success: 0, errors: nil } + @milestone_errors = { missing: { header: {}, titles: [] } } + end + + attr_reader :user, :project, :provided_titles, :results, :milestone_errors + + def execute + available_milestones = find_milestones_by_titles + return ServiceResponse.success if provided_titles.sort == available_milestones.sort + + milestone_errors[:missing][:header] = 'Milestone' + milestone_errors[:missing][:titles] = provided_titles.difference(available_milestones) || [] + ServiceResponse.error(message: "", payload: milestone_errors) + end + + def find_milestones_by_titles + # Find if these milestones exist in the project or its group and group ancestors + finder_params = { + project_ids: [project.id], + title: provided_titles + } + finder_params[:group_ids] = project.group.self_and_ancestors.select(:id) if project.group + MilestonesFinder.new(finder_params).execute.map(&:title).uniq + end + end +end diff --git a/app/services/integrations/group_mention_service.rb b/app/services/integrations/group_mention_service.rb new file mode 100644 index 00000000000..2389bf33432 --- /dev/null +++ b/app/services/integrations/group_mention_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# GroupMentionService class +# +# Used for sending group mention notifications +# +# Ex. +# Integrations::GroupMentionService.new(mentionable, hook_data: data, is_confidential: true).execute +# +module Integrations + class GroupMentionService + def initialize(mentionable, hook_data:, is_confidential:) + @mentionable = mentionable + @hook_data = hook_data + @is_confidential = is_confidential + end + + def execute + return ServiceResponse.success if mentionable.nil? || hook_data.nil? + + @hook_data = hook_data.clone + # Fake a "group_mention" object kind so integrations can handle this as a separate class of event + hook_data[:object_attributes][:object_kind] = hook_data[:object_kind] + hook_data[:object_kind] = 'group_mention' + + if confidential? + hook_data[:event_type] = 'group_confidential_mention' + hook_scope = :group_confidential_mention_hooks + else + hook_data[:event_type] = 'group_mention' + hook_scope = :group_mention_hooks + end + + groups = mentionable.referenced_groups(mentionable.author) + groups.each do |group| + group_hook_data = hook_data.merge( + mentioned: { + object_kind: 'group', + name: group.full_path, + url: group.web_url + } + ) + group.execute_integrations(group_hook_data, hook_scope) + end + + ServiceResponse.success + end + + private + + attr_reader :mentionable, :hook_data, :is_confidential + + def confidential? + return is_confidential if is_confidential.present? + + mentionable.project.visibility_level != Gitlab::VisibilityLevel::PUBLIC + end + end +end diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb index 31c8f02c7b6..48240f297fe 100644 --- a/app/services/integrations/test/project_service.rb +++ b/app/services/integrations/test/project_service.rb @@ -35,6 +35,8 @@ module Integrations deployment_events_data when 'release' releases_events_data + when 'award_emoji' + emoji_events_data end end end diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 9ef9fb76e3c..95338374ca6 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -23,6 +23,24 @@ module Issuable raise CSV::MalformedCSVError.new('Invalid CSV format - missing required headers.', 1) end + + def preprocess! + preprocess_milestones! + + raise PreprocessError if results[:preprocess_errors].any? + end + + def preprocess_milestones! + # Pre-Process Milestone if header is present + return unless csv_data.lines.first.downcase.include?('milestone') + + provided_titles = with_csv_lines.filter_map { |row| row[:milestone]&.strip&.downcase }.uniq + result = ::ImportCsv::PreprocessMilestonesService.new(user, project, provided_titles).execute + return if result.success? + + # collate errors here and throw errors + results[:preprocess_errors][:milestone_errors] = result.payload + end end end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index f982d66eb08..b9b7cd08b68 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -111,6 +111,10 @@ module Issues issue.namespace.execute_integrations(issue_data, hooks_scope) execute_incident_hooks(issue, issue_data) if issue.work_item_type&.incident? + + return unless Feature.enabled?(:group_mentions, issue.project) + + execute_group_mention_hooks(issue, issue_data) if action == 'open' end # We can remove this code after proposal in @@ -121,6 +125,21 @@ module Issues issue.namespace.execute_integrations(issue_data, :incident_hooks) end + def execute_group_mention_hooks(issue, issue_data) + return unless issue.instance_of?(Issue) + + args = { + mentionable_type: 'Issue', + mentionable_id: issue.id, + hook_data: issue_data, + is_confidential: issue.confidential? + } + + issue.run_after_commit_or_now do + Integrations::GroupMentionWorker.perform_async(args) + end + end + def update_project_counter_caches?(issue) super || issue.confidential_changed? end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index a65fc0c7c87..63cad593936 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -83,18 +83,17 @@ module Issues params.delete(:work_item_type) end - base_type = work_item_type&.base_type - - if create_issue_type_allowed?(container, base_type) - issue.work_item_type = work_item_type - # Up to this point issue_type might be set to the default, so we need to sync if a work item type is provided - issue.issue_type = base_type - else - # If no work item type was provided or not allowed, we need to set it to issue_type, - # and that includes the column default - issue_type = issue_params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE - issue.work_item_type = WorkItems::Type.default_by_type(issue_type) - end + # We need to support the legacy input params[:issue_type] even if we don't have the issue_type column anymore. + # In the future only params[:work_item_type] should be provided + base_type = work_item_type&.base_type || params[:issue_type] + + issue.work_item_type = if create_issue_type_allowed?(container, base_type) + work_item_type || WorkItems::Type.default_by_type(base_type) + else + # If no work item type was provided or not allowed, we need to set it to + # the default issue_type + WorkItems::Type.default_by_type(::Issue::DEFAULT_ISSUE_TYPE) + end end def model_klass @@ -109,8 +108,6 @@ module Issues :confidential ] - public_issue_params << :issue_type if create_issue_type_allowed?(container, params[:issue_type]) - params.slice(*public_issue_params) end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 17b6866773e..e1ddfe47439 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -51,6 +51,7 @@ module Issues # current_user (defined in BaseService) is not available within run_after_commit block user = current_user + assign_description_from_template(issue) issue.run_after_commit do NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s) Issues::PlacementWorker.perform_async(nil, issue.project_id) @@ -127,6 +128,35 @@ module Issues set_crm_contacts(issue, contacts) end + + def assign_description_from_template(issue) + return if issue.description.present? + + # Find the exact name for the default template (if the project has one). + # Since there are multiple possibilities regarding the capitalization(s) that the + # default template file name can have, getting the exact template name here will + # allow us to extract the contents later, and bail early if the project does not have + # a default template + templates = TemplateFinder.all_template_names(project, :issues) + template = templates.values.flatten.find { |tmpl| tmpl[:name].casecmp?('default') } + + return unless template + + begin + default_template = TemplateFinder.build( + :issues, + issue.project, + { + name: template[:name], + source_template_project_id: issue.project.id + } + ).execute + rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + nil + end + + issue.description = default_template.content if default_template.present? + end end end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 9e524d90505..99c0e9f1a37 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -34,14 +34,14 @@ module Issues 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, - 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, - 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, - 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) }, - 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, + 'Due Date' => -> (issue) { issue.due_date&.to_fs(:csv) }, + 'Created At (UTC)' => -> (issue) { issue.created_at&.to_fs(:csv) }, + 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_fs(:csv) }, + 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_fs(:csv) }, 'Milestone' => -> (issue) { issue.milestone&.title }, 'Weight' => -> (issue) { issue.weight }, 'Labels' => -> (issue) { issue_labels(issue) }, - 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, + 'Time Estimate' => ->(issue) { issue.time_estimate.to_fs(:csv) }, 'Time Spent' => -> (issue) { issue_time_spent(issue) } } end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 7ad56d5a755..839d0e664a4 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -32,10 +32,9 @@ module Issues end def change_work_item_type(issue) - return unless issue.changed_attributes['issue_type'] + return unless params[:issue_type].present? - issue_type = params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE - type_id = find_work_item_type_id(issue_type) + type_id = find_work_item_type_id(params[:issue_type]) issue.work_item_type_id = type_id end @@ -180,16 +179,22 @@ module Issues end def handle_issue_type_change(issue) - return unless issue.previous_changes.include?('issue_type') + return unless issue.previous_changes.include?('work_item_type_id') do_handle_issue_type_change(issue) end def do_handle_issue_type_change(issue) - SystemNoteService.change_issue_type(issue, current_user, issue.issue_type_before_last_save) + old_work_item_type = ::WorkItems::Type.find(issue.work_item_type_id_before_last_save).base_type + SystemNoteService.change_issue_type(issue, current_user, old_work_item_type) ::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation? end + + override :allowed_update_params + def allowed_update_params(params) + super.except(:issue_type) + end end end diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index 699c5b94c53..a6fff3003ac 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -34,16 +34,7 @@ module Members # @param sources [Group, Project, Array<Group>, Array<Project>, Group::ActiveRecord_Relation, # Project::ActiveRecord_Relation] - Can't be an array of source ids because we don't know the type of source. # @return Array<Member> - def add_members( - sources, - invitees, - access_level, - current_user: nil, - expires_at: nil, - tasks_to_be_done: [], - tasks_project_id: nil, - ldap: nil - ) # rubocop:disable Metrics/ParameterLists + def add_members(sources, invitees, access_level, **args) return [] unless invitees.present? sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source @@ -51,7 +42,9 @@ module Members Member.transaction do sources.flat_map do |source| # If this user is attempting to manage Owner members and doesn't have permission, do not allow - next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user) + if managing_owners?(args[:current_user], access_level) && cannot_manage_owners?(source, args[:current_user]) + next [] + end emails, users, existing_members = parse_users_list(source, invitees) @@ -59,12 +52,8 @@ module Members source: source, access_level: access_level, existing_members: existing_members, - current_user: current_user, - expires_at: expires_at, - tasks_to_be_done: tasks_to_be_done, - tasks_project_id: tasks_project_id, - ldap: ldap - } + tasks_to_be_done: args[:tasks_to_be_done] || [] + }.merge(parsed_args(args)) members = emails.map do |email| new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute @@ -79,26 +68,21 @@ module Members end end - def add_member( - source, - invitee, - access_level, - current_user: nil, - expires_at: nil, - ldap: nil - ) # rubocop:disable Metrics/ParameterLists - add_members( - source, - [invitee], - access_level, - current_user: current_user, - expires_at: expires_at, - ldap: ldap - ).first + def add_member(source, invitee, access_level, **args) + add_members(source, [invitee], access_level, **args).first end private + def parsed_args(args) + { + current_user: args[:current_user], + expires_at: args[:expires_at], + tasks_project_id: args[:tasks_project_id], + ldap: args[:ldap] + } + end + def managing_owners?(current_user, access_level) current_user && Gitlab::Access.sym_options_with_owner[access_level] == Gitlab::Access::OWNER end diff --git a/app/services/members/groups/creator_service.rb b/app/services/members/groups/creator_service.rb index dd3d44e4d96..864be01a96d 100644 --- a/app/services/members/groups/creator_service.rb +++ b/app/services/members/groups/creator_service.rb @@ -21,3 +21,5 @@ module Members end end end + +Members::Groups::CreatorService.prepend_mod_with('Members::Groups::CreatorService') diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index ec8a17162ca..aaa91548d19 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -36,6 +36,10 @@ module MergeRequests execute_external_hooks(merge_request, merge_data) + if action == 'open' && Feature.enabled?(:group_mentions, merge_request.project) + execute_group_mention_hooks(merge_request, merge_data) + end + enqueue_jira_connect_messages_for(merge_request) end @@ -43,6 +47,21 @@ module MergeRequests # Implemented in EE end + def execute_group_mention_hooks(merge_request, merge_data) + return unless merge_request.instance_of?(MergeRequest) + + args = { + mentionable_type: 'MergeRequest', + mentionable_id: merge_request.id, + hook_data: merge_data, + is_confidential: false + } + + merge_request.run_after_commit_or_now do + Integrations::GroupMentionWorker.perform_async(args) + end + end + def handle_changes(merge_request, options) old_associations = options.fetch(:old_associations, {}) old_assignees = old_associations.fetch(:assignees, []) diff --git a/app/services/merge_requests/cleanup_refs_service.rb b/app/services/merge_requests/cleanup_refs_service.rb index 2094ea00160..5081655601b 100644 --- a/app/services/merge_requests/cleanup_refs_service.rb +++ b/app/services/merge_requests/cleanup_refs_service.rb @@ -16,7 +16,6 @@ module MergeRequests @merge_request = merge_request @repository = merge_request.project.repository @ref_path = merge_request.ref_path - @merge_ref_path = merge_request.merge_ref_path @ref_head_sha = @repository.commit(merge_request.ref_path)&.id @merge_ref_sha = merge_request.merge_ref_head&.id end @@ -42,7 +41,7 @@ module MergeRequests private - attr_reader :repository, :ref_path, :merge_ref_path, :ref_head_sha, :merge_ref_sha + attr_reader :repository, :ref_path, :ref_head_sha, :merge_ref_sha def scheduled? merge_request.cleanup_schedule.present? && merge_request.cleanup_schedule.scheduled_at <= Time.current @@ -79,7 +78,7 @@ module MergeRequests end def delete_refs - repository.delete_refs(ref_path, merge_ref_path) + merge_request.schedule_cleanup_refs end def update_schedule diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index 1bd26f06e41..acd3bc36e1d 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -56,13 +56,6 @@ module MergeRequests params[:first_parent_ref] || merge_request.target_branch_ref end - ## - # The parameter `allow_conflicts` is a flag whether merge conflicts should be merged into diff - # Default is false - def allow_conflicts - params[:allow_conflicts] || false - end - def commit(cache_merge_to_ref_calls = false) if cache_merge_to_ref_calls Rails.cache.fetch(cache_key, expires_in: 1.day) do @@ -79,8 +72,7 @@ module MergeRequests branch: merge_request.target_branch, target_ref: target_ref, message: commit_message, - first_parent_ref: first_parent_ref, - allow_conflicts: allow_conflicts) + first_parent_ref: first_parent_ref) rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error raise MergeError, error.message end diff --git a/app/services/merge_requests/mergeability_check_batch_service.rb b/app/services/merge_requests/mergeability_check_batch_service.rb new file mode 100644 index 00000000000..7697b596a83 --- /dev/null +++ b/app/services/merge_requests/mergeability_check_batch_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeabilityCheckBatchService + def initialize(merge_requests, user) + @merge_requests = merge_requests + @user = user + end + + def execute + return unless merge_requests.present? + + MergeRequests::MergeabilityCheckBatchWorker.perform_async(merge_requests.map(&:id), user&.id) + end + + private + + attr_reader :merge_requests, :user + end +end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index d6740cdf1ac..447f4f9428c 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -169,7 +169,13 @@ module MergeRequests @outdate_service ||= Suggestions::OutdateService.new end + def abort_auto_merges?(merge_request) + merge_request.merge_params.with_indifferent_access[:sha] != @push.newrev + end + def abort_auto_merges(merge_request) + return unless abort_auto_merges?(merge_request) + abort_auto_merge(merge_request, 'source branch was updated') end diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb index 6c3edd2e147..e8a14adc10d 100644 --- a/app/services/milestones/create_service.rb +++ b/app/services/milestones/create_service.rb @@ -5,11 +5,19 @@ module Milestones def execute milestone = parent.milestones.new(params) + before_create(milestone) + if milestone.save && milestone.project_milestone? event_service.open_milestone(milestone, current_user) end milestone end + + private + + def before_create(milestone) + milestone.check_for_spam(user: current_user, action: :create) + end end end diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb index b9a12a35d31..90cb8ea9f5c 100644 --- a/app/services/milestones/update_service.rb +++ b/app/services/milestones/update_service.rb @@ -13,11 +13,22 @@ module Milestones end if params.present? - milestone.update(params.except(:state_event)) + milestone.assign_attributes(params.except(:state_event)) end + if milestone.changed? + before_update(milestone) + end + + milestone.save milestone end + + private + + def before_update(milestone) + milestone.check_for_spam(user: current_user, action: :update) + end end end diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb index 25525265e1c..c391320db5e 100644 --- a/app/services/namespace_settings/update_service.rb +++ b/app/services/namespace_settings/update_service.rb @@ -23,6 +23,12 @@ module NamespaceSettings param_key: :new_user_signups_cap, user_policy: :change_new_user_signups_cap ) + validate_settings_param_for_root_group( + param_key: :default_branch_protection, + user_policy: :update_default_branch_protection + ) + + handle_default_branch_protection unless settings_params[:default_branch_protection].blank? if group.namespace_settings group.namespace_settings.attributes = settings_params @@ -33,6 +39,17 @@ module NamespaceSettings private + def handle_default_branch_protection + # We are migrating default_branch_protection from an integer + # column to a jsonb column. While completing the rest of the + # work, we want to start translating the updates sent to the + # existing column into the json. Eventually, we will be updating + # the jsonb column directly and deprecating the original update + # path. Until then, we want to sync up both columns. + protection = Gitlab::Access::BranchProtection.new(settings_params.delete(:default_branch_protection).to_i) + settings_params[:default_branch_protection_defaults] = protection.to_hash + end + def validate_resource_access_token_creation_allowed_param return if settings_params[:resource_access_token_creation_allowed].nil? diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index c9375fe14a1..9465b5218b0 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -36,10 +36,19 @@ module Notes return unless note.project note_data = hook_data - hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks + is_confidential = note.confidential?(include_noteable: true) + hooks_scope = is_confidential ? :confidential_note_hooks : :note_hooks note.project.execute_hooks(note_data, hooks_scope) note.project.execute_integrations(note_data, hooks_scope) + + return unless Feature.enabled?(:group_mentions, note.project) + + execute_group_mention_hooks(note, note_data, is_confidential) + end + + def execute_group_mention_hooks(note, note_data, is_confidential) + Integrations::GroupMentionService.new(note, hook_data: note_data, is_confidential: is_confidential).execute end end end diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb deleted file mode 100644 index a9481504d2b..00000000000 --- a/app/services/packages/debian/find_or_create_package_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Debian - class FindOrCreatePackageService < ::Packages::CreatePackageService - include Gitlab::Utils::StrongMemoize - - def execute - packages = project.packages - .existing_debian_packages_with(name: params[:name], version: params[:version]) - - package = packages.with_debian_codename_or_suite(params[:distribution_name]).first - - unless package - package_in_other_distribution = packages.first - - if package_in_other_distribution - raise ArgumentError, "Debian package #{params[:name]} #{params[:version]} exists " \ - "in distribution #{package_in_other_distribution.debian_distribution.codename}" - end - end - - package ||= create_package!( - :debian, - debian_publication_attributes: { distribution_id: distribution.id } - ) - - ServiceResponse.success(payload: { package: package }) - end - - private - - def distribution - Packages::Debian::DistributionsFinder.new( - project, - codename_or_suite: params[:distribution_name] - ).execute.last! - end - strong_memoize_attr :distribution - end - end -end diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb deleted file mode 100644 index eb88e7c9b59..00000000000 --- a/app/services/packages/debian/process_changes_service.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Debian - class ProcessChangesService - include ExclusiveLeaseGuard - include Gitlab::Utils::StrongMemoize - - # used by ExclusiveLeaseGuard - DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze - - def initialize(package_file, creator) - @package_file = package_file - @creator = creator - end - - def execute - # return if changes file has already been processed - return if package_file.debian_file_metadatum&.changes? - - validate! - - try_obtain_lease do - package_file.transaction do - update_files_metadata - update_changes_metadata - end - - ::Packages::Debian::GenerateDistributionWorker.perform_async(:project, package.debian_distribution.id) - end - end - - private - - attr_reader :package_file, :creator - - def validate! - 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 - files.each do |filename, entry| - file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute - - ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id) - .execute - - # Force reload from database, as package has changed - entry.package_file.reload_package - - entry.package_file.debian_file_metadatum.update!( - file_type: file_metadata[:file_type], - component: files[filename].component, - architecture: file_metadata[:architecture], - fields: file_metadata[:fields] - ) - end - end - - def update_changes_metadata - ::Packages::UpdatePackageFileService.new(package_file, package_id: package.id) - .execute - - # Force reload from database, as package has changed - package_file.reload_package - - package_file.debian_file_metadatum.update!( - file_type: metadata[:file_type], - fields: metadata[:fields] - ) - end - - def metadata - ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute - end - strong_memoize_attr :metadata - - def files - metadata[:files] - end - - def project - package_file.package.project - end - - def package - params = { - name: metadata[:fields]['Source'], - version: metadata[:fields]['Version'], - distribution_name: metadata[:fields]['Distribution'] - } - response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute - response.payload[:package] - end - strong_memoize_attr :package - - # used by ExclusiveLeaseGuard - def lease_key - "packages:debian:process_changes_service:package_file:#{package_file.id}" - end - - # used by ExclusiveLeaseGuard - def lease_timeout - DEFAULT_LEASE_TIMEOUT - end - end - end -end diff --git a/app/services/packages/npm/create_metadata_cache_service.rb b/app/services/packages/npm/create_metadata_cache_service.rb index 75cff5c5453..f470b9f1202 100644 --- a/app/services/packages/npm/create_metadata_cache_service.rb +++ b/app/services/packages/npm/create_metadata_cache_service.rb @@ -30,7 +30,7 @@ module Packages attr_reader :package_name, :project def metadata_content - metadata.payload.to_json + ::API::Entities::NpmPackage.represent(metadata.payload).to_json end strong_memoize_attr :metadata_content diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index 2c578760cc5..f6f2dbb8415 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -18,7 +18,7 @@ module Packages ApplicationRecord.transaction { create_npm_package! } end - return error('Could not obtain package lease.', 400) unless package + return error('Could not obtain package lease. Please try again.', 400) unless package package end @@ -40,7 +40,7 @@ module Packages def create_npm_metadatum!(package) package.create_npm_metadatum!(package_json: package_json) rescue ActiveRecord::RecordInvalid => e - if package.npm_metadatum && package.npm_metadatum.errors.added?(:package_json, 'structure is too large') + if package.npm_metadatum && package.npm_metadatum.errors.where(:package_json, :too_large).any? # rubocop: disable CodeReuse/ActiveRecord Gitlab::ErrorTracking.track_exception(e, field_sizes: field_sizes_for_error_tracking) end diff --git a/app/services/packages/npm/deprecate_package_service.rb b/app/services/packages/npm/deprecate_package_service.rb index 2633e9f877c..bca81ebe1de 100644 --- a/app/services/packages/npm/deprecate_package_service.rb +++ b/app/services/packages/npm/deprecate_package_service.rb @@ -31,7 +31,7 @@ module Packages def packages ::Packages::Npm::PackageFinder - .new(params['package_name'], project: project, last_of_each_version: false) + .new(params['package_name'], project: project) .execute end diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb index 800c3ce19b4..e1795079513 100644 --- a/app/services/packages/npm/generate_metadata_service.rb +++ b/app/services/packages/npm/generate_metadata_service.rb @@ -98,7 +98,7 @@ module Packages end def package_tags - Packages::Tag.for_package_ids(packages.last_of_each_version_ids) + Packages::Tag.for_package_ids(packages) .preload_package end diff --git a/app/services/packages/nuget/extract_metadata_content_service.rb b/app/services/packages/nuget/extract_metadata_content_service.rb new file mode 100644 index 00000000000..28653654018 --- /dev/null +++ b/app/services/packages/nuget/extract_metadata_content_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ExtractMetadataContentService + ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns' + + XPATHS = { + package_name: "#{ROOT_XPATH}:id", + package_version: "#{ROOT_XPATH}:version", + authors: "#{ROOT_XPATH}:authors", + description: "#{ROOT_XPATH}:description", + license_url: "#{ROOT_XPATH}:licenseUrl", + project_url: "#{ROOT_XPATH}:projectUrl", + icon_url: "#{ROOT_XPATH}:iconUrl" + }.freeze + + XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze + XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze + XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze + XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze + + def initialize(nuspec_file_content) + @nuspec_file_content = nuspec_file_content + end + + def execute + ServiceResponse.success(payload: extract_metadata(nuspec_file_content)) + end + + private + + attr_reader :nuspec_file_content + + def extract_metadata(file) + doc = Nokogiri::XML(file) + + XPATHS.transform_values { |query| doc.xpath(query).text.presence } + .compact + .tap do |metadata| + metadata[:package_dependencies] = extract_dependencies(doc) + metadata[:package_tags] = extract_tags(doc) + metadata[:package_types] = extract_package_types(doc) + end + end + + def extract_dependencies(doc) + dependencies = [] + + doc.xpath(XPATH_DEPENDENCIES).each do |node| + dependencies << extract_dependency(node) + end + + doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node| + target_framework = group_node.attr('targetFramework') + + group_node.xpath('xmlns:dependency').each do |node| + dependencies << extract_dependency(node).merge(target_framework: target_framework) + end + end + + dependencies + end + + def extract_dependency(node) + { + name: node.attr('id'), + version: node.attr('version') + }.compact + end + + def extract_tags(doc) + tags = doc.xpath(XPATH_TAGS).text + + return [] if tags.blank? + + tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR) + end + + def extract_package_types(doc) + doc.xpath(XPATH_PACKAGE_TYPES).map { |node| node.attr('name') }.uniq + end + end + end +end diff --git a/app/services/packages/nuget/extract_metadata_file_service.rb b/app/services/packages/nuget/extract_metadata_file_service.rb new file mode 100644 index 00000000000..61e4892fee7 --- /dev/null +++ b/app/services/packages/nuget/extract_metadata_file_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ExtractMetadataFileService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + MAX_FILE_SIZE = 4.megabytes.freeze + + def initialize(package_file_id) + @package_file_id = package_file_id + end + + def execute + raise ExtractionError, 'invalid package file' unless valid_package_file? + + ServiceResponse.success(payload: nuspec_file_content) + end + + private + + attr_reader :package_file_id + + def package_file + ::Packages::PackageFile.find_by_id(package_file_id) + end + strong_memoize_attr :package_file + + def valid_package_file? + package_file && + package_file.package&.nuget? && + package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + end + + def nuspec_file_content + with_zip_file do |zip_file| + entry = zip_file.glob('*.nuspec').first + + raise ExtractionError, 'nuspec file not found' unless entry + raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size + + Tempfile.open("nuget_extraction_package_file_#{package_file_id}") do |file| + entry.extract(file.path) { true } # allow #extract to overwrite the file + file.unlink + file.read + end + rescue Zip::EntrySizeError => e + raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" + end + end + + def with_zip_file + package_file.file.use_open_file do |open_file| + zip_file = Zip::File.new(open_file, false, true) # rubocop:disable Performance/Rubyzip + yield(zip_file) + end + end + end + end +end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 5c60a2912ae..e1ee29ef2c6 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -3,123 +3,30 @@ module Packages module Nuget class MetadataExtractionService - include Gitlab::Utils::StrongMemoize - - ExtractionError = Class.new(StandardError) - - ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns' - - XPATHS = { - package_name: "#{ROOT_XPATH}:id", - package_version: "#{ROOT_XPATH}:version", - authors: "#{ROOT_XPATH}:authors", - description: "#{ROOT_XPATH}:description", - license_url: "#{ROOT_XPATH}:licenseUrl", - project_url: "#{ROOT_XPATH}:projectUrl", - icon_url: "#{ROOT_XPATH}:iconUrl" - }.freeze - - XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze - XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze - XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze - XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze - - MAX_FILE_SIZE = 4.megabytes.freeze - def initialize(package_file_id) @package_file_id = package_file_id end def execute - raise ExtractionError, 'invalid package file' unless valid_package_file? - - extract_metadata(nuspec_file_content) + ServiceResponse.success(payload: metadata) end private - def package_file - ::Packages::PackageFile.find_by_id(@package_file_id) - end - strong_memoize_attr :package_file - - def valid_package_file? - package_file && - package_file.package&.nuget? && - package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate - end - - def extract_metadata(file) - doc = Nokogiri::XML(file) - - XPATHS.transform_values { |query| doc.xpath(query).text.presence } - .compact - .tap do |metadata| - metadata[:package_dependencies] = extract_dependencies(doc) - metadata[:package_tags] = extract_tags(doc) - metadata[:package_types] = extract_package_types(doc) - end - end - - def extract_dependencies(doc) - dependencies = [] - - doc.xpath(XPATH_DEPENDENCIES).each do |node| - dependencies << extract_dependency(node) - end - - doc.xpath(XPATH_DEPENDENCY_GROUPS).each do |group_node| - target_framework = group_node.attr("targetFramework") - - group_node.xpath("xmlns:dependency").each do |node| - dependencies << extract_dependency(node).merge(target_framework: target_framework) - end - end - - dependencies - end - - def extract_dependency(node) - { - name: node.attr('id'), - version: node.attr('version') - }.compact - end - - def extract_package_types(doc) - doc.xpath(XPATH_PACKAGE_TYPES).map { |node| node.attr('name') }.uniq - end - - def extract_tags(doc) - tags = doc.xpath(XPATH_TAGS).text - - return [] if tags.blank? - - tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR) - end + attr_reader :package_file_id def nuspec_file_content - with_zip_file do |zip_file| - entry = zip_file.glob('*.nuspec').first - - raise ExtractionError, 'nuspec file not found' unless entry - raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size - - Tempfile.open("nuget_extraction_package_file_#{@package_file_id}") do |file| - entry.extract(file.path) { true } # allow #extract to overwrite the file - file.unlink - file.read - end - rescue Zip::EntrySizeError => e - raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" - end + ExtractMetadataFileService + .new(package_file_id) + .execute + .payload end - def with_zip_file(&block) - package_file.file.use_open_file do |open_file| - zip_file = Zip::File.new(open_file, false, true) - yield(zip_file) - end + def metadata + ExtractMetadataContentService + .new(nuspec_file_content) + .execute + .payload end end end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 8e2679db31b..d82509fff5e 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -145,7 +145,7 @@ module Packages end def metadata - ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute + ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute.payload end strong_memoize_attr :metadata diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb index 6fc3110a70b..3b075364458 100644 --- a/app/services/personal_access_tokens/last_used_service.rb +++ b/app/services/personal_access_tokens/last_used_service.rb @@ -24,12 +24,7 @@ module PersonalAccessTokens return true if last_used.nil? - if Feature.enabled?(:update_personal_access_token_usage_information_every_10_minutes) && - last_used <= 10.minutes.ago - return true - end - - last_used <= 1.day.ago + last_used <= 10.minutes.ago end end end diff --git a/app/services/personal_access_tokens/revoke_token_family_service.rb b/app/services/personal_access_tokens/revoke_token_family_service.rb new file mode 100644 index 00000000000..547ba6c3bdc --- /dev/null +++ b/app/services/personal_access_tokens/revoke_token_family_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class RevokeTokenFamilyService + def initialize(token) + @token = token + end + + def execute + # Despite using #update_all, there should only be a single active token. + # A token family is a chain of rotated tokens. Once rotated, the + # previous token is revoked. + pat_family.active.update_all(revoked: true) + + ServiceResponse.success + end + + private + + attr_reader :token + + def pat_family + # rubocop: disable CodeReuse/ActiveRecord + cte = Gitlab::SQL::RecursiveCTE.new(:personal_access_tokens_cte) + personal_access_token_table = Arel::Table.new(:personal_access_tokens) + + cte << PersonalAccessToken + .where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id)) + cte << PersonalAccessToken + .from([personal_access_token_table, cte.table]) + .where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id])) + PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table)) + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index 64b0c5c98a9..b765aacef68 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -41,6 +41,7 @@ module PersonalAccessTokens def create_token_params(token) { name: token.name, + previous_personal_access_token_id: token.id, impersonation: token.impersonation, scopes: token.scopes, expires_at: Date.today + EXPIRATION_PERIOD } diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 2279ab301dc..a5c12384b59 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -58,6 +58,10 @@ module Projects unless remove_repository(project.wiki.repository) raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.')) end + + unless remove_repository(project.design_repository) + raise_error(s_('DeleteProject|Failed to remove design repository. Please try again or contact administrator.')) + end end def trash_relation_repositories! diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb index 72cb3997045..22104409199 100644 --- a/app/services/projects/download_service.rb +++ b/app/services/projects/download_service.rb @@ -2,7 +2,7 @@ module Projects class DownloadService < BaseService - WHITELIST = [ + ALLOWLIST = [ /^[^.]+\.fogbugz.com$/ ].freeze @@ -33,7 +33,7 @@ module Projects def valid_domain?(url) host = URI.parse(url).host - WHITELIST.any? { |entry| entry === host } + ALLOWLIST.any? { |entry| entry === host } end end end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 8c807e0016b..44cd6e9926f 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -30,6 +30,8 @@ module Projects end def all_members + return [] if Feature.enabled?(:disable_all_mention) + [{ username: "all", name: "All Project and Group Members", count: project_members.count }] end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index f1c093c89b7..22a882c4648 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -89,7 +89,9 @@ module Projects # AlertManagement::HttpIntegrations is complete, # we should use use the HttpIntegration as SSOT. # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734 - return false if project.alert_management_http_integrations.legacy.prometheus.any? + return false if project.alert_management_http_integrations + .for_endpoint_identifier('legacy-prometheus') + .any? prometheus = project.find_or_initialize_integration('prometheus') return false unless prometheus.manual_configuration? diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index b048ec128d8..d5c8e958bbd 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -93,7 +93,7 @@ module Projects # TODO: Support LFS sync over SSH # https://gitlab.com/gitlab-org/gitlab/-/issues/249587 - return unless remote_mirror.url =~ %r{\Ahttps?://}i + return unless %r{\Ahttps?://}i.match?(remote_mirror.url) return unless remote_mirror.password_auth? Lfs::PushService.new( diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index b5f6bff756b..d1798ce6fc0 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -188,7 +188,8 @@ module QuickActions next unless definition definition.execute(self, arg) - usage_ping_tracking(definition.name, arg) + # summarize_diff will be removed https://gitlab.com/gitlab-org/gitlab/-/issues/407258#note_1385269274 + usage_ping_tracking(definition.name, arg) unless definition.name == :summarize_diff end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 71314f85984..73d46a9ba70 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -4,7 +4,6 @@ module Search class ProjectService include Search::Filter include Gitlab::Utils::StrongMemoize - include ProjectsHelper ALLOWED_SCOPES = %w(blobs issues merge_requests wiki_blobs commits notes milestones users).freeze @@ -18,13 +17,13 @@ module Search def execute Gitlab::ProjectSearchResults.new(current_user, - params[:search], - project: project, - repository_ref: params[:repository_ref], - order_by: params[:order_by], - sort: params[:sort], - filters: filters - ) + params[:search], + project: project, + repository_ref: params[:repository_ref], + order_by: params[:order_by], + sort: params[:sort], + filters: filters + ) end def allowed_scopes @@ -33,10 +32,12 @@ module Search def scope strong_memoize(:scope) do - next params[:scope] if allowed_scopes.include?(params[:scope]) && project_search_tabs?(params[:scope].to_sym) + search_navigation = Search::Navigation.new(user: current_user, project: project) + scope = params[:scope] + next scope if allowed_scopes.include?(scope) && search_navigation.tab_enabled_for_project?(scope.to_sym) - allowed_scopes.find do |scope| - project_search_tabs?(scope.to_sym) + allowed_scopes.find do |s| + search_navigation.tab_enabled_for_project?(s.to_sym) end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 5705e4c7cef..433e9b0da6d 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -102,16 +102,6 @@ class SearchService end end - def show_elasticsearch_tabs? - # overridden in EE - false - end - - def show_epics? - # overridden in EE - false - end - def global_search_enabled_for_scope? return false if show_snippets? && Feature.disabled?(:global_search_snippet_titles_tab, current_user, type: :ops) diff --git a/app/services/service_desk/custom_emails/base_service.rb b/app/services/service_desk/custom_emails/base_service.rb new file mode 100644 index 00000000000..62152f31012 --- /dev/null +++ b/app/services/service_desk/custom_emails/base_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmails + class BaseService < ::BaseProjectService + private + + def legitimate_user? + can?(current_user, :admin_project, project) + end + + def setting? + project.service_desk_setting.present? + end + + def credential? + project.service_desk_custom_email_verification.present? + end + + def verification? + project.service_desk_custom_email_credential.present? + end + + def feature_flag_enabled? + Feature.enabled?(:service_desk_custom_email, project) + end + + def error_user_not_authorized + error_response(s_('ServiceDesk|User cannot manage project.')) + end + + def error_feature_flag_disabled + error_response('Feature flag service_desk_custom_email is not enabled') + end + + def error_response(message) + ServiceResponse.error(message: message) + end + end + end +end diff --git a/app/services/service_desk/custom_emails/create_service.rb b/app/services/service_desk/custom_emails/create_service.rb new file mode 100644 index 00000000000..c3ca98a0259 --- /dev/null +++ b/app/services/service_desk/custom_emails/create_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmails + class CreateService < BaseService + def execute + return error_feature_flag_disabled unless feature_flag_enabled? + return error_user_not_authorized unless legitimate_user? + return error_params_missing unless has_required_params? + return error_custom_email_exists if credential? || verification? + + return error_cannot_create_custom_email unless create_credential + + if update_settings.error? + # We don't warp everything in a single transaction here and roll it back + # because ServiceDeskSettings::UpdateService uses safe_find_or_create_by! + rollback_credential + return error_cannot_create_custom_email + end + + project.reset + + # The create service may return an error response if the verification fails early. + # Here We want to indicate whether adding a custom email address was successful, so + # we don't use its response here. + create_verification + + ServiceResponse.success + end + + private + + def update_settings + ServiceDeskSettings::UpdateService.new(project, current_user, create_setting_params).execute + end + + def rollback_credential + ::ServiceDesk::CustomEmailCredential.find_by_project_id(project.id)&.destroy + end + + def create_credential + credential = ::ServiceDesk::CustomEmailCredential.new(create_credential_params.merge(project: project)) + credential.save + end + + def create_verification + ::ServiceDesk::CustomEmailVerifications::CreateService.new(project: project, current_user: current_user).execute + end + + def create_setting_params + ensure_params.permit(:custom_email) + end + + def create_credential_params + ensure_params.permit(:smtp_address, :smtp_port, :smtp_username, :smtp_password) + end + + def ensure_params + return params if params.is_a?(ActionController::Parameters) + + ActionController::Parameters.new(params) + end + + def has_required_params? + required_keys.all? { |key| params.key?(key) && params[key].present? } + end + + def required_keys + %i[custom_email smtp_address smtp_port smtp_username smtp_password] + end + + def error_custom_email_exists + error_response(s_('ServiceDesk|Custom email already exists')) + end + + def error_params_missing + error_response(s_('ServiceDesk|Parameters missing')) + end + + def error_cannot_create_custom_email + error_response(s_('ServiceDesk|Cannot create custom email')) + end + end + end +end diff --git a/app/services/service_desk/custom_emails/destroy_service.rb b/app/services/service_desk/custom_emails/destroy_service.rb new file mode 100644 index 00000000000..1aa5994edd8 --- /dev/null +++ b/app/services/service_desk/custom_emails/destroy_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmails + class DestroyService < BaseService + def execute + return error_feature_flag_disabled unless feature_flag_enabled? + return error_user_not_authorized unless legitimate_user? + return error_does_not_exist unless verification? || credential? || setting? + + project.service_desk_custom_email_verification&.destroy + project.service_desk_custom_email_credential&.destroy + project.reset + project.service_desk_setting&.update!(custom_email: nil, custom_email_enabled: false) + + ServiceResponse.success + end + + private + + def error_does_not_exist + error_response(s_('ServiceDesk|Custom email does not exist')) + end + end + end +end diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb index 5fe74f1f2ff..61cb6fce11f 100644 --- a/app/services/service_desk_settings/update_service.rb +++ b/app/services/service_desk_settings/update_service.rb @@ -8,9 +8,9 @@ module ServiceDeskSettings params[:project_key] = nil if params[:project_key].blank? if settings.update(params) - success + ServiceResponse.success else - error(settings.errors.full_messages.to_sentence) + ServiceResponse.error(message: settings.errors.full_messages.to_sentence) end end end diff --git a/app/services/service_response.rb b/app/services/service_response.rb index da4773ab9c7..86efc01bd30 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -56,6 +56,10 @@ class ServiceResponse reason: reason) end + def deconstruct_keys(keys) + to_h.slice(*keys) + end + def success? status == :success end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 2ecd431fd91..e0a6d58b904 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -85,7 +85,7 @@ module Spam # than the override verdict's priority value), then we don't need to override it. return false if SUPPORTED_VERDICTS[verdict][:priority] > SUPPORTED_VERDICTS[OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM][:priority] - target.allow_possible_spam? + target.allow_possible_spam?(user) || user.allow_possible_spam? end def spamcheck_client diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb index 7758c1e8597..d71388a1552 100644 --- a/app/services/system_notes/merge_requests_service.rb +++ b/app/services/system_notes/merge_requests_service.rb @@ -181,3 +181,5 @@ module SystemNotes end end end + +SystemNotes::MergeRequestsService.prepend_mod_with('SystemNotes::MergeRequestsService') diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index b7a2afbaf15..f9084ed67d3 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -147,9 +147,9 @@ module SystemNotes readable_date = date_key.humanize.downcase if changed_date.nil? - "removed #{readable_date} #{changed_dates[date_key].first.to_s(:long)}" + "removed #{readable_date} #{changed_dates[date_key].first.to_fs(:long)}" else - "changed #{readable_date} to #{changed_date.to_s(:long)}" + "changed #{readable_date} to #{changed_date.to_fs(:long)}" end end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index dcd92ac2b8c..42af65ebd57 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -32,6 +32,8 @@ module TestHooks wiki_page_events_data when 'releases_events' releases_events_data + when 'emoji_events' + emoji_events_data end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index c55e1680bfe..1f6cf2c83c9 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -377,7 +377,7 @@ class TodoService attributes = { project_id: target&.project&.id, target_id: target.id, - target_type: target.class.name, + target_type: target.class.try(:polymorphic_name) || target.class.name, commit_id: nil } diff --git a/app/services/users/allow_possible_spam_service.rb b/app/services/users/allow_possible_spam_service.rb new file mode 100644 index 00000000000..d9273fe0fc1 --- /dev/null +++ b/app/services/users/allow_possible_spam_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Users + class AllowPossibleSpamService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + custom_attribute = { + user_id: user.id, + key: UserCustomAttribute::ALLOW_POSSIBLE_SPAM, + value: "#{current_user.username}/#{current_user.id}+#{Time.current}" + } + UserCustomAttribute.upsert_custom_attributes([custom_attribute]) + end + end +end diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb index 5ed31cdb778..20c34b15f15 100644 --- a/app/services/users/ban_service.rb +++ b/app/services/users/ban_service.rb @@ -2,6 +2,8 @@ module Users class BanService < BannedUserBaseService + extend ::Gitlab::Utils::Override + private def update_user(user) @@ -15,6 +17,11 @@ module Users def action :ban end + + override :track_event + def track_event(user) + experiment(:phone_verification_for_low_risk_users, user: user).track(:banned) + end end end diff --git a/app/services/users/banned_user_base_service.rb b/app/services/users/banned_user_base_service.rb index 74c10581a6e..cec351904a9 100644 --- a/app/services/users/banned_user_base_service.rb +++ b/app/services/users/banned_user_base_service.rb @@ -12,6 +12,7 @@ module Users if update_user(user) log_event(user) + track_event(user) success else messages = user.errors.full_messages @@ -23,6 +24,9 @@ module Users attr_reader :current_user + # Overridden in Users::BanService + def track_event(_); end + def state_error(user) error(_("You cannot %{action} %{state} users." % { action: action.to_s, state: user.state }), :forbidden) end diff --git a/app/services/users/disallow_possible_spam_service.rb b/app/services/users/disallow_possible_spam_service.rb new file mode 100644 index 00000000000..e31ba7ddff0 --- /dev/null +++ b/app/services/users/disallow_possible_spam_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Users + class DisallowPossibleSpamService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).delete_all + end + end +end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 9ab6fcc9832..6837bc47035 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -189,6 +189,7 @@ class WebHookService 'Content-Type' => 'application/json', 'User-Agent' => "GitLab/#{Gitlab::VERSION}", Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name), + Gitlab::WebHooks::GITLAB_UUID_HEADER => SecureRandom.uuid, Gitlab::WebHooks::GITLAB_INSTANCE_HEADER => Gitlab.config.gitlab.base_url } diff --git a/app/services/work_items/export_csv_service.rb b/app/services/work_items/export_csv_service.rb index ee20a2832ce..74bc1f526bf 100644 --- a/app/services/work_items/export_csv_service.rb +++ b/app/services/work_items/export_csv_service.rb @@ -28,7 +28,7 @@ module WorkItems '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) } + 'Created At (UTC)' => ->(work_item) { work_item.created_at.to_fs(:csv) } } end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index f947f70985c..87a624ddb60 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -165,7 +165,7 @@ class FileUploader < GitlabUploader def secret @secret ||= self.class.generate_secret - raise InvalidSecret unless @secret =~ VALID_SECRET_PATTERN + raise InvalidSecret unless VALID_SECRET_PATTERN.match?(@secret) @secret end diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb index ff390a624c5..29620543a0f 100644 --- a/app/validators/abstract_path_validator.rb +++ b/app/validators/abstract_path_validator.rb @@ -21,7 +21,7 @@ class AbstractPathValidator < ActiveModel::EachValidator end def validate_each(record, attribute, value) - unless value =~ self.class.format_regex + unless self.class.format_regex.match?(value) record.errors.add(attribute, self.class.format_error_message) return end diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb index 79c9c67ae58..527116ba69b 100644 --- a/app/validators/cluster_name_validator.rb +++ b/app/validators/cluster_name_validator.rb @@ -16,7 +16,7 @@ class ClusterNameValidator < ActiveModel::EachValidator record.errors.add(attribute, " is invalid syntax") end - unless value =~ Gitlab::Regex.kubernetes_namespace_regex + unless Gitlab::Regex.kubernetes_namespace_regex.match(value) record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message) end end diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb index 91b9cfcccc4..c12b29410d4 100644 --- a/app/validators/cron_validator.rb +++ b/app/validators/cron_validator.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true class CronValidator < ActiveModel::EachValidator - ATTRIBUTE_WHITELIST = %i[cron freeze_start freeze_end].freeze + ATTRIBUTE_ALLOWLIST = %i[cron freeze_start freeze_end].freeze - NonWhitelistedAttributeError = Class.new(StandardError) + NonAllowlistedAttributeError = Class.new(StandardError) def validate_each(record, attribute, value) - if ATTRIBUTE_WHITELIST.include?(attribute) + if ATTRIBUTE_ALLOWLIST.include?(attribute) cron_parser = Gitlab::Ci::CronParser.new(record.public_send(attribute), record.cron_timezone) # rubocop:disable GitlabSecurity/PublicSend record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? else - raise NonWhitelistedAttributeError, "Non-whitelisted attribute" + raise NonAllowlistedAttributeError, "Non-allowlisted attribute" end end end diff --git a/app/validators/devise_email_validator.rb b/app/validators/devise_email_validator.rb index 6ca921ca7fa..b91cfe23f08 100644 --- a/app/validators/devise_email_validator.rb +++ b/app/validators/devise_email_validator.rb @@ -31,6 +31,6 @@ class DeviseEmailValidator < ActiveModel::EachValidator end def validate_each(record, attribute, value) - record.errors.add(attribute, :invalid) unless value =~ options[:regexp] + record.errors.add(attribute, :invalid) unless options[:regexp].match?(value) end end diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json index bd2945c08fb..d93527ad0a4 100644 --- a/app/validators/json_schemas/default_branch_protection_defaults.json +++ b/app/validators/json_schemas/default_branch_protection_defaults.json @@ -62,14 +62,8 @@ "code_owner_approval_required": { "type": "boolean" }, - "merge_access_level": { - "type": "integer" - }, - "push_access_level": { - "type": "integer" - }, - "unprotect_access_level": { - "type": "integer" + "developer_can_initial_push": { + "type": "boolean" } }, "additionalProperties": false diff --git a/app/validators/json_schemas/organization_settings.json b/app/validators/json_schemas/organization_settings.json new file mode 100644 index 00000000000..350ce7d9066 --- /dev/null +++ b/app/validators/json_schemas/organization_settings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Settings for Organizations", + "type": "object", + "additionalProperties": false, + "properties": { + "restricted_visibility_levels": { + "type": "array", + "items": { + "type": "integer" + } + } + } +} diff --git a/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json new file mode 100644 index 00000000000..e0051179a1d --- /dev/null +++ b/app/validators/json_schemas/scan_result_policy_vulnerability_attributes.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Scan result policy vulnerability_attributes", + "type": "object", + "properties": { + "false_positive": { + "type": "boolean" + }, + "fix_available": { + "type": "boolean" + } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json index fb6b80e0725..9cfb62d4439 100644 --- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -32,12 +32,12 @@ }, { "field": "SEARCH_MAX_DEPTH", - "label": "Search maximum depth", + "label": "Search Maximum Depth", "type": "string", "default_value": "", "value": "", "size": "SMALL", - "description": "Maximum depth of language and framework detection" + "description": "Specifies the number of directory levels to be included in the repository search phase during SAST analysis. SAST scanner searches through the repository to detect the programming languages used and selects the corresponding analyzers. After that, the entire repository is analyzed." } ], "analyzers": [ @@ -80,56 +80,72 @@ "label": "Kubesec", "enabled": true, "description": "Kubernetes manifests, Helm Charts", - "variables": [] + "variables": [ + + ] }, { "name": "nodejs-scan", "label": "Node.js Scan", "enabled": true, "description": "Node.js", - "variables": [] + "variables": [ + + ] }, { "name": "phpcs-security-audit", "label": "PHP Security Audit", "enabled": true, "description": "PHP", - "variables": [] + "variables": [ + + ] }, { "name": "pmd-apex", "label": "PMD APEX", "enabled": true, "description": "Apex (Salesforce)", - "variables": [] + "variables": [ + + ] }, { "name": "security-code-scan", "label": "Security Code Scan", "enabled": true, "description": ".NET Core, .NET Framework", - "variables": [] + "variables": [ + + ] }, { "name": "semgrep", "label": "Semgrep", "enabled": true, "description": "Multi-language scanning", - "variables": [] + "variables": [ + + ] }, { "name": "sobelow", "label": "Sobelow", "enabled": true, "description": "Elixir (Phoenix)", - "variables": [] + "variables": [ + + ] }, { "name": "spotbugs", "label": "Spotbugs", "enabled": true, "description": "Groovy, Java, Scala", - "variables": [] + "variables": [ + + ] } ] -}
\ No newline at end of file +} diff --git a/app/validators/line_code_validator.rb b/app/validators/line_code_validator.rb index a351180790e..e1abccc1dff 100644 --- a/app/validators/line_code_validator.rb +++ b/app/validators/line_code_validator.rb @@ -7,7 +7,7 @@ class LineCodeValidator < ActiveModel::EachValidator PATTERN = /\A[a-z0-9]+_\d+_\d+\z/.freeze def validate_each(record, attribute, value) - unless value =~ PATTERN + unless PATTERN.match?(value) record.errors.add(attribute, "must be a valid line code") end end 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 d29fa9c5b85..af67ed28309 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -51,11 +51,11 @@ = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2' .help-block = _('Specify an email address regex pattern to identify default internal users.') - = link_to _('Learn more.'), help_page_path('user/admin_area/external_users', anchor: 'set-a-new-user-to-external'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/external_users', anchor: 'set-a-new-user-to-external'), target: '_blank', rel: 'noopener noreferrer' - unless Gitlab.com? .form-group = f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold' - - dormant_users_help_link = help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users') + - dormant_users_help_link = help_page_path('administration/moderate_users', anchor: 'automatically-deactivate-dormant-users') - dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link } = 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 diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml index 41b0a08128e..97f46adef51 100644 --- a/app/views/admin/application_settings/_ai_access.html.haml +++ b/app/views/admin/application_settings/_ai_access.html.haml @@ -12,8 +12,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p - = s_('CodeSuggestionsSM|Enable Code Suggestion for users of this GitLab instance.') - = link_to sprite_icon('question-o'), code_suggestions_docs_url, target: '_blank', class: 'has-tooltip', title: _('More information') + = code_suggestions_description .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f| @@ -22,7 +21,7 @@ %fieldset .form-group = f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled, - s_('CodeSuggestionsSM|Turn on Code Suggestions for this instance. By turning on this feature, you:'), + s_('CodeSuggestionsSM|Enable Code Suggestions for this instance %{beta}').html_safe % { beta: gl_badge_tag(_('Beta'), variant: :neutral, size: :sm) }, help_text: code_suggestions_agreement = f.label :ai_access_token, token_label, class: 'label-bold' = f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' } diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 0c9d5a5a8df..0125c83dc72 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -28,13 +28,13 @@ = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' .form-text.text-muted = _("The maximum file size for job artifacts.") - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size') .form-group = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold' = f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input' .form-text.text-muted = html_escape(_("Set the default expiration time for job artifacts in all projects. Set to %{code_open}0%{code_close} to never expire artifacts by default. If no unit is written, it defaults to seconds. For example, these are all equivalent: %{code_open}3600%{code_close}, %{code_open}60 minutes%{code_close}, or %{code_open}one hour%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'default-artifacts-expiration') .form-group = f.gitlab_ui_checkbox_component :keep_latest_artifact, s_('AdminSettings|Keep the latest artifacts for all jobs in the latest successful pipelines'), help_text: s_('AdminSettings|The latest artifacts for all jobs in the most recent successful pipelines in each project are stored and do not expire.') .form-group @@ -42,7 +42,7 @@ = f.text_field :archive_builds_in_human_readable, class: 'form-control gl-form-input' .form-text.text-muted = html_escape(_("Jobs older than the configured time are considered expired and are archived. Archived jobs can no longer be retried. Leave empty to never archive jobs automatically. The default unit is in days, but you can use other units, for example %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}. Minimum value is 1 day.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'archive-jobs') + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'archive-jobs') .form-group = f.gitlab_ui_checkbox_component :protected_ci_variables, s_('AdminSettings|Protect CI/CD variables by default'), help_text: s_('AdminSettings|New CI/CD variables in projects and groups default to protected.') .form-group diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 153600f1299..f8bd5b68431 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -9,7 +9,7 @@ = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") = link_to sprite_icon('question-o'), - help_page_path('user/admin_area/diff_limits', + help_page_path('administration/diff_limits', anchor: 'diff-limits-administration') = f.label :diff_max_files, _('Maximum files in a diff'), class: 'label-light' @@ -18,7 +18,7 @@ = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") = link_to sprite_icon('question-o'), - help_page_path('user/admin_area/diff_limits', + help_page_path('administration/diff_limits', anchor: 'diff-limits-administration') = f.label :diff_max_lines, _('Maximum lines in a diff'), class: 'label-light' @@ -27,6 +27,6 @@ = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") = link_to sprite_icon('question-o'), - help_page_path('user/admin_area/diff_limits', + help_page_path('administration/diff_limits', anchor: 'diff-limits-administration') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 80a7d3607ef..2f31eb5f6d1 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -10,7 +10,7 @@ = f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold' = f.text_field :commit_email_hostname, class: 'form-control gl-form-input' .form-text.text-muted - - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer' + - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('administration/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer' = _("Hostname used in private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link } = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml index aa42cd99e89..b57371286d5 100644 --- a/app/views/admin/application_settings/_error_tracking.html.haml +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -20,9 +20,9 @@ %p.text-secondary = s_("ErrorTracking|Access token is %{token_in_code_tag}").html_safe % { token_in_code_tag: content_tag(:code, Gitlab::CurrentSettings.error_tracking_access_token, id: 'error-tracking-access-token') } .form-inline - = button_to _("Reset error tracking access token"), reset_error_tracking_access_token_admin_application_settings_path, - method: :put, class: 'gl-button btn btn-danger btn-sm', - data: { confirm: _('Are you sure you want to reset the error tracking access token?') } + - reset_url = reset_error_tracking_access_token_admin_application_settings_url + = render Pajamas::ButtonComponent.new(method: :put, href: reset_url, variant: :danger, size: :small, button_options: { data: { confirm: _('Are you sure you want to reset the error tracking access token?') }}) do + = _("Reset error tracking access token") = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml new file mode 100644 index 00000000000..4bd44b922fa --- /dev/null +++ b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml @@ -0,0 +1,19 @@ +%section.settings.no-animate#js-gitlab-shell-operation-limits-settings{ class: ('expanded' if expanded_by_default?), 'data-testid': 'gitlab-shell-operation-limits' } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('ShellOperations|Git SSH operations rate limit') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = s_('ShellOperations|Limit the number of Git operations a user can perform per minute, per repository.') + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-gitlab-shell-operation-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :gitlab_shell_operation_limit, s_('ShellOperations|Maximum number of Git operations per minute'), class: 'gl-font-bold' + = f.number_field :gitlab_shell_operation_limit, class: 'form-control gl-form-input' + + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index e76a83662af..9509806fc41 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -18,7 +18,7 @@ .form-group = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'gl-font-weight-bold' = f.text_field :help_page_documentation_base_url, class: 'form-control gl-form-input', placeholder: 'https://docs.gitlab.com' - - docs_link_url = help_page_path('user/admin_area/settings/help_page', anchor: 'destination-requirements') + - docs_link_url = help_page_path('administration/settings/help_page', anchor: 'destination-requirements') - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } %span.form-text.text-muted#support_help_block= html_escape(_('Requests for pages at %{code_start}%{help_text_url}%{code_end} redirect to the URL. The destination must meet certain requirements. %{docs_link_start}Learn more.%{docs_link_end}')) % { code_start: '<code>'.html_safe, help_text_url: help_url, code_end: '</code>'.html_safe, docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 669c47bafba..19d321ca205 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -7,7 +7,7 @@ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' .form-text.text-muted = _('Default first day of the week in calendars and date pickers.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :time_tracking, _('Time tracking'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index 53832e93ed2..b112c273aad 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -7,7 +7,7 @@ = s_('Runners|Runner version management') %span.form-text.gl-mb-3.gl-mt-0 - help_text = s_('Runners|Official runner version data is periodically fetched from GitLab.com to determine whether the runners need upgrades.') - - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/configure_runners.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer' + - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :update_runner_versions_enabled, s_('Runners|Fetch GitLab Runner release version data from GitLab.com'), help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link } @@ -16,7 +16,7 @@ = s_('Runners|Runner registration') %span.form-text.gl-mb-3.gl-mt-0 = 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' + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' = hidden_field_tag "application_setting[valid_runner_registrars][]", nil - ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES.each do |type| = f.gitlab_ui_checkbox_component :valid_runner_registrars, s_("Runners|Members of the %{type} can register runners") % { type: type }, diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 85841059c5e..5518122b5cf 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -32,7 +32,7 @@ = f.label :admin_mode, _('Admin mode'), class: 'label-bold' = sprite_icon('lock', css_class: 'gl-icon') - help_text = _('Require additional authentication for administrative tasks.') - - help_link = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :admin_mode, _('Enable admin mode'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } diff --git a/app/views/admin/application_settings/_slack.html.haml b/app/views/admin/application_settings/_slack.html.haml index 69a5e284b4c..e4f46fdf7f2 100644 --- a/app/views/admin/application_settings/_slack.html.haml +++ b/app/views/admin/application_settings/_slack.html.haml @@ -1,33 +1,66 @@ -- return unless Gitlab.dev_or_test_env? || Gitlab.com? - +- gitlab_com = Gitlab.com? - expanded = integration_expanded?('slack_app_') + %section.settings.as-slack.no-animate#js-slack-settings{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Slack application') + = s_('Integrations|GitLab for Slack app') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p - = _('Slack integration allows you to interact with GitLab via slash commands in a chat window.') + = s_('SlackIntegration|Configure your GitLab for Slack app.') + = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app'), target: '_blank', rel: 'noopener noreferrer') + .settings-content + - unless gitlab_com + %h5 + = s_('SlackIntegration|Step 1: Create your GitLab for Slack app') + %p + = s_('SlackIntegration|You must do this step only once.') + %p + = render Pajamas::ButtonComponent.new(href: slack_app_manifest_share_admin_application_settings_path) do + = s_("SlackIntegration|Create Slack app") + %hr + %h5 + = s_('SlackIntegration|Step 2: Configure the app settings') + %p + - tag_pair_slack_apps = tag_pair(link_to('', 'https://api.slack.com/apps', target: '_blank', rel: 'noopener noreferrer'), :link_start, :link_end) + - tag_pair_strong = tag_pair(tag.strong, :strong_open, :strong_close) + = safe_format(s_('SlackIntegration|Copy the %{link_start}settings%{link_end} from %{strong_open}%{settings_heading}%{strong_close} in your GitLab for Slack app.'), tag_pair_slack_apps, tag_pair_strong, settings_heading: 'App Credentials') + = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'configure-the-settings'), target: '_blank', rel: 'noopener noreferrer') = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-slack-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) if expanded - %fieldset .form-group - = f.gitlab_ui_checkbox_component :slack_app_enabled, s_('ApplicationSettings|Enable Slack application'), - help_text: s_('ApplicationSettings|This option is only available on GitLab.com') + = f.gitlab_ui_checkbox_component :slack_app_enabled, s_('ApplicationSettings|Enable GitLab for Slack app') .form-group = f.label :slack_app_id, s_('SlackIntegration|Client ID'), class: 'label-bold' = f.text_field :slack_app_id, class: 'form-control gl-form-input' .form-group = f.label :slack_app_secret, s_('SlackIntegration|Client secret'), class: 'label-bold' = f.text_field :slack_app_secret, class: 'form-control gl-form-input' + .form-text.text-muted + = s_('SlackIntegration|Used for authenticating OAuth requests from the GitLab for Slack app.') .form-group = f.label :slack_app_signing_secret, s_('SlackIntegration|Signing secret'), class: 'label-bold' = f.text_field :slack_app_signing_secret, class: 'form-control gl-form-input' + .form-text.text-muted + = s_('SlackIntegration|Used for authenticating API requests from the GitLab for Slack app.') .form-group = f.label :slack_app_verification_token, s_('SlackIntegration|Verification token'), class: 'label-bold' = f.text_field :slack_app_verification_token, class: 'form-control gl-form-input' - + .form-text.text-muted + = s_('SlackIntegration|Used only for authenticating slash commands from the GitLab for Slack app. This method of authentication is deprecated by Slack.') = f.submit _('Save changes'), pajamas_button: true + + - unless gitlab_com + %hr + %h5 + = s_('SlackIntegration|Update your Slack app') + %p + = s_('SlackIntegration|When GitLab releases new features for the GitLab for Slack app, you might have to manually update your copy to use the new features.') + = link_to(_('Learn more.'), help_page_path('user/admin_area/settings/slack_app', anchor: 'update-the-gitlab-for-slack-app'), target: '_blank', rel: 'noopener noreferrer') + %p + = render Pajamas::ButtonComponent.new(href: slack_app_manifest_download_admin_application_settings_path, icon: 'download') do + = s_("SlackIntegration|Download latest manifest file") + diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 2eda3eab8c7..91cd6fe7ca0 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -7,13 +7,13 @@ %fieldset .form-group - - help_link_start = link_start % { url: help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check') } + - help_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'version-check') } = f.gitlab_ui_checkbox_component :version_check_enabled, _('Enable version check'), help_text: _("GitLab informs you if a new version is available. %{link_start}What information does GitLab Inc. collect?%{link_end}").html_safe % { link_start: help_link_start, link_end: link_end } .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? - service_ping_link_start = link_start % { url: help_page_path('development/service_ping/index') } - - deactivating_service_ping_link_start = link_start % { url: help_page_path('user/admin_area/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') } + - deactivating_service_ping_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') } - usage_ping_help_text = s_('AdminSettings|To help improve GitLab and its user experience, GitLab periodically collects usage information. %{link_start}What information is shared with GitLab Inc.?%{link_end}').html_safe % { link_start: service_ping_link_start, link_end: link_end } - disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end } = f.gitlab_ui_checkbox_component :usage_ping_enabled, s_('AdminSettings|Enable Service Ping'), @@ -28,7 +28,7 @@ .form-group - usage_ping_enabled = @application_setting.usage_ping_enabled? - label = s_('AdminSettings|Enable Registration Features') - - label_link = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/usage_statistics', anchor: 'registration-features-program') + - label_link = link_to sprite_icon('question-o'), help_page_path('administration/settings/usage_statistics', anchor: 'registration-features-program') - help_text = usage_ping_enabled ? s_('AdminSettings|You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.') : s_('AdminSettings|To enable Registration Features, first enable Service Ping.') = f.gitlab_ui_checkbox_component :usage_ping_features_enabled?, '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link }, help_text: '<span id="service_ping_features_helper_text">%{help_text}</span>'.html_safe % { help_text: help_text }, @@ -37,7 +37,7 @@ .form-text.gl-text-gray-500.gl-pl-6 %p.gl-mb-3= s_('AdminSettings|Registration Features include:') - email_from_gitlab_path = help_page_path('user/admin_area/email_from_gitlab') - - repo_size_limit_path = help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'repository-size-limit') + - repo_size_limit_path = help_page_path('administration/settings/account_and_limit_settings', anchor: 'repository-size-limit') - restrict_ip_path = help_page_path('user/group/access_and_permissions', anchor: 'restrict-group-access-by-ip-address') - email_from_gitlab_link = link_start % { url: email_from_gitlab_path } - repo_size_limit_link = link_start % { url: repo_size_limit_path } diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 1b0e974a0ca..fb5c320268e 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -3,9 +3,8 @@ = gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f| = form_errors(@appearance) - .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0= _('Navigation bar') .col-lg-8 @@ -25,7 +24,7 @@ = _('Maximum file size is 1MB. Pages are optimized for a 24px tall header logo') %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0 Favicon .col-lg-8 @@ -50,7 +49,7 @@ %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0= _('Sign in/Sign up pages') .col-lg-8 @@ -79,7 +78,7 @@ %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0= _('Progressive Web App (PWA)') .col-lg-8 @@ -111,7 +110,7 @@ %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0= _('New project pages') .col-lg-8 @@ -124,7 +123,7 @@ %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0= _('Profile image guideline') .col-lg-8 diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml index d7bb3a85f3a..2ca037db532 100644 --- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml @@ -2,7 +2,7 @@ %hr .row - .col-lg-4.profile-settings-sidebar + .col-lg-4 %h4.gl-mt-0 = _('System header and footer') diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 022930bd6b4..2d56e9dd0dd 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -96,7 +96,6 @@ = render 'admin/application_settings/plantuml' = render 'admin/application_settings/diagramsnet' = render 'admin/application_settings/sourcegraph' -= render_if_exists 'admin/application_settings/slack' -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417 = render_if_exists 'admin/application_settings/dingtalk_integration' -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/640 @@ -109,4 +108,5 @@ = render 'admin/application_settings/floc' = render_if_exists 'admin/application_settings/add_license' = render 'admin/application_settings/jira_connect' += render 'admin/application_settings/slack' = render_if_exists 'admin/application_settings/ai_access' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 18ce7c1ceba..3b9fb930fd7 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -84,6 +84,9 @@ .settings-content = render 'git_lfs_limits' + += render 'gitlab_shell_operation_limits' + %section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml index 24f132b982a..634d006e736 100644 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ b/app/views/admin/application_settings/service_usage_data.html.haml @@ -23,7 +23,7 @@ title: _('Service Ping payload not found in the application cache')) do |c| - c.with_body do - - enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') + - enable_service_ping_link_url = help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics') - enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url } - generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping') - generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url } diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml index f9fd5864176..82544d36ba0 100644 --- a/app/views/admin/applications/_delete_form.html.haml +++ b/app/views/admin/applications/_delete_form.html.haml @@ -1,4 +1,2 @@ -- submit_btn_css ||= 'gl-button btn btn-danger btn-danger-secondary btn-sm js-application-delete-button' - -%button{ class: submit_btn_css, data: { path: admin_application_path(application), name: application.name } } += render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, button_options: { data: { path: admin_application_path(application), name: application.name }, class: 'js-application-delete-button' }) do = _('Destroy') diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index a8d5a45041d..27622dfa0bb 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -32,4 +32,4 @@ .gl-mt-5 = f.submit _('Save application'), pajamas_button: true, data: { qa_selector: 'save_application_button' } - = link_to _('Cancel'), admin_applications_path, class: "gl-button btn btn-default btn-cancel" + = link_button_to _('Cancel'), admin_applications_path diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml index 4e2292a9f67..c39e8fb0057 100644 --- a/app/views/admin/cohorts/_cohorts_table.html.haml +++ b/app/views/admin/cohorts/_cohorts_table.html.haml @@ -2,7 +2,7 @@ .bs-callout.clearfix %p = s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] } - = link_to sprite_icon('question-o'), help_page_path('user/admin_area/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank', rel: 'noopener noreferrer' + = link_to sprite_icon('question-o'), help_page_path('administration/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank', rel: 'noopener noreferrer' .table-holder.d-xl-table %table.table diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 4ba69126906..5f5f6c98663 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -43,7 +43,7 @@ %li %span.light= _('Created on:') %strong - = @group.created_at.to_s(:medium) + = @group.created_at.to_fs(:medium) %li %span.light= _('ID:') diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index a309e874317..92a664e1ca8 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -6,10 +6,10 @@ %p.form-text.text-muted= _('URL must be percent-encoded if necessary.') .form-group = form.label :token, _('Secret token'), class: 'label-bold' - = form.text_field :token, class: 'form-control gl-form-input' + = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48' %p.form-text.text-muted= _('Use this token to validate received payloads.') .form-group - = form.label :url, _('Trigger'), class: 'label-bold' + = form.label :url, _('Trigger'), class: 'label-bold gl-mb-0' .form-text.text-secondary.gl-mb-5= _('System hooks are triggered on sets of events like creating a project or adding an SSH key. You can also enable extra triggers, such as push events.') %fieldset.form-group = form.gitlab_ui_checkbox_component :repository_update_events, _('Repository update events'), diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 14d37b77a41..29b90f69800 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -3,17 +3,17 @@ = render 'shared/web_hooks/hook_errors', hook: @hook -.row.gl-mt-3 - .col-lg-3 - = render 'shared/web_hooks/title_and_docs', hook: @hook +.gl-mt-5 + = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-9.gl-mb-3 - = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f| - = render partial: 'form', locals: { form: f, hook: @hook } - .form-actions - %span>= f.submit _('Save changes'), class: 'gl-mr-3', pajamas_button: true + = gitlab_ui_form_for @hook, as: :hook, url: admin_hook_path do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + + .gl-display-flex.gl-justify-content-space-between + %div + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-sm-mr-3' = render 'shared/web_hooks/test_button', hook: @hook - = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' } + = link_button_to _('Delete'), admin_hook_path(@hook), method: :delete, aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this webhook?'), confirm_btn_variant: 'danger' }, variant: :danger %hr diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index d4aeb8dc7e8..14137e788bc 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,14 +1,7 @@ - page_title @hook.pluralized_name -.row.gl-mt-3 - .col-lg-4 - = render 'shared/web_hooks/title_and_docs', hook: @hook - - .col-lg-8.gl-mb-3 - = gitlab_ui_form_for @hook, as: :hook, url: admin_hooks_path do |f| - = render partial: 'form', locals: { form: f, hook: @hook } - = f.submit _('Add system hook'), pajamas_button: true - - = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class +.settings-section + = render 'shared/web_hooks/title_and_docs', hook: @hook + = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class, partial: 'form', url: admin_hooks_path = render 'shared/file_hooks/index' diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index f4f64eadf21..19460ddb0e5 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -10,8 +10,8 @@ .dropdown-menu.dropdown-menu-right %ul %li - = link_to edit_admin_label_path(label), class: 'btn gl-btn label-action dropdown-item btn-link' do + = link_to edit_admin_label_path(label), class: 'btn label-action dropdown-item btn-link' do = _('Edit') %li - = link_to admin_label_path(label), class: 'btn gl-btn js-remove-label dropdown-item btn-link gl-text-red-500!', data: { confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do + = link_to admin_label_path(label), class: 'btn js-remove-label dropdown-item btn-link gl-text-red-500!', data: { confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do = _('Delete') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 8eb72fa281e..0637b0eae47 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -60,7 +60,7 @@ %span.light = _('Created on:') %strong - = @project.created_at.to_s(:medium) + = @project.created_at.to_fs(:medium) %li{ class: 'gl-px-5!' } %span.light @@ -158,10 +158,10 @@ = _("This repository has never been checked.") - elsif @project.last_repository_check_failed? - failed_message = _("This repository was last checked %{last_check_timestamp}. The check %{strong_start}failed.%{strong_end} See the 'repocheck.log' file for error messages.") - - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" } + - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_fs(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" } = failed_message.html_safe - else - = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium) } + = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_fs(:medium) } = link_to sprite_icon('question-o'), help_page_path('administration/repository_checks') diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml index 3d245722270..6ce094bacf1 100644 --- a/app/views/admin/runners/edit.html.haml +++ b/app/views/admin/runners/edit.html.haml @@ -39,7 +39,8 @@ .input-group = search_field_tag :search, params[:search], class: 'form-control gl-form-input', spellcheck: false .input-group-append - = submit_tag _('Search'), class: 'gl-button btn btn-default' + = render Pajamas::ButtonComponent.new(type: 'submit', variant: :default) do + = _('Search') %td - @projects.each do |project| diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index d0ee3acf0b8..f880c2631ed 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ -= form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do += gitlab_ui_form_for(:user, url: admin_session_path, html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' } }) do |f| .form-group - = label_tag :user_password, _('Password'), class: 'label-bold' - = password_field_tag 'user[password]', nil, { class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } } + = f.label :password, _('Password') + = f.password_field :password, class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } - .submit-container - = submit_tag _('Enter admin mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'enter_admin_mode_button' } }) do + = _('Enter admin mode') diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index 70cad880293..114b32ca581 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -12,6 +12,6 @@ = render_if_exists 'devise/sessions/new_smartcard' - if allow_admin_mode_password_authentication_for_web? - .login-box.tab-pane.gl-p-5{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) } + .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) } .login-body = render 'admin/sessions/new_base' diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 7301b0f6e04..b3e24d5b3ac 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -1,8 +1,8 @@ - page_title _('Enter admin mode') - add_page_specific_style 'page_bundles/login' -.row.justify-content-center - .col-md-5.new-session-forms-container +.row.gl-mt-5.justify-content-center + .col-md-5 .login-page #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) } - if any_form_based_providers_enabled? diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml index bfe66e2477e..ef004004227 100644 --- a/app/views/admin/sessions/two_factor.html.haml +++ b/app/views/admin/sessions/two_factor.html.haml @@ -2,7 +2,7 @@ - add_page_specific_style 'page_bundles/login' .row.justify-content-center - .col-md-5.new-session-forms-container + .col-md-5 .login-page #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) } = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project) diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml index ce2b5ad793c..3e8a023ec9f 100644 --- a/app/views/admin/topics/_topic.html.haml +++ b/app/views/admin/topics/_topic.html.haml @@ -16,5 +16,5 @@ = number_with_delimiter(topic.total_projects_count) .controls.gl-flex-shrink-0.gl-ml-5 - = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default' - = link_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger' + = link_button_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}" + = link_button_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, variant: :danger diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml index b4f61a1b665..bb89b5baf28 100644 --- a/app/views/admin/users/_profile.html.haml +++ b/app/views/admin/users/_profile.html.haml @@ -5,7 +5,7 @@ %ul.content-list %li %span.light= _('Member since') - %strong= user.created_at.to_s(:medium) + %strong= user.created_at.to_fs(:medium) - unless user.public_email.blank? %li %span.light= _('E-mail:') diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index 1f3e8f4bba2..fa89c3d4b4f 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -18,8 +18,7 @@ .float-right %span.light.vertical-align-middle= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from group') do - = sprite_icon('remove', size: 16, css_class: 'gl-icon') + = link_button_to nil, group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member), confirm_btn_variant: 'danger', testid: 'remove-user' }, aria: { label: _('Remove') }, method: :delete, remote: true, class: 'gl-ml-3', title: _('Remove user from group'), variant: :danger, size: :small, icon: 'remove' .row .col-md-6 @@ -50,5 +49,4 @@ %span.light.vertical-align-middle= member.human_access - if member.respond_to? :project - = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do - = sprite_icon('remove', size: 16, css_class: 'gl-icon') + = link_button_to nil, project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: 'gl-ml-3', title: _('Remove user from project'), variant: :danger, size: :small, icon: 'remove' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index ea6525e1b96..a4ae29bed81 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -42,8 +42,7 @@ %span.light= _('Secondary email:') %strong = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = link_button_to nil, remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email }, 'confirm-btn-variant': 'danger' }, method: :delete, class: 'float-right', title: _('Remove secondary email'), id: "remove_email_#{email.id}", variant: :danger, size: :small, icon: 'close' %li %span.light ID: %strong{ data: { qa_selector: 'user_id_content' } } @@ -58,7 +57,7 @@ %strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' } - if @user.two_factor_enabled? = _('Enabled') - = link_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication') + = link_button_to _('Disable'), disable_two_factor_admin_user_path(@user), aria: { label: _('Disable') }, data: { confirm: _('Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :patch, class: 'float-right', title: _('Disable Two-factor Authentication'), variant: :danger, size: :small - else = _('Disabled') @@ -86,12 +85,12 @@ %li %span.light= _('Member since:') %strong - = @user.created_at.to_s(:medium) + = @user.created_at.to_fs(:medium) - if @user.confirmed_at %li %span.light= _('Confirmed at:') %strong - = @user.confirmed_at.to_s(:medium) + = @user.confirmed_at.to_fs(:medium) - else %li %span.ligh= _('Confirmed:') @@ -106,7 +105,7 @@ %li %span.light= _('Current sign-in at:') %strong - = @user.current_sign_in_at&.to_s(:medium) || _('never') + = @user.current_sign_in_at&.to_fs(:medium) || _('never') %li %span.light= _('Last sign-in IP:') @@ -116,7 +115,7 @@ %li %span.light= _('Last sign-in at:') %strong - = @user.last_sign_in_at&.to_s(:medium) || _('never') + = @user.last_sign_in_at&.to_fs(:medium) || _('never') %li %span.light= _('Sign-in count:') diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml deleted file mode 100644 index 4d36c5094a3..00000000000 --- a/app/views/clusters/clusters/_integrations.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -.settings.expanded.border-0.m-0 - %p - = s_('ClusterIntegration|Integrations allow you to use applications installed in your cluster as part of your GitLab workflow.') - = link_to _('Learn more'), help_page_path('user/clusters/integrations.md'), target: '_blank', rel: 'noopener noreferrer' - .settings-content#integrations-settings-section - - if can?(current_user, :admin_cluster, @cluster) - .sub-section.form-group - = gitlab_ui_form_for @prometheus_integration, as: :integration, namespace: :prometheus, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |prometheus_form| - = prometheus_form.hidden_field :application_type, value: @prometheus_integration.application_type - .form-group.gl-form-group - - help_text = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Prometheus for metrics.') - - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations"), target: '_blank', rel: 'noopener noreferrer') - = prometheus_form.gitlab_ui_checkbox_component :enabled, - s_('ClusterIntegration|Enable Prometheus integration'), - help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } - = prometheus_form.submit _('Save changes'), class: 'btn gl-button btn-confirm' diff --git a/app/views/clusters/clusters/_integrations_tab.html.haml b/app/views/clusters/clusters/_integrations_tab.html.haml deleted file mode 100644 index e229c1fbe1e..00000000000 --- a/app/views/clusters/clusters/_integrations_tab.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- active = params[:tab] == 'integrations' - -= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'integrations' }), { item_active: active } do - = _('Integrations') diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 57de6d980f8..1287f4e689f 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -32,7 +32,6 @@ = gl_tabs_nav do = render 'clusters/clusters/details_tab' = render_if_exists 'clusters/clusters/environments_tab' - = render 'clusters/clusters/integrations_tab' if !Feature.enabled?(:remove_monitor_metrics) = render 'clusters/clusters/advanced_settings_tab' .tab-content.py-3 diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index ebd7f20c54a..e20fccc218a 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -20,7 +20,7 @@ - else = _("(removed)") - .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start.gl-lg-align-items-center + .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start .todo-avatar.gl-display-none.gl-sm-display-inline-block = author_avatar(todo, size: 24) .todo-note @@ -47,6 +47,8 @@ %span.action-description< = first_line_in_markdown(todo, :body, 125, is_todo: true, project: todo.project, group: todo.group) + = render_if_exists "dashboard/todos/diff_summary", local_assigns: { todo: todo } + .todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0 %span.todo-timestamp.gl-font-sm.gl-text-secondary = todo_due_date(todo) diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index ca6b1071f03..c5f70397fad 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -53,7 +53,7 @@ - if params[:action_id].present? = hidden_field_tag(:action_id, params[:action_id]) = dropdown_tag(todo_actions_dropdown_label(params[:action_id], _("Action")), options: { toggle_class: 'js-action-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', data: { data: todo_actions_options, default_label: _("Action") } }) - .filter-item.sort-filter.gl-mt-3.gl-sm-mt-0.gl-mb-0.gl-sm-mb-0 + .filter-item.sort-filter.gl-my-2 .dropdown %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', class: 'gl-xs-w-full!', 'data-toggle' => 'dropdown' } %span.light diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index bb398eaf4be..4b1441662ab 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -7,7 +7,7 @@ = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', required: true .form-group = f.label :password, _('Password') - = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{:crowd}_password", name: 'password' } + %input.form-control.gl-form-input.js-password{ data: { id: 'crowd_password', name: 'password' } } - if render_remember_me = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' } diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index f9b6f462661..471cc053e6e 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -9,7 +9,7 @@ = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { qa_selector: 'username_field' }, required: true .form-group = f.label :password, _('Password') - = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' } + %input.form-control.gl-form-input.js-password{ data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' } } - if render_remember_me = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' } diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml index 0744faa148c..c35e43b909e 100644 --- a/app/views/devise/shared/_footer.html.haml +++ b/app/views/devise/shared/_footer.html.haml @@ -1,10 +1,11 @@ -%hr.footer-fixed -.container.footer-container.gl-display-flex.gl-justify-content-space-between - .footer-links - - unless public_visibility_restricted? - = link_to _("Explore"), explore_root_path - = link_to _("Help"), help_path - = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" - = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' - = render 'devise/shared/language_switcher' +.footer-container.gl-w-full.gl-align-self-end + %hr.gl-m-0 + .container.gl-py-5.gl-display-flex.gl-justify-content-space-between + .gl-display-flex.gl-gap-5.gl-flex-wrap + - unless public_visibility_restricted? + = link_to _("Explore"), explore_root_path + = link_to _("Help"), help_path + = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" + = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' + = render 'devise/shared/language_switcher' = footer_message diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 684ade87720..6d37257232b 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -5,14 +5,14 @@ .gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') } = yield :omniauth_providers_top if show_omniauth_providers - = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f| + = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }}, data: { testid: 'signup-form' }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: resource - if Gitlab::CurrentSettings.invisible_captcha_enabled = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) .name.form-row .col.form-group - = f.label :first_name, _('First name'), for: 'new_user_first_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" + = f.label :first_name, _('First name'), for: 'new_user_first_name' = f.text_field :first_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_first_name_length, @@ -21,7 +21,7 @@ required: true, title: _('This field is required.') .col.form-group - = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" + = f.label :last_name, _('Last name'), for: 'new_user_last_name' = f.text_field :last_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', data: { max_length: max_last_name_length, @@ -30,7 +30,7 @@ required: true, title: _('This field is required.') .username.form-group - = f.label :username, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" + = f.label :username, _('Username') = f.text_field :username, class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', data: signup_username_data_attributes, @@ -41,7 +41,7 @@ %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') .form-group - = f.label :email, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" + = f.label :email, _('Email') = f.email_field :email, class: 'form-control gl-form-input middle js-validate-email', data: { qa_selector: 'new_user_email_field' }, @@ -52,7 +52,7 @@ -# This is used for providing entry to Jihu on email verification = render_if_exists 'devise/shared/signup_email_additional_info' .form-group.gl-mb-5 - = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" + = f.label :password, _('Password') %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }, minimum_password_length: @minimum_password_length, @@ -62,18 +62,16 @@ %p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } = render_if_exists 'shared/password_requirements_list' = render_if_exists 'devise/shared/phone_verification', form: f - %div - - if arkose_labs_enabled? - = render_if_exists 'devise/registrations/arkose_labs' - - elsif show_recaptcha_sign_up? - = recaptcha_tags nonce: content_security_policy_nonce + .form-group + - if arkose_labs_enabled? + = render_if_exists 'devise/registrations/arkose_labs' + - elsif show_recaptcha_sign_up? + = recaptcha_tags nonce: content_security_policy_nonce + + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'new_user_register_button' }}) do + = button_text - .submit-container.gl-mt-5 - = f.submit button_text, pajamas_button: true, class: 'gl-w-full', data: { qa_selector: 'new_user_register_button' } - - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project) - .gl-pt-5 - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) = render 'devise/shared/terms_of_service_notice', button_text: button_text = yield :omniauth_providers_bottom if show_omniauth_providers diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index e8c82e456ae..60c37316c62 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -4,7 +4,7 @@ = _("Register with:") .gl-text-center.gl-ml-auto.gl-mr-auto - providers.each do |provider| - = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do + = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text @@ -14,7 +14,7 @@ = _("Create an account using:") .gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do + = render Pajamas::ButtonComponent.new(href: omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), method: :post, variant: :default, button_options: { class: "gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label}, id: "oauth-login-#{provider}" }) do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml index b9d50e48d05..8bae27020c2 100644 --- a/app/views/devise/unlocks/new.html.haml +++ b/app/views/devise/unlocks/new.html.haml @@ -1,14 +1,18 @@ = render 'devise/shared/tab_single', tab_title: _('Resend unlock instructions') .login-box .login-body - = gitlab_ui_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| + = gitlab_ui_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-p-5 gl-show-field-errors' }) do |f| .devise-errors = render "devise/shared/error_messages", resource: resource - .form-group.gl-mb-6 - = f.label :email - = f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: _('Please provide a valid email address.') - .clearfix - = f.submit _('Resend unlock instructions'), pajamas_button: true, class: 'gl-w-full' + .form-group + = f.label :email, _('Email') + = f.email_field :email, class: 'form-control gl-form-input', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: _('Please provide a valid email address.') -.clearfix.prepend-top-20 + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do + = _('Resend unlock instructions') + +- if Feature.enabled?(:restyle_login_page, @project) = render 'devise/shared/sign_in_link' +- else + .gl-mt-3 + = render 'devise/shared/sign_in_link' diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index a35ba12dd52..e34a5cebe78 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -15,16 +15,14 @@ = badge_counter = render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge } - .flash-container - - .discussion-reply-holder - - if can_create_note? - .discussion-with-resolve-btn - = link_to_reply_discussion(discussion) - - elsif !current_user - .disabled-comment.text-center - Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') - or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') - to reply + %li.discussion-reply-holder.clearfix{ class: 'gl-border-t-0! gl-pb-5!' } + - if can_create_note? + .discussion-with-resolve-btn + = link_to_reply_discussion(discussion) + - elsif !current_user + .disabled-comment.text-center + Please + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + or + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + to reply diff --git a/app/views/events/_event_push.atom.haml b/app/views/events/_event_push.atom.haml index c3786d7c16d..51b60fe0152 100644 --- a/app/views/events/_event_push.atom.haml +++ b/app/views/events/_event_push.atom.haml @@ -6,7 +6,7 @@ = link_to "(#{truncate_sha(event.commit_id)})", event_url if event_url %i at - = event.created_at.to_s(:short) + = event.created_at.to_fs(:short) - unless event.rm_ref? .blockquote= markdown(escape_once(event.commit_title), pipeline: :atom, project: event.project, author: event.author) - if event.commits_count > 1 diff --git a/app/views/explore/projects/_project.atom.builder b/app/views/explore/projects/_project.atom.builder new file mode 100644 index 00000000000..f0500901a73 --- /dev/null +++ b/app/views/explore/projects/_project.atom.builder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +xml.entry do + xml.title project.name + xml.link href: project_url(project), rel: "alternate", type: "text/html" + xml.id project_url(project) + xml.updated project.created_at + + if project.description.present? + xml.summary(type: "xhtml") do |summary| + summary << project.description + end + end +end diff --git a/app/views/explore/projects/topic.atom.builder b/app/views/explore/projects/topic.atom.builder new file mode 100644 index 00000000000..4712d415daa --- /dev/null +++ b/app/views/explore/projects/topic.atom.builder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +xml.title @topic.name +xml.link href: topic_explore_projects_url(@topic.name, rss_url_options), rel: "self", type: "application/atom+xml" +xml.link href: topic_explore_projects_url(@topic.name), rel: "alternate", type: "text/html" +xml.id topic_explore_projects_url(@topic.id) +xml.updated @projects[0].updated_at.xmlschema if @projects[0] + +xml << render(@projects) if @projects.any? diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index b26abefcb0e..329e7cc161c 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -22,9 +22,10 @@ %div{ class: container_class } .gl-py-5.gl-border-gray-100.gl-border-b-solid.gl-border-b-1 %h3.gl-m-0= _('Projects with this topic') - .top-area.gl-pt-2.gl-pb-2 + .top-area.gl-pt-2.gl-pb-2.gl-justify-content-space-between .nav-controls = render 'shared/projects/search_form' = render 'filter' + = link_button_to nil, topic_explore_projects_path(@topic.name, rss_url_options), title: s_("Topics|Subscribe to the new projects feed"), class: 'd-none d-sm-inline-flex has-tooltip', icon: 'rss' = render 'projects', projects: @projects diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 04bf3f98a1e..e5c66c2c432 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -3,16 +3,18 @@ .row.gl-mt-3 .col-lg-12 - .gl-display-flex.gl-flex-wrap + .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between - if can_admin_group_member?(@group) %h4 = _('Group members') %p.gl-w-full.order-md-1 = group_member_header_subtext(@group) - .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3 + .gl-display-flex.gl-flex-wrap.gl-align-items-center.gl-gap-3.gl-md-w-auto.gl-w-full .js-invite-group-trigger{ data: { classes: 'gl-md-w-auto gl-w-full', display_text: _('Invite a group') } } + - if can_admin_service_accounts?(@group) + = render_if_exists 'groups/group_members/create_service_account' .js-invite-members-trigger{ data: { variant: 'confirm', - classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', + classes: 'gl-md-w-auto gl-w-full', trigger_source: 'group-members-page', display_text: _('Invite members') } } = render 'groups/invite_groups_modal', group: @group, reload_page_on_submit: true diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 89f460606cb..e84fd7a8692 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -10,12 +10,16 @@ = render "shared/milestones/form_dates", f: f .form-group = f.label :description, _("Description") - = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do - = render 'shared/zen', f: f, attr: :description, - classes: 'note-textarea', - qa_selector: 'milestone_description_field', - supports_autocomplete: true, - placeholder: _('Write milestone description...') + - @gfm_form = true + .js-markdown-editor{ data: { render_markdown_path: group_preview_markdown_path, + markdown_docs_path: help_page_path('user/markdown'), + qa_selector: 'milestone_description_field', + form_field_placeholder: _('Write milestone description...'), + supports_quick_actions: 'false', + enable_autocomplete: 'true', + autofocus: 'false', + form_field_classes: 'note-textarea js-gfm-input markdown-area' } } + = f.hidden_field :description .clearfix .error-alert diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml index b6cf26c3677..6d0f24bf08c 100644 --- a/app/views/groups/packages/index.html.haml +++ b/app/views/groups/packages/index.html.haml @@ -10,4 +10,5 @@ npm_instance_url: package_registry_instance_url(:npm), project_list_url: '', settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '', + can_delete_packages: can_delete_group_packages?(@group).to_s, group_list_url: group_packages_path(@group) } } diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 8c73fc95544..22ed6ea4403 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -33,7 +33,7 @@ = render 'shared/choose_avatar_button', f: f - if @group.avatar? %hr - = link_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' + = link_button_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger, category: :secondary .form-group.gl-form-group = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group = f.submit s_('Groups|Save changes'), pajamas_button: true, class: 'js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index 96a492e599e..ac3be429461 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -4,40 +4,39 @@ - type_plural = _('group access tokens') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4 - %h4.gl-mt-0 - = page_title - %p - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/settings/group_access_tokens') } +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/settings/group_access_tokens') } - if current_user.can?(:create_resource_access_tokens, @group) = _('Generate group access tokens scoped to this group for your applications that need access to the GitLab API.') - %p - = html_escape(_('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe } + = html_escape(_('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe } - else - = html_escape(_('Group access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe } - %p + = _('Group access token creation is disabled in this group.') - root_group = @group.root_ancestor - if current_user.can?(:admin_group, root_group) - group_settings_link = edit_group_path(root_group) - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link } = html_escape(_('You can enable group access token creation in %{link_start}group settings%{link_end}.')) % { link_start: link_start, link_end: '</a>'.html_safe } + = html_escape(_('You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe } - .col-lg-8 - #js-new-access-token-app{ data: { access_token_type: type } } + #js-new-access-token-app{ data: { access_token_type: type } } - - if current_user.can?(:create_resource_access_tokens, @group) - = render 'shared/access_tokens/form', - ajax: true, - type: type, - path: group_settings_access_tokens_path(@group), - resource: @group, - token: @resource_access_token, - scopes: @scopes, - access_levels: GroupMember.access_level_roles, - default_access_level: Gitlab::Access::GUEST, - prefix: :resource_access_token, - help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') + - if current_user.can?(:create_resource_access_tokens, @group) + = render 'shared/access_tokens/form', + ajax: true, + type: type, + path: group_settings_access_tokens_path(@group), + resource: @group, + token: @resource_access_token, + scopes: @scopes, + access_levels: GroupMember.access_level_roles, + default_access_level: Gitlab::Access::GUEST, + prefix: :resource_access_token, + help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index d31d22c61be..9b23a8c5e0e 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -7,5 +7,5 @@ = f.number_field :max_artifacts_size, class: 'form-control' %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index bd0e4b51a63..2edd9cd5592 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -24,4 +24,5 @@ .col-md-4 = password_field_tag :password, nil, class: 'form-control gl-form-input' .form-actions - = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _('Continue to the next step') diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index 5f65405c8bc..d368f013e6b 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -6,7 +6,7 @@ %p = _("You are already a member of this %{member_source}.") % { member_source: @invite_details[:title] } .actions - = link_to _("Go to %{source_name}") % { source_name: @invite_details[:title] }, @invite_details[:url], class: "btn gl-button btn-confirm" + = link_button_to _("Go to %{source_name}") % { source_name: @invite_details[:title] }, @invite_details[:url], variant: :confirm - else %p diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml index 21b9a604a35..711a3d66ff7 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -1,4 +1,5 @@ - return unless google_tag_manager_enabled? + - if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops) = javascript_tag do :plain diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index d3a4c5c5ba8..53ecad1b474 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -3,12 +3,11 @@ - omit_og = sign_in_with_redirect? %head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } } %meta{ charset: "utf-8" } - - %title= page_title(site_name) - - = render 'layouts/loading_hints' - %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } + %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' } + %title= page_title(site_name) + = Gon::Base.render_data(nonce: content_security_policy_nonce) + = yield :project_javascripts = render 'layouts/startup_js' = yield :startup_js @@ -18,14 +17,9 @@ = yield :prefetch_asset_tags - = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' - - - if startup_css_enabled? - = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) } - - else - - diffs_colors = user_diffs_colors - = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename - = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path + - diffs_colors = user_diffs_colors + = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename + = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path - if user_application_theme == 'gl-dark' %meta{ name: 'color-scheme', content: 'dark light' } @@ -43,13 +37,9 @@ = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" - - if startup_css_enabled? - = render 'layouts/startup_css_activation' - = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = render 'layouts/snowplow' - - = Gon::Base.render_data(nonce: content_security_policy_nonce) + = render 'layouts/loading_hints' = render_if_exists 'layouts/header/translations' - if Feature.enabled?(:enable_new_sentry_clientside_integration, current_user) && Gitlab::CurrentSettings.sentry_enabled @@ -64,8 +54,6 @@ = webpack_controller_bundle_tags - = yield :project_javascripts - - unless omit_og -# Open Graph - http://ogp.me/ %meta{ property: 'og:type', content: "object" } @@ -84,16 +72,13 @@ %meta{ property: 'twitter:image', content: page_image } = page_card_meta_tags - %meta{ name: "description", content: page_description } - - %link{ rel: 'manifest', href: manifest_path(format: :json) } - %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' } - %meta{ name: 'theme-color', content: user_theme_primary_color } - = csrf_meta_tags = csp_meta_tag = action_cable_meta_tag + %link{ rel: 'manifest', href: manifest_path(format: :json) } + = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' + -# Apple Safari/iOS home screen icons = favicon_link_tag 'apple-touch-icon.png', rel: 'apple-touch-icon' @@ -106,3 +91,5 @@ = render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id') -# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/184) = render_if_exists "layouts/frontend_monitor" + %meta{ name: "description", content: page_description } + %meta{ name: 'theme-color', content: user_theme_primary_color } diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml index 1d5f2583bbd..add518723e5 100644 --- a/app/views/layouts/_header_search.html.haml +++ b/app/views/layouts/_header_search.html.haml @@ -9,7 +9,7 @@ %input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', autocomplete: 'off', - data: { qa_selector: 'search_box' } } + data: { testid: 'search_box' } } = hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group] = hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project] diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml index 979ebeb0a02..c1fe3ae0924 100644 --- a/app/views/layouts/_img_loader.html.haml +++ b/app/views/layouts/_img_loader.html.haml @@ -13,6 +13,6 @@ img.removeAttribute('data-src'); img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); - img.dataset.qa_selector = 'js_lazy_loaded_content'; + img.dataset.testid = 'js_lazy_loaded_content'; }); } diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index 95ebe09a2e6..8b864c2685e 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -1,5 +1,5 @@ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 8e52f973e9e..3bb59db32aa 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -5,9 +5,9 @@ -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new. - group = @parent_group || @group - - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user) + - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json - %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s } } + %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - if display_whats_new? #whats-new-app{ data: { version_digest: whats_new_version_digest } } diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml deleted file mode 100644 index 64a86cf319e..00000000000 --- a/app/views/layouts/_startup_css.haml +++ /dev/null @@ -1,9 +0,0 @@ -- startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general' -- startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default -- diffs_colors = user_diffs_colors - -%style - = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename - = Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe - -= render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml deleted file mode 100644 index 7dfb9cd1530..00000000000 --- a/app/views/layouts/_startup_css_activation.haml +++ /dev/null @@ -1,7 +0,0 @@ -= javascript_tag do - :plain - document.querySelectorAll('link[media="print"]').forEach(linkTag => { - linkTag.setAttribute('data-startupcss', 'loading'); - const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded'); - linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true}); - }) diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6e1d3ba678c..94f25a9f0ae 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,19 +1,19 @@ - add_page_specific_style 'page_bundles/login' - custom_text = custom_sign_in_description !!! 5 -%html.devise-layout-html{ class: system_message_class } +%html.html-devise-layout{ lang: I18n.locale } = render "layouts/head", { startup_filename: 'signin' } - %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } = header_message = render "layouts/init_client_detection_flags" - if Feature.enabled?(:restyle_login_page, @project) - .page-wrap.borderless - .container.navless-container + .gl-h-full.borderless.gl-display-flex.gl-flex-wrap + .container .content = render "layouts/flash" - if custom_text.present? .row - .col-md.order-12.sm-bg-gray-10 + .col-md.order-12.sm-bg-gray .col-sm-12 %h1.mb-3.gl-font-size-h2 = brand_title @@ -33,11 +33,11 @@ .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar = yield - = render 'devise/shared/footer', footer_message: footer_message + = render 'devise/shared/footer' - else - .page-wrap - = render "layouts/header/empty" - .container.navless-container + = render "layouts/header/empty" + .gl-h-full.gl-display-flex.gl-flex-wrap + .container .content = render "layouts/flash" .row.mt-3 @@ -60,7 +60,7 @@ %p = _('This is a self-managed instance of GitLab.') - .col-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } + .col-md-6.order-1{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' } = yield - = render 'devise/shared/footer', footer_message: footer_message + = render 'devise/shared/footer' diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 89aba85984f..3e969b866a6 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,15 +1,15 @@ - add_page_specific_style 'page_bundles/login' !!! 5 -%html.devise-layout-html{ lang: "en", class: system_message_class } +%html.html-devise-layout{ lang: I18n.locale } = render "layouts/head" - %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}" } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}" } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/empty" - = render "layouts/broadcast" - .container.navless-container - .content - = render "layouts/flash" - = yield + .gl-h-full.gl-display-flex.gl-flex-wrap + .container + .content + = render "layouts/flash" + = yield - = render 'devise/shared/footer', footer_message: footer_message + = render 'devise/shared/footer' diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 3ddd8c6780f..5ad20478f51 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } %title= yield(:title) diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 65dbafc19da..e04ffc2e88a 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', track_action: "click_link", track_label: "user_profile", track_property: "navigation_top", 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" } do = render 'layouts/header/current_user_dropdown_item' - else .gl-py-3.gl-px-4 @@ -19,7 +19,7 @@ = dispensable_render_if_exists 'layouts/header/start_trial' - if current_user_menu?(:settings) %li - = 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" } + = link_to s_("CurrentUser|Edit profile"), profile_path, data: { testid: 'edit_profile_link', track_action: "click_link", track_label: "user_edit_profile", track_property: "navigation_top" } %li = link_to s_("CurrentUser|Preferences"), profile_preferences_path, data: { track_action: "click_link", track_label: "user_preferences", track_property: "navigation_top" } = render_if_exists 'layouts/header/buy_pipeline_minutes', project: @project, namespace: @group @@ -48,4 +48,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', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" } + = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { testid: 'sign_out_link', track_action: "click_link", track_label: "user_sign_out", track_property: "navigation_top" } diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 2c6ccb4abaf..1c22a853dd0 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,7 +1,7 @@ - has_impersonation_link = header_link?(:admin_impersonation) - user_status_data = user_status_properties(current_user) -%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } } +%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { testid: 'navbar' } } %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid .header-content.js-header-content @@ -12,7 +12,7 @@ = brand_header_logo .gl-display-flex.gl-align-items-center - if Gitlab.com_and_canary? - = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do + = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { testid: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do = _('Next') - if current_user @@ -47,7 +47,7 @@ - if header_link?(:issues) = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues js-prefetch-document', aria: { label: _('Issues') }, - data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', + data: { testid: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_issues_link', track_property: 'navigation_top', @@ -60,7 +60,7 @@ = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do - top_level_link = assigned_mrs_dashboard_path = link_to top_level_link, class: 'dashboard-shortcuts-merge_requests has-tooltip', title: _('Merge requests'), aria: { label: _('Merge requests') }, - data: { qa_selector: 'merge_requests_shortcut_button', + data: { testid: 'merge_requests_shortcut_button', toggle: "dropdown", placement: 'bottom', track_label: 'merge_requests_menu', @@ -92,7 +92,7 @@ - if header_link?(:todos) = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos js-prefetch-document', - data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', + data: { testid: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', track_label: 'main_navigation', track_action: 'click_to_do_link', track_property: 'navigation_top', @@ -115,16 +115,16 @@ %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: { qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) } + %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { testid: 'user-dropdown' }, class: ('mr-0' if has_impersonation_link) } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown", track_label: "profile_dropdown", track_action: "click_dropdown", track_property: "navigation_top" } do - = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) + = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { testid: 'user_avatar_content' } }) = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/current_user_dropdown' - if has_impersonation_link %li.nav-item.impersonation.ml-0 - = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } }) + = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', testid: 'stop_impersonation_btn' } }) - if header_link?(:sign_in) - if allow_signup? %li.nav-item @@ -133,7 +133,7 @@ %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' } } + %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: 'gl-border-none!', data: { testid: 'mobile_navbar_button' } } %span.sr-only= _('Toggle navigation') %span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold %span.gl-pr-2= _('Menu') diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml index 50a2b45aa7e..3fe2894f236 100644 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ b/app/views/layouts/header/_new_dropdown.html.haml @@ -13,7 +13,7 @@ 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 + testid: 'new-menu-toggle' } do = sprite_icon('plus-square') = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right.dropdown-extended-height diff --git a/app/views/layouts/in_product_marketing_mailer.html.haml b/app/views/layouts/in_product_marketing_mailer.html.haml index 65c68c95d9a..312e1811e3c 100644 --- a/app/views/layouts/in_product_marketing_mailer.html.haml +++ b/app/views/layouts/in_product_marketing_mailer.html.haml @@ -1,5 +1,5 @@ !!! -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } %meta{ content: "width=device-width, initial-scale=1", name: "viewport" } diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index 80bbe578510..89d5abd4266 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -1,4 +1,4 @@ -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } %title diff --git a/app/views/layouts/nav/_ask_duo_button.html.haml b/app/views/layouts/nav/_ask_duo_button.html.haml new file mode 100644 index 00000000000..f17ccfc8afe --- /dev/null +++ b/app/views/layouts/nav/_ask_duo_button.html.haml @@ -0,0 +1,13 @@ +- if Gitlab.ee? && ::Gitlab::Llm::TanukiBot.show_breadcrumbs_entry_point_for?(user: current_user) + - label = s_('TanukiBot|Ask GitLab Duo') + = render Pajamas::ButtonComponent.new(variant: :confirm, + category: :secondary, + icon: 'tanuki-ai', + size: 'small', + button_options: { class: 'js-tanuki-bot-chat-toggle gl-ml-3 gl-display-none gl-md-display-inline', data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button' }, aria: { label: label }}) do + = label + = render Pajamas::ButtonComponent.new(variant: :confirm, + category: :secondary, + icon: 'tanuki-ai', + size: 'small', + button_options: { class: 'js-tanuki-bot-chat-toggle has-tooltip gl-ml-3 gl-md-display-none', title: label, data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button', placement: 'left' }, aria: { label: label }}) diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml index a0e03c9c0cf..73b253e18bd 100644 --- a/app/views/layouts/nav/_top_bar.html.haml +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -5,10 +5,11 @@ - top_bar_class = [@no_top_bar_container ? 'container-fluid' : container_class, @content_class] - top_bar_container_class = 'gl-border-b' -%div{ class: top_bar_class } - .top-bar-container.gl-display-flex.gl-align-items-center{ :class => top_bar_container_class } +%div{ class: top_bar_class, data: { testid: 'top-bar' } } + .top-bar-container.gl-display-flex.gl-align-items-center.gl-gap-2{ :class => top_bar_container_class } - if show_super_sidebar? - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } }) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } }) - elsif defined?(@left_sidebar) - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) = render "layouts/nav/breadcrumbs/breadcrumbs" + = render "layouts/nav/ask_duo_button" diff --git a/app/views/layouts/nav/sidebar/_organization.html.haml b/app/views/layouts/nav/sidebar/_organization.html.haml new file mode 100644 index 00000000000..de6c87f97d7 --- /dev/null +++ b/app/views/layouts/nav/sidebar/_organization.html.haml @@ -0,0 +1 @@ += render partial: 'shared/nav/sidebar', object: Sidebars::Organizations::Panel.new(organization_sidebar_context(@organization, current_user)) diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index c557dc36534..1f526ec221d 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -1,4 +1,4 @@ -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } %title diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml index 8d241dfd207..d5e0e8e9c1d 100644 --- a/app/views/layouts/oauth_error.html.haml +++ b/app/views/layouts/oauth_error.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } %title= yield(:title) diff --git a/app/views/layouts/organization.html.haml b/app/views/layouts/organization.html.haml new file mode 100644 index 00000000000..5a357c6f805 --- /dev/null +++ b/app/views/layouts/organization.html.haml @@ -0,0 +1,6 @@ +- page_title @organization.name +- header_title @organization.name, organization_path(@organization) +- nav "organization" +- @left_sidebar = true + += render template: "layouts/application" diff --git a/app/views/layouts/service_desk.html.haml b/app/views/layouts/service_desk.html.haml index 7ac108e7f31..13e9785317c 100644 --- a/app/views/layouts/service_desk.html.haml +++ b/app/views/layouts/service_desk.html.haml @@ -1,4 +1,4 @@ -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" } -# haml-lint:disable NoPlainNodes diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index 8cbea686d51..a5953021671 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -1,13 +1,14 @@ +- add_page_specific_style 'page_bundles/signup' +- add_page_specific_style 'page_bundles/login' !!! 5 -%html.devise-layout-html.navless{ class: system_message_class } - - add_page_specific_style 'page_bundles/signup' - - add_page_specific_style 'page_bundles/login' +%html.html-devise-layout{ lang: I18n.locale } = render "layouts/head" - %body.signup-page{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } - = render "layouts/header/logo_with_title" + %body.signup-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + = header_message = render "layouts/init_client_detection_flags" - .page-wrap - .container.signup-box-container.navless-container - = render "layouts/broadcast" - .content - = yield + = render "layouts/header/logo_with_title" + .container + .content + = yield + + = footer_message diff --git a/app/views/layouts/simple_registration.html.haml b/app/views/layouts/simple_registration.html.haml deleted file mode 100644 index a68941b031f..00000000000 --- a/app/views/layouts/simple_registration.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -!!! 5 -%html{ lang: "en" } - = render "layouts/head" - - add_page_specific_style 'page_bundles/login' - %body.login-page.application.navless{ class: user_application_theme, data: { page: body_data_page } } - = render "layouts/header/logo_with_title" - = render "layouts/broadcast" - .container.navless-container.pt-0 - .content.mw-460.mx-auto - = render "layouts/flash" - = yield diff --git a/app/views/notify/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml index 88e0bbf6125..683ee97aca3 100644 --- a/app/views/notify/_successful_pipeline.html.haml +++ b/app/views/notify/_successful_pipeline.html.haml @@ -100,7 +100,8 @@ %tbody %tr - common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;" - - pipeline_link = content_tag(:a, "\##{@pipeline.id}", href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe + - pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" + - pipeline_link = content_tag(:a, pipeline_link_text, href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe %td{ style: "#{common_style} font-weight:500;vertical-align:baseline;" } = s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link } - if @pipeline.user diff --git a/app/views/notify/_successful_pipeline.text.erb b/app/views/notify/_successful_pipeline.text.erb index 5798a2346fa..fcdeda5a213 100644 --- a/app/views/notify/_successful_pipeline.text.erb +++ b/app/views/notify/_successful_pipeline.text.erb @@ -24,9 +24,12 @@ Committed by: <%= commit.committer_name %> <% job_count = @pipeline.total_size -%> <% stage_count = @pipeline.stages_count -%> + +<% pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" %> + <% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) +Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API +Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> successfully completed <%= job_count %> <%= 'job'.pluralize(job_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>. diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml index 0b20d4f3d3a..0b856f87175 100644 --- a/app/views/notify/approved_merge_request_email.html.haml +++ b/app/views/notify/approved_merge_request_email.html.haml @@ -1,5 +1,5 @@ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml index 0008085025b..771495b57ba 100644 --- a/app/views/notify/import_issues_csv_email.html.haml +++ b/app/views/notify/import_issues_csv_email.html.haml @@ -1,4 +1,5 @@ - text_style = 'font-size:16px; text-align:center; line-height:30px;' +- error_style = 'font-size:13px; text-align:center; line-height:16px; color:#dd2b0e;' %p{ style: text_style } - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none;") @@ -16,3 +17,18 @@ - if @results[:parse_error] %p{ style: text_style } = s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.') + +- preprocess_errors = @results[:preprocess_errors] +- if preprocess_errors.present? + + - missing_milestone_errors = preprocess_errors.dig(:milestone_errors, :missing) || [] + + - if missing_milestone_errors.present? + %p{ style: error_style } + = s_('Notify|Could not find the following %{column} values in %{project}%{parent_groups_clause}: %{error_lines}') % { error_lines: missing_milestone_errors[:titles].join(', '), + column: missing_milestone_errors[:header].downcase, project: @project.full_name, + parent_groups_clause: @project.group.present? ? ' or its parent groups' : ''} + +- if @results[:error_lines].present? || preprocess_errors.present? + %p{ style: text_style } + = s_('Notify|Please fix the errors above and try the CSV import again.') diff --git a/app/views/notify/import_issues_csv_email.text.erb b/app/views/notify/import_issues_csv_email.text.erb index 1117f90714d..ef99914a821 100644 --- a/app/views/notify/import_issues_csv_email.text.erb +++ b/app/views/notify/import_issues_csv_email.text.erb @@ -9,3 +9,20 @@ Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%= <% if @results[:parse_error] %> Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values. <% end %> + +<% preprocess_errors = @results[:preprocess_errors] %> +<% + if preprocess_errors.present? + missing_milestone_errors = preprocess_errors.dig(:milestone_errors, :missing) || [] +%> + + <% if missing_milestone_errors.present? %> + <%= s_('Notify|Could not find the following %{column} values in %{project}%{parent_groups_clause}: %{error_lines}') % { error_lines: missing_milestone_errors[:titles].join(', '), + column: missing_milestone_errors[:header].downcase, project: @project.full_name, + parent_groups_clause: @project.group.present? ? ' or its parent groups' : ''} %> + <% end %> +<% end %> + +<% if @results[:error_lines].present? || preprocess_errors.present? %> + <%= s_('Notify|Please fix the errors above and try the CSV import again.') %> +<% end %> diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml index 9dd501022dd..f1959ce2557 100644 --- a/app/views/notify/issue_due_email.html.haml +++ b/app/views/notify/issue_due_email.html.haml @@ -5,7 +5,7 @@ %p = assignees_label(@issue) %p - = sprintf(s_('Notify|This issue is due on: %{issue_due_date}'), { issue_due_date: @issue.due_date.to_s(:medium) }).html_safe + = sprintf(s_('Notify|This issue is due on: %{issue_due_date}'), { issue_due_date: @issue.due_date.to_fs(:medium) }).html_safe - if @issue.description .md diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml index 9c25567696f..d376a1fdecf 100644 --- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml +++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml @@ -1,5 +1,5 @@ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" } %meta{ content: "width=device-width, initial-scale=1", name: "viewport" } diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index 5d4d2c0fcd8..bffb9f4ee5a 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -6,7 +6,7 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" } %img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } - = s_('Notify|Pipeline #%{pipeline_id} has failed!') % { pipeline_id: @pipeline.id } + = s_('Notify|Pipeline %{pipeline_name_or_id} has failed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" } %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -98,7 +98,8 @@ %tbody %tr - common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" - - pipeline_link = link_to "##{@pipeline.id}", pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" + - pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" + - pipeline_link = link_to pipeline_link_text, pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" %td{ style: "#{common_style}" } = s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link } - if @pipeline.user diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index c82b7a8dd2a..97823bf3998 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -1,4 +1,4 @@ -Pipeline #<%= @pipeline.id %> has failed! +<%= "Pipeline #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has failed!" %> Project: <%= @project.name %> ( <%= project_url(@project) %> ) Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> ) @@ -22,16 +22,18 @@ Committed by: <%= commit.committer_name %> <% end -%> <% end -%> -<% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) -<% else -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API -<% end -%> -<% failed = @pipeline.latest_statuses.failed -%> +<% pipeline_link_text = sanitize_name(@pipeline.name) || "##{@pipeline.id}" %> + +<% if @pipeline.user %> +Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) +<% else %> +Pipeline <%= pipeline_link_text %> ( <%= pipeline_url(@pipeline) %> ) triggered by API +<% end %> +<% failed = @pipeline.latest_statuses.failed %> had <%= failed.size %> failed <%= 'job'.pluralize(failed.size) %>. <% failed.each do |build| -%> <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> Stage: <%= build.stage_name %> Name: <%= build.name %> -<% end -%> +<% end -%>
\ No newline at end of file diff --git a/app/views/notify/pipeline_fixed_email.html.haml b/app/views/notify/pipeline_fixed_email.html.haml index 33b83b104b1..cf5ebb0649a 100644 --- a/app/views/notify/pipeline_fixed_email.html.haml +++ b/app/views/notify/pipeline_fixed_email.html.haml @@ -1 +1 @@ -= render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and #%{pipeline_id} has passed!') % {pipeline_id: @pipeline.id} += render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and %{pipeline_name_or_id} has passed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" } diff --git a/app/views/notify/pipeline_fixed_email.text.erb b/app/views/notify/pipeline_fixed_email.text.erb index 32334260a5e..3b8be0cf7a0 100644 --- a/app/views/notify/pipeline_fixed_email.text.erb +++ b/app/views/notify/pipeline_fixed_email.text.erb @@ -1 +1 @@ -<%= render 'notify/successful_pipeline', title: "Pipeline has been fixed and ##{@pipeline.id} has passed!" -%> +<%= render 'notify/successful_pipeline', title: "Pipeline has been fixed and #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has passed!" -%> diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml index 47832907663..3139741b5c1 100644 --- a/app/views/notify/pipeline_success_email.html.haml +++ b/app/views/notify/pipeline_success_email.html.haml @@ -1 +1 @@ -= render 'notify/successful_pipeline', title: "Pipeline ##{@pipeline.id} has passed!" += render 'notify/successful_pipeline', title: s_('Notify|Pipeline %{pipeline_name_or_id} has passed!') % { pipeline_name_or_id: sanitize_name(@pipeline.name) || "##{@pipeline.id}" } diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index 83cdb72d252..58999614d05 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -1 +1 @@ -<%= render 'notify/successful_pipeline', title: "Pipeline ##{@pipeline.id} has passed!" -%> +<%= render 'notify/successful_pipeline', title: "Pipeline #{sanitize_name(@pipeline.name) || "##{@pipeline.id}"} has passed!" -%> diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml index cdc97d583df..25dc8e59073 100644 --- a/app/views/notify/prometheus_alert_fired_email.html.haml +++ b/app/views/notify/prometheus_alert_fired_email.html.haml @@ -25,7 +25,3 @@ - if @alert.show_incident_issues_link? %p = link_to(_('View incident issues.'), @alert.incident_issues_link) - -- if @alert.show_performance_dashboard_link? - %p - = link_to(_('View performance dashboard.'), @alert.performance_dashboard_link) diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb index b23cd8b6ccc..a9c1d98a396 100644 --- a/app/views/notify/prometheus_alert_fired_email.text.erb +++ b/app/views/notify/prometheus_alert_fired_email.text.erb @@ -18,7 +18,3 @@ <% if @alert.show_incident_issues_link? %> <%= _('View incident issues.') %> <%= @alert.incident_issues_link %> <% end %> - -<% if @alert.show_performance_dashboard_link? %> -<%= _('View the performance dashboard at') %> <%= @alert.performance_dashboard_link %> -<% end %> diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index d493f9d5d98..199865ba644 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -19,7 +19,7 @@ %li %strong= link_to(commit.short_id, project_commit_url(@message.project, commit)) %div - = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_s(:iso8601), committed_at_end: '</i>'.html_safe} + = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_fs(:iso8601), committed_at_end: '</i>'.html_safe} %pre.commit-message = commit.safe_message diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml index 2ba0a2cf4ab..38a439864b7 100644 --- a/app/views/notify/repository_push_email.text.haml +++ b/app/views/notify/repository_push_email.text.haml @@ -8,7 +8,7 @@ \ = @message.reverse_compare? ? "Deleted commits:" : "Commits:" - @message.commits.each do |commit| - #{commit.short_id} by #{commit.author_name} at #{commit.committed_date.to_s(:iso8601)} + #{commit.short_id} by #{commit.author_name} at #{commit.committed_date.to_fs(:iso8601)} #{commit.safe_message} \- - - - - \ diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml index 94e2d0377aa..b0573251fd3 100644 --- a/app/views/notify/unapproved_merge_request_email.html.haml +++ b/app/views/notify/unapproved_merge_request_email.html.haml @@ -1,5 +1,5 @@ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -%html{ lang: "en" } +%html{ lang: I18n.locale } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ diff --git a/app/views/organizations/organizations/directory.html.haml b/app/views/organizations/organizations/directory.html.haml deleted file mode 100644 index 1d2fb66112b..00000000000 --- a/app/views/organizations/organizations/directory.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- breadcrumb_title @organization.name -- page_title @organization.name diff --git a/app/views/organizations/organizations/groups_and_projects.html.haml b/app/views/organizations/organizations/groups_and_projects.html.haml new file mode 100644 index 00000000000..8890f4b1ce5 --- /dev/null +++ b/app/views/organizations/organizations/groups_and_projects.html.haml @@ -0,0 +1,3 @@ +- page_title _('Groups and projects') + +#js-organizations-groups-and-projects diff --git a/app/views/organizations/organizations/show.html.haml b/app/views/organizations/organizations/show.html.haml new file mode 100644 index 00000000000..8ba2a3d96ac --- /dev/null +++ b/app/views/organizations/organizations/show.html.haml @@ -0,0 +1,2 @@ +- page_title s_('Organization|Organization overview') +- @skip_current_level_breadcrumb = true diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index 6c6fa32f736..6f0c091dfdb 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -1,6 +1,6 @@ -- button_class = 'btn btn-default gl-button gl-mb-3 gl-mr-3' +- button_class = 'btn btn-default gl-button' -%label.label-bold +%label.label-bold.gl-mb-0 = s_('Profiles|Connected Accounts') %p= s_('Profiles|Select a service to sign in with.') - providers.each do |provider| diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index fec5d2d5ff5..799dfaae8c5 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -14,90 +14,88 @@ - c.with_body do = html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe } -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('Profiles|Two-factor authentication') +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Profiles|Two-factor authentication') + %p.gl-text-secondary + = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).") + %div %p - = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).") - .col-lg-8 - %p - #{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')} + %span.gl-font-weight-bold + #{_('Status')}: + #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')} - if current_user.two_factor_enabled? = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path) do = _('Manage two-factor authentication') - else - .gl-mb-3 - = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do - = _('Enable two-factor authentication') - .col-lg-12 - %hr + = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do + = _('Enable two-factor authentication') - if display_providers_on_profile? - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('Profiles|Service sign-in') - %p - = s_('Profiles|Connect a service for sign-in.') - .col-lg-8 - = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] - .col-lg-12 - %hr + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Profiles|Service sign-in') + %p.gl-text-secondary + = s_('Profiles|Connect a service for sign-in.') + = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities] + - if current_user.can_change_username? - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0.warning-title - = s_('Profiles|Change username') - %p - = s_('Profiles|Changing your username can have unintended side effects.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8 - - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } - #update-username{ data: data } - .col-lg-12 - %hr + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0.warning-title + = s_('Profiles|Change username') + %p.gl-text-secondary + = s_('Profiles|Changing your username can have unintended side effects.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer' + - data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) } + #update-username{ data: data } - if prevent_delete_account? - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0.danger-title - = s_('Profiles|Delete account') - .col-lg-8 - %p - = s_('Profiles|Account deletion is not allowed by your administrator.') + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0.danger-title + = s_('Profiles|Delete account') + %p.gl-text-secondary + = s_('Profiles|Account deletion is not allowed by your administrator.') - else - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0.danger-title - = s_('Profiles|Delete account') - .col-lg-8 - - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) - %p - = s_('Profiles|Deleting an account has the following effects:') - = render 'users/deletion_guidance', user: current_user - - -# Delete button here - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-mt-0.danger-title = s_('Profiles|Delete account') + - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user) + %p.gl-text-secondary + = s_('Profiles|Deleting an account has the following effects:') + = render 'users/deletion_guidance', user: current_user - #delete-account-modal{ data: { action_url: user_registration_path, - confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), - username: current_user.username } } + -# Delete button here + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do + = s_('Profiles|Delete account') + + #delete-account-modal{ data: { action_url: user_registration_path, + confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), + username: current_user.username } } + - else + - if current_user.solo_owned_groups.present? + %p + = s_('Profiles|Your account is currently an owner in these groups:') + %ul + - current_user.solo_owned_groups.each do |group| + %li= group.name + %p + = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') + - elsif !current_user.can_remove_self? + %p + = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} + %p + = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe} - else - - if current_user.solo_owned_groups.present? - %p - = s_('Profiles|Your account is currently an owner in these groups:') - %strong= current_user.solo_owned_groups.map(&:name).join(', ') - %p - = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - - elsif !current_user.can_remove_self? - %p - = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe} - %p - = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe} - - else - %p - = s_("Profiles|You don't have access to delete this user.") -.gl-mb-3 + %p + = s_("Profiles|You don't have access to delete this user.") diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index 9ec8d694dac..e91c28e6e84 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -27,9 +27,5 @@ - unless is_current_session .float-right - = link_to(revoke_session_path(active_session), - { data: { confirm: _('Are you sure? The device will be signed out of GitLab and all remember me tokens revoked.') }, - method: :delete, - class: "gl-button btn btn-danger gl-ml-3" }) do - %span.sr-only= _('Revoke') + = link_button_to revoke_session_path(active_session), data: { confirm: _('Are you sure? The device will be signed out of GitLab and all remember me tokens revoked.'), confirm_btn_variant: :danger }, method: :delete, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do = _('Revoke') diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index 1952655937e..baca9559e08 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -1,16 +1,15 @@ - page_title _('Active Sessions') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.') - .col-lg-8 - .gl-mb-3 +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.') - = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c| - - c.with_body do - %ul.list-group.list-group-flush - = render partial: 'profiles/active_sessions/active_session', collection: @sessions + = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c| + - c.with_body do + %ul.list-group.list-group-flush + = render partial: 'profiles/active_sessions/active_session', collection: @sessions diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 44cfbc1f74f..d47f1ea7c25 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,11 +1,12 @@ - page_title _('Authentication log') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('This is a security log of authentication events involving your account.') - .col-lg-8 - = render 'event_table', events: @events +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = _('This is a security log of authentication events involving your account.') + + = render 'event_table', events: @events diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml index afc3894c23b..0ac8ede3c62 100644 --- a/app/views/profiles/chat_names/_chat_name.html.haml +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -10,4 +10,4 @@ = _('Never') %td - = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' } + = link_button_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'float-right', aria: { label: _('Remove') }, data: { confirm: _('Are you sure you want to remove this nickname?'), confirm_btn_variant: 'danger' }, variant: :danger diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 264ee040d7d..7a63fc30d9c 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -2,29 +2,27 @@ - @hide_search_settings = true - @force_desktop_expanded_sidebar = true -.row.gl-mt-5.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('You can see your chat accounts.') +.settings-section.js-search-settings-section.gl-mt-3 + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title - .col-lg-8 - %h5.gl-mt-0 - = sprintf(_('Active chat names (%{count})'), { count: @chat_names.size }) + %h5.gl-font-lg.gl-mt-0 + = sprintf(_('Active chat names (%{count})'), { count: @chat_names.size }) - - if @chat_names.present? - .table-responsive - %table.table - %thead - %tr - %th= _('Team domain') - %th= _('Nickname') - %th= _('Last used') - %th - %tbody - = render @chat_names + - if @chat_names.present? + .table-responsive + %table.table + %thead + %tr + %th= _('Team domain') + %th= _('Nickname') + %th= _('Last used') + %th + %tbody + = render @chat_names - - else - .settings-message.text-center - = _("You don't have any active chat names.") + - else + .gl-text-secondary.settings-message + = _("You don't have any active chat names.") diff --git a/app/views/profiles/comment_templates/index.html.haml b/app/views/profiles/comment_templates/index.html.haml index dd5b43aa802..0692f5d8ebb 100644 --- a/app/views/profiles/comment_templates/index.html.haml +++ b/app/views/profiles/comment_templates/index.html.haml @@ -1,10 +1,11 @@ -- page_title _('Comment Templates') +- page_title _('Comment templates') -#js-comment-templates-root.row.gl-mt-5{ data: { base_path: profile_comment_templates_path } } - .col-lg-4 - %h4.gl-mt-0 - = page_title - %p - = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.') - .col-lg-8 - = gl_loading_icon(size: 'lg') +#js-comment-templates-root.settings-section.gl-mt-3{ data: { base_path: profile_comment_templates_path } } + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.') + + = gl_loading_icon(size: 'lg') diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index c16f3c3b12b..743c26260e4 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,24 +1,26 @@ - page_title _('Emails') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('Control emails linked to your account') - .col-lg-8 - %h4.gl-mt-0 - = _('Add email address') +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('Add email address') + %p.gl-text-secondary + = _('Control emails linked to your account') + %div = gitlab_ui_form_for 'email', url: profile_emails_path do |f| .form-group = f.label :email, _('Email'), class: 'label-bold' = f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' } .gl-mt-3 = f.submit _('Add email address'), data: { qa_selector: 'add_email_address_button' }, pajamas_button: true - %hr - %h4.gl-mt-0 - = _('Linked emails (%{email_count})') % { email_count: @emails.load.size } + +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('Linked emails (%{email_count})') % { email_count: @emails.load.size } .account-well.gl-mb-3 %ul %li @@ -59,8 +61,6 @@ .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3 - unless email.confirmed? - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" - = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default' + = link_button_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, size: :small - = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger' do - %span.sr-only= _('Remove') - = sprite_icon('remove') + = link_button_to nil, profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, variant: :danger, size: :small, icon: 'remove', 'aria-label': _('Remove') diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index d52b16814c0..d8b8dda29dc 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -19,9 +19,6 @@ .float-right %span.key-created-at = html_escape(s_('Profiles|Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) } - = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-icon btn-danger gl-ml-3" do - %span.sr-only= _('Remove') - = sprite_icon('remove') - = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "gl-button btn btn-danger gl-ml-3" do - %span.sr-only= _('Revoke') + = link_button_to nil, profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: 'gl-ml-3', variant: :danger, icon: 'remove', 'aria-label': _('Remove') + = link_button_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: 'gl-ml-3', variant: :danger, 'aria-label': _('Revoke') do = _('Revoke') diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index b21a4da16b9..2dfd6c7860f 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -2,21 +2,25 @@ - add_page_specific_style 'page_bundles/profile' - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('GPG keys allow you to verify signed commits.') - .col-lg-8 - %h5.gl-mt-0 - = _('Add a GPG key') - %p.profile-settings-content - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') } - = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more.%{help_link_end}').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } - = render 'form' - %hr - %h5 - = _('Your GPG keys (%{count})') % { count: @gpg_keys.count } - .gl-mb-3 - = render 'key_table' +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = _('GPG keys allow you to verify signed commits.') + + %h5.gl-font-lg.gl-mt-0 + = _('Add a GPG key') + %p + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') } + = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } + = render 'form' + +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('Your GPG keys (%{count})') % { count: @gpg_keys.count } + .gl-mb-3 + = render 'key_table' diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 4f3d97fb90c..f1d5a127728 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -14,13 +14,13 @@ %strong= ssh_key_usage_types.invert[@key.usage_type] %li %span.light= _('Created on:') - %strong= @key.created_at.to_s(:medium) + %strong= @key.created_at.to_fs(:medium) %li %span.light= _('Expires:') - %strong= @key.expires_at.try(:to_s, :medium) || _('Never') + %strong= @key.expires_at&.to_fs(:medium) || _('Never') %li %span.light= _('Last used on:') - %strong= @key.last_used_at.try(:to_s, :medium) || _('Never') + %strong= @key.last_used_at&.to_fs(:medium) || _('Never') .col-md-8 = form_errors(@key, type: 'key') unless @key.valid? diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index e7c0cf813b5..c2e65dcc8ef 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -2,27 +2,27 @@ - add_page_specific_style 'page_bundles/profile' - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('SSH keys allow you to establish a secure connection between your computer and GitLab.') - %br - %h4.gl-mt-0 - = _('SSH Fingerprints') - %p - - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url } - = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe } - .col-lg-8 - %h5.gl-mt-0 - = _('Add an SSH key') - %p.profile-settings-content - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') } - = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more.%{help_link_end}').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } - = render 'form' - %hr - %h5 - = _('Your SSH keys (%{count})') % { count: @keys.count } - .gl-mb-3 - = render 'key_table' +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = _('SSH keys allow you to establish a secure connection between your computer and GitLab.') + - config_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_instance_configuration_url } + = html_escape(s_('SSH fingerprints verify that the client is connecting to the correct host. Check the %{config_link_start}current instance configuration%{config_link_end}.')) % { config_link_start: config_link_start, config_link_end: '</a>'.html_safe } + + %h5.gl-font-lg.gl-mt-0 + = _('Add an SSH key') + %p + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') } + = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } + = render 'form' + +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('Your SSH keys (%{count})') % { count: @keys.count } + .gl-mb-3 + = render 'key_table' diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml index cd7a7ced1d4..60f366f8878 100644 --- a/app/views/profiles/notifications/_email_settings.html.haml +++ b/app/views/profiles/notifications/_email_settings.html.haml @@ -1,7 +1,6 @@ - form = local_assigns.fetch(:form) -.form-group - .js-notification-email-listbox-input{ data: { label: _('Notification Email'), name: 'user[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Use primary email (%{email})') % { email: @user.email }, value: @user.notification_email, disabled: local_assigns.fetch(:email_change_disabled, nil) } } - .help-block - = local_assigns.fetch(:help_text, nil) +.js-notification-email-listbox-input.gl-mb-3{ data: { label: _('Global notification email'), name: 'user[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Use primary email (%{email})') % { email: @user.email }, value: @user.notification_email, disabled: local_assigns.fetch(:email_change_disabled, nil) } } +.help-block + = local_assigns.fetch(:help_text, nil) .form-group = form.gitlab_ui_checkbox_component :email_opted_in, _('Receive product marketing emails') diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index 898762ca78a..1878634e56c 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -1,17 +1,15 @@ - emails_disabled = group.emails_disabled? -.gl-responsive-table-row.notification-list-item - .table-section.section-40 - %span.notification.gl-mr-2 +.notification-list-item.gl-md-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-px-3.gl-py-4 + .gl-mb-2.gl-md-mb-0 + %span.gl-mr-2 = notification_icon(notification_icon_level(setting, emails_disabled)) - %span.str-truncated + %span = link_to group.name, group_path(group) - .table-section.section-30.text-right + .gl-display-flex.gl-gap-3.gl-flex-wrap - if setting - .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, container_class: 'gl-mr-3', show_label: "true" } } - - .table-section.section-30 + .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, group_id: group.id, show_label: "true" } } = form_for setting, url: profile_group_notifications_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f| - .js-notification-email-listbox-input{ data: { name: 'notification_setting[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Global notification email') , value: setting.notification_email } } + .js-notification-email-listbox-input{ data: { name: 'notification_setting[notification_email]', emails: @user.public_verified_emails.to_json, empty_value_text: _('Global notification email') , value: setting.notification_email, placement: 'right' } } diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml index e6953d1b32e..955449f0ba1 100644 --- a/app/views/profiles/notifications/_project_settings.html.haml +++ b/app/views/profiles/notifications/_project_settings.html.haml @@ -1,12 +1,13 @@ - emails_disabled = project.emails_disabled? -%li.notification-list-item - %span.notification.gl-mr-2 - = notification_icon(notification_icon_level(setting, emails_disabled)) +.notification-list-item.gl-md-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-px-3.gl-py-4 + .gl-mb-2.gl-md-mb-0 + %span.gl-mr-2 + = notification_icon(notification_icon_level(setting, emails_disabled)) - %span.str-truncated - = link_to_project(project) + %span + = link_to_project(project) - .float-right + .gl-display-flex.gl-gap-3.gl-flex-wrap - if setting .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(setting).to_json, notification_level: setting.level, project_id: project.id, container_class: 'gl-mr-3', show_label: "true" } } diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 06d37787d2e..2c7ef2b7e0e 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -2,55 +2,59 @@ - page_title _('Notifications') - @force_desktop_expanded_sidebar = true -%div - - if @user.errors.any? - = render Pajamas::AlertComponent.new(variant: :danger) do |c| - - c.with_body do - %ul - - @user.errors.full_messages.each do |msg| - %li= msg - - = hidden_field_tag :notification_type, 'global' - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 +- if @user.errors.any? + = render Pajamas::AlertComponent.new(variant: :danger) do |c| + - c.with_body do + %ul + - @user.errors.full_messages.each do |msg| + %li= msg + += hidden_field_tag :notification_type, 'global' +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = page_title - %p - = _('You can specify notification level per group or per project.') - %p - = _('By default, all projects and groups will use the global notifications setting.') - .col-lg-8 - %h5.gl-mt-0 - = _('Global notification settings') - - = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f| - = render_if_exists 'profiles/notifications/email_settings', form: f - - = label_tag :global_notification_level, _('Global notification level'), class: "label-bold" - %br - .clearfix - .form-group.float-left.global-notification-setting - - if @global_notification_setting - .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } } - - .clearfix - - = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put do |f| - .form-group - = f.gitlab_ui_checkbox_component :notified_of_own_activity, _('Receive notifications about your own activity') - - %hr - %h5 - = _('Groups (%{count})') % { count: @user_groups.total_count } - %div - - @group_notifications.each do |setting| - = render 'group_settings', setting: setting, group: setting.source - = paginate @user_groups, theme: 'gitlab' - %h5 - = _('Projects (%{count})') % { count: @project_notifications.size } - %p.account-well - = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') - .gl-mb-3 - %ul.bordered-list + %p.gl-text-secondary + = _('You can specify notification level per group or per project.') + + .gl-mt-0 + = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f| + = render_if_exists 'profiles/notifications/email_settings', form: f + + = label_tag :global_notification_level, _('Global notification level'), class: "label-bold gl-mb-0" + .gl-text-secondary.gl-mb-3 + = _('By default, all projects and groups use the global notifications setting.') + + .form-group.global-notification-setting.gl-mb-3 + - if @global_notification_setting + .js-vue-notification-dropdown{ data: { dropdown_items: notification_dropdown_items(@global_notification_setting).to_json, notification_level: @global_notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), show_label: 'true' } } + + = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put do |f| + .form-group + = f.gitlab_ui_checkbox_component :notified_of_own_activity, _('Receive notifications about your own activity') + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + %h3.gl-new-card-title + = _('Groups (%{count})') % { count: @user_groups.total_count } + - c.with_body do + - if @user_groups.total_count > 0 + - @group_notifications.each do |setting| + = render 'group_settings', setting: setting, group: setting.source + = paginate @user_groups, theme: 'gitlab' + - else + .gl-new-card-empty.gl-px-3.gl-py-4= _("You do not belong to any groups yet.") + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header gl-display-block'}, body_options: { class: 'gl-new-card-body' }) do |c| + - c.with_header do + %h3.gl-new-card-title + = _('Projects (%{count})') % { count: @project_notifications.size } + .gl-new-card-description + = _('To specify the notification level per project of a group you belong to, visit the project page and change the notification level there.') + - c.with_body do + - if @project_notifications.size > 0 - @project_notifications.each do |setting| = render 'project_settings', setting: setting, project: setting.source + - else + .gl-new-card-empty.gl-px-3.gl-py-4= _("You do not belong to any projects yet.") diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 4fdf80c1eb1..4848a9dc595 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -2,36 +2,34 @@ - page_title _('Password') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = _('After a successful password update, you will be redirected to the login page where you can log in with your new password.') - .col-lg-8 - %h5.gl-mt-0 - - if @user.password_automatically_set - = _('Change your password') - - else - = _('Change your password or recover your current one') - = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| - = form_errors(@user) +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + - if @user.password_automatically_set + = _('Change your password.') + - else + = _('Change your password or recover your current one.') + = gitlab_ui_form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| + = form_errors(@user) - - unless @user.password_automatically_set? - .form-group - = f.label :password, _('Current password'), class: 'label-bold' - = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } - %p.form-text.text-muted - = _('You must provide your current password in order to change it.') - .form-group - = f.label :new_password, _('New password'), class: 'label-bold' - = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' } - = render_if_exists 'shared/password_requirements_list' + - unless @user.password_automatically_set? .form-group - = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' - = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } - .gl-mt-3.gl-mb-3 - = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true - - unless @user.password_automatically_set? - = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do - = _('I forgot my password') + = f.label :password, _('Current password'), class: 'label-bold' + = f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'current_password_field' } + %p.form-text.text-muted + = _('You must provide your current password in order to change it.') + .form-group + = f.label :new_password, _('New password'), class: 'label-bold' + = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { qa_selector: 'new_password_field' } + = render_if_exists 'shared/password_requirements_list' + .form-group + = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold' + = f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { qa_selector: 'confirm_password_field' } + .gl-mt-3.gl-mb-3 + = f.submit _('Save password'), class: "gl-mr-3", data: { qa_selector: 'save_password_button' }, pajamas_button: true + - unless @user.password_automatically_set? + = render Pajamas::ButtonComponent.new(href: reset_profile_password_path, variant: :link, method: :put) do + = _('I forgot my password') diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 57c0badd033..5020f6cbb22 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -4,27 +4,26 @@ - type_plural = _('personal access tokens') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.') - %p - = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.') - = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.') +.settings-section.settings-section-no-bottom.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.') + = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.') + = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.') - .col-lg-8 - #js-new-access-token-app{ data: { access_token_type: type } } + #js-new-access-token-app{ data: { access_token_type: type } } - = render 'shared/access_tokens/form', - ajax: true, - type: type, - path: profile_personal_access_tokens_path, - token: @personal_access_token, - scopes: @scopes, - help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') + = render 'shared/access_tokens/form', + ajax: true, + type: type, + path: profile_personal_access_tokens_path, + token: @personal_access_token, + scopes: @scopes, + help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } } + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } } #js-tokens-app{ data: { tokens_data: tokens_app_data } } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index a085840ee84..e5e7c1dc3f4 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -11,165 +11,152 @@ = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename = gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f| - .row.gl-mt-3.js-preferences-form.js-search-settings-section - .col-lg-4.application-theme#navigation-theme - %h4.gl-mt-0 - = s_('Preferences|Color theme') + .settings-section.js-preferences-form.js-search-settings-section.application-theme#navigation-theme + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Color theme') + %p.gl-text-secondary + = s_('Preferences|Customize the color of GitLab.') + - if show_super_sidebar? %p - = s_('Preferences|Customize the color of GitLab.') - - if show_super_sidebar? - %p - = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.') - .col-lg-8.application-theme - .row - - Gitlab::Themes.each do |theme| - %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center - .preview{ class: theme.css_class } - = f.gitlab_ui_radio_component :theme_id, theme.id, - theme.name, - radio_options: { checked: user_theme_id == theme.id } + = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.') + .application-theme.row + - Gitlab::Themes.each do |theme| + %label.col-6.col-sm-4.col-md-3.col-xl-2.gl-mb-5 + .preview{ class: theme.css_class } + = f.gitlab_ui_radio_component :theme_id, theme.id, + theme.name, + radio_options: { checked: user_theme_id == theme.id } - .col-sm-12 - %hr - - .row.js-preferences-form.js-search-settings-section - .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme - %h4.gl-mt-0 - = s_('Preferences|Syntax highlighting theme') - %p - = s_('Preferences|Customize the appearance of the syntax.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8.syntax-theme + .settings-section.js-preferences-form.js-search-settings-section#syntax-highlighting-theme + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Syntax highlighting theme') + %p.gl-text-secondary + = s_('Preferences|Customize the appearance of the syntax.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer' + .syntax-theme.row - Gitlab::ColorSchemes.each do |scheme| - = label_tag do + %label.col-6.col-sm-4.col-md-3.col-lg-auto.gl-mb-5 .preview= image_tag "#{scheme.css_class}-scheme-preview.png" = f.gitlab_ui_radio_component :color_scheme_id, scheme.id, - scheme.name, - radio_options: { checked: user_color_schema_id == scheme.id } + scheme.name, + radio_options: { checked: user_color_schema_id == scheme.id } - .col-sm-12 - %hr + .settings-section.js-preferences-form.js-search-settings-section#diffs-colors + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Diff colors') + %p.gl-text-secondary + = s_('Preferences|Customize the colors of removed and added lines in diffs.') + .form-group + #js-profile-preferences-diffs-colors-app{ data: user_diffs_colors } - .row.js-preferences-form.js-search-settings-section - .col-lg-4.profile-settings-sidebar#diffs-colors - %h4.gl-mt-0 - = s_('Preferences|Diff colors') - %p - = s_('Preferences|Customize the colors of removed and added lines in diffs.') - .col-lg-8 - .form-group - #js-profile-preferences-diffs-colors-app{ data: user_diffs_colors } + .settings-section.js-preferences-form.js-search-settings-section#behavior + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Behavior') + %p.gl-text-secondary + = s_('Preferences|Customize the behavior of the system layout and default views.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer' + .form-group + = f.label :layout, class: 'label-bold' do + = s_('Preferences|Layout width') + = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' + .form-text.text-muted + = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } + .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, fluid_width: true.to_s } } - .col-sm-12 - %hr + = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific - .row.js-preferences-form.js-search-settings-section - .col-lg-4.profile-settings-sidebar#behavior - %h4.gl-mt-0 - = s_('Preferences|Behavior') - %p - = s_('Preferences|Customize the behavior of the system layout and default views.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8 - .form-group - = f.label :layout, class: 'label-bold' do - = s_('Preferences|Layout width') - = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' - .form-text.text-muted - = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } - .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } } + .form-group + = f.label :project_view, class: 'label-bold' do + = s_('Preferences|Project overview content') + = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select' + .form-text.text-muted + = s_('Preferences|Choose what content you want to see on a project’s overview page.') + .form-group + = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview') + .form-group + = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE') + .form-group + = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs') + .form-group + = f.gitlab_ui_checkbox_component :view_diffs_file_by_file, + s_("Preferences|Show one file at a time on merge request's Changes tab"), + help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") + .form-group + - supported_characters = %w(" ' ` ( [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ') + = f.gitlab_ui_checkbox_component :markdown_surround_selection, + s_('Preferences|Surround text selection when typing quotes or brackets'), + help_text: sprintf(s_("Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe + .form-group + = f.gitlab_ui_checkbox_component :markdown_automatic_lists, + s_('Preferences|Automatically add new list items'), + help_text: html_escape(s_('Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below.')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } - = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific + .form-group + = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' + = f.number_field :tab_width, + class: 'form-control gl-form-input', + min: Gitlab::TabWidth::MIN, + max: Gitlab::TabWidth::MAX, + required: true + .form-text.text-muted + = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX } - .form-group - = f.label :project_view, class: 'label-bold' do - = s_('Preferences|Project overview content') - = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select' - .form-text.text-muted - = s_('Preferences|Choose what content you want to see on a project’s overview page.') - .form-group - = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview') - .form-group - = f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE') - .form-group - = f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs') - .form-group - = f.gitlab_ui_checkbox_component :view_diffs_file_by_file, - s_("Preferences|Show one file at a time on merge request's Changes tab"), - help_text: s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.") - .form-group - - supported_characters = %w(" ' ` ( [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ') - = f.gitlab_ui_checkbox_component :markdown_surround_selection, - s_('Preferences|Surround text selection when typing quotes or brackets'), - help_text: sprintf(s_("Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe - .form-group - = f.gitlab_ui_checkbox_component :markdown_automatic_lists, - s_('Preferences|Automatically add new list items'), - help_text: html_escape(s_('Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below.')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } + .settings-section.js-preferences-form.js-search-settings-section#localization + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('Localization') + %p.gl-text-secondary + = _('Customize language and region related settings.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer' + .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language, block: true.to_s, fluid_width: true.to_s } } + %p.gl-mt-n5 + = link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do + = _("Help translate GitLab into your language") + %span{ aria: { label: _('Open new window') } } + = sprite_icon('external-link') + .form-group + = f.label :first_day_of_week, class: 'label-bold' do + = _('First day of the week') + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select' - .form-group - = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' - = f.number_field :tab_width, - class: 'form-control gl-form-input', - min: Gitlab::TabWidth::MIN, - max: Gitlab::TabWidth::MAX, - required: true - .form-text.text-muted - = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX } - - .col-sm-12 - %hr - .row.js-preferences-form.js-search-settings-section - .col-lg-4.profile-settings-sidebar#localization - %h4.gl-mt-0 - = _('Localization') - %p - = _('Customize language and region related settings.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8 - .js-listbox-input{ data: { label: _('Language'), description: s_('Preferences|This feature is experimental and translations are not yet complete.'), name: 'user[preferred_language]', items: language_choices.to_json, value: current_user.preferred_language } } - %p.gl-mt-n5 - = link_to help_page_url('development/i18n/translation'), class: 'text-nowrap', target: '_blank', rel: 'noopener noreferrer' do - = _("Help translate GitLab into your language") - %span{ aria: { label: _('Open new window') } } - = sprite_icon('external-link') - .form-group - = f.label :first_day_of_week, class: 'label-bold' do - = _('First day of the week') - = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select' - .col-sm-12 - %hr - .row.js-preferences-form.js-search-settings-section - .col-lg-4.profile-settings-sidebar#time-preferences - %h4.gl-mt-0 - = s_('Preferences|Time preferences') - %p - = s_('Preferences|Configure how dates and times display for you.') + .settings-section.js-preferences-form.js-search-settings-section#time-preferences + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Time preferences') + %p.gl-text-secondary + = s_('Preferences|Configure how dates and times display for you.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer' + .form-group + = f.gitlab_ui_checkbox_component :time_display_relative, + s_('Preferences|Use relative times'), + help_text: s_('Preferences|For example: 30 minutes ago.') + - if Feature.enabled?(:disable_follow_users, @user) + .settings-section.js-preferences-form.js-search-settings-section#enabled_following + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_('Preferences|Enable follow users feature') + %p.gl-text-secondary + = s_('Preferences|Turns on or off the ability to follow or be followed by other users.') = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8 + = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer' .form-group - = f.gitlab_ui_checkbox_component :time_display_relative, - s_('Preferences|Use relative times'), - help_text: s_('Preferences|For example: 30 minutes ago.') - - if Feature.enabled?(:disable_follow_users, @user) - .row.js-preferences-form.js-search-settings-section - .col-sm-12 - %hr - .col-lg-4.profile-settings-sidebar#enabled_following - %h4.gl-mt-0 - = s_('Preferences|Enable follow users feature') - %p - = s_('Preferences|Turns on or off the ability to follow or be followed by other users.') - = succeed '.' do - = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer' - .col-lg-8 - .form-group - = f.gitlab_ui_checkbox_component :enabled_following, - s_('Preferences|Enable follow users') + = f.gitlab_ui_checkbox_component :enabled_following, + s_('Preferences|Enable follow users') = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f = render_if_exists 'profiles/preferences/zoekt_settings', form: f diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 1a932ed7b35..ebdea5786f5 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -7,50 +7,50 @@ - if Feature.enabled?(:edit_user_profile_vue, current_user) .js-user-profile - else - = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_("Profiles|Public avatar") - %p - - if @user.avatar? - - if gravatar_enabled? - = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } - - else - = s_("Profiles|You can change your avatar here") - - else - - if gravatar_enabled? - = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } - - else - = s_("Profiles|You can upload your avatar here") - - if current_appearance&.profile_image_guidelines? - .md - = brand_profile_image_guidelines - .col-lg-8 - .avatar-image - = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') - %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .gl-display-flex.gl-align-items-center.gl-my-3 - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do - = s_("Profiles|Choose file...") - %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") - = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' - .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") + = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_("Profiles|Public avatar") + %p.gl-text-secondary - if @user.avatar? - = render Pajamas::ButtonComponent.new(variant: :danger, - category: :secondary, - href: profile_avatar_path, - button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, - method: :delete) do - = s_("Profiles|Remove avatar") - .col-lg-12 - %hr - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0= s_("Profiles|Current status") - %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") - .col-lg-8 + - if gravatar_enabled? + = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can change your avatar here") + - else + - if gravatar_enabled? + = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can upload your avatar here") + - if current_appearance&.profile_image_guidelines? + .md + = brand_profile_image_guidelines + .avatar-image + = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do + = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5') + %h5.gl-mt-0= s_("Profiles|Upload new avatar") + .gl-display-flex.gl-align-items-center.gl-my-3 + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do + = s_("Profiles|Choose file...") + %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") + = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' + .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") + - if @user.avatar? + = render Pajamas::ButtonComponent.new(variant: :danger, + category: :secondary, + href: profile_avatar_path, + button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } }, + method: :delete) do + = s_("Profiles|Remove avatar") + + .settings-section.js-search-settings-section.gl-border-t.gl-pt-6 + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= s_("Profiles|Current status") + %p.gl-text-secondary= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") + .gl-max-w-80 #js-user-profile-set-status-form = f.fields_for :status, @user.status do |status_form| = status_form.hidden_field :emoji, data: { js_name: 'emoji' } @@ -59,121 +59,117 @@ = status_form.hidden_field :clear_status_after, value: user_clear_status_at(@user), data: { js_name: 'clearStatusAfter' } - .col-lg-12 - %hr - .row.user-time-preferences.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0= s_("Profiles|Time settings") - %p= s_("Profiles|Set your local time zone.") - .col-lg-8 - = f.label :user_timezone, _("Time zone") - .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } - .col-lg-12 - %hr - .row.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_("Profiles|Main settings") - %p - = s_("Profiles|This information will appear on your profile.") - - if current_user.ldap_user? - = s_("Profiles|Some options are unavailable for LDAP accounts") - .col-lg-8 - .row - .form-group.gl-form-group.col-md-9.rspec-full-name - = render 'profiles/name', form: f, user: @user - .form-group.gl-form-group.col-md-3 - = f.label :id, s_('Profiles|User ID') - = f.text_field :id, class: 'gl-form-input form-control', readonly: true - .form-group.gl-form-group - = f.label :pronouns, s_('Profiles|Pronouns') - = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Enter your pronouns to let people know how to refer to you.") - .form-group.gl-form-group - = f.label :pronunciation, s_('Profiles|Pronunciation') - = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") - = render_if_exists 'profiles/extra_settings', form: f - = render_if_exists 'profiles/email_settings', form: f - .form-group.gl-form-group - = f.label :skype - = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") - .form-group.gl-form-group - = f.label :linkedin - = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") - .form-group.gl-form-group - = f.label :twitter - = f.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 = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer" - - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end)) - - min_discord_length = 17 - - max_discord_length = 20 - = f.label :discord - = f.text_field :discord, - class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', - placeholder: s_("Profiles|User ID"), - data: { min_length: min_discord_length, - min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, - max_length: max_discord_length, - max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, - allow_empty: true} - %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 - = f.label :location, s_('Profiles|Location') - - if @user.read_only_attribute?(:location) - = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true - %small.form-text.text-gl-muted - = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - - else - = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") - .form-group.gl-form-group - = f.label :job_title, s_('Profiles|Job title') - = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' - .form-group.gl-form-group - = f.label :organization, s_('Profiles|Organization') - = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' - %small.form-text.text-gl-muted - = s_("Profiles|Who you represent or work for.") - .form-group.gl-form-group - = f.label :bio, s_('Profiles|Bio') - = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 + .settings-section.user-time-preferences.js-search-settings-section.gl-border-t.gl-pt-6 + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= s_("Profiles|Time settings") + %p.gl-text-secondary= s_("Profiles|Set your local time zone.") + = f.label :user_timezone, _("Time zone") + .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } } + + .settings-section.js-search-settings-section.gl-border-t.gl-pt-6 + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = s_("Profiles|Main settings") + %p.gl-text-secondary + = s_("Profiles|This information will appear on your profile.") + - if current_user.ldap_user? + = s_("Profiles|Some options are unavailable for LDAP accounts") + .form-group.gl-form-group.rspec-full-name.gl-max-w-80 + = render 'profiles/name', form: f, user: @user + .form-group.gl-form-group.gl-md-form-input-lg + = f.label :id, s_('Profiles|User ID') + = f.text_field :id, class: 'gl-form-input form-control', readonly: true + .form-group.gl-form-group + = f.label :pronouns, s_('Profiles|Pronouns') + = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Enter your pronouns to let people know how to refer to you.") + .form-group.gl-form-group + = f.label :pronunciation, s_('Profiles|Pronunciation') + = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Enter how your name is pronounced to help people address you correctly.") + = render_if_exists 'profiles/extra_settings', form: f + = render_if_exists 'profiles/email_settings', form: f + .form-group.gl-form-group + = f.label :skype + = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username") + .form-group.gl-form-group + = f.label :linkedin + = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") + .form-group.gl-form-group + = f.label :twitter + = f.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 = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer" + - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end)) + - min_discord_length = 17 + - max_discord_length = 20 + = f.label :discord + = f.text_field :discord, + class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length', + placeholder: s_("Profiles|User ID"), + data: { min_length: min_discord_length, + min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length }, + max_length: max_discord_length, + max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length }, + allow_empty: true} + %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 + = f.label :location, s_('Profiles|Location') + - if @user.read_only_attribute?(:location) + = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true %small.form-text.text-gl-muted - = s_("Profiles|Tell us about yourself in fewer than 250 characters.") - %hr + = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } + - else + = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country") + .form-group.gl-form-group + = f.label :job_title, s_('Profiles|Job title') + = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg' + .form-group.gl-form-group + = f.label :organization, s_('Profiles|Organization') + = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg' + %small.form-text.text-gl-muted + = s_("Profiles|Who you represent or work for.") + .form-group.gl-form-group.gl-mb-6.gl-max-w-80 + = f.label :bio, s_('Profiles|Bio') + = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250 + %small.form-text.text-gl-muted + = s_("Profiles|Tell us about yourself in fewer than 250 characters.") + .gl-border-t.gl-pt-6 %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label + %legend.col-form-label = _('Private profile') - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.") - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe } %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label + %legend.col-form-label = s_("Profiles|Private contributions") = f.gitlab_ui_checkbox_component :include_private_contributions, s_('Profiles|Include private contributions on your profile'), help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") - %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label + %fieldset.form-group.gl-form-group.gl-mb-0 + %legend.col-form-label = s_("Profiles|Achievements") = f.gitlab_ui_checkbox_component :achievements_enabled, s_('Profiles|Display achievements on your profile') - .row.js-hide-when-nothing-matches-search - .col-lg-12 - %hr - = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true - = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do - = s_('TagsPage|Cancel') + + .js-hide-when-nothing-matches-search.settings-sticky-footer + = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true + = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do + = s_('TagsPage|Cancel') #password-prompt-modal diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 461164e1ae9..42297a0cf3d 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -55,7 +55,8 @@ = label_tag :pin_code, _('Enter verification code'), class: "label-bold" = text_field_tag :pin_code, nil, autocomplete: 'off', inputmode: 'numeric', class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } .gl-mt-3 - = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { data: { qa_selector: 'register_2fa_app_button' } }) do + = _('Register with two-factor app') %hr @@ -101,7 +102,7 @@ - else %span.gl-text-gray-500 = _("no name set") - %td= registration[:created_at].to_date.to_s(:medium) + %td= registration[:created_at].to_date.to_fs(:medium) %td = render Pajamas::ButtonComponent.new(variant: :danger, href: registration[:delete_path], diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 118f6fb1296..00da6c73081 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -3,8 +3,7 @@ .nav-block.d-none.d-sm-flex.activities.gl-static = render 'shared/event_filter' .controls.gl-display-flex - = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do - = sprite_icon('rss', css_class: 'gl-icon') + = link_button_to nil, project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'd-none d-sm-inline-flex has-tooltip', icon: 'rss' - if is_project_overview && can?(current_user, :download_code, @project) .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' diff --git a/app/views/projects/_customize_workflow.html.haml b/app/views/projects/_customize_workflow.html.haml index ded43a34b48..da4b257f224 100644 --- a/app/views/projects/_customize_workflow.html.haml +++ b/app/views/projects/_customize_workflow.html.haml @@ -5,4 +5,4 @@ %p Get started with GitLab by enabling features that work best for your project. From issues and wikis, to merge requests and pipelines, GitLab can help manage your workflow from idea to production! - if can?(current_user, :admin_project, @project) - = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-confirm" + = link_button_to "Get started", edit_project_path(@project), variant: :confirm diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 97f5cdb54e5..3ef2c722e98 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -20,10 +20,12 @@ %li= _('Webhooks') %li= _('Any encrypted tokens') - if project.export_status == :finished - = link_to _('Download export'), download_export_project_path(project), - rel: 'nofollow', download: '', method: :get, class: "btn gl-button btn-default", data: { qa_selector: 'download_export_link' } - = link_to _('Generate new export'), generate_new_export_project_path(project), - method: :post, class: "btn gl-button btn-default" + = render Pajamas::ButtonComponent.new(href: download_export_project_path(project), + method: :get, + button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do + = _('Download export') + = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do + = _('Generate new export') - else - = link_to _('Export project'), export_project_path(project), - method: :post, class: "btn gl-button btn-default", data: { qa_selector: 'export_project_link' } + = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do + = _('Export project') diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml index a4bf72edf12..4ad2c339bcc 100644 --- a/app/views/projects/_find_file_link.html.haml +++ b/app/views/projects/_find_file_link.html.haml @@ -1,2 +1,2 @@ -= link_to project_find_file_path(@project, @ref), class: 'gl-button btn btn-default shortcuts-find-file', rel: 'nofollow' do += link_button_to project_find_file_path(@project, @ref), class: 'shortcuts-find-file', rel: 'nofollow' do = _('Find file') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 9cb5ec39de2..59147138834 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -27,9 +27,7 @@ .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 - if current_user - if current_user.admin? - = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'), - data: {toggle: 'tooltip', placement: 'top', container: 'body'} do - = sprite_icon('admin') + = link_button_to nil, [:admin, @project], icon: 'admin', title: _('View project in admin area'), data: {toggle: 'tooltip', placement: 'top', container: 'body'} - if @notification_setting .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 947a1007fd5..6315c6dc52d 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -43,9 +43,7 @@ - if gitea_import_enabled? %div - = link_to new_import_gitea_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } do - .gl-button-icon - = custom_icon('gitea_logo') + = render Pajamas::ButtonComponent.new(href: new_import_gitea_path(namespace_id: namespace_id), icon: 'gitea', button_options: { class: 'import_gitea js-import-project-btn', data: { platform: 'gitea', **tracking_attrs_data(track_label, 'click_button', 'gitea') } }) do Gitea - if git_import_enabled? diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 6049d1cc110..983b8056358 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -98,4 +98,4 @@ -# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675 = render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label = f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true -= link_to _('Cancel'), @parent_group || dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } += link_button_to _('Cancel'), @parent_group || dashboard_groups_path, data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" } diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml index 85a53edc160..c3d66396256 100644 --- a/app/views/projects/_readme.html.haml +++ b/app/views/projects/_readme.html.haml @@ -24,4 +24,4 @@ distributed with computer software, forming part of its documentation. GitLab will render it here instead of this message. %p - = link_to "Add Readme", @project.add_readme_path, class: 'gl-button btn btn-confirm' + = link_button_to "Add Readme", @project.add_readme_path, variant: :confirm diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 14991ce3824..0a83efdb3b8 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -12,8 +12,8 @@ enabled: "#{@project.service_desk_enabled}", issue_tracker_enabled: "#{@project.project_feature.issues_enabled?}", incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), - custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), - custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}", + service_desk_email: (@project.service_desk_custom_address if @project.service_desk_enabled), + service_desk_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}", selected_template: "#{@project.service_desk_setting&.issue_template_key}", selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 45d0aee4332..e82e0972d82 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -14,4 +14,4 @@ - if can_create_wiki %p = _("Add a homepage to your wiki that contains information about your project and GitLab will display it here instead of this message.") - = link_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', class: "btn gl-button btn-confirm" + = link_button_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', variant: :confirm diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index ccda06c7e4c..ebeeaed7ae9 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -18,10 +18,8 @@ = link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path) .tree-controls< - = link_to download_project_job_artifacts_path(@project, @build), - rel: 'nofollow', download: '', class: 'gl-button btn btn-default download' do - = sprite_icon('download', css_class: 'gl-mr-2') - Download artifacts archive + = link_button_to download_project_job_artifacts_path(@project, @build), rel: 'nofollow', download: '', class: 'download', icon: 'download' do + = _('Download artifacts archive') .tree-content-holder %table.table.tree-table diff --git a/app/views/projects/artifacts/external_file.html.haml b/app/views/projects/artifacts/external_file.html.haml index a014d134e31..67f6ccd5695 100644 --- a/app/views/projects/artifacts/external_file.html.haml +++ b/app/views/projects/artifacts/external_file.html.haml @@ -1,3 +1,4 @@ +- external_url = @blob.external_url(@build) - page_title @path, _('Artifacts'), "#{@build.name} (##{@build.id})", _('Jobs') = render "projects/jobs/header" @@ -8,8 +9,8 @@ %h2= _("You are being redirected away from GitLab") %p= _("This page is hosted on GitLab pages but contains user-generated content and may contain malicious code. Do not accept unless you trust the author and source.") - = link_to @blob.external_url(@project, @build), - @blob.external_url(@project, @build), + = link_to external_url, + external_url, target: '_blank', title: _('Opens in a new window'), rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index 79b13dc861a..417c11ba37a 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -22,14 +22,11 @@ -# only show normal/blame view links for text files - if blob.readable_text? - if blame - = link_to 'Normal view', project_blob_path(@project, @id), - class: 'gl-button btn btn-default' + = link_button_to _('Normal view'), project_blob_path(@project, @id) - else - = link_to 'Blame', project_blame_path(@project, @id), - class: 'gl-button btn btn-default js-blob-blame-link' unless blob.empty? + = link_button_to _('Blame'), project_blame_path(@project, @id), class: 'js-blob-blame-link' unless blob.empty? - = link_to 'History', project_commits_path(@project, @id), - class: 'gl-button btn btn-default' + = link_button_to _('History'), project_commits_path(@project, @id) - = link_to 'Permalink', project_blob_path(@project, - tree_join(@commit.sha, @path)), class: 'gl-button btn btn-default js-data-file-blob-permalink-url' + = link_button_to _('Permalink'), project_blob_path(@project, tree_join(@commit.sha, @path)), + class: 'js-data-file-blob-permalink-url' diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index 3ae7741d24d..f1da9154df9 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -16,6 +16,6 @@ .form-actions = submit_tag _("Create directory"), class: 'btn gl-button btn-confirm' - = link_to _('Cancel'), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal" + = link_button_to _('Cancel'), '#', "data-dismiss" => "modal" = render 'shared/projects/edit_information' diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index adff64fad5a..ae8d230f356 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -11,13 +11,13 @@ = branch.name = clipboard_button(text: branch.name, title: _("Copy branch name")) - if is_default_branch - = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } + = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2' } - if protected_branch?(@project, branch) - = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } } + = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2' } = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch - .block-truncated + .gl-text-truncate - if commit = render 'projects/branches/commit', commit: commit, project: @project - else @@ -28,35 +28,33 @@ .pipeline-status.d-none.d-md-block< - if commit_status - = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5' + = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-3' - elsif show_commit_status - .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5 + .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-3 %svg.s16 - - - if mr_status.present? - .issuable-reference.gl-display-flex.gl-justify-content-end.gl-min-w-10.gl-ml-5.gl-mr-4 - = gl_badge_tag issuable_reference(related_merge_request), - { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) }, - { class: 'gl-mr-2', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } } - - .controls.d-none.d-md-block< - - if mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project) - = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip gl-mr-2!', title: _('New merge request') }) do - = _('New') - - = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-1!' - - - if !is_default_branch - .js-branch-more-actions{ data: { - branch_name: branch.name, - default_branch_name: @repository.root_ref, - can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s, - is_protected_branch: protected_branch?(@project, branch).to_s, - merged: merged.to_s, - compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), - delete_path: project_branch_path(@project, branch.name), - } } - - else - .gl-display-inline-flex.gl-w-7 - + .right-block.gl-display-flex.gl-align-items-center.gl-justify-content-end + .gl-mr-3 + - if mr_status.present? + .issuable-reference.gl-display-flex.gl-justify-content-end.gl-overflow-hidden + = gl_badge_tag issuable_reference(related_merge_request), + { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) }, + { class: 'gl-display-block gl-text-truncate', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body' } } + + - elsif mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project) + = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip', title: _('New merge request') }) do + = _('New') + + = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-2!' + + .gl-w-7 + - if !is_default_branch + .js-branch-more-actions{ data: { + branch_name: branch.name, + default_branch_name: @repository.root_ref, + can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s, + is_protected_branch: protected_branch?(@project, branch).to_s, + merged: merged.to_s, + compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), + delete_path: project_branch_path(@project, branch.name), + } } diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index 6bbd0617598..7662caceb15 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,7 +1,7 @@ -.branch-commit.gl-font-sm.gl-text-gray-500 +.branch-commit.gl-font-sm.gl-text-gray-500.gl-text-truncate = link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha" · - %span.str-truncated + %span = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message gl-text-gray-500!" · %span.gl-text-secondary= time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index a632e29d34f..c01e3677c19 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -7,9 +7,9 @@ - return unless branches.any? -= render Pajamas::CardComponent.new(card_options: {class: 'gl-mt-5 gl-bg-gray-10'}, header_options: {class: 'gl-px-5 gl-py-4 gl-bg-white'}, body_options: {class: 'gl-px-3 gl-py-0'}, footer_options: {class: 'gl-bg-white'}) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }, footer_options: { class: 'gl-new-card-footer' }) do |c| - c.with_header do - %h3.card-title.h5.gl-line-height-24.gl-m-0 + %h3.gl-new-card-title.h5 = panel_title - c.with_body do %ul.content-list.branches-list.all-branches{ data: { qa_selector: 'all_branches_container' } } diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 8992753c676..c03de6646cf 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -1,8 +1,6 @@ - add_page_specific_style 'page_bundles/branches' - page_title _('Branches') - add_to_breadcrumbs(_('Repository'), project_tree_path(@project)) -- can_access_branch_rules = can?(current_user, :maintainer_access, @project) -- can_push_code = (can? current_user, :push_code, @project) -# Possible values for variables passed down from the projects/branches_controller.rb -# @@ -24,21 +22,21 @@ sorted_by: @sort } } - - if can_access_branch_rules - = link_to project_settings_repository_path(@project, anchor: 'js-branch-rules'), class: 'gl-button btn btn-default' do + - if can_view_branch_rules? + = link_button_to project_settings_repository_path(@project, anchor: 'js-branch-rules') do = s_('Branches|View branch rules') - - if can_push_code - = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do + - if can_push_code? + = link_button_to new_project_branch_path(@project), variant: :confirm do = s_('Branches|New branch') - .js-delete-merged-branches{ data: { + .js-delete-merged-branches.gl-w-7{ data: { default_branch: @project.repository.root_ref, form_path: project_merged_branches_path(@project) } } = render_if_exists 'projects/commits/mirror_status' -- if can_access_branch_rules +- if can_view_branch_rules? = render 'branch_rules_info' .js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } } diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 9fd9943fd26..7f6a37fc210 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -20,4 +20,4 @@ = _('Existing branch name, tag, or commit SHA') = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do = _('Create branch') - = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel' + = link_button_to _('Cancel'), project_branches_path(@project) diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index ab026d9c6ac..db5d1ff5693 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -3,7 +3,7 @@ - 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' } } + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { id: 'clone-dropdown', class: 'clone-dropdown-btn', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }) do %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index bbee7d66dcb..23d18236738 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -6,7 +6,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.gl-dropdown.inline{ class: css_class }> - %button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } + = render Pajamas::ButtonComponent.new(button_options: { class: 'dropdown-toggle gl-dropdown-toggle dropdown-icon-only has-tooltip', title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }) do = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') = sprite_icon('chevron-down', css_class: 'gl-icon dropdown-chevron') diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index d36aed44e18..31185fc1532 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,4 +1,4 @@ .btn-group.ml-0.w-100 - Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) - = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "gl-button btn btn-sm #{index == 0 ? 'btn-confirm' : 'btn-default'}" + = link_button_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', variant: index == 0 ? :confirm : :default, size: :small diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 6d05f1dc955..c9dcfaff8c6 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -2,17 +2,15 @@ - if current_user .count-badge.btn-group - if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default has-tooltip fork-btn' do - = sprite_icon('fork', css_class: 'icon') - %span= s_('ProjectOverview|Fork') + = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'has-tooltip fork-btn', icon: 'fork' do + = s_('ProjectOverview|Fork') - else - disabled_tooltip = fork_button_disabled_tooltip(@project) - count_class = 'disabled' unless can?(current_user, :read_code, @project) - button_class = 'disabled' if disabled_tooltip %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip } - = link_to new_project_fork_path(@project), class: "gl-button btn btn-default fork-btn #{button_class}", data: { qa_selector: 'fork_button' } do - = sprite_icon('fork', css_class: 'icon') - %span= s_('ProjectOverview|Fork') - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default count has-tooltip fork-count #{count_class}" do + = link_button_to new_project_fork_path(@project), class: "fork-btn #{button_class}", data: { qa_selector: 'fork_button' }, icon: 'fork' do + = s_('ProjectOverview|Fork') + = link_button_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "count has-tooltip fork-count #{count_class}" do = @project.forks_count diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index d4dcfbdff54..35318f68f57 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -6,12 +6,11 @@ .count-badge.d-inline-flex.align-item-stretch.btn-group = render Pajamas::ButtonComponent.new(size: :medium, icon: icon, button_text_classes: button_text_classes, button_options: { class: 'star-btn toggle-star', data: { endpoint: toggle_star_project_path(@project, :json) } }) do - button_text - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do + = link_button_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'has-tooltip star-count count' do = @project.star_count - else .count-badge.d-inline-flex.align-item-stretch.btn-group - = link_to new_user_session_path, class: 'gl-button btn btn-default has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do - = sprite_icon('star-o', css_class: 'icon') - %span= s_('ProjectOverview|Star') - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do + = link_button_to new_user_session_path, class: 'has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project'), icon: 'star-o' do + = s_('ProjectOverview|Star') + = link_button_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'has-tooltip star-count count' do = @project.star_count diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index ecdd43a54f9..4017db459a9 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -103,33 +103,28 @@ .gl-text-right .btn-group - if can?(current_user, :read_job_artifacts, job) && job.artifacts? - = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'gl-button btn btn-default btn-icon' do - = sprite_icon('download', css_class: 'gl-icon') + = link_button_to nil, download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), icon: 'download' - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'gl-button btn btn-default btn-icon' do - = sprite_icon('cancel', css_class: 'gl-icon') + = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel' - elsif job.scheduled? - .gl-button.btn.btn-default.btn-icon.disabled{ disabled: true } - = sprite_icon('planning', css_class: 'gl-icon') + = render Pajamas::ButtonComponent.new(disabled: true, icon: 'planning') do %time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 } = duration_in_numbers(job.execute_in) - confirmation_message = s_("DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes.") % { job_name: job.name } - = link_to play_project_job_path(job.project, job, return_to: request.original_url), + = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: s_('DelayedJobs|Start now'), - class: 'gl-button btn btn-default btn-icon has-tooltip', - data: { confirm: confirmation_message } do - = sprite_icon('play', css_class: 'gl-icon') - = link_to unschedule_project_job_path(job.project, job, return_to: request.original_url), + class: 'has-tooltip', + data: { confirm: confirmation_message }, + icon: 'play' + = link_button_to nil, unschedule_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: s_('DelayedJobs|Unschedule'), - class: 'gl-button btn btn-default btn-icon has-tooltip' do - = sprite_icon('time-out', css_class: 'gl-icon') + class: 'has-tooltip', + icon: 'time-out' - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'gl-button btn btn-default btn-icon' do - = sprite_icon('play', css_class: 'gl-icon') + = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), icon: 'play' - elsif job.retryable? - = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do - = sprite_icon('retry', css_class: 'gl-icon') + = link_button_to nil, retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), icon: 'retry' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index c161e1c9d2a..24d063d3b4d 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -19,7 +19,7 @@ #{time_ago_with_tooltip(@commit.committed_date)} #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } } - = link_to _('Browse files'), project_tree_path(@project, @commit), class: "btn gl-button btn-default gl-mr-3 gl-xs-w-full gl-xs-mb-3" + = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-xs-w-full gl-xs-mb-3' #js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) } .commit-box{ data: { project_path: project_path(@project) } } diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index f7ae462e8f9..382cb499fe1 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -4,6 +4,7 @@ #commit-pipeline-table-view{ data: { disable_initialization: disable_initialization, endpoint: endpoint, full_path: @project.full_path, + graphql_path: api_graphql_path, "empty-state-svg-path" => image_path('illustrations/empty-state/empty-pipeline-md.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), "project-id": @project.id, diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml deleted file mode 100644 index 656adef6a72..00000000000 --- a/app/views/projects/commit/_signature_badge_user.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- user = signature.signed_by_user - -- if user - = link_to user_path(user), class: 'gpg-popover-user-link' do - %div - = user_avatar_without_link(user: user, size: 32) - - %div - %strong= user.name - %div= user.to_reference -- elsif signature.gpg? # SSH signatures do not have an email embedded in them - - user_name = signature.gpg_key_user_name - - user_email = signature.gpg_key_user_email - - if user_name && user_email - = mail_to user_email do - %div - = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) - - %div - %strong= user_name - %div= user_email diff --git a/app/views/projects/commit/_verified_system_signature_badge.html.haml b/app/views/projects/commit/_verified_system_signature_badge.html.haml new file mode 100644 index 00000000000..96ff26ecbd7 --- /dev/null +++ b/app/views/projects/commit/_verified_system_signature_badge.html.haml @@ -0,0 +1,5 @@ +- title = _('Verified commit') +- description = _('This commit was created in the GitLab UI, and signed with a GitLab-verified signature.') +- 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/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml deleted file mode 100644 index da749172369..00000000000 --- a/app/views/projects/commit/x509/_signature_badge_user.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- user_email = signature.x509_certificate.email -- user = signature.signed_by_user - -- if user - = link_to user_path(user), class: 'gpg-popover-user-link' do - %div - = user_avatar_without_link(user: user, size: 32) - - %div - %strong= user.name - %div= user.to_reference - -- else - = mail_to user_email do - %div - = user_avatar_without_link(user_email: user_email, size: 32) - - %div - %strong= user_email diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 6209ef48f96..13a406d442d 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -25,7 +25,7 @@ .avatar-cell.d-none.d-sm-block = author_avatar(commit, size: 40, has_tooltip: false) - .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-flex-start.gl-flex-grow-1.gl-min-w-0 + .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-flex-grow-1.gl-min-w-0 .commit-content{ data: { qa_selector: 'commit_content' } } - if view_details && merge_request = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)] diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml index 22f4594c1d5..721040f9a09 100644 --- a/app/views/projects/commits/_commit_list.html.haml +++ b/app/views/projects/commits/_commit_list.html.haml @@ -4,7 +4,7 @@ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-py-0'}) do |c| - c.with_header do - Commits (#{@total_commit_count}) + = s_('CompareRevisions|Commits on Source (%{commits_amount})').html_safe % { commits_amount: @total_commit_count } - c.with_body do - if hidden > 0 %ul.content-list diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 4c5a9acdf83..8afc9ade3e1 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -18,7 +18,7 @@ .tree-controls - if @merge_request.present? .control.d-none.d-md-block - = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button' + = link_button_to _("View open merge request"), project_merge_request_path(@project, @merge_request) - elsif create_mr_button?(from: @ref, source_project: @project) .control.d-none.d-md-block = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path(from: @ref, source_project: @project)) do @@ -28,8 +28,7 @@ = form_tag(project_commits_path(@project, @id, ref_type: @ref_type), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path(ref_type: @ref_type)}) do = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false } .control.d-none.d-md-block - = link_to project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do - = sprite_icon('rss') + = link_button_to nil, project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), icon: 'rss' = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 58da76a3231..4a29402bfe7 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -1,16 +1,6 @@ -- breadcrumb_title _("Compare revisions") -- page_title _("Compare revisions") +- breadcrumb_title s_("CompareRevisions|Compare revisions") -%h1.page-title.gl-font-size-h-display - = _("Compare Git revisions") -%div - - example_branch = capture do - %code.ref-name= @project.default_branch_or_main - - example_sha = capture do - %code.ref-name 4eedf23 - = html_escape(_("To see what's changed or create a merge request, choose a branch or tag (like %{branch}), or enter a commit (like %{sha}).")) % { branch: example_branch.html_safe, sha: example_sha.html_safe } - %br - = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } +- page_title _("CompareRevisions|Compare revisions") .prepend-top-20 #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, @compare_params) } diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 9185afc0771..5b6f7c392dd 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -1,7 +1,7 @@ -- add_to_breadcrumbs _("Compare revisions"), project_compare_index_path(@project) -- page_title "#{params[:from]}...#{params[:to]}" +- add_to_breadcrumbs s_("CompareRevisions|Compare revisions"), project_compare_index_path(@project) +- page_title "#{params[:from]} to #{params[:to]}" -.sub-header-block.gl-border-b-0.gl-mb-0 +.sub-header-block.gl-border-b-0.gl-mb-0.gl-pt-4 .js-signature-container{ data: { 'signatures-path' => signatures_namespace_project_compare_index_path } } #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) } @@ -20,13 +20,13 @@ = render Pajamas::CardComponent.new(card_options: { class: "gl-bg-gray-50 gl-mb-5 gl-border-none gl-text-center" }) do |c| - c.with_body do %h4 - = s_("CompareBranches|There isn't anything to compare.") + = s_("CompareRevisions|There isn't anything to compare.") %p.gl-mb-4.gl-line-height-24 - if params[:to] == params[:from] - source_branch = capture do %span.ref-name= params[:from] - target_branch = capture do %span.ref-name= params[:to] - = (s_("CompareBranches|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe + = (s_("CompareRevisions|%{source_branch} and %{target_branch} are the same.") % { source_branch: source_branch, target_branch: target_branch }).html_safe - else = _("You'll need to use different branch names to get a valid comparison.") diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml index 6fec9b501ea..283408ffa63 100644 --- a/app/views/projects/confluences/show.html.haml +++ b/app/views/projects/confluences/show.html.haml @@ -8,6 +8,6 @@ - wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629' - wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url } = html_escape(s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.")) % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe } - = link_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm external-url', title: s_('WikiEmpty|Go to Confluence') do + = link_button_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'external-url', title: s_('WikiEmpty|Go to Confluence'), variant: :confirm do = s_('WikiEmpty|Go to Confluence') = sprite_icon('external-link') diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml index 91444a00334..997443d5fa9 100644 --- a/app/views/projects/deploy_keys/edit.html.haml +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -7,4 +7,4 @@ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions = f.submit _('Save changes'), pajamas_button: true - = link_to _('Cancel'), project_settings_repository_path(@project), class: 'gl-button btn btn-default btn-cancel' + = link_button_to _('Cancel'), project_settings_repository_path(@project) diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 982ecbbae51..9193fc4ef25 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -12,7 +12,7 @@ .files-changed-inner .inline-parallel-buttons.gl-display-none.gl-md-display-flex.gl-relative - if !diffs_expanded? && diff_files.any?(&:collapsed?) - = link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default' + = link_button_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)) - if show_whitespace_toggle - if current_controller?(:commit) = commit_diff_whitespace_link(diffs.project, @commit, class: 'd-none d-sm-inline-block') diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 5ec95c3095d..3db1467df60 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -18,9 +18,7 @@ #js-diff-stats{ data: diff_file_stats_data(diff_file) } - if diff_file.blob&.readable_text? - unless @diff_notes_disabled - %span.has-tooltip{ title: _("Toggle comments for this file") } - = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected' do - = sprite_icon('comment') + = link_button_to nil, '#', class: 'js-toggle-diff-comments has-tooltip', icon: 'comment', title: _("Toggle comments for this file") \ - if editable_diff?(diff_file) - link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {} diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a5224db1be9..98e8c2dd61b 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -7,12 +7,10 @@ = render_if_exists 'shared/ultimate_feature_removal_banner', project: @project -- if Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment) - = render Pajamas::AlertComponent.new(variant: :info, - title: _('GitLab Pages has moved'), - alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - - c.with_body do - = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deployments > Pages', project_pages_path(@project)).html_safe} += render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), + alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| + - c.with_body do + = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe} %section.settings.general-settings.no-animate.expanded#js-general-settings .settings-header @@ -79,8 +77,8 @@ %p = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' - = link_to _('Run housekeeping'), housekeeping_project_path(@project), - method: :post, class: "btn gl-button btn-default" + = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do + = _('Run housekeeping') .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') } } diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml index c7752a45c63..1c107784e08 100644 --- a/app/views/projects/environments/edit.html.haml +++ b/app/views/projects/environments/edit.html.haml @@ -2,7 +2,7 @@ - add_page_specific_style 'page_bundles/environments' #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)), project_path: @project.full_path, - environment: environment_data(@environment) } } + environment_name: @environment.name, + kas_tunnel_url: ::Gitlab::Kas.tunnel_url } } diff --git a/app/views/projects/environments/empty_metrics.html.haml b/app/views/projects/environments/empty_metrics.html.haml deleted file mode 100644 index df05909e8ef..00000000000 --- a/app/views/projects/environments/empty_metrics.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- page_title _("Metrics") - -.row.empty-state - .col-sm-12 - .svg-content - = image_tag 'illustrations/operations_metrics_empty.svg' - .col-12 - .text-content - %h4.text-center - = s_('Environments|No deployed environments') - %p.state-description - = s_('Metrics|Check out the CI/CD documentation on deploying to an environment') - .text-center - = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm' diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml deleted file mode 100644 index 31041d124e4..00000000000 --- a/app/views/projects/environments/metrics.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- add_page_specific_style 'page_bundles/prometheus' - -- page_title _("Metrics Dashboard"), @environment.name - -.prometheus-container - #prometheus-graphs{ data: metrics_data(@project, @environment) } diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 9e8484b88b9..301c19ee6f0 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -3,4 +3,4 @@ - page_title s_("Environments|New Environment") - add_page_specific_style 'page_bundles/environments' -#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, } } +#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, kas_tunnel_url: ::Gitlab::Kas.tunnel_url } } diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index 7c837d4ded0..c2ad9191800 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -13,8 +13,7 @@ .col-sm-6 .nav-controls - if @environment.external_url.present? - = link_to @environment.external_url, class: 'gl-button btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('external-link') + = link_button_to nil, @environment.external_url, target: '_blank', rel: 'noopener noreferrer nofollow', icon: 'external-link' = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index afb49c48146..7e93e44c463 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -2,11 +2,11 @@ - add_page_specific_style 'page_bundles/tree' .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 + .nav-block.gl-xs-mr-0 + .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } } - %ul.breadcrumb.repo-breadcrumb - %li.breadcrumb-item + %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap + %li.breadcrumb-item.gl-white-space-nowrap = link_to project_tree_path(@project, @ref) do = @project.path %li.file-finder.breadcrumb-item diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index cff5899b960..f589c8f9566 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -18,4 +18,4 @@ = error - c.with_actions do - = link_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: "btn gl-alert-action btn-info btn-md gl-button" + = link_button_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: 'gl-alert-action', variant: :confirm diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index d28ee30b6f9..49047749b71 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -20,12 +20,10 @@ - if current_user && can?(current_user, :fork_project, @project) - if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm gl-md-ml-3' do - = sprite_icon('fork', size: 12) - %span= _('Fork') + = link_button_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'gl-md-ml-3', variant: :confirm, icon: 'fork' do + = _('Fork') - else - = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm gl-md-ml-3 gl-mt-3 gl-md-mt-0' do - = sprite_icon('fork', size: 12) - %span= _('Fork') + = link_button_to new_project_fork_path(@project), title: _("Fork project"), class: 'gl-md-ml-3 gl-mt-3 gl-md-mt-0', variant: :confirm, icon: 'fork' do + = _('Fork') = render 'projects', projects: @forks diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index 0f4dc4b5e32..30084e3310b 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -9,6 +9,6 @@ - if @hook_log.oversize? = button_tag _("Resend Request"), class: "btn gl-button btn-default float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large") - else - = link_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3" + = link_button_to _("Resend Request"), @hook_log.present.retry_path, method: :post, class: 'float-right gl-ml-3' = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index b553249c4b8..26ec09c76db 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -3,17 +3,16 @@ = render 'shared/web_hooks/hook_errors', hook: @hook -.row.gl-mt-3 - .col-lg-3 - = render 'shared/web_hooks/title_and_docs', hook: @hook +.gl-mt-5 + = render 'shared/web_hooks/title_and_docs', hook: @hook - .col-lg-9.gl-mb-3 - = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f| - = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f| + = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit _('Save changes'), pajamas_button: true + %div + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-sm-mr-3' = render 'shared/web_hooks/test_button', hook: @hook - = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' } + = link_button_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'gl-float-right', aria: { label: s_('Webhooks|Delete webhook') }, data: { confirm: s_('Webhooks|Are you sure you want to delete this project hook?'), confirm_btn_variant: 'danger' }, variant: :danger %hr diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 4d71161c96e..b57adc0ef0d 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -2,13 +2,6 @@ - page_title _('Webhooks') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4 - = render 'shared/web_hooks/title_and_docs', hook: @hook - - .col-lg-8.gl-mb-3 - = gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]), html: { class: 'js-webhook-form' } do |f| - = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" } - - = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class +.gl-mt-3.js-search-settings-section + = render 'shared/web_hooks/title_and_docs', hook: @hook + = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class, partial: 'shared/web_hooks/form', url: polymorphic_path([@project, :hooks]) diff --git a/app/views/projects/integrations/shimos/show.html.haml b/app/views/projects/integrations/shimos/show.html.haml index 92b9e03d5bd..e6cd8c15809 100644 --- a/app/views/projects/integrations/shimos/show.html.haml +++ b/app/views/projects/integrations/shimos/show.html.haml @@ -6,5 +6,5 @@ = s_('Shimo|Shimo Workspace integration is enabled') %p = s_("Shimo|You've enabled the Shimo Workspace integration. You can view your wiki directly in Shimo.") - = link_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-confirm', title: s_('Shimo|Go to Shimo Workspace') do + = link_button_to @project.shimo_integration.external_wiki_url, target: '_blank', rel: 'noopener noreferrer', title: s_('Shimo|Go to Shimo Workspace'), variant: :confirm do = s_('Shimo|Go to Shimo Workspace') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 3d6a266dc4d..21f1a4d19fa 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,24 +1,25 @@ - if @related_branches.any? - if @related_branches.any? - = render Pajamas::CardComponent.new(card_options: { class: 'gl-bg-gray-10 gl-mt-5 gl-mb-0' }, header_options: { class: 'gl-bg-white gl-pl-5 gl-pr-4 gl-py-4' } , body_options: { class: 'gl-py-3 gl-px-4' }) do |c| + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' } , body_options: { class: 'gl-new-card-body' }) do |c| - c.with_header do - %h3.card-title.h5.gl-my-0.gl-display-flex.gl-align-items-center.gl-flex-grow-1.gl-relative.gl-line-height-24 - = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true - = _('Related branches') - .gl-display-inline-flex.gl-mx-3.gl-text-gray-500 - .gl-display-inline-flex.gl-align-items-center - = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon") - = @related_branches.size + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true + = _('Related branches') + .gl-new-card-count + = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon") + = @related_branches.size - c.with_body do - %ul.related-merge-requests.content-list.gl-p-3! - - @related_branches.each do |branch| - %li.list-item{ class: "gl-py-0! gl-border-0!" } - .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2 - .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7 - .item-title.gl-display-flex.mb-xl-0.gl-min-w-0 - - if branch[:pipeline_status].present? - %span.related-branch-ci-status - = render 'ci/status/icon', status: branch[:pipeline_status] - %span.related-branch-info - %strong - = link_to branch[:name], branch[:link], class: "ref-name" + .gl-new-card-content + %ul.related-merge-requests.content-list + - @related_branches.each do |branch| + %li.list-item{ class: "gl-py-0! gl-border-0!" } + .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2 + .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7 + .item-title.gl-display-flex.mb-xl-0.gl-min-w-0 + - if branch[:pipeline_status].present? + %span.related-branch-ci-status + = render 'ci/status/icon', status: branch[:pipeline_status] + %span.related-branch-info + %strong + = link_to branch[:name], branch[:link], class: "ref-name" diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 3cc419716e5..9793f21e4a9 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -8,15 +8,26 @@ - support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(User.support_bot) }.to_json .js-service-desk-issues.service-desk-issues{ data: { support_bot: support_bot_attrs } } - .top-area - = render 'shared/issuable/nav', type: :issues - .nav-controls.d-block.d-sm-none - = render "projects/issues/service_desk/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false + - if ::Feature.enabled?(:service_desk_vue_list, @project) + .js-service-desk-list{ data: { project_data: project_issues_list_data(@project, current_user), + service_desk_email_address: @project.service_desk_address, + can_admin_issues: can?(current_user, :admin_issue, @project).to_s, + can_edit_project_settings: can?(current_user, :admin_project, @project).to_s, + service_desk_callout_svg_path: image_path('service_desk_callout.svg'), + service_desk_settings_path: edit_project_path(@project, anchor: 'js-service-desk'), + service_desk_help_path: help_page_path('user/project/service_desk'), + is_service_desk_supported: Gitlab::ServiceDesk.supported?.to_s, + is_service_desk_enabled: @project.service_desk_enabled?.to_s } } + - else + .top-area + = render 'shared/issuable/nav', type: :issues + .nav-controls.gl-display-block.gl-sm-display-none + = render "projects/issues/service_desk/nav_btns", show_feed_buttons: false, show_import_button: false, show_export_button: false - - if @issues.present? - = render 'shared/issuable/search_bar', type: :issues - - if Gitlab::ServiceDesk.supported? - = render 'projects/issues/service_desk/service_desk_info_content' + - if @issues.present? + = render 'shared/issuable/search_bar', type: :issues + - if Gitlab::ServiceDesk.supported? + = render 'projects/issues/service_desk/service_desk_info_content' - .issues-holder - = render 'projects/issues/issues', empty_state_path: 'projects/issues/service_desk/service_desk_empty_state' + .issues-holder + = render 'projects/issues/issues', empty_state_path: 'projects/issues/service_desk/service_desk_empty_state' diff --git a/app/views/projects/issues/service_desk/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml index 04ea6103b83..5b98712d3eb 100644 --- a/app/views/projects/issues/service_desk/_issue.html.haml +++ b/app/views/projects/issues/service_desk/_issue.html.haml @@ -33,7 +33,7 @@ %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue? && !issue.closed?}", title: _('Due date') } = sprite_icon('calendar') - = issue.due_date.to_s(:medium) + = issue.due_date.to_fs(:medium) = render_if_exists "projects/issues/issue_weight", issue: issue = render_if_exists "projects/issues/health_status", issue: issue diff --git a/app/views/projects/issues/service_desk/_nav_btns.html.haml b/app/views/projects/issues/service_desk/_nav_btns.html.haml index a0a290f340a..3b7b3f57abd 100644 --- a/app/views/projects/issues/service_desk/_nav_btns.html.haml +++ b/app/views/projects/issues/service_desk/_nav_btns.html.haml @@ -7,12 +7,13 @@ .nav-controls.issues-nav-controls.gl-font-size-0 - if @can_bulk_update - = button_tag _("Bulk edit"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" + = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mr-3 js-bulk-update-toggle' }) do + = _("Bulk edit") - if show_new_issue_link?(@project) - = link_to _("New issue"), new_project_issue_path(@project, - issue: { milestone_id: finder.milestones.first.try(:id) }), - class: "gl-button btn btn-confirm gl-mr-3", - id: "new_issue_link" + = render Pajamas::ButtonComponent.new(variant: :confirm, + href: new_project_issue_path(@project, issue: { milestone_id: finder.milestones.first.try(:id) }), + button_options: { id: 'new_issue_link', class: 'gl-mr-3' }) do + = _("New issue") .dropdown.gl-dropdown = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Actions') } do diff --git a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml index 855625368a9..831bd107961 100644 --- a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml @@ -21,7 +21,7 @@ - if can_edit_project_settings && !service_desk_enabled .text-center - = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-confirm' + = link_button_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), variant: :confirm - else .empty-state .svg-content diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml index 95837748c7f..093a47e63be 100644 --- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml @@ -21,4 +21,4 @@ - if can_edit_project_settings && !service_desk_enabled .gl-mt-3 - = link_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-confirm' + = link_button_to s_("ServiceDesk|Enable Service Desk"), edit_project_path(@project), variant: :confirm diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index 954c77a21f3..0bb512b4035 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -12,7 +12,7 @@ = s_('Jobs|Use jobs to automate your tasks') %p = s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.') - = link_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'btn gl-button btn-confirm js-empty-state-button' + = link_button_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'js-empty-state-button', variant: :confirm - else .nothing-here-block= s_('Jobs|No jobs to show') - else diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 5f249f693ff..b151c355b3e 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -7,4 +7,4 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -#js-job-page{ data: jobs_data } +#js-job-page{ data: jobs_data(@project, @build) } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 7a4ae409ee2..e1c904d000f 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -16,12 +16,13 @@ .labels-container -# Only show it in the first page - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') - .prioritized-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b - %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24 - = _('Prioritized labels') - .gl-font-sm.gl-font-weight-semibold.gl-text-gray-500 - = _('Drag to reorder prioritized labels and change their relative priority.') + .prioritized-labels.gl-new-card{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } + .gl-new-card-header + .gl-new-card-title-wrapper.gl-flex-direction-column + %h3.gl-new-card-title + = _('Prioritized labels') + .gl-new-card-description + = _('Drag to reorder prioritized labels and change their relative priority.') .js-prioritized-labels.gl-px-3.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } = render 'shared/empty_states/priority_labels' @@ -32,12 +33,14 @@ = _('No prioritized labels with such name or description') - if @labels.any? - .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4 - .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b - %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24{ class: ('hide' if hide) }= _('Other labels') - .js-other-labels.gl-px-3.gl-rounded-base.manage-labels-list - = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project } - = paginate @labels, theme: 'gitlab' + .other-labels.gl-new-card + .gl-new-card-header + .gl-new-card-title-wrapper + %h3.gl-new-card-title{ class: ('hide' if hide) }= _('Other labels') + .gl-new-card-body + .js-other-labels.manage-labels-list.gl-new-card-content + = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project } + = paginate @labels, theme: 'gitlab' - elsif search.present? .other-labels diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml index 5886c0565b1..c53e805fae1 100644 --- a/app/views/projects/mattermosts/_no_teams.html.haml +++ b/app/views/projects/mattermosts/_no_teams.html.haml @@ -9,4 +9,4 @@ and try again. %hr .clearfix - = link_to 'Go back', edit_project_settings_integration_path(@project, @integration), class: 'gl-button btn btn-lg float-right' + = link_button_to 'Go back', edit_project_settings_integration_path(@project, @integration), class: 'float-right' 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 a3536ead240..ab841d4f1b2 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -1,6 +1,7 @@ .js-mr-more-dropdown{ data: { merge_request: @merge_request.to_json, project_path: @project.full_path, + url: merge_request_url(@merge_request), edit_url: edit_project_merge_request_path(@project, @merge_request), is_current_user: issuable_author_is_current_user(@merge_request), is_logged_in: current_user, @@ -11,5 +12,4 @@ clipboard_text: @merge_request.to_reference(full: true), report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, - reported_from_url: merge_request_url(@merge_request), } } diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 5f1c72156eb..6d2e2cfcc54 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,3 +1,4 @@ = gitlab_ui_form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| + = render 'source_and_target', mr: @merge_request = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 6f662b81dd7..1774401ed78 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,3 +1,3 @@ -.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } +.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-baseline.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } = render 'shared/issuable/status_box', issuable: @merge_request = merge_request_header(@project, @merge_request) diff --git a/app/views/projects/merge_requests/_source_and_target.html.haml b/app/views/projects/merge_requests/_source_and_target.html.haml new file mode 100644 index 00000000000..68cd4fe9372 --- /dev/null +++ b/app/views/projects/merge_requests/_source_and_target.html.haml @@ -0,0 +1,10 @@ +%span{ + id: "js-merge-request-metadata", + class: ["js-merge-request-metadata", "gl-display-none"], + data: { + "source-project-id": mr.source_project_id, + "source-branch": mr.source_branch, + "target-project-id": mr.target_project_id, + "target-branch": mr.target_branch + } +} diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 576fed58609..606d4e06d33 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -23,4 +23,9 @@ window.gl.mrWidgetData.user_preferences_gitpod_path = '#{profile_preferences_path(anchor: 'user_gitpod_enabled')}'; window.gl.mrWidgetData.user_profile_enable_gitpod_path = '#{profile_path(user: { gitpod_enabled: true })}'; -#js-vue-mr-widget.mr-widget +%h2#merge-request-widgets-heading.gl-sr-only + = _("Merge request reports") +#js-vue-mr-widget.mr-widget{ + role: 'region', + 'aria-labelledby': 'merge-request-widgets-heading' +} diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index bec7cb3fd34..a7151421acb 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -1,6 +1,7 @@ %h1.page-title.gl-font-size-h-display = _('New merge request') = gitlab_ui_form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| + = render "projects/merge_requests/source_and_target", mr: @merge_request = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter = f.hidden_field :source_project_id = f.hidden_field :source_branch diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index be6f9ac83dc..a592062a17d 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -12,13 +12,16 @@ = render 'shared/milestones/form_dates', f: f .form-group = f.label :description, _('Description') - = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do - = render 'shared/zen', f: f, attr: :description, - classes: 'note-textarea', - qa_selector: 'milestone_description_field', - supports_autocomplete: true, - placeholder: _('Write milestone description...') - = render 'shared/notes/hints' + - @gfm_form = true + .js-markdown-editor{ data: { render_markdown_path: preview_markdown_path(@project), + markdown_docs_path: help_page_path('user/markdown'), + qa_selector: 'milestone_description_field', + form_field_placeholder: _('Write milestone description...'), + supports_quick_actions: 'false', + enable_autocomplete: 'true', + autofocus: 'false', + form_field_classes: 'note-textarea js-gfm-input markdown-area' } } + = f.hidden_field :description .clearfix .error-alert @@ -26,7 +29,7 @@ - if @milestone.new_record? = f.submit _('Create milestone'), data: { qa_selector: 'create_milestone_button' }, class: 'gl-mr-2', pajamas_button: true - = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-default btn-cancel' + = link_button_to _('Cancel'), project_milestones_path(@project) - else = f.submit _('Save changes'), class: 'gl-mr-2', pajamas_button: true - = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-default btn-cancel' + = link_button_to _('Cancel'), project_milestone_path(@project, @milestone) diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 326a7c4027f..a7a21ef0440 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -9,14 +9,14 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do + = link_button_to new_project_milestone_path(@project), class: 'gl-ml-3', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') - if @milestones.blank? = render 'shared/empty_states/milestones_tab' do - if can?(current_user, :admin_milestone, @project) .text-center - = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do + = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') - else @@ -32,5 +32,5 @@ = render 'shared/empty_states/milestones' do - if can?(current_user, :admin_milestone, @project) .text-center - = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-confirm', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do + = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml new file mode 100644 index 00000000000..2caba2ae9be --- /dev/null +++ b/app/views/projects/ml/models/index.html.haml @@ -0,0 +1,5 @@ +- breadcrumb_title s_('ModelRegistry|Model registry') +- page_title s_('ModelRegistry|Model registry') +- presenter = ::Ml::ModelsIndexPresenter.new(@models) + +#js-index-ml-models{ data: { view_model: presenter.present } } diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index e3f46d601a3..3abec75b971 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -13,14 +13,14 @@ %hr .no-repo-actions - = link_to project_repository_path(@project), method: :post, class: 'btn gl-button btn-confirm' do - #{ _('Create empty repository') } + = link_button_to project_repository_path(@project), method: :post, variant: :confirm do + = _('Create empty repository') %strong.gl-ml-3.gl-mr-3 or - = link_to new_project_import_path(@project), class: 'btn gl-button btn-default' do - #{ _('Import repository') } + = link_button_to new_project_import_path(@project) do + = _('Import repository') - if can? current_user, :remove_project, @project .prepend-top-20 - = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project), confirm_btn_variant: 'danger' }, aria: { label: _('Delete project') }, method: :delete, class: "btn gl-button btn-danger float-right" + = link_button_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project), confirm_btn_variant: 'danger' }, aria: { label: _('Delete project') }, method: :delete, class: 'float-right', variant: :danger diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml index 48aaf0884c8..5397828d48e 100644 --- a/app/views/projects/packages/packages/index.html.haml +++ b/app/views/projects/packages/packages/index.html.haml @@ -10,4 +10,5 @@ npm_instance_url: package_registry_instance_url(:npm), project_list_url: project_packages_path(@project), settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '', + can_delete_packages: can_delete_packages?(@project).to_s, group_list_url: '' } } diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 50e48187be3..6eab31075d4 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -1,5 +1,5 @@ - if @project.pages_deployed? - - pages_url = @project.pages_url(with_unique_domain: true) + - pages_url = build_pages_url(@project, with_unique_domain: true) = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c| - c.with_header do diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 57371aa49f6..38e15d02a39 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -26,8 +26,8 @@ - if domain.expired? = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger %div - = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted" - = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped" + = link_button_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: 'btn-grouped', variant: :confirm, category: :secondary, size: :small + = link_button_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: 'btn-grouped', variant: :danger, size: :small - if domain.needs_verification? %li.list-group-item.bs-callout-warning - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml index b9d2af9cf19..89f8f62ea83 100644 --- a/app/views/projects/pages/new.html.haml +++ b/app/views/projects/pages/new.html.haml @@ -1,9 +1,5 @@ -- if Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment) - - @breadcrumb_link = project_pages_path(@project) - - breadcrumb_title s_('GitLabPages|Pages') - - page_title s_('GitLabPages|Pages') -- else - %section.js-search-settings-section +- @breadcrumb_link = project_pages_path(@project) +- page_title s_('GitLabPages|Pages') - if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group) #js-pages{ data: @pipeline_wizard_data } diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 01477967394..698ce404be8 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,4 +1,4 @@ -- page_title _('Pages') +- page_title s_('GitLabPages|Pages') - unless @project.pages_deployed? = render 'waiting' @@ -11,7 +11,7 @@ = render 'pages_settings' %hr.clearfix - = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".") + = render 'ssl_limitations_warning' if pages_subdomain(@project).include?(".") = render 'access' - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https = render 'list' diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml index 3e6a92d8bc0..0edce28bb9d 100644 --- a/app/views/projects/pages_domains/_dns.html.haml +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -1,5 +1,5 @@ - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? -- dns_record = "#{domain_presenter.domain} ALIAS #{domain_presenter.project.pages_subdomain}.#{Settings.pages.host}." +- dns_record = "#{domain_presenter.domain} ALIAS #{pages_subdomain(domain_presenter.project)}.#{Settings.pages.host}." .form-group.border-section .row @@ -21,11 +21,11 @@ .gl-mb-3 - text, status = domain_presenter.unverified? ? [_('Unverified'), :danger] : [_('Verified'), :success] = gl_badge_tag text, variant: status - = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-sm btn-default has-tooltip", title: _("Retry verification") + = link_button_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: 'gl-ml-2 has-tooltip', title: _("Retry verification"), size: :small .input-group = text_field_tag :domain_verification, domain_presenter.verification_record, class: "monospace js-select-on-focus form-control", readonly: true .input-group-append = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') %p.form-text.text-muted - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) - = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help } + = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration within seven days.").html_safe % { link_to_help: link_to_help } diff --git a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml index d6c213571f2..68b6884c4f5 100644 --- a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml +++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml @@ -9,7 +9,7 @@ = sprite_icon('warning-solid', css_class: ' mr-2 gl-text-orange-600') = _("Something went wrong while obtaining the Let's Encrypt certificate.") .row.mx-0.mt-3 - = link_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: "gl-button btn btn-default btn-sm btn-grouped", method: :post + = link_button_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: 'btn-grouped', method: :post, size: :small - elsif !domain_presenter.certificate_gitlab_provided? .form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } .row diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index c88255e23f9..c58209f8806 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -8,4 +8,4 @@ = render 'form', { f: f } .form-actions.gl-display-flex = f.submit _('Create New Domain'), class: 'gl-mr-3', pajamas_button: true - = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-cancel' + = link_button_to _('Cancel'), project_pages_path(@project) diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index b8de364babc..d34650d3f5a 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -10,4 +10,4 @@ = render 'form', { f: f } .form-actions.gl-display-flex = f.submit _('Save Changes'), class: 'gl-mr-3', pajamas_button: true - = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-inverse' + = link_button_to _('Cancel'), project_pages_path(@project) diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 235b89b8c5b..df85963218d 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -40,4 +40,4 @@ = f.gitlab_ui_checkbox_component :active, _('Active'), checkbox_options: { value: @schedule.active, required: false } .footer-block = f.submit _('Save pipeline schedule'), pajamas_button: true - = link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn gl-button btn-default btn-cancel' + = link_button_to _('Cancel'), pipeline_schedules_path(@project) diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 37b2b3ecfde..a050808f13c 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -35,14 +35,11 @@ %td{ role: 'cell', data: { label: _('Actions') } } .float-right.btn-group - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) - = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do - = sprite_icon('play') + = link_button_to nil, play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), icon: 'play' - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) && pipeline_schedule.owner != current_user = render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do = s_('PipelineSchedules|Take ownership') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) - = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-button btn-default btn-icon' do - = sprite_icon('pencil') + = link_button_to nil, edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), icon: 'pencil' - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) - = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn gl-button btn-danger btn-icon', aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' } do - = sprite_icon('remove') + = link_button_to nil, pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, aria: { label: _('Delete pipeline schedule') }, data: { confirm: _("Are you sure you want to delete this pipeline schedule?"), confirm_btn_variant: 'danger' }, variant: :danger, icon: 'remove' diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml index 3f843ce6aec..4e1ae53a101 100644 --- a/app/views/projects/pipeline_schedules/edit.html.haml +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -5,9 +5,8 @@ %h1.page-title.gl-font-size-h-display = _("Edit Pipeline Schedule") -%hr - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-edit{ data: { full_path: @project.full_path } } + #pipeline-schedules-form-edit{ data: js_pipeline_schedules_form_data(@project, @schedule) } - else = render "form" diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index ab86d505f0f..88a60b1fb06 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -6,7 +6,7 @@ #pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } } - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project) } } + #pipeline-schedules-app{ data: { full_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project) } } - else .top-area - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } @@ -14,8 +14,8 @@ - if can?(current_user, :create_pipeline_schedule, @project) .nav-controls - = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-confirm' do - %span= _('New schedule') + = link_button_to new_project_pipeline_schedule_path(@project), variant: :confirm do + = _('New schedule') - if @schedules.present? %ul.content-list diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml index 2d4ed5a9872..ef99a79b06f 100644 --- a/app/views/projects/pipeline_schedules/new.html.haml +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -9,6 +9,6 @@ = _("Schedule a new pipeline") - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-new{ data: { full_path: @project.full_path, cron: @schedule.cron, daily_limit: @schedule.daily_limit, timezone_data: timezone_data.to_json, cron_timezone: @schedule.cron_timezone, project_id: @project.id, default_branch: @project.default_branch, settings_link: project_settings_ci_cd_path(@project), } } + #pipeline-schedules-form-new{ data: js_pipeline_schedules_form_data(@project, @schedule) } - else = render "form" diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml deleted file mode 100644 index 753bb77e755..00000000000 --- a/app/views/projects/pipelines/_info.html.haml +++ /dev/null @@ -1,75 +0,0 @@ -- if @pipeline.name - .gl-border-t.gl-p-5.gl-px-0 - %h3.gl-m-0.gl-text-body - = @pipeline.name -- else - .commit-box - %h3.commit-title - = markdown(commit.title, pipeline: :single_line) - - if commit.description.present? - %pre.commit-description< - = preserve(markdown(commit.description, pipeline: :single_line)) - -.info-well - .well-segment.pipeline-info{ class: "gl-align-items-baseline! gl-flex-direction-column" } - %div - .icon-container - = sprite_icon('clock', css_class: 'gl-top-0!') - = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size - = @pipeline.ref_text_legacy - - if @pipeline.finished_at - - duration = time_interval_in_words(@pipeline.duration) - - queued_duration = time_interval_in_words(@pipeline.queued_duration) - %span.gl-pl-7{ 'data-testid': 'pipeline-stats-text' } - = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration - - - if has_pipeline_badges?(@pipeline) - .well-segment - .icon-container - = sprite_icon('flag', css_class: 'gl-top-0!') - - if @pipeline.schedule? - = gl_badge_tag _('Scheduled'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-scheduled', title: _('This pipeline was triggered by a schedule.') } - - if @pipeline.child? - - text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe - = gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") } - - if @pipeline.latest? - = gl_badge_tag s_('Pipelines|latest'), { variant: :success, size: :sm }, { class: 'js-pipeline-url-latest has-tooltip', title: _("Latest pipeline for the most recent commit on this branch") } - - if @pipeline.merge_train_pipeline? - = gl_badge_tag s_('Pipelines|merge train'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-train has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.") } - - if @pipeline.has_yaml_errors? - = gl_badge_tag s_('Pipelines|yaml invalid'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-yaml has-tooltip', title: @pipeline.yaml_errors } - - if @pipeline.failure_reason? - = gl_badge_tag s_('Pipelines|error'), { variant: :danger, size: :sm }, { class: 'js-pipeline-url-failure has-tooltip', title: @pipeline.failure_reason } - - if @pipeline.auto_devops_source? - - popover_title_text = html_escape(_('This pipeline makes use of a predefined CI/CD configuration enabled by %{b_open}Auto DevOps.%{b_close}')) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } - - popover_content_url = help_page_path('topics/autodevops/index.md') - - popover_content_text = _('Learn more about Auto DevOps') - = gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } } - - if @pipeline.detached_merge_request_pipeline? - = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', data: { qa_selector: 'merge_request_badge_tag' }, title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") } - - if @pipeline.stuck? - = gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' } - - .well-segment{ 'data-testid': 'commit-row' } - .icon-container.commit-icon - = sprite_icon('commit', css_class: 'gl-top-0!') - - if @pipeline.name - = markdown(commit.title, pipeline: :single_line) - = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA")) - = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha" - - else - = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha" - = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA")) - - .well-segment.related-merge-request-info - .icon-container - = sprite_icon("git-merge", css_class: 'gl-top-0!') - %span.related-merge-requests - %span.js-truncated-mr-list - = @pipeline.all_related_merge_request_text(limit: 1) - - if @pipeline.has_many_merge_requests? - = link_to("#", class: "js-toggle-mr-list") do - %span.text-expander - = sprite_icon('ellipsis_h', size: 12) - %span.js-full-mr-list.hide - = @pipeline.all_related_merge_request_text diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 46e1cd07a17..bdf09e5356f 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -9,16 +9,10 @@ - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } - - if Feature.enabled?(:pipeline_details_header_vue, @project) - #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) } - - else - #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } + #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) } = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline - - if @pipeline.commit.present? && !Feature.enabled?(:pipeline_details_header_vue, @project) - = render "projects/pipelines/info", commit: @pipeline.commit - - if pipeline_has_errors = render Pajamas::AlertComponent.new(title: s_('Pipelines|Unable to create pipeline'), variant: :danger, diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml index 9dde86f77b4..93c53fc99fc 100644 --- a/app/views/projects/project_templates/_template.html.haml +++ b/app/views/projects/project_templates/_template.html.haml @@ -8,7 +8,7 @@ .text-muted = template.description .controls.d-flex.align-items-center - %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" } } + = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-mr-3', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" }, rel: 'noopener noreferrer' }, href: template.preview, target: '_blank') do = _("Preview") %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name, 'data-testid': "use_template_#{template.name}" } diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index 11e09d843e0..a016ccf8656 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -8,7 +8,7 @@ = expanded ? _('Collapse') : _('Expand') %p = s_("ProtectedTag|Limit access to creating and updating tags.") - = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags") + = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags") .settings-content %p = s_("ProtectedTag|By default, protected tags restrict who can modify the tag.") diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index ed5b5b17942..4fe1c8bd3cb 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -19,4 +19,4 @@ - if can? current_user, :admin_project, @project %td - = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' + = link_button_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], aria: { label: s_('ProtectedTags|Unprotect tag') }, data: { confirm: 'Tag will be writable for developers. Are you sure?', confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index d71bcd12e64..32a2e36c779 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -13,10 +13,10 @@ %br %br - if @project.group_runners_enabled? - = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-default', method: :post do + = link_button_to toggle_group_runners_project_runners_path(@project), method: :post do = _('Disable group runners') - else - = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm-secondary', method: :post do + = link_button_to toggle_group_runners_project_runners_path(@project), method: :post, variant: :confirm, category: :secondary do = _('Enable group runners') = _('for this project') diff --git a/app/views/projects/runners/_project_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml index af8f39ce0ad..0f2f0c3f21c 100644 --- a/app/views/projects/runners/_project_runners.html.haml +++ b/app/views/projects/runners/_project_runners.html.haml @@ -3,26 +3,14 @@ .bs-callout.help-callout %p= s_('Runners|These runners are assigned to this project.') - - if Feature.enabled?(:create_runner_workflow_for_namespace, @project.namespace) - - if can?(current_user, :create_runner, @project) - = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do - = s_('Runners|New project runner') - .gl-display-inline - #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } - - else - = _('Please contact an admin to create runners.') - = 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' + - if can?(current_user, :create_runner, @project) + = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do + = s_('Runners|New project runner') + .gl-display-inline + #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } } - else - - if can?(current_user, :register_project_runners, @project) - = render partial: 'ci/runner/how_to_setup_runner', - locals: { registration_token: @project.runners_token, - type: _('project'), - reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path, - project_path: @project.path_with_namespace, - group_path: '' } - - else - = _('Please contact an admin to register runners.') - = 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' + = _('Please contact an admin to create runners.') + = link_to _('Learn more.'), help_page_path('administration/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' %hr diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index e517b37aae9..12432cd3484 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -13,19 +13,16 @@ .gl-ml-2 .btn-group.btn-group-sm - if @project_runners.include?(runner) - = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-icon', title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' } do - = sprite_icon('pencil') + = link_button_to nil, edit_project_runner_path(@project, runner), title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' }, icon: 'pencil' - if runner.active? - = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") } do - = sprite_icon('pause') + = link_button_to nil, pause_project_runner_path(@project, runner), method: :post, title: s_('Runners|Pause from accepting jobs'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', container: 'body', confirm: _("Are you sure?") }, icon: 'pause' - else - = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' } do - = sprite_icon('play') + = link_button_to nil, resume_project_runner_path(@project, runner), method: :post, title: s_('Runners|Resume accepting jobs'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', container: 'body' }, icon: 'play' - if runner.belongs_to_one_project? - = link_to _('Remove runner'), project_runner_path(@project, runner), aria: { label: _('Remove') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger' + = link_button_to _('Remove runner'), project_runner_path(@project, runner), aria: { label: _('Remove') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger - else - runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord - = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), aria: { label: _('Disable') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'btn gl-button btn-danger' + = link_button_to _('Disable for this project'), project_runner_project_path(@project, runner_project), aria: { label: _('Disable') }, data: { confirm: _("Are you sure?"), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger - elsif runner.project_type? = form_for [@project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 70e14eadaf9..05685c26ac5 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -9,14 +9,10 @@ - if @project.archived? - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = link_to _('Unarchive project'), unarchive_project_path(@project), - aria: { label: _('Unarchive project') }, - data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, - method: :post, class: "gl-button btn btn-confirm" + = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do + = _('Unarchive project') - else - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = link_to _('Archive project'), archive_project_path(@project), - aria: { label: _('Archive project') }, - data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' }, - method: :post, class: "gl-button btn btn-confirm" + = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do + = _('Archive project') diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 847f9ad3e2a..f5c275827fc 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -35,6 +35,6 @@ = render 'shared/choose_avatar_button', f: f - if @project.avatar? %hr - = link_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' + = link_button_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, variant: :danger, category: :secondary = f.submit _('Save changes'), pajamas_button: true, class: "gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' } diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index df517b5d642..e4af6d59cad 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -4,31 +4,28 @@ - type_plural = _('project access tokens') - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4 - %h4.gl-mt-0 - = page_title - %p - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') } +.gl-mt-5.js-search-settings-section + %h4.gl-my-0 + = page_title + %p.gl-text-secondary + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') } - if current_user.can?(:create_resource_access_tokens, @project) = _('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') - %p - = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: help_link_start, link_end: '</a>'.html_safe } - else - = _('Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - %p + = _('Project access token creation is disabled in this group.') - root_group = @project.group.root_ancestor - if current_user.can?(:admin_group, root_group) - group_settings_link = edit_group_path(root_group) - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link } = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = html_escape(_('You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: help_link_start, link_end: '</a>'.html_safe } - .col-lg-8 - #js-new-access-token-app{ data: { access_token_type: type } } + #js-new-access-token-app{ data: { access_token_type: type } } - - if current_user.can?(:create_resource_access_tokens, @project) - = render_if_exists 'projects/settings/access_tokens/form', - type: type + - if current_user.can?(:create_resource_access_tokens, @project) + = render_if_exists 'projects/settings/access_tokens/form', + type: type - #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 6f64d3f3f76..6eccbd245af 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -75,7 +75,7 @@ = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") - = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' + = link_to sprite_icon('question-o'), help_page_path('administration/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index c7bb6a7f5da..007169809c9 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -33,12 +33,13 @@ = render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded -%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded || params[:expand_runners]), data: { qa_selector: 'runners_settings_content' } } +- expand_runners = expanded || params[:expand_runners] +%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expand_runners), data: { qa_selector: 'runners_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _("Runners") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') + = expand_runners ? _('Collapse') : _('Expand') %p = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") = link_to s_('What is GitLab Runner?'), 'https://docs.gitlab.com/runner/', target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/settings/slacks/edit.html.haml b/app/views/projects/settings/slacks/edit.html.haml index 867b90655e3..537ae767b1d 100644 --- a/app/views/projects/settings/slacks/edit.html.haml +++ b/app/views/projects/settings/slacks/edit.html.haml @@ -17,4 +17,4 @@ .footer-block.row-content-block = form.submit _('Save changes'), pajamas_button: true - = link_to _('Cancel'), edit_project_settings_integration_path(@project, @service), class: 'btn gl-button btn-cancel' + = link_button_to _('Cancel'), edit_project_settings_integration_path(@project, @service) diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index 7c936c849d0..ae9a8307eb9 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -9,7 +9,7 @@ - if new_project_snippet_link.present? .nav-controls - = link_to _("New snippet"), new_project_snippet_link, class: "gl-button btn btn-confirm", title: _("New snippet") + = link_button_to _("New snippet"), new_project_snippet_link, title: _("New snippet"), variant: :confirm = render 'shared/snippets/list' - else diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml index 9a6c18df2ca..42af8d4f59f 100644 --- a/app/views/projects/tags/_edit_release_button.html.haml +++ b/app/views/projects/tags/_edit_release_button.html.haml @@ -1,9 +1,8 @@ - release_btn_text = s_('TagsPage|Create release') - release_btn_path = new_project_release_path(project, tag_name: tag.name) - option_css_classes = local_assigns.fetch(:option_css_classes, '') -- css_classes = "btn gl-button btn-default btn-icon btn-edit has-tooltip #{option_css_classes}" - if release - release_btn_text = s_('TagsPage|Edit release') - release_btn_path = edit_project_release_path(project, release) -= link_to release_btn_path, class: css_classes do += render Pajamas::ButtonComponent.new(href: release_btn_path, button_options: { class: option_css_classes }) do = release_btn_text diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index fda797f3228..b0be748eb36 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -9,10 +9,9 @@ .nav-controls #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } } - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do - = sprite_icon('rss', css_class: 'gl-icon') + = link_button_to nil, project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'has-tooltip gl-ml-auto', icon: 'rss' - if can?(current_user, :admin_tag, @project) - = link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do + = link_button_to new_project_tag_path(@project), data: { qa_selector: "new_tag_button" }, variant: :confirm do = s_('TagsPage|New tag') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 5127972c406..1649e56043e 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -45,10 +45,8 @@ = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) = render 'edit_release_button', tag: @tag, project: @project, release: @release - = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse files') do - = sprite_icon('folder-open', css_class: 'gl-icon') - = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default has-tooltip', title: s_('TagsPage|Browse commits') do - = sprite_icon('history', css_class: 'gl-icon') + = link_button_to nil, project_tree_path(@project, @tag.name), class: 'has-tooltip', title: s_('TagsPage|Browse files'), icon: 'folder-open' + = link_button_to nil, project_commits_path(@project, @tag.name), class: 'has-tooltip', title: s_('TagsPage|Browse commits'), icon: 'history' = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_tag, @project) = render 'projects/buttons/remove_tag', project: @project, tag: @tag diff --git a/app/views/projects/tracing/index.html.haml b/app/views/projects/tracing/index.html.haml new file mode 100644 index 00000000000..ae6608cf343 --- /dev/null +++ b/app/views/projects/tracing/index.html.haml @@ -0,0 +1,4 @@ +- page_title _('Tracing') + +#js-tracing{ data: { view_model: observability_tracing_view_model(@project) } } + diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml index c2a5dd8a9b0..69969b7f848 100644 --- a/app/views/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_protected_branch.html.haml @@ -27,4 +27,4 @@ %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" + = link_button_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, variant: :danger, size: :small diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 986bc53fd81..caaa209a702 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -8,6 +8,7 @@ = render "layouts/one_trust" = render "layouts/bizible" = render "layouts/google_tag_manager_body" + .row.gl-flex-grow-1 .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5 .edit-profile.login-page.d-flex.flex-column.gl-align-items-center diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index 188ead4008e..a896cbc5cba 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -1,4 +1,4 @@ -%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' } +%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-5! gl-mt-5 gl-mb-0!' } .col-sm-9 %span.gl-display-flex.gl-align-items-center = gl_badge_tag issuable_state_text(issuable), variant: issuable_state_to_badge_class(issuable), size: :sm @@ -11,6 +11,11 @@ = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe .description.term.gl-px-0 = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) + - if issuable.labels.any? + .gl-mt-3 + - presented_labels_sorted_by_title(issuable.labels, issuable.project).each do |label| + = link_to_label(label, small: true) + .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right - if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0 %li.gl-list-style-none diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index d6900c397a0..08d8ffcf250 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,9 +1,6 @@ -- project = wiki_blob.project -- wiki_blob_link = project_wiki_path(project, wiki_blob.basename) - %div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' } %span.gl-display-flex.gl-align-items-center - = link_to wiki_blob_link, data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do + = link_to wiki_blob_link(wiki_blob), data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do %span.term.str-truncated.gl-font-weight-bold= ::Wiki.canonicalize_filename(wiki_blob.path) .description.term.col-sm-10.gl-px-0 = simple_search_highlight_and_truncate(wiki_blob.data, @search_term) diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 03b030eb257..16e4ff4d17f 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -16,4 +16,4 @@ %p = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true), class: 'gl-button btn btn-confirm gl-mr-3' - = link_to _('Cancel'), new_user_session_path, class: 'gl-button btn gl-mr-3' + = link_button_to _('Cancel'), new_user_session_path, class: 'gl-mr-3' diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index f4af3ea70d4..79a9bafc4f0 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -8,5 +8,5 @@ %div = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') - c.with_actions do - = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-confirm' - = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-3' + = link_button_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link', variant: :confirm + = link_button_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link gl-ml-3' diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index dd3a31f5a59..1fd430527a1 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -1,4 +1,5 @@ - referenced_users = local_assigns.fetch(:referenced_users, nil) +- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) - if @merge_request&.discussion_locked? .issuable-note-warning @@ -8,14 +9,14 @@ = _('Only project members can comment.') .md-area.position-relative - .md-header.gl-bg-gray-50.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2 + .md-header.gl-px-3.gl-rounded-top-base.gl-border-b.gl-border-gray-100 .gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-justify-content-space-between - .md-header-toolbar.gl-display-flex.gl-py-2.gl-flex-wrap - = render 'shared/blob/markdown_buttons' - .switch-preview.gl-py-2.gl-display-flex.gl-align-items-center.gl-ml-auto + .md-header-toolbar.gl-display-flex.gl-py-3.gl-flex-wrap.gl-row-gap-3 = render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'js-md-preview-button', value: 'preview' }) do = _('Preview') - = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, size: :small, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter gl-ml-2', data: { container: 'body' } }) + = render 'shared/blob/markdown_buttons', supports_quick_actions: supports_quick_actions + .full-screen + = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, size: :small, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter', data: { container: 'body' } }) .md-write-holder = yield diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml index fb3dfba2691..b84efd2d577 100644 --- a/app/views/shared/_new_merge_request_checkbox.html.haml +++ b/app/views/shared/_new_merge_request_checkbox.html.haml @@ -2,7 +2,8 @@ - nonce = SecureRandom.hex = render Pajamas::CheckboxTagComponent.new(name: 'create_merge_request', checked: true, - checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" }) do |c| + checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" }, + label_options: { for: "create_merge_request-#{nonce}" }) do |c| - c.with_label do - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" } - translation = _('Start a %{new_merge_request} with these changes') % translation_variables diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index e9c0858e090..a3f24da5d7c 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -5,5 +5,5 @@ - c.with_body do = s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.") - c.with_actions do - = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "gl-alert-action btn btn-confirm btn-md gl-button" - = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button' + = link_button_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: 'gl-alert-action', variant: :confirm + = link_button_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action' diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml index ce49193e27b..a99db32c40e 100644 --- a/app/views/shared/_project_limit.html.haml +++ b/app/views/shared/_project_limit.html.haml @@ -5,5 +5,5 @@ - c.with_body do = _("You won't be able to create new projects because you have reached your project limit.") - c.with_actions do - = link_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message btn gl-button btn-confirm' - = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link btn gl-button btn-default gl-ml-3' + = link_button_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message', variant: :confirm + = link_button_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link gl-ml-3' diff --git a/app/views/shared/_prometheus_configuration_banner.html.haml b/app/views/shared/_prometheus_configuration_banner.html.haml index 2d948cf28a6..7469260a997 100644 --- a/app/views/shared/_prometheus_configuration_banner.html.haml +++ b/app/views/shared/_prometheus_configuration_banner.html.haml @@ -17,11 +17,11 @@ .col-sm-10 %p.text-success.gl-mt-3 = s_('PrometheusService|You have a cluster with the Prometheus integration enabled.') - = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-default' + = link_button_to s_('PrometheusService|Manage clusters'), project_clusters_path(project) - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 %p.gl-mt-3 = s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.') - = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm' + = link_button_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), variant: :confirm diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml index 053c511830c..6e386866dfb 100644 --- a/app/views/shared/_registration_features_discovery_message.html.haml +++ b/app/views/shared/_registration_features_discovery_message.html.haml @@ -1,5 +1,5 @@ - feature_title = local_assigns.fetch(:feature_title, s_('RegistrationFeatures|use this feature')) -- registration_features_docs_path = help_page_path('user/admin_area/settings/usage_statistics.md', anchor: 'registration-features-program') +- registration_features_docs_path = help_page_path('administration/settings/usage_statistics.md', anchor: 'registration-features-program') - registration_features_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: registration_features_docs_path } %div diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index bc80ebe3950..fa5c862b768 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -3,5 +3,4 @@ button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body' } }, icon_classes: 'spin') - elsif remote_mirror.enabled? - = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now') do - = sprite_icon("retry") + = link_button_to nil, update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: 'rspec-update-now-button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now'), icon: 'retry' diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml index 108d846e3ee..e0313710736 100644 --- a/app/views/shared/_service_ping_consent.html.haml +++ b/app/views/shared/_service_ping_consent.html.haml @@ -1,11 +1,14 @@ - if session[:ask_for_usage_stats_consent] = render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c| - c.with_body do - - docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link' - - settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link' - = s_('To help improve GitLab, we would like to periodically %{docs_link}. This can be changed at any time in %{settings_link}.').html_safe % { docs_link: docs_link, settings_link: settings_link } + - docs_link = link_to '', help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link' + - settings_link = link_to '', metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link' + = safe_format s_('ServicePing|To help improve GitLab, we would like to periodically %{link_start}collect usage information%{link_end}.'), tag_pair(docs_link, :link_start, :link_end) + = safe_format s_('ServicePing|This can be changed at any time in %{link_start}your settings%{link_end}.'), tag_pair(settings_link, :link_start, :link_end) - c.with_actions do - send_service_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 }) - not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 }) - = link_to _("Send service data"), send_service_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link btn gl-button btn-confirm' - = link_to _("Don't send service data"), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link btn gl-button btn-default gl-ml-3' + = render Pajamas::ButtonComponent.new(href: send_service_data_path, method: :put, variant: :confirm, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link' }) do + = _('Send service data') + = render Pajamas::ButtonComponent.new(href: not_now_path, method: :put, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link gl-ml-3' }) do + = _("Don't send service data") diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml index 290152d5803..e372dbd983c 100644 --- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml +++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml @@ -8,5 +8,5 @@ = s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.') = link_to _('Learn more.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'recovery-codes'), target: '_blank', rel: 'noopener noreferrer' - c.with_actions do - = link_to profile_two_factor_auth_path, class: 'deferred-link btn gl-alert-action btn-confirm btn-md gl-button' do + = link_button_to profile_two_factor_auth_path, class: 'deferred-link gl-alert-action', variant: :confirm do = s_('Profiles|Manage two-factor authentication') diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index ac359d37c49..54af364aca3 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -7,36 +7,27 @@ - access_levels = local_assigns.fetch(:access_levels, false) - default_access_level = local_assigns.fetch(:default_access_level, false) -%h5.gl-mt-0 +%h5.gl-font-lg.gl-mt-0 = title -%p.profile-settings-content - = s_("AccessTokens|Enter the name of your application, and we'll return a unique %{type}.") % { type: type } = gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f| - = form_errors(token) - .row - .form-group.col - .row - = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold col-md-12' - .col-md-6 - - resource_type = resource.is_a?(Group) ? "group" : "project" - = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' - %span.form-text.text-muted.col-md-12#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } + .form-group + = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold' + - resource_type = resource.is_a?(Group) ? "group" : "project" + = f.text_field :name, class: 'form-control gl-form-input gl-max-w-80', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text' + %span.form-text.text-muted#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type } - .row - .col - .js-access-tokens-expires-at{ data: expires_at_field_data } - = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } + .js-access-tokens-expires-at{ data: expires_at_field_data } + = f.text_field :expires_at, class: 'gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } - if resource - .row - .form-group.col-md-6 - = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold" - .select-wrapper - = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' } - = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") + .form-group + = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold" + .select-wrapper.gl-form-input-md + = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' } + = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") .form-group %b{ :'aria-describedby' => 'select_scope_help_text' } diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml index a3d3c1c8231..16bffaca810 100644 --- a/app/views/shared/blob/_markdown_buttons.html.haml +++ b/app/views/shared/blob/_markdown_buttons.html.haml @@ -1,32 +1,33 @@ - modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+') - supports_file_upload = local_assigns.fetch(:supports_file_upload, true) +- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) = markdown_toolbar_button({ icon: "bold", - css_class: 'gl-mr-3', + css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "**", "md-shortcuts": '["mod+b"]' }, title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) }) = markdown_toolbar_button({ icon: "italic", - css_class: 'gl-mr-3', + css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' }, title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) }) = markdown_toolbar_button({ icon: "strikethrough", - css_class: 'gl-mr-3', + css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' }, title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) }) -= markdown_toolbar_button({ icon: "quote", css_class: 'gl-mr-3', data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") }) -= markdown_toolbar_button({ icon: "code", css_class: 'gl-mr-3', data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") }) += markdown_toolbar_button({ icon: "quote", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") }) += markdown_toolbar_button({ icon: "code", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") }) = markdown_toolbar_button({ icon: "link", - css_class: 'gl-mr-3', + css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["mod+k"]' }, title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) }) -= markdown_toolbar_button({ icon: "list-bulleted", css_class: 'gl-mr-3', data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") }) -= markdown_toolbar_button({ icon: "list-numbered", css_class: 'gl-mr-3', data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) -= markdown_toolbar_button({ icon: "list-task", css_class: 'gl-mr-3', data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") }) += markdown_toolbar_button({ icon: "list-bulleted", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") }) += markdown_toolbar_button({ icon: "list-numbered", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) += markdown_toolbar_button({ icon: "list-task", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") }) = markdown_toolbar_button({ icon: "list-indent", css_class: 'gl-display-none gl-mr-3', data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' }, @@ -36,9 +37,11 @@ data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' }, title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) }) = markdown_toolbar_button({ icon: "details-block", - css_class: 'gl-mr-3', + css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" }, title: _("Add a collapsible section") }) -= markdown_toolbar_button({ icon: "table", css_class: 'gl-mr-3', data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") }) += markdown_toolbar_button({ icon: "table", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") }) - if supports_file_upload - = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, size: :small, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button gl-mr-3', data: { testid: 'button-attach-file', container: 'body' } }) + = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, size: :small, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button haml-markdown-button gl-mr-3', data: { testid: 'button-attach-file', container: 'body' } }) +- if supports_quick_actions + = markdown_toolbar_button({ icon: "quick-actions", css_class: 'haml-markdown-button gl-mr-3', data: { "md-tag" => "/", "md-prepend" => true }, title: _("Add a quick action") }) diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 0f290f34a95..8821804ce6b 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -1,4 +1,4 @@ -%p.profile-settings-content +%p - group_deploy_tokens_help_link_url = help_page_path('user/project/deploy_tokens/index.md') - group_deploy_tokens_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_deploy_tokens_help_link_url } = s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index a7bf3bfb81e..3827ecf73a4 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -16,7 +16,7 @@ %tr %td= token.name %td= token.username - %td= token.created_at.to_date.to_s(:medium) + %td= token.created_at.to_date.to_fs(:medium) %td - if token.expires? %span{ class: ('text-warning' if token.expires_soon?) } diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml index 628a34e1278..ae539c46cf1 100644 --- a/app/views/shared/doorkeeper/applications/_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f| += gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form gl-max-w-80' } do |f| = form_errors(@application) .form-group diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml index abfe3baf8b4..bf78f275d65 100644 --- a/app/views/shared/doorkeeper/applications/_index.html.haml +++ b/app/views/shared/doorkeeper/applications/_index.html.haml @@ -1,88 +1,86 @@ - @force_desktop_expanded_sidebar = true -.row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = page_title - %p - - if oauth_applications_enabled - - if oauth_authorized_applications_enabled - = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.") - - else - = _("Manage applications that use GitLab as an OAuth provider.") - - else - = _("Manage applications that you've authorized to use your account.") - .col-lg-8 +.settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title + %p.gl-text-secondary - if oauth_applications_enabled - %h5.gl-mt-0 - = _('Add new application') - = render 'shared/doorkeeper/applications/form', url: form_url - %hr + - if oauth_authorized_applications_enabled + = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.") + - else + = _("Manage applications that use GitLab as an OAuth provider.") - else - .bs-callout.bs-callout-disabled - = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission') - - if oauth_applications_enabled - .oauth-applications - %h5 - = _("Your applications (%{size})") % { size: @applications.size } - - if @applications.any? - .table-responsive - %table.table - %thead - %tr - %th= _('Name') - %th= _('Callback URL') - %th= _('Clients') - %th.last-heading - %tbody - - @applications.each do |application| - %tr{ id: "application_#{application.id}" } - %td= link_to application.name, application_url.call(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td.gl-display-flex - = link_to edit_application_url.call(application), class: "gl-button btn btn-default btn-icon gl-mr-3" do - %span.sr-only - = _('Edit') - = sprite_icon('pencil') - = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true - - else - .settings-message.text-center - = _("You don't have any applications") - - if oauth_authorized_applications_enabled - .oauth-authorized-applications.prepend-top-20.gl-mb-3 - - if oauth_applications_enabled - %h5 - = _("Authorized applications (%{size})") % { size: @authorized_tokens.size } + = _("Manage applications that you've authorized to use your account.") + - if oauth_applications_enabled + %h5.gl-mt-0 + = _('Add new application') + .gl-border-b.gl-pb-6 + = render 'shared/doorkeeper/applications/form', url: form_url + + - else + .bs-callout.bs-callout-disabled + = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission') + - if oauth_applications_enabled + .oauth-applications.gl-pt-6 + %h5.gl-mt-0 + = _("Your applications (%{size})") % { size: @applications.size } + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th= _('Name') + %th= _('Callback URL') + %th= _('Clients') + %th.last-heading + %tbody + - @applications.each do |application| + %tr{ id: "application_#{application.id}" } + %td= link_to application.name, application_url.call(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td.gl-display-flex + = link_button_to nil, edit_application_url.call(application), class: 'gl-mr-3', icon: 'pencil', 'aria-label': _('Edit') + = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true + - else + .settings-message + = _("You don't have any applications") + - if oauth_authorized_applications_enabled + .oauth-authorized-applications.gl-mt-4 + - if oauth_applications_enabled + %h5.gl-mt-0 + = _("Authorized applications (%{size})") % { size: @authorized_tokens.size } - - if @authorized_tokens.any? - .table-responsive - %table.table.table-striped - %thead - %tr - %th= _('Name') - %th= _('Authorized At') - %th= _('Scope') - %th - %tbody - - @authorized_tokens.each do |token| - %tr{ id: ("application_#{token.application.id}" if token.application) } - %td - - if token.application - = token.application.name - - else - = _('Anonymous') - .form-text.text-muted - %em= _("Authorization was granted by entering your username and password in the application.") - %td= token.created_at - %td= token.scopes - %td - - if token.application - = render 'doorkeeper/authorized_applications/delete_form', application: token.application - - else - = render 'doorkeeper/authorized_applications/delete_form', token: token - - else - .settings-message.text-center - = _("You don't have any authorized applications") + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th= _('Name') + %th= _('Authorized At') + %th= _('Scope') + %th + %tbody + - @authorized_tokens.each do |token| + %tr{ id: ("application_#{token.application.id}" if token.application) } + %td + - if token.application + = token.application.name + - else + = _('Anonymous') + .form-text.text-muted + %em= _("Authorization was granted by entering your username and password in the application.") + %td= token.created_at + %td= token.scopes + %td + - if token.application + = render 'doorkeeper/authorized_applications/delete_form', application: token.application + - else + = render 'doorkeeper/authorized_applications/delete_form', token: token + - else + .settings-message + = _("You don't have any authorized applications") diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index b9095e2a1a1..b075cece877 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -43,8 +43,8 @@ .form-actions.gl-display-flex.gl-justify-content-space-between %div - 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' + = link_button_to _('Continue'), index_path, class: 'gl-mr-3', variant: :confirm + = link_button_to _('Edit'), edit_path = render 'shared/doorkeeper/applications/delete_form', path: delete_path -# Create a hidden field to save the ID of application created diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 387a83873b5..a4ea98a0fb7 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -19,7 +19,7 @@ = _("To widen your search, change or remove filters above") - if show_new_issue_link?(@project) .text-center - = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm" + = link_button_to _("New issue"), new_project_issue_path(@project), variant: :confirm - elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0 %h4.text-center = _("There are no open issues") @@ -27,7 +27,7 @@ = _("To keep this project going, create a new issue") - if show_new_issue_link?(@project) .text-center - = link_to _("New issue"), new_project_issue_path(@project), class: "gl-button btn btn-confirm" + = link_button_to _("New issue"), new_project_issue_path(@project), variant: :confirm - elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0 %h4.text-center = _("There are no closed issues") @@ -39,7 +39,7 @@ - if button_path .text-center - if show_new_issue_link?(@project) - = link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link' + = link_button_to _('New issue'), button_path, id: 'new_issue_link', variant: :confirm - if show_import_button .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: 'issue', import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } } @@ -59,4 +59,4 @@ %p = _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.") .text-center - = link_to _('Register / Sign In'), new_user_session_path, class: 'gl-button btn btn-confirm' + = link_button_to _('Register / Sign In'), new_user_session_path, variant: :confirm diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index da88c139a6e..4d2127c0161 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -8,7 +8,7 @@ %p= _("You can also star a label to make it a priority label.") .text-center - if can?(current_user, :admin_label, @project) - = link_to _('New label'), new_project_label_path(@project), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link' - = link_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, class: 'btn gl-button btn-confirm-secondary', title: _('Generate a default set of labels'), id: 'generate_labels_link' + = link_button_to _('New label'), new_project_label_path(@project), title: _('New label'), id: 'new_label_link', variant: :confirm + = link_button_to _('Generate a default set of labels'), generate_project_labels_path(@project), method: :post, title: _('Generate a default set of labels'), id: 'generate_labels_link', variant: :confirm, category: :secondary - if can?(current_user, :admin_label, @group) - = link_to _('New label'), new_group_label_path(@group), class: 'btn gl-button btn-confirm', title: _('New label'), id: 'new_label_link' + = link_button_to _('New label'), new_group_label_path(@group), title: _('New label'), id: 'new_label_link', variant: :confirm diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 94589996c3a..5b377818c6e 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -18,7 +18,7 @@ = _("To widen your search, change or remove filters above") .text-center - if can_create_merge_request - = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request") + = link_button_to _("New merge request"), button_path || project_new_merge_request_path(@project), title: _("New merge request"), variant: :confirm - elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0 %h4.text-center = _("There are no open merge requests") @@ -26,7 +26,7 @@ = _("To keep this project going, create a new merge request") .text-center - if can_create_merge_request - = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request") + = link_button_to _("New merge request"), button_path || project_new_merge_request_path(@project), title: _("New merge request"), variant: :confirm - elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0 %h4.text-center = _("There are no closed merge requests") @@ -37,4 +37,4 @@ = _("Interested parties can even contribute by pushing commits if they want to.") - if button_path .text-center - = 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_button_to _('New merge request'), button_path, title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" }, variant: :confirm diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml index b24fa0b3bdb..688df1705aa 100644 --- a/app/views/shared/empty_states/_priority_labels.html.haml +++ b/app/views/shared/empty_states/_priority_labels.html.haml @@ -1,8 +1,8 @@ -.text-center.gl-mt-1.gl-mb-6 +.text-center.gl-mt-1.gl-mb-5 .svg-content{ data: { qa_selector: 'label_svg_content' } } = image_tag 'illustrations/empty-state/empty-labels-starred-md.svg' - if can?(current_user, :admin_label, @project) - %div + %h5.gl-my-0 = _("No prioritized labels yet!") - %div + %p.gl-text-secondary = _("Star labels to start sorting by priority.") diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml index c813fd691f1..ba5fbd90528 100644 --- a/app/views/shared/empty_states/_profile_tabs.html.haml +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -13,9 +13,9 @@ %p= current_user_empty_message_description - if secondary_button_link.present? - = link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-confirm btn-inverted' + = link_button_to secondary_button_label, secondary_button_link, variant: :confirm, category: :secondary - if primary_button_link.present? - = link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-confirm' + = link_button_to primary_button_label, primary_button_link, variant: :confirm - else %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index 87de756093d..6fe36d75453 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -12,7 +12,7 @@ = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') .gl-mt-3< - if button_path - = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' } - = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation') + = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }, variant: :confirm + = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), title: s_('SnippetsEmptyState|Documentation') - else %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index 57f1c9d381e..9e628a1f409 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -4,7 +4,7 @@ - if !hide_create && can?(current_user, :create_wiki, @wiki.container) - create_path = wiki_page_path(@wiki, params[:id], view: 'create') - - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm', title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' } + - create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }, variant: :confirm = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do %h4.text-left @@ -26,7 +26,7 @@ %p.text-left = messages.dig(:issuable, :body).html_safe % { issues_link: issues_link } - if show_new_issue_link?(@project) - = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement') + = link_button_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), title: s_('WikiEmptyIssueMessage|Suggest wiki improvement'), variant: :confirm - else = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index ba968c6b2d2..9f1b11d6ab5 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -1,26 +1,30 @@ - file_hooks = Gitlab::FileHook.files -.row.gl-mt-3 - .col-lg-4 - %h4.gl-mt-0 - = _('File Hooks') - %p - = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.') - = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks') +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = _('File Hooks') + %p.gl-text-secondary + = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.') + = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks') - - .col-lg-8.gl-mb-3 - - if file_hooks.any? - = render Pajamas::CardComponent.new do |c| - - c.with_header do - = _('File Hooks (%{count})') % { count: file_hooks.count } - - c.with_body do - %ul.content-list - - file_hooks.each do |file| - %li - .monospace - = File.basename(file) - - else - = render Pajamas::CardComponent.new do |c| - - c.with_body do - .nothing-here-block= _('No file hooks found.') + .gl-mb-3 + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = _('File Hooks') + %span.gl-new-card-count + = sprite_icon('hook', css_class: 'gl-mr-2') + #{file_hooks.count} + - c.with_body do + .gl-new-card-content + - if file_hooks.any? + %ul.content-list{ class: 'gl-my-n3!' } + - file_hooks.each do |file| + %li.label-list-item + .monospace + = File.basename(file) + - else + .gl-new-card-empty.gl-text-center= _('No file hooks found.') diff --git a/app/views/shared/form_elements/_apply_generated_description_warning.haml b/app/views/shared/form_elements/_apply_generated_description_warning.haml new file mode 100644 index 00000000000..906f60d01e6 --- /dev/null +++ b/app/views/shared/form_elements/_apply_generated_description_warning.haml @@ -0,0 +1,13 @@ +.form-group.row.js-ai-description-warning.hidden.js-issuable-ai-description-warning + .col-sm-12 + .warning_message.mb-0{ role: 'alert' } + %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": "true", "aria-label": _("Close") } + = sprite_icon("close") + + %p + = s_("AI|Replace the existing description with an AI-generated description? Any changes you have made will be lost.") + + = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3 js-ai-override-description' }) do + = s_("AI|Apply AI-generated description") + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-cancel-btn' }) do + = _("Cancel") diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 415849672b6..75f678dea5c 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -15,12 +15,15 @@ = render 'shared/issuable/form/template_selector', issuable: model = render 'shared/form_elements/apply_template_warning', issuable: model + - if model.is_a?(MergeRequest) + = render 'shared/form_elements/apply_generated_description_warning', issuable: model .js-markdown-editor{ data: { render_markdown_path: preview_url, markdown_docs_path: help_page_path('user/markdown'), quick_actions_docs_path: help_page_path('user/project/quick_actions'), qa_selector: 'issuable_form_description_field', form_field_placeholder: placeholder, + autofocus: 'false', form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } } = form.hidden_field :description diff --git a/app/views/shared/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml index 7dab14b95c1..ee7d5b79560 100644 --- a/app/views/shared/hook_logs/_index.html.haml +++ b/app/views/shared/hook_logs/_index.html.haml @@ -2,10 +2,9 @@ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url } - link_end = '</a>'.html_safe -.row.gl-mt-3.gl-mb-3 - .col-lg-3 - %h4.gl-mt-0 - = _('Recent events') - %p= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end } - .col-lg-9 - = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs } +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0= _('Recent events') + %p.gl-text-secondary= _('GitLab events trigger webhooks. Use the request details of a webhook to help troubleshoot problems. %{link_start}How do I troubleshoot?%{link_end}').html_safe % { link_start: link_start, link_end: link_end } + = render partial: 'shared/hook_logs/recent_deliveries_table', locals: { hook: hook, hook_logs: hook_logs } diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml deleted file mode 100644 index b22a6eeca90..00000000000 --- a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -= link_to add_to_slack_link(project, slack_app_id), class: 'btn btn-default gl-button gl-pr-6!' do - = image_tag 'illustrations/slack_logo.svg', class: 'gl-icon gl-button-icon gl-w-9! gl-h-9! gl-my-n3! gl-mr-0!' - %strong.gl-button-text - = label diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml index 5c9f77f8c12..e5d05a8a83d 100644 --- a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml +++ b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml @@ -20,13 +20,16 @@ %td{ class: 'gl-py-3!' } = time_ago_with_tooltip(slack_integration.created_at) %td{ class: 'gl-py-3!' } - .controls + .controls.gl-display-flex.gl-gap-3 - project = integration.project - = link_to _('Edit'), edit_project_settings_slack_path(project), class: 'btn gl-button btn-default' - = link_to sprite_icon('remove', css_class: 'gl-icon'), project_settings_slack_path(project), method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary', aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') } + = render Pajamas::ButtonComponent.new(href: edit_project_settings_slack_path(project)) do + = _('Edit') + = render Pajamas::ButtonComponent.new(method: :delete, category: 'secondary', variant: "danger", href: project_settings_slack_path(project), icon: 'remove', button_options: { aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') }}) .gl-my-5 - = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Reinstall GitLab for Slack app') + = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do + = s_('SlackIntegration|Reinstall GitLab for Slack app…') %p = html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application.md', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe} - else - = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Install GitLab for Slack app') + = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do + = s_('SlackIntegration|Install GitLab for Slack app…') diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml index 0264196f60c..a7a650aa95d 100644 --- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml @@ -11,7 +11,7 @@ %strong = s_('PrometheusService|Custom metrics') = gl_badge_tag 0, nil, class: 'gl-ml-2 js-custom-monitored-count' - = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden' + = link_button_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'gl-ml-auto js-new-metric-button hidden', variant: :confirm - c.with_body do .flash-container.hidden .flash-warning diff --git a/app/views/shared/integrations/slack_slash_commands/_help.html.haml b/app/views/shared/integrations/slack_slash_commands/_help.html.haml index fee0ca15808..43a240fa6fe 100644 --- a/app/views/shared/integrations/slack_slash_commands/_help.html.haml +++ b/app/views/shared/integrations/slack_slash_commands/_help.html.haml @@ -57,7 +57,7 @@ = label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold' .col-12 = image_tag(asset_url('slash-command-logo.png', skip_pipeline: true), width: 36, height: 36, class: 'mr-3') - = link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'gl-button btn btn-default btn-sm', target: '_blank', rel: 'noopener noreferrer') + = link_button_to _('Download image'), asset_url('gitlab_logo.png'), target: '_blank', rel: 'noopener noreferrer', size: :small .form-group = label_tag nil, _('Autocomplete'), class: 'col-12 col-form-label label-bold' diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index b6bd691213c..42f035b99aa 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -61,14 +61,14 @@ = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button js-reset-autosave', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - if issuable.new_record? - = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave' + = link_button_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'js-reset-autosave' - else - = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default js-reset-autosave' + = link_button_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'js-reset-autosave' - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) - confirm_title = _('Delete %{issuableType}?') % { issuableType: issuable.human_class_name } - confirm_body = _('You’re about to permanently delete the %{issuableType} ‘%{strongOpen}%{issuableTitle}%{strongClose}’. To avoid data loss, consider %{strongOpen}closing this %{issuableType}%{strongClose} instead. Once deleted, it cannot be undone or recovered.') % { issuableType: issuable.human_class_name, issuableTitle: issuable.title, strongOpen: '<strong>', strongClose: '</strong>' } - confirm_primary_btn_text = _('Delete %{issuableType}') % { issuableType: issuable.human_class_name } - = link_to _('Delete'), polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { title: confirm_title, confirm: confirm_body, is_html_message: true, confirm_btn_variant: 'danger'}, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right js-reset-autosave', "aria-label": confirm_primary_btn_text + = link_button_to _('Delete'), polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { title: confirm_title, confirm: confirm_body, is_html_message: true, confirm_btn_variant: 'danger'}, method: :delete, class: 'gl-float-right js-reset-autosave', "aria-label": confirm_primary_btn_text, variant: :danger, category: :secondary - if issuable.respond_to?(:issue_type) = form.hidden_field :issue_type diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index b8f98c28574..d590c859945 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -34,7 +34,7 @@ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg @@ -44,7 +44,7 @@ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } %li.filter-dropdown-item{ data: { value: "{{ title }}" } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } {{ title }} %span.btn-helptext {{ help }} @@ -60,10 +60,10 @@ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore - if current_user @@ -76,10 +76,10 @@ #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore - if current_user @@ -94,101 +94,101 @@ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.filter-dropdown-item{ data: { value: 'Upcoming' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Upcoming') %li.filter-dropdown-item{ data: { value: 'Started' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Started') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link.js-data-value{ type: 'button' } + %button.btn.btn-link.js-data-value{ type: 'button' } {{title}} = render_if_exists 'shared/issuable/filter_iteration', type: type #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link.js-data-value{ type: 'button' } + %button.btn.btn-link.js-data-value{ type: 'button' } {{title}} #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('None') %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } %gl-emoji %span.js-data-value.gl-ml-3 {{name}} #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Yes') %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('No') - if ::Feature.enabled?(:mr_approved_filter, type: :ops) #js-dropdown-approved.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Yes') %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('No') #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('Yes') %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.gl-button.btn.btn-link{ type: 'button' } + %button.btn.btn-link{ type: 'button' } = _('No') - unless disable_target_branch #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link.js-data-value.monospace + %button.btn.btn-link.js-data-value.monospace {{title}} #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.gl-button.btn.btn-link.js-data-value{ type: 'button' } + %button.btn.btn-link.js-data-value{ type: 'button' } {{title}} = render_if_exists 'shared/issuable/filter_weight', type: type diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index ee1ca364b07..fadaeafeaf6 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -12,10 +12,10 @@ - moved_sidebar_enabled = moved_mr_sidebar_enabled? - is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled -%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } +%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" } .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } - %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' } } + = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do = sidebar_gutter_toggle_icon - if signed_in && !is_merge_request_with_flag .js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index c6f3e4d97a8..a27bb506c87 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -2,7 +2,6 @@ - dropdown_options = assignees_dropdown_options(issuable_type) .js-sidebar-assignees-root{ data: { field: issuable_type, - signed_in: signed_in, max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: can_admin_project_member?(@project) } } .title.hide-collapsed diff --git a/app/views/shared/issuable/_status_box.html.haml b/app/views/shared/issuable/_status_box.html.haml index 125ef921cfa..cca51b48322 100644 --- a/app/views/shared/issuable/_status_box.html.haml +++ b/app/views/shared/issuable/_status_box.html.haml @@ -2,7 +2,7 @@ - badge_icon = state_name_with_icon(issuable)[1] - badge_variant = issuable.open? ? :success : issuable.merged? ? :info : :danger - badge_status_class = issuable.open? ? 'issuable-status-badge-open' : issuable.merged? ? 'issuable-status-badge-merged' : 'issuable-status-badge-closed' -- badge_classes = "js-mr-status-box issuable-status-badge gl-mr-3 #{badge_status_class} #{'gl-vertical-align-bottom' if issuable.is_a?(MergeRequest)}" +- badge_classes = "js-mr-status-box issuable-status-badge gl-mr-3 gl-align-self-center #{badge_status_class} #{'gl-vertical-align-bottom' if issuable.is_a?(MergeRequest)}" = gl_badge_tag({ variant: badge_variant, icon: badge_icon, icon_classes: 'gl-mr-0!' }, { class: badge_classes, data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, issuable_type: 'merge_request', state: issuable.state } }) do %span.gl-display-none.gl-sm-display-block.gl-ml-2 diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index b6c0b73a83d..4997d429587 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -16,7 +16,6 @@ #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } = issuable_meta(issuable, @project) - %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } - = sprite_icon('chevron-double-lg-left') + = render Pajamas::ButtonComponent.new(href: '#', icon: 'chevron-double-lg-left', button_options: { class: 'gl-float-right gl-display-block gl-sm-display-none! gutter-toggle issuable-gutter-toggle js-sidebar-toggle' }) .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) } diff --git a/app/views/shared/members/_manage_access_button.html.haml b/app/views/shared/members/_manage_access_button.html.haml index c88198ec380..910d62d4dc4 100644 --- a/app/views/shared/members/_manage_access_button.html.haml +++ b/app/views/shared/members/_manage_access_button.html.haml @@ -1,7 +1,5 @@ - path = local_assigns.fetch(:path, nil) .gl-float-right - = link_to path, class: 'btn btn-default btn-sm gl-button' do - = sprite_icon('pencil', css_class: 'gl-icon gl-button-icon') - %span.gl-button-text - = _('Manage access') + = link_button_to path, size: :small, icon: 'pencil' do + = _('Manage access') diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 376e51a6b15..c86993f5b77 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -43,7 +43,7 @@ = _("Given access %{time_ago}").html_safe % { time_ago: time_ago_with_tooltip(member.created_at) } %span.js-expires-in{ class: ('gl-display-none' unless member.expires?) } · - %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_s(:medium) if member.expires?) } + %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_fs(:medium) if member.expires?) } - if member.expires? - preposition = current_user.time_display_relative ? '' : 'on' = _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition } diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index d2bee57992d..1e856bf4355 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -8,7 +8,7 @@ = markdown_field(label, :description) .float-right.d-none.d-lg-block - = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary' do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue') - = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary' do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue') + = link_button_to milestones_issues_path(options.merge(state: 'opened')), category: :tertiary do + = n_('open issue', 'open issues', milestone_issues_by_label_count(@milestone, label, state: :opened)) + = link_button_to milestones_issues_path(options.merge(state: 'closed')), category: :tertiary do + = n_('closed issue', 'closed issues', milestone_issues_by_label_count(@milestone, label, state: :closed)) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 01548325c83..c36d3a8b92b 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -52,6 +52,6 @@ = render Pajamas::ButtonComponent.new(icon: 'level-up', category: :tertiary, size: :small, button_options: { class: 'js-promote-project-milestone-button', title: s_('Milestones|Promote to Group Milestone'), disabled: true, data: { toggle: 'tooltip', container: 'body', url: promote_project_milestone_path(milestone.project, milestone), milestone_title: milestone.title, group_name: @project.group.name } }) - if can?(current_user, :admin_milestone, milestone) - if milestone.closed? - = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm gl-ml-3" + = link_button_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: 'gl-ml-3', size: :small - else - = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn gl-button btn-default btn-default-secondary btn-sm gl-ml-3" + = link_button_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: 'gl-ml-3', category: :secondary, size: :small diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 5477b9395ea..1b0eeb424c2 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -26,7 +26,7 @@ .value %span.value-content{ data: { qa_selector: 'start_date_content' } } - if milestone.start_date - %span.bold= milestone.start_date.to_s(:medium) + %span.bold= milestone.start_date.to_fs(:medium) - else %span.no-value= s_('MilestoneSidebar|No start date') @@ -63,7 +63,7 @@ .value.hide-collapsed %span.value-content{ data: { qa_selector: 'due_date_content' } } - if milestone.due_date - %span.bold= milestone.due_date.to_s(:medium) + %span.bold= milestone.due_date.to_fs(:medium) - else %span.no-value= s_('MilestoneSidebar|No due date') - remaining_days = remaining_days_in_words(milestone.due_date, milestone.start_date) diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml index 915352996d9..91b0582e04a 100644 --- a/app/views/shared/nav/_sidebar.html.haml +++ b/app/views/shared/nav/_sidebar.html.haml @@ -1,6 +1,6 @@ %aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label } .nav-sidebar-inner-scroll - %ul.sidebar-top-level-items{ data: { qa_selector: sidebar_qa_selector(sidebar.container) } } + %ul.sidebar-top-level-items{ data: { testid: sidebar_qa_selector(sidebar.container) } } - if sidebar.render_raw_scope_menu_partial = render sidebar.render_raw_scope_menu_partial - elsif sidebar.scope_menu diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index bc0648c14e0..27f77ed4813 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -2,7 +2,7 @@ - if sidebar_menu.menu_with_partial? = render_if_exists sidebar_menu.menu_partial, **sidebar_menu.menu_partial_options - else - = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do + = link_to sidebar_menu.link, **sidebar_menu.link_html_options, data: { testid: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do - if sidebar_menu.icon_or_image? %span.nav-icon-container - if sidebar_menu.image_path diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml index eea36127745..ef488d06e87 100644 --- a/app/views/shared/nav/_sidebar_menu_item.html.haml +++ b/app/views/shared/nav/_sidebar_menu_item.html.haml @@ -1,5 +1,5 @@ = nav_link(**sidebar_menu_item.active_routes, html_options: sidebar_menu_item.nav_link_html_options) do - = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do + = link_to sidebar_menu_item.link, **sidebar_menu_item.link_html_options, data: { testid: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do %span.gl-flex-grow-1 = sidebar_menu_item.title - if sidebar_menu_item.sprite_icon diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 98008fede90..9a5e9b2179f 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -5,7 +5,7 @@ - else - preview_url = preview_markdown_path(@project) -= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| += form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder gl-border-top-0!", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) @@ -25,14 +25,14 @@ = f.hidden_field :position .discussion-form-container.discussion-with-resolve-btn.flex-column.p-0 - = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do + = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true, supports_quick_actions: supports_quick_actions } do = render 'shared/zen', f: f, qa_selector: 'note_field', attr: :note, classes: 'note-textarea js-note-text', placeholder: _("Write a comment or drag your files here…"), supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete - = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions + = render 'shared/notes/hints' .error-alert .note-form-actions.clearfix.gl-display-flex.gl-flex-wrap diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index d7d6e477ab1..23ce38d50e0 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,13 +1,7 @@ -- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) - supports_file_upload = local_assigns.fetch(:supports_file_upload, true) -.comment-toolbar.gl-mx-2.gl-mb-2.gl-px-4.gl-bg-gray-10.gl-rounded-bottom-left-base.gl-rounded-bottom-right-base.clearfix - .toolbar-text.gl-font-sm - - markdownLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/markdown') } - - quickActionsLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/quick_actions') } - - if supports_quick_actions - = html_escape(s_('NoteToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}. For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe, quickActionsDocsLinkStart: quickActionsLinkStart, quickActionsDocsLinkEnd: '</a>'.html_safe, keyboardStart: '<kbd>'.html_safe, keyboardEnd: '</kbd>'.html_safe } - - else - = html_escape(s_('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe } +.comment-toolbar.gl-px-2.gl-display-flex.gl-justify-content-end.gl-rounded-bottom-left-base.gl-rounded-bottom-right-base.clearfix + .content-editor-switcher.gl-display-inline-flex.gl-align-items-center + = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'markdown-mark', size: :small, href: help_page_path('user/markdown'), target: '_blank', button_options: { class: 'gl-px-3!' }) - if supports_file_upload %span.uploading-container.gl-line-height-32.gl-font-sm %span.uploading-progress-container.hide diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 72709b3ed2f..2388bf2f0be 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -51,5 +51,5 @@ .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 + = link_button_to new_project_path, class: 'gl-display-inline gl-mb-0!', variant: :confirm do = _('New Project') diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 7eafd6ae092..a0e55cd5723 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -3,7 +3,7 @@ .js-vue-webhook-form{ data: webhook_form_data(hook) } .form-group = form.label :token, s_('Webhooks|Secret token'), class: 'label-bold' - = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input' + = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-48' %p.form-text.text-muted - code_start = '<code>'.html_safe - code_end = '</code>'.html_safe @@ -11,59 +11,66 @@ .form-group = form.label :url, s_('Webhooks|Trigger'), class: 'label-bold' %ul.list-unstyled - %li.gl-pb-5 + %li.gl-pb-3 .js-vue-push-events{ data: { push_events: hook.push_events.to_s, strategy: hook.branch_filter_strategy, is_new_hook: hook.new_record?.to_s, push_events_branch_filter: hook.push_events_branch_filter } } - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :tag_push_events, integration_webhook_event_human_name(:tag_push_events), help_text: s_('Webhooks|A new tag is pushed to the repository.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :note_events, integration_webhook_event_human_name(:note_events), help_text: s_('Webhooks|A comment is added to an issue or merge request.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :confidential_note_events, integration_webhook_event_human_name(:confidential_note_events), help_text: s_('Webhooks|A comment is added to a confidential issue.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :issues_events, integration_webhook_event_human_name(:issues_events), help_text: s_('Webhooks|An issue is created, updated, closed, or reopened.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :confidential_issues_events, integration_webhook_event_human_name(:confidential_issues_events), help_text: s_('Webhooks|A confidential issue is created, updated, closed, or reopened.') - if @group = render_if_exists 'groups/hooks/member_events', form: form = render_if_exists 'groups/hooks/subgroup_events', form: form - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :merge_requests_events, integration_webhook_event_human_name(:merge_requests_events), help_text: s_('Webhooks|A merge request is created, updated, or merged.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :job_events, integration_webhook_event_human_name(:job_events), help_text: s_("Webhooks|A job's status changes.") - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :pipeline_events, integration_webhook_event_human_name(:pipeline_events), help_text: s_("Webhooks|A pipeline's status changes.") - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :wiki_page_events, integration_webhook_event_human_name(:wiki_page_events), help_text: s_('Webhooks|A wiki page is created or updated.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :deployment_events, integration_webhook_event_human_name(:deployment_events), help_text: s_('Webhooks|A deployment starts, finishes, fails, or is canceled.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :feature_flag_events, integration_webhook_event_human_name(:feature_flag_events), help_text: s_('Webhooks|A feature flag is turned on or off.') - %li.gl-pb-5 + %li.gl-pb-3 = form.gitlab_ui_checkbox_component :releases_events, integration_webhook_event_human_name(:releases_events), help_text: s_('Webhooks|A release is created or updated.') + - if Feature.enabled?(:emoji_webhooks, hook.parent) + %li.gl-pb-5 + - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') + = form.gitlab_ui_checkbox_component :emoji_events, + integration_webhook_event_human_name(:emoji_events), + help_text: s_('Webhooks|An emoji is awarded or revoked. %{help_link}?').html_safe % { help_link: emoji_help_link } + .form-group = form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox' %ul.list-unstyled diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 155a7b1827f..50ce6552616 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -1,10 +1,10 @@ - sslStatus = hook.enable_ssl_verification ? _('enabled') : _('disabled') - sslBadgeText = _('SSL Verification:') + ' ' + sslStatus -%li - .row - .col-md-8.col-lg-7 - %strong.light-header +%li.label-list-item + .gl-display-flex.lgl-align-items-center.row.gl-mx-n1 + .col-md-8.col-lg-7.gl-px-3 + .light-header.gl-mb-2 = hook.url - if hook.rate_limited? = gl_badge_tag(_('Disabled'), variant: :danger, size: :sm) @@ -19,7 +19,7 @@ = gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm) = gl_badge_tag(sslBadgeText, size: :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 + .col-md-4.col-lg-5.gl-mt-2.gl-px-3.gl-gap-3.gl-display-flex.gl-md-justify-content-end.gl-align-items-baseline = render 'shared/web_hooks/test_button', hook: hook, size: 'small' = render Pajamas::ButtonComponent.new(href: edit_hook_path(hook), size: :small) do = _('Edit') diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 8a81e697a59..0ea6a0307ba 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -1,13 +1,24 @@ -%hr -= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index' }, body_options: { class: 'gl-py-0'}) do |c| += render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index', class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header'}, body_options: { class: 'gl-new-card-body'}) do |c| - c.with_header do - = hook_class.underscore.humanize.titleize.pluralize - (#{hooks.size}) + .gl-new-card-title-wrapper + %h3.gl-new-card-title + = hook_class.underscore.humanize.titleize.pluralize + %span.gl-new-card-count + = sprite_icon('hook', css_class: 'gl-mr-2') + #{hooks.size} + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content' }) do + = _('Add new webhook') - c.with_body do - - if hooks.any? - %ul.content-list - - hooks.each do |hook| - = render 'shared/web_hooks/hook', hook: hook - - else - %p.text-center.gl-mt-3.gl-mb-3 - = _('No webhooks enabled. Select trigger events above.') + .gl-new-card-content + = gitlab_ui_form_for @hook, as: :hook, url: url, html: { class: 'js-webhook-form gl-new-card-add-form gl-mb-3 gl-display-none js-toggle-content' } do |f| + = render partial: partial, locals: { form: f, hook: @hook } + = f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" } + = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'js-webhook-edit-close gl-ml-2 js-toggle-button' }) do + = _('Cancel') + - if hooks.any? + %ul.content-list{ class: 'gl-my-n3!' } + - hooks.each do |hook| + = render 'shared/web_hooks/hook', hook: hook + - else + %p.gl-new-card-empty.gl-text-center + = _('No webhooks enabled. Select trigger events above.') diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml index c220b46f70f..ae32dcea7cb 100644 --- a/app/views/shared/web_hooks/_title_and_docs.html.haml +++ b/app/views/shared/web_hooks/_title_and_docs.html.haml @@ -1,10 +1,12 @@ - webhooks_link_start = '<a href="%{url}">'.html_safe % { url: help_page_path(hook.help_path) } -%h4.gl-mt-0 - = page_title +.settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 + = page_title - if @project - integrations_link_start = '<a href="%{url}">'.html_safe % { url: scoped_integrations_path(project: @project) } - %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } + %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project. We recommend using an %{integrations_link_start}integration%{link_end} in preference to a webhook.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } - else - %p= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe } + %p.gl-text-secondary= _("%{webhooks_link_start}%{webhook_type}%{link_end} enable you to send notifications to web applications in response to events in a group or project.").html_safe % { webhooks_link_start: webhooks_link_start, webhook_type: hook.pluralized_name, link_end: '</a>'.html_safe } diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml index cbbb2f51fd5..1580fc0bd6d 100644 --- a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml +++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml @@ -10,4 +10,4 @@ = succeed '.' do = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' - c.with_actions do - = link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button' + = link_button_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'gl-alert-action', variant: :confirm diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml index c1fd8c48c60..41831c95198 100644 --- a/app/views/shared/wikis/_main_links.html.haml +++ b/app/views/shared/wikis/_main_links.html.haml @@ -1,6 +1,6 @@ - if @page&.persisted? - = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button btn-default", role: "button", data: { qa_selector: 'page_history_button' } do + = link_button_to wiki_page_path(@wiki, @page, action: :history), role: "button", data: { qa_selector: 'page_history_button' } do = s_("Wiki|Page history") - if can?(current_user, :create_wiki, @wiki.container) - = link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-confirm-secondary", role: "button", data: { qa_selector: 'new_page_button' } do + = link_button_to wiki_path(@wiki, action: :new), role: "button", data: { qa_selector: 'new_page_button' }, variant: :confirm, category: :secondary do = s_("Wiki|New page") diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index 8b8c981da96..a34827602ab 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -32,5 +32,5 @@ = 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 + = link_button_to wiki_path(@wiki, action: :pages), data: { qa_selector: 'view_all_pages_button' }, block: true do = s_("Wiki|View All Pages") diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml index ee6c7f307a7..67772ec40c1 100644 --- a/app/views/shared/wikis/diff.html.haml +++ b/app/views/shared/wikis/diff.html.haml @@ -12,7 +12,7 @@ = _('Changes') .nav-controls.pb-md-3.pb-lg-0 - = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn gl-button', role: 'button', data: { qa_selector: 'page_history_button' } do + = link_button_to wiki_page_path(@wiki, @page, action: :history), role: 'button', data: { qa_selector: 'page_history_button' } do = s_('Wiki|Page history') .page-content-header diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index f35649d031c..4656bb8d453 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -8,8 +8,7 @@ = s_("Wiki|Wiki Pages") .nav-controls.pb-md-3.pb-lg-0 - = link_to wiki_path(@wiki, action: :git_access), class: 'btn gl-button' do - = sprite_icon('download') + = link_button_to wiki_path(@wiki, action: :git_access), icon: 'download' do = _("Clone repository") .dropdown.inline.wiki-sort-dropdown diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 3841113231c..28699ca27f3 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -14,18 +14,23 @@ = render 'shared/wikis/main_links' - if @page.historical? - .warning_message - = s_("WikiHistoricalPage|This is an old version of this page.") - - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), wiki_page_path(@wiki, @page) - - history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history) - = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe + = render Pajamas::AlertComponent.new(variant: :warning, + dismissible: false) do |c| + - c.with_body do + = s_("WikiHistoricalPage|This is an old version of this page.") + - c.with_actions do + .gl-display-flex.gl-gap-3 + = render Pajamas::ButtonComponent.new(category: :primary, variant: :confirm, href: wiki_page_path(@wiki, @page)) do + = s_('WikiHistoricalPage|Go to most recent version') + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :history)) do + = s_('WikiHistoricalPage|Browse history') .gl-mt-5.gl-mb-3 .gl-display-flex.gl-justify-content-space-between %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page.human_title %div - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding - = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' } + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }}) .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } diff --git a/app/views/users/_follow_user.html.haml b/app/views/users/_follow_user.html.haml new file mode 100644 index 00000000000..3ee8c81496c --- /dev/null +++ b/app/views/users/_follow_user.html.haml @@ -0,0 +1,11 @@ +- link_classes = "flex-grow-1 gl-display-inline-block" + +- if current_user&.following_users_allowed?(@user) + - if current_user.following?(@user) + = form_tag user_unfollow_path(@user, :json), class: link_classes do + = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do + = _('Unfollow') + - else + = form_tag user_follow_path(@user, :json), class: link_classes do + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do + = _('Follow') diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index ce82a5e1614..0b76ed6c086 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -13,12 +13,10 @@ .col-12.col-md-10.col-lg-8.gl-my-6 .gl-display-flex %ol.breadcrumb.gl-breadcrumb-list.gl-mb-4 - %li.breadcrumb-item.gl-breadcrumb-item + %li.gl-breadcrumb-item = link_to project_path(@user.user_project) do = @user.username - %span.gl-breadcrumb-separator - = sprite_icon("chevron-right", size: 16) - %li.breadcrumb-item.gl-breadcrumb-item + %li.gl-breadcrumb-item = link_to @user.user_readme.path, @user.user_project.readme_url - if current_user == @user .gl-ml-auto diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index 7c50031598c..6de9e80008e 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -2,8 +2,9 @@ = render 'middle_dot_divider', stacking: true do @#{@user.username} - if can?(current_user, :read_user_profile, @user) - = render 'middle_dot_divider', stacking: true do - = s_('UserProfile|User ID: %{id}') % { id: @user.id } - = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) + - unless Feature.enabled?(:user_profile_overflow_menu_vue) + = render 'middle_dot_divider', stacking: true do + = s_('UserProfile|User ID: %{id}') % { id: @user.id } + = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } diff --git a/app/views/users/_view_gpg_keys.html.haml b/app/views/users/_view_gpg_keys.html.haml new file mode 100644 index 00000000000..aa0f69ffe3c --- /dev/null +++ b/app/views/users/_view_gpg_keys.html.haml @@ -0,0 +1,5 @@ +- verified_gpg_keys = @user.gpg_keys.select(&:verified?) +- if verified_gpg_keys.any? + = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, + icon: 'key', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) diff --git a/app/views/users/_view_user_in_admin_area.html.haml b/app/views/users/_view_user_in_admin_area.html.haml new file mode 100644 index 00000000000..b13f22956f6 --- /dev/null +++ b/app/views/users/_view_user_in_admin_area.html.haml @@ -0,0 +1,4 @@ +- if current_user && current_user.admin? + = render Pajamas::ButtonComponent.new(href: [:admin, @user], + icon: 'user', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }) diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 3571031fbfa..e98dd87a307 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -1,5 +1,5 @@ %h4.prepend-top-20 - = html_escape(_("Contributions for %{calendar_date}")) % { calendar_date: tag.strong(@calendar_date.to_s(:medium)) } + = html_escape(_("Contributions for %{calendar_date}")) % { calendar_date: tag.strong(@calendar_date.to_fs(:medium)) } - if @events.any? %ul.bordered-list diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 4113a276416..380d6aacb84 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -5,7 +5,6 @@ - page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' - add_page_specific_style 'page_bundles/profile' -- link_classes = "flex-grow-1 mx-1 " - if show_super_sidebar? - @left_sidebar = true - @force_desktop_expanded_sidebar = true @@ -17,35 +16,32 @@ .user-profile .cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1 %div{ class: container_class } - = render layout: 'users/cover_controls' do - - if @user == current_user - = render Pajamas::ButtonComponent.new(href: profile_path, - icon: 'pencil', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - elsif current_user - #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } - - verified_gpg_keys = @user.gpg_keys.select(&:verified?) - - if verified_gpg_keys.any? - = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, - icon: 'key', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - if can?(current_user, :read_user_profile, @user) - = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), - icon: 'rss', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - if current_user && current_user.admin? - = render Pajamas::ButtonComponent.new(href: [:admin, @user], - icon: 'user', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}}) - - if current_user && current_user.following_users_allowed?(@user) - - if current_user.following?(@user) - = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do - = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do - = _('Unfollow') - - else - = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do - = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do - = _('Follow') + - if Feature.enabled?(:user_profile_overflow_menu_vue) + .cover-controls.d-flex.px-2.pb-4.d-sm-block.p-sm-0 + = render 'users/follow_user' + -# The following edit button is mutually exclusive to the follow user button, they won't be shown together + - if @user == current_user + = render Pajamas::ButtonComponent.new(href: profile_path, + button_options: { class: 'gl-flex-grow-1', title: s_('UserProfile|Edit profile') }) do + = s_("UserProfile|Edit profile") + = render 'users/view_gpg_keys' + = render 'users/view_user_in_admin_area' + .js-user-profile-actions{ data: { user_id: @user.id } } + - else + = render layout: 'users/cover_controls' do + - if @user == current_user + = render Pajamas::ButtonComponent.new(href: profile_path, + icon: 'pencil', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + - elsif current_user + #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } + = render 'users/view_gpg_keys' + - if can?(current_user, :read_user_profile, @user) + = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), + icon: 'rss', + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + = render 'users/view_user_in_admin_area' + = render 'users/follow_user' .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?), ('gl-mb-4!' if show_super_sidebar?)] } .gl-display-inline-block.gl-mx-8.gl-vertical-align-top diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f8aa06943ee..6f6fd9ddb65 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -318,15 +318,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: cronjob:clusters_integrations_check_prometheus_health - :worker_name: Clusters::Integrations::CheckPrometheusHealthWorker - :feature_category: :incident_management - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: cronjob:container_expiration_policy :worker_name: ContainerExpirationPolicyWorker :feature_category: :container_registry @@ -561,15 +552,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: cronjob:metrics_dashboard_schedule_annotations_prune - :worker_name: Metrics::Dashboard::ScheduleAnnotationsPruneWorker - :feature_category: :metrics - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: cronjob:metrics_global_metrics_update :worker_name: Metrics::GlobalMetricsUpdateWorker :feature_category: :metrics @@ -1740,15 +1722,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: package_repositories:packages_debian_process_changes - :worker_name: Packages::Debian::ProcessChangesWorker - :feature_category: :package_registry - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: package_repositories:packages_debian_process_package_file :worker_name: Packages::Debian::ProcessPackageFileWorker :feature_category: :package_registry @@ -2001,6 +1974,15 @@ :weight: 3 :idempotent: false :tags: [] +- :name: pipeline_default:ci_pipeline_cleanup_ref + :worker_name: Ci::PipelineCleanupRefWorker + :feature_category: :continuous_integration + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 3 + :idempotent: true + :tags: [] - :name: pipeline_default:ci_retry_pipeline :worker_name: Ci::RetryPipelineWorker :feature_category: :continuous_integration @@ -2424,6 +2406,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: bulk_imports_finish_batched_pipeline + :worker_name: BulkImports::FinishBatchedPipelineWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: bulk_imports_finish_batched_relation_export :worker_name: BulkImports::FinishBatchedRelationExportWorker :feature_category: :importers @@ -2442,6 +2433,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bulk_imports_pipeline_batch + :worker_name: BulkImports::PipelineBatchWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bulk_imports_relation_batch_export :worker_name: BulkImports::RelationBatchExportWorker :feature_category: :importers @@ -2856,6 +2856,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: integrations_group_mention + :worker_name: Integrations::GroupMentionWorker + :feature_category: :integrations + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: integrations_irker :worker_name: Integrations::IrkerWorker :feature_category: :integrations @@ -2973,6 +2982,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: merge_requests_cleanup_ref + :worker_name: MergeRequests::CleanupRefWorker + :feature_category: :code_review_workflow + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge_requests_close_issue :worker_name: MergeRequests::CloseIssueWorker :feature_category: :code_review_workflow @@ -3072,24 +3090,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: metrics_dashboard_prune_old_annotations - :worker_name: Metrics::Dashboard::PruneOldAnnotationsWorker - :feature_category: :metrics - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] -- :name: metrics_dashboard_sync_dashboards - :worker_name: Metrics::Dashboard::SyncDashboardsWorker - :feature_category: :metrics - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: migrate_external_diffs :worker_name: MigrateExternalDiffsWorker :feature_category: :code_review_workflow @@ -3486,6 +3486,15 @@ :weight: 2 :idempotent: false :tags: [] +- :name: redis_migration + :worker_name: RedisMigrationWorker + :feature_category: :redis + :has_external_dependencies: false + :urgency: :throttled + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: releases_create_evidence :worker_name: Releases::CreateEvidenceWorker :feature_category: :release_evidence diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index 530419dac26..44759916f99 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -15,35 +15,40 @@ module BulkImports end def perform(entity_id) - entity = BulkImports::Entity.find(entity_id) + @entity = BulkImports::Entity.find(entity_id) - entity.update!(source_xid: entity_source_xid(entity)) if entity.source_xid.nil? - - request_export(entity) + set_source_xid + request_export BulkImports::EntityWorker.perform_async(entity_id) end def perform_failure(exception, entity_id) - entity = BulkImports::Entity.find(entity_id) + @entity = BulkImports::Entity.find(entity_id) - log_and_fail(exception, entity) + log_and_fail(exception) end private - def request_export(entity) - http_client(entity).post(entity.export_relations_url_path) + attr_reader :entity + + def set_source_xid + entity.update!(source_xid: entity_source_xid) if entity.source_xid.nil? + end + + def request_export + http_client.post(export_url) end - def http_client(entity) + def http_client @client ||= Clients::HTTP.new( url: entity.bulk_import.configuration.url, token: entity.bulk_import.configuration.access_token ) end - def failure_attributes(exception, entity) + def failure_attributes(exception) { bulk_import_entity_id: entity.id, pipeline_class: 'ExportRequestWorker', @@ -53,23 +58,20 @@ module BulkImports } end - def graphql_client(entity) + def graphql_client @graphql_client ||= BulkImports::Clients::Graphql.new( url: entity.bulk_import.configuration.url, token: entity.bulk_import.configuration.access_token ) end - def entity_source_xid(entity) - query = entity_query(entity) - client = graphql_client(entity) - - response = client.execute( - client.parse(query.to_s), + def entity_source_xid + response = graphql_client.execute( + graphql_client.parse(entity_query.to_s), { full_path: entity.source_full_path } ).original_hash - ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id + ::GlobalID.parse(response.dig(*entity_query.data_path, 'id')).model_id rescue StandardError => e log_exception(e, { @@ -86,12 +88,12 @@ module BulkImports nil end - def entity_query(entity) - if entity.group? - BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil) - else - BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil) - end + def entity_query + @entity_query ||= if entity.group? + BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil) + else + BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil) + end end def logger @@ -104,7 +106,7 @@ module BulkImports logger.error(structured_payload(payload)) end - def log_and_fail(exception, entity) + def log_and_fail(exception) log_exception(exception, { bulk_import_entity_id: entity.id, @@ -117,9 +119,13 @@ module BulkImports } ) - BulkImports::Failure.create(failure_attributes(exception, entity)) + BulkImports::Failure.create(failure_attributes(exception)) entity.fail_op! end + + def export_url + entity.export_relations_url_path(batched: Feature.enabled?(:bulk_imports_batched_import_export)) + end end end diff --git a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb new file mode 100644 index 00000000000..4200d0e4a0f --- /dev/null +++ b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module BulkImports + class FinishBatchedPipelineWorker + include ApplicationWorker + include ExceptionBacktrace + + REQUEUE_DELAY = 5.seconds + + idempotent! + deduplicate :until_executing + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :importers + + def perform(pipeline_tracker_id) + @tracker = Tracker.find(pipeline_tracker_id) + + return unless tracker.batched? + return unless tracker.started? + return re_enqueue if import_in_progress? + + if tracker.stale? + tracker.batches.map(&:fail_op!) + tracker.fail_op! + else + tracker.finish! + end + + ensure + ::BulkImports::EntityWorker.perform_async(tracker.entity.id, tracker.stage) + end + + private + + attr_reader :tracker + + def re_enqueue + self.class.perform_in(REQUEUE_DELAY, tracker.id) + end + + def import_in_progress? + tracker.batches.any?(&:started?) + end + end +end diff --git a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb index aa7bbffa732..92a33a971e7 100644 --- a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb +++ b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb @@ -5,7 +5,7 @@ module BulkImports include ApplicationWorker idempotent! - data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + data_consistency :sticky feature_category :importers REENQUEUE_DELAY = 5.seconds diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb new file mode 100644 index 00000000000..378eff99b52 --- /dev/null +++ b/app/workers/bulk_imports/pipeline_batch_worker.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module BulkImports + class PipelineBatchWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :importers + sidekiq_options retry: false, dead: false + worker_has_external_dependencies! + + def perform(batch_id) + @batch = ::BulkImports::BatchTracker.find(batch_id) + @tracker = @batch.tracker + + try_obtain_lease { run } + ensure + ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) + end + + private + + attr_reader :batch, :tracker + + def run + return batch.skip! if tracker.failed? || tracker.finished? + + batch.start! + tracker.pipeline_class.new(context).run + batch.finish! + rescue BulkImports::RetryPipelineError => e + retry_batch(e) + rescue StandardError => e + fail_batch(e) + end + + def fail_batch(exception) + batch.fail_op! + + Gitlab::ErrorTracking.track_exception( + exception, + batch_id: batch.id, + tracker_id: tracker.id, + pipeline_class: tracker.pipeline_name, + pipeline_step: 'pipeline_batch_worker_run' + ) + + BulkImports::Failure.create( + bulk_import_entity_id: batch.tracker.entity.id, + pipeline_class: tracker.pipeline_name, + pipeline_step: 'pipeline_batch_worker_run', + exception_class: exception.class.to_s, + exception_message: exception.message.truncate(255), + correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id + ) + end + + def context + @context ||= ::BulkImports::Pipeline::Context.new(tracker, batch_number: batch.batch_number) + end + + def retry_batch(exception) + batch.retry! + + re_enqueue(exception.retry_delay) + end + + def lease_timeout + 30 + end + + def lease_key + "gitlab:bulk_imports:pipeline_batch_worker:#{batch.id}" + end + + def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY) + self.class.perform_in(delay, batch.id) + end + end +end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index f03e0bc0656..e0db18cb987 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -31,7 +31,6 @@ module BulkImports fail_tracker(StandardError.new(message)) unless pipeline_tracker.finished? || pipeline_tracker.skipped? end end - ensure ::BulkImports::EntityWorker.perform_async(entity_id, stage) end @@ -49,9 +48,17 @@ module BulkImports return re_enqueue if export_empty? || export_started? - pipeline_tracker.update!(status_event: 'start', jid: jid) - pipeline_tracker.pipeline_class.new(context).run - pipeline_tracker.finish! + if file_extraction_pipeline? && export_status.batched? + pipeline_tracker.update!(status_event: 'start', jid: jid, batched: true) + + return pipeline_tracker.finish! if export_status.batches_count < 1 + + enqueue_batches + else + pipeline_tracker.update!(status_event: 'start', jid: jid) + pipeline_tracker.pipeline_class.new(context).run + pipeline_tracker.finish! + end rescue BulkImports::RetryPipelineError => e retry_tracker(e) rescue StandardError => e @@ -179,5 +186,13 @@ module BulkImports time_since_tracker_created > Pipeline::NDJSON_EXPORT_TIMEOUT end + + def enqueue_batches + 1.upto(export_status.batches_count) do |batch_number| + batch = pipeline_tracker.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord + + ::BulkImports::PipelineBatchWorker.perform_async(batch.id) + end + end end end diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb index b6693f0b07d..531edc6c7a7 100644 --- a/app/workers/bulk_imports/relation_export_worker.rb +++ b/app/workers/bulk_imports/relation_export_worker.rb @@ -18,9 +18,7 @@ module BulkImports portable = portable(portable_id, portable_class) config = BulkImports::FileTransfer.config_for(portable) - if Feature.enabled?(:bulk_imports_batched_import_export) && - Gitlab::Utils.to_boolean(batched) && - config.batchable_relation?(relation) + if Gitlab::Utils.to_boolean(batched) && config.batchable_relation?(relation) BatchedRelationExportService.new(user, portable, relation, jid).execute else RelationExportService.new(user, portable, relation, jid).execute diff --git a/app/workers/ci/pipeline_cleanup_ref_worker.rb b/app/workers/ci/pipeline_cleanup_ref_worker.rb new file mode 100644 index 00000000000..291e1090c18 --- /dev/null +++ b/app/workers/ci/pipeline_cleanup_ref_worker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Ci + class PipelineCleanupRefWorker + include ApplicationWorker + include Projects::RemoveRefs + + sidekiq_options retry: 3 + include PipelineQueue + + idempotent! + deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + + urgency :low + + # Even though this worker is de-duplicated we need to acquire lock + # on a project to avoid running many concurrent refs removals + # + # TODO: Once underlying fix is done we can remove `in_lock` + # + # Related to: + # - https://gitlab.com/gitlab-org/gitaly/-/issues/5368 + # - https://gitlab.com/gitlab-org/gitaly/-/issues/5369 + def perform(pipeline_id) + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + return unless pipeline + return unless pipeline.persistent_ref.should_delete? + + serialized_remove_refs(pipeline.project_id) do + pipeline.reset.persistent_ref.delete + end + end + end +end diff --git a/app/workers/clusters/integrations/check_prometheus_health_worker.rb b/app/workers/clusters/integrations/check_prometheus_health_worker.rb deleted file mode 100644 index b65b3424c3a..00000000000 --- a/app/workers/clusters/integrations/check_prometheus_health_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Integrations - class CheckPrometheusHealthWorker - include ApplicationWorker - - data_consistency :always - - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext - - feature_category :incident_management - urgency :low - - idempotent! - worker_has_external_dependencies! - - def perform; end - end - end -end diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb index 448a16ad309..9ec02dd613e 100644 --- a/app/workers/container_registry/cleanup_worker.rb +++ b/app/workers/container_registry/cleanup_worker.rb @@ -16,8 +16,6 @@ module ContainerRegistry BATCH_SIZE = 200 def perform - log_counts - reset_stale_deletes delete_stale_ongoing_repair_details @@ -54,26 +52,13 @@ module ContainerRegistry end def should_enqueue_record_detail_jobs? - return false unless Gitlab.com? + return false unless Gitlab.com_except_jh? return false unless Feature.enabled?(:registry_data_repair_worker) return false unless ContainerRegistry::GitlabApiClient.supports_gitlab_api? Project.pending_data_repair_analysis.exists? end - def log_counts - ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries do - log_extra_metadata_on_done( - :delete_scheduled_container_repositories_count, - ContainerRepository.delete_scheduled.count - ) - log_extra_metadata_on_done( - :stale_delete_container_repositories_count, - stale_delete_container_repositories.count - ) - end - end - def stale_delete_container_repositories ContainerRepository.delete_ongoing.with_stale_delete_at(STALE_DELETE_THRESHOLD.ago) end diff --git a/app/workers/container_registry/record_data_repair_detail_worker.rb b/app/workers/container_registry/record_data_repair_detail_worker.rb index 390481f8e01..3e40dbbb99a 100644 --- a/app/workers/container_registry/record_data_repair_detail_worker.rb +++ b/app/workers/container_registry/record_data_repair_detail_worker.rb @@ -17,7 +17,7 @@ module ContainerRegistry LEASE_TIMEOUT = 1.hour.to_i def perform_work - return unless Gitlab.com? + return unless Gitlab.com_except_jh? return unless next_project return if next_project.container_registry_data_repair_detail @@ -51,7 +51,7 @@ module ContainerRegistry end def remaining_work_count - return 0 unless Gitlab.com? + return 0 unless Gitlab.com_except_jh? return 0 unless Feature.enabled?(:registry_data_repair_worker) return 0 unless ContainerRegistry::GitlabApiClient.supports_gitlab_api? @@ -69,7 +69,7 @@ module ContainerRegistry end def next_project - Project.pending_data_repair_analysis.first + Project.pending_data_repair_analysis.limit(max_running_jobs * 2).sample end strong_memoize_attr :next_project diff --git a/app/workers/integrations/execute_worker.rb b/app/workers/integrations/execute_worker.rb index 443f1d9fe8e..6fe1937a222 100644 --- a/app/workers/integrations/execute_worker.rb +++ b/app/workers/integrations/execute_worker.rb @@ -13,6 +13,8 @@ module Integrations worker_has_external_dependencies! def perform(hook_id, data) + return if ::Gitlab::SilentMode.enabled? + data = data.with_indifferent_access integration = Integration.find_by_id(hook_id) return unless integration diff --git a/app/workers/integrations/group_mention_worker.rb b/app/workers/integrations/group_mention_worker.rb new file mode 100644 index 00000000000..6cde1657ccd --- /dev/null +++ b/app/workers/integrations/group_mention_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Integrations + class GroupMentionWorker + include ApplicationWorker + + idempotent! + feature_category :integrations + deduplicate :until_executed + data_consistency :delayed + urgency :low + + worker_has_external_dependencies! + + def perform(args) + args = args.with_indifferent_access + + mentionable_type = args[:mentionable_type] + mentionable_id = args[:mentionable_id] + hook_data = args[:hook_data] + is_confidential = args[:is_confidential] + + mentionable = case mentionable_type + when 'Issue' + Issue.find(mentionable_id) + when 'MergeRequest' + MergeRequest.find(mentionable_id) + end + + if mentionable.nil? + Sidekiq.logger.error( + message: 'Integrations::GroupMentionWorker: mentionable not supported', + mentionable_type: mentionable_type, + mentionable_id: mentionable_id + ) + return + end + + Integrations::GroupMentionService.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute + end + end +end diff --git a/app/workers/merge_requests/cleanup_ref_worker.rb b/app/workers/merge_requests/cleanup_ref_worker.rb new file mode 100644 index 00000000000..c714b976a2b --- /dev/null +++ b/app/workers/merge_requests/cleanup_ref_worker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module MergeRequests + class CleanupRefWorker + include ApplicationWorker + include Projects::RemoveRefs + + sidekiq_options retry: 3 + loggable_arguments 2 + feature_category :code_review_workflow + + idempotent! + deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute + data_consistency :delayed + + urgency :low + + # Even though this worker is de-duplicated we need to acquire lock + # on a project to avoid running many concurrent refs removals + # + # TODO: Once underlying fix is done we can remove `in_lock` + # + # Related to: + # - https://gitlab.com/gitlab-org/gitaly/-/issues/5368 + # - https://gitlab.com/gitlab-org/gitaly/-/issues/5369 + def perform(merge_request_id, only) + merge_request = MergeRequest.find_by_id(merge_request_id) + return unless merge_request + + serialized_remove_refs(merge_request.target_project_id) do + merge_request.cleanup_refs(only: only.to_sym) + end + end + end +end diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb index cbe34ac3790..f48e9c234ab 100644 --- a/app/workers/merge_requests/mergeability_check_batch_worker.rb +++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb @@ -15,10 +15,16 @@ module MergeRequests @logger ||= Sidekiq.logger end - def perform(merge_request_ids) + def perform(merge_request_ids, user_id) merge_requests = MergeRequest.id_in(merge_request_ids) + user = User.find_by_id(user_id) merge_requests.each do |merge_request| + # Skip projects that user doesn't have update_merge_request access + next if merge_status_recheck_not_allowed?(merge_request, user) + + merge_request.mark_as_checking + result = merge_request.check_mergeability next unless result&.error? @@ -30,5 +36,12 @@ module MergeRequests ) end end + + private + + def merge_status_recheck_not_allowed?(merge_request, user) + ::Feature.enabled?(:restrict_merge_status_recheck, merge_request.project) && + !Ability.allowed?(user, :update_merge_request, merge_request.project) + end end end diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb deleted file mode 100644 index 5b34f85606d..00000000000 --- a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class PruneOldAnnotationsWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - DELETE_LIMIT = 10_000 - DEFAULT_CUT_OFF_PERIOD = 2.weeks - - feature_category :metrics - - idempotent! # in the scope of 24 hours - - def perform; end - end - end -end diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb deleted file mode 100644 index fe002ffa4a0..00000000000 --- a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class ScheduleAnnotationsPruneWorker - include ApplicationWorker - - data_consistency :always - - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext - - feature_category :metrics - - idempotent! # PruneOldAnnotationsWorker worker is idempotent in the scope of 24 hours - - def perform; end - end - end -end diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb deleted file mode 100644 index 668542e51a5..00000000000 --- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Metrics - module Dashboard - class SyncDashboardsWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - feature_category :metrics - - idempotent! - - def perform(project_id); end - end - end -end diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb deleted file mode 100644 index 0a716c61203..00000000000 --- a/app/workers/packages/debian/process_changes_worker.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Debian - class ProcessChangesWorker - include ApplicationWorker - - data_consistency :always - include Gitlab::Utils::StrongMemoize - - deduplicate :until_executed - idempotent! - - queue_namespace :package_repositories - feature_category :package_registry - - def perform(package_file_id, user_id) - @package_file_id = package_file_id - @user_id = user_id - - return unless package_file && user - - ::Packages::Debian::ProcessChangesService.new(package_file, user).execute - rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id) - package_file.destroy! - end - - private - - attr_reader :package_file_id, :user_id - - def package_file - strong_memoize(:package_file) do - ::Packages::PackageFile.find_by_id(package_file_id) - end - end - - def user - strong_memoize(:user) do - ::User.find_by_id(user_id) - end - end - end - end -end diff --git a/app/workers/redis_migration_worker.rb b/app/workers/redis_migration_worker.rb new file mode 100644 index 00000000000..bad9baeac70 --- /dev/null +++ b/app/workers/redis_migration_worker.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class RedisMigrationWorker + include ApplicationWorker + + idempotent! + data_consistency :delayed + feature_category :redis + urgency :throttled + loggable_arguments 0 + + SCAN_START_STOP = '0' + + def perform(job_class_name, cursor, options = {}) + migrator = self.class.fetch_migrator!(job_class_name) + + scan_size = options[:scan_size] || 1000 + deadline = Time.now.utc + 3.minutes + + while Time.now.utc < deadline + cursor, keys = migrator.redis.scan(cursor, match: migrator.scan_match_pattern, count: scan_size) + + migrator.perform(keys) if keys.any? + + sleep(0.01) + break if cursor == SCAN_START_STOP + end + + self.class.perform_async(job_class_name, cursor, options) unless cursor == SCAN_START_STOP + end + + class << self + def fetch_migrator!(job_class_name) + job_class = "Gitlab::BackgroundMigration::Redis::#{job_class_name}".safe_constantize + raise NotImplementedError, "#{job_class_name} does not exist" if job_class.nil? + + job_class.new + end + end +end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 4ca366efcad..dab92e16ee3 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -33,10 +33,15 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker def run_pipeline_schedule(schedule, user) response = Ci::CreatePipelineService .new(schedule.project, user, ref: schedule.ref) - .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + .execute( + :schedule, + save_on_errors: Feature.enabled?(:persist_failed_pipelines_from_schedules, schedule.project), + ignore_skip_ci: true, schedule: schedule + ) return response if response.payload.persisted? + # Remove with FF persist_failed_pipelines_from_schedules enabled, as corrupted yml is not longer logged # This is a user operation error such as corrupted .gitlab-ci.yml. Log the error for debugging purpose. log_extra_metadata_on_done(:pipeline_creation_error, response.message) rescue StandardError => e |